PostType becomes an output filename, so an unset or unvalidated value does
not stay a data problem -- it creates a file. OutputMode wrote untyped rows
to `PostType ?? "Unknown"`, IngestMode read that file back and derived the
literal type "Unknown" from its name, and the two would have regenerated
each other indefinitely.
Nothing was setting the type in the first place. AddPost -- the path every
notes/likes harvest goes through -- omitted PostType from its INSERT column
list entirely, so 1790 rows across 469 blogs had none. Ingest could never
repair them: it types a post only when it meets it inside a real export
file, and these posts appear in none.
Type at the source, from what each path actually knows:
- TraverseDirectory takes it from the filename it is already reading
("texts.txt" -> "texts"), the same rule ingest uses.
- CollectLikes has no file, so it reads the legacy-format `type` field that
GrabLikes already requests with npf=false, mapped singular -> plural.
- AddPost/UpdatePost gained the plumbing to carry it. UpdatePost fills a
missing type but never overwrites one, and its change-detection clause
had to learn about PostType or the SET would be unreachable for a row
whose content was already current.
PostTypes is the single source of truth: eight canonical names, and
anything else normalizes to null. Null is safe -- OutputMode skips those
rows -- while a stray value would have become a stray file. IngestMode and
TraverseDirectory now skip non-export .txt files outright, and the legacy
importer no longer passes a pre-column NULL straight back in.
For the rows already stored untyped, content is the only signal left, so
the migration infers from which columns they carry. Verified against a copy
of the live database: 1780 of 1790 typed, 10 left untyped for want of any
content at all, idempotent on a second pass. HasImage is deliberately not
consulted -- it is set on 12,420 of 19,828 known text posts.
Co-Authored-By: Claude Opus 5 <[email protected]>
217 lines
11 KiB
C#
217 lines
11 KiB
C#
using Microsoft.Extensions.Configuration;
|
|
|
|
namespace URLNotesGrabberCORE
|
|
{
|
|
// Port of ThreeTxtFileHelper RunOutputMode + WritePostToFile + RenameExistingTxtFilesToBak.
|
|
// For each Blog with a TTFolderPath, renames any existing .txt files in that folder to .bak,
|
|
// then writes one .txt per PostType containing all posts of that type (date-sorted, fixed
|
|
// field order). Reads from TL.db via DataAccess.GetAllPostsForBlog.
|
|
public static class OutputMode
|
|
{
|
|
public static int Run(IConfiguration config, string[]? args = null)
|
|
{
|
|
DataAccess.EnsureTTFileHelperColumnsExist();
|
|
|
|
string dbPath = DataAccess.GetActiveDbPath();
|
|
Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}");
|
|
|
|
if (!RefreshPaths(config, args ?? Array.Empty<string>()))
|
|
return 1;
|
|
|
|
var blogs = DataAccess.GetAllBlogsWithTTFolderPath();
|
|
int activeBlogs = DataAccess.CountActiveBlogs();
|
|
Console.WriteLine($"{blogs.Count} of {activeBlogs} active blog(s) have a TTFolderPath.");
|
|
|
|
if (blogs.Count == 0)
|
|
{
|
|
Console.WriteLine($"\nNothing to export: no blog in {Path.GetFullPath(dbPath)} has a TTFolderPath.");
|
|
Console.WriteLine("Point --output at a TumblThree root so it can populate them: --output <root>,");
|
|
Console.WriteLine("or set appSettings:PathTTRoot so the refresh runs automatically.");
|
|
return 1;
|
|
}
|
|
|
|
int missingFolderCount = 0;
|
|
int writtenCount = 0;
|
|
|
|
foreach (var (blogName, folder) in blogs)
|
|
{
|
|
Console.WriteLine($"\nProcessing blog: {blogName}");
|
|
|
|
// A stored path that this machine cannot see means the value was written on
|
|
// another machine -- re-running --updatepaths locally is the fix, so say so
|
|
// rather than lumping it in with "not set".
|
|
if (!Directory.Exists(folder))
|
|
{
|
|
Console.WriteLine($" TTFolderPath folder not found: {folder}. Skipping.");
|
|
missingFolderCount++;
|
|
continue;
|
|
}
|
|
|
|
Console.WriteLine($" TTFolderPath: {folder}");
|
|
writtenCount++;
|
|
|
|
try
|
|
{
|
|
foreach (var bakFile in Directory.GetFiles(folder, "*.bak"))
|
|
File.Delete(bakFile);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($" Error deleting .bak files: {ex.Message}");
|
|
}
|
|
|
|
RenameExistingTxtFilesToBak(folder);
|
|
|
|
var posts = DataAccess.GetAllPostsForBlog(blogName);
|
|
Console.WriteLine($" Found {posts.Count} post(s) for this blog.");
|
|
|
|
// A post's type becomes a filename, so only a recognized type may be written. The
|
|
// old `PostType ?? "Unknown"` invented Unknown.txt for untyped rows, which --ingest
|
|
// then read back as a type named "Unknown" -- the two regenerated each other.
|
|
// Untyped rows are skipped instead: after the backfill these are only rows with no
|
|
// content signal at all, so nothing meaningful is lost, and nothing is invented.
|
|
var typed = posts.Where(p => PostTypes.Normalize(p.PostType) != null).ToList();
|
|
int untyped = posts.Count - typed.Count;
|
|
if (untyped > 0)
|
|
Console.WriteLine($" Skipping {untyped} post(s) with no recognized PostType.");
|
|
|
|
var grouped = typed.GroupBy(p => PostTypes.Normalize(p.PostType)!);
|
|
foreach (var typeGroup in grouped)
|
|
{
|
|
string postType = typeGroup.Key;
|
|
string outputFilePath = Path.Combine(folder, $"{postType}.txt");
|
|
var ordered = typeGroup.OrderBy(p => p.Date).ToList();
|
|
Console.WriteLine($" Writing {ordered.Count} post(s) to {postType}.txt");
|
|
|
|
using var writer = new StreamWriter(outputFilePath, false, System.Text.Encoding.UTF8);
|
|
bool isFirst = true;
|
|
foreach (var post in ordered)
|
|
{
|
|
if (!isFirst)
|
|
{
|
|
writer.WriteLine();
|
|
writer.WriteLine();
|
|
}
|
|
WritePostToFile(writer, post);
|
|
isFirst = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
Console.WriteLine($"\nOutput mode complete. {writtenCount} blog(s) exported, {missingFolderCount} skipped for a missing folder.");
|
|
|
|
if (writtenCount == 0)
|
|
Console.WriteLine("Every TTFolderPath points at a folder this machine cannot see. The paths were most likely written on another machine -- re-run --updatepaths <root> here so they match local drive letters.");
|
|
|
|
return 0;
|
|
}
|
|
|
|
// Re-reads the TumblThree Index metadata into Blogs.TTFolderPath before exporting.
|
|
// A TL.db synced between machines cannot hold one absolute path that is valid on
|
|
// both, so the stored paths are only trustworthy on the machine that wrote them --
|
|
// which makes this refresh part of a normal export rather than a separate chore.
|
|
// Returns false only when the run should stop.
|
|
private static bool RefreshPaths(IConfiguration config, string[] args)
|
|
{
|
|
var settings = config.GetSection("appSettings");
|
|
|
|
if (args.Any(a => string.Equals(a, "--norefresh", StringComparison.OrdinalIgnoreCase)))
|
|
{
|
|
Console.WriteLine("Path refresh skipped (--norefresh); exporting to whatever paths TL.db already holds.");
|
|
return true;
|
|
}
|
|
|
|
string? root = args.FirstOrDefault(a => !a.StartsWith("--", StringComparison.Ordinal))
|
|
?? settings.GetValue<string>("PathTTRoot");
|
|
|
|
var result = UpdateBlogPathsRunner.Scan(root, verbose: false);
|
|
|
|
switch (result.Outcome)
|
|
{
|
|
case UpdateBlogPathsRunner.ScanOutcome.NoRootConfigured:
|
|
Console.WriteLine("No TumblThree root configured (appSettings:PathTTRoot is empty and none was passed),");
|
|
Console.WriteLine("so TTFolderPath was not refreshed. Pass one as --output <root> to refresh it.");
|
|
return true;
|
|
|
|
case UpdateBlogPathsRunner.ScanOutcome.IndexFolderMissing:
|
|
// Silently exporting stale paths here would defeat the point of folding
|
|
// the refresh in, so a bad root is a hard stop.
|
|
Console.WriteLine($"Index folder not found at: {result.IndexPath}");
|
|
Console.WriteLine("Fix the root (or pass --norefresh to export the paths already in TL.db).");
|
|
return false;
|
|
|
|
default:
|
|
Console.WriteLine($"Refreshed paths from {result.IndexPath}: " +
|
|
$"{result.MetadataFiles} metadata file(s), {result.Written} written, " +
|
|
$"{result.Unchanged} already correct, {result.NoLocation} without a location, " +
|
|
$"{result.NoMatchingRow} without a blog row, {result.Errors} error(s).");
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private static void RenameExistingTxtFilesToBak(string folderPath)
|
|
{
|
|
try
|
|
{
|
|
foreach (var txtFile in Directory.GetFiles(folderPath, "*.txt"))
|
|
{
|
|
string bakPath = Path.ChangeExtension(txtFile, ".bak");
|
|
if (File.Exists(bakPath)) File.Delete(bakPath);
|
|
File.Move(txtFile, bakPath, overwrite: true);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Console.WriteLine($" Error renaming txt files to .bak: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
private static void WritePostToFile(StreamWriter writer, TTPostRecord post)
|
|
{
|
|
var startColumns = new[] { "Post ID", "Date", "Post URL", "Slug", "Reblog Key", "Reblog URL", "Reblog Name", "Title", "Body" };
|
|
var endColumns = new[] { "Tags", "Downloaded Files" };
|
|
|
|
var columns = new Dictionary<string, string>();
|
|
if (!string.IsNullOrWhiteSpace(post.PostId)) columns["Post ID"] = post.PostId;
|
|
if (!string.IsNullOrWhiteSpace(post.Date)) columns["Date"] = post.Date!;
|
|
if (!string.IsNullOrWhiteSpace(post.PostUrl)) columns["Post URL"] = post.PostUrl!;
|
|
if (!string.IsNullOrWhiteSpace(post.Slug)) columns["Slug"] = post.Slug!;
|
|
if (!string.IsNullOrWhiteSpace(post.ReblogKey)) columns["Reblog Key"] = post.ReblogKey!;
|
|
if (!string.IsNullOrWhiteSpace(post.ReblogUrl)) columns["Reblog URL"] = post.ReblogUrl!;
|
|
if (!string.IsNullOrWhiteSpace(post.ReblogName)) columns["Reblog Name"] = post.ReblogName!;
|
|
if (!string.IsNullOrWhiteSpace(post.Title)) columns["Title"] = post.Title!;
|
|
if (!string.IsNullOrWhiteSpace(post.Body)) columns["Body"] = post.Body!;
|
|
if (!string.IsNullOrWhiteSpace(post.HasImage)) columns["Has Image"] = post.HasImage!;
|
|
if (!string.IsNullOrWhiteSpace(post.Summary)) columns["Summary"] = post.Summary!;
|
|
if (!string.IsNullOrWhiteSpace(post.Quote)) columns["Quote"] = post.Quote!;
|
|
if (!string.IsNullOrWhiteSpace(post.Link)) columns["Link"] = post.Link!;
|
|
if (!string.IsNullOrWhiteSpace(post.PhotoUrl)) columns["Photo URL"] = post.PhotoUrl!;
|
|
if (!string.IsNullOrWhiteSpace(post.PhotoCaption)) columns["Photo Caption"] = post.PhotoCaption!;
|
|
if (!string.IsNullOrWhiteSpace(post.AudioCaption)) columns["Audio Caption"] = post.AudioCaption!;
|
|
if (!string.IsNullOrWhiteSpace(post.Question)) columns["Question"] = post.Question!;
|
|
if (!string.IsNullOrWhiteSpace(post.Answer)) columns["Answer"] = post.Answer!;
|
|
if (!string.IsNullOrWhiteSpace(post.Tags)) columns["Tags"] = post.Tags!;
|
|
if (!string.IsNullOrWhiteSpace(post.DownloadedFiles)) columns["Downloaded Files"] = post.DownloadedFiles!;
|
|
|
|
foreach (var col in startColumns)
|
|
{
|
|
if (columns.ContainsKey(col))
|
|
{
|
|
writer.WriteLine($"{col}: {columns[col]}");
|
|
columns.Remove(col);
|
|
}
|
|
}
|
|
|
|
var remaining = columns.Keys.Where(k => !endColumns.Contains(k)).OrderBy(k => k).ToList();
|
|
foreach (var col in remaining)
|
|
writer.WriteLine($"{col}: {columns[col]}");
|
|
|
|
foreach (var col in endColumns)
|
|
{
|
|
if (columns.ContainsKey(col))
|
|
writer.WriteLine($"{col}: {columns[col]}");
|
|
}
|
|
}
|
|
}
|
|
}
|