Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4177a0c9 | ||
|
|
721224bc13 | ||
|
|
ef6629d86a | ||
|
|
6320e2c0c9 |
@@ -124,6 +124,36 @@ though the true content never changes.
|
|||||||
source can legitimately supply `"."` as "field absent" before deciding whether it needs
|
source can legitimately supply `"."` as "field absent" before deciding whether it needs
|
||||||
the same `CASE` treatment — don't assume every column needs it
|
the same `CASE` treatment — don't assume every column needs it
|
||||||
|
|
||||||
|
**`--ingest` (`UpsertPostFromTextFile`) uses `NULL`, not `"."`, for the same "field absent"
|
||||||
|
convention, and reconciling exactly this kind of duplicate IS the feature's job.**
|
||||||
|
`IngestMode` strips a trailing `_N` from the folder name before it ever reaches
|
||||||
|
`UpsertPostFromTextFile`, so a duplicate export folder collapses onto the same `BlogName` on
|
||||||
|
purpose — the whole point is to merge multiple differently-formatted files for the same post
|
||||||
|
into one row. `IngestMode.G(key)` returns `null` (not `"."`) when a field's line is absent
|
||||||
|
from a given file, `LegacyPostsDbImporter` passes `null` straight from a `NULL` source column,
|
||||||
|
and files are walked in raw filesystem enumeration order — never sorted — so which file's call
|
||||||
|
lands last for a given `(BlogName, PostID)` is arbitrary.
|
||||||
|
|
||||||
|
- Before the fix, the `UPDATE` branch set every column unconditionally, so whichever file
|
||||||
|
processed last for a `PostID` would null out every field its own record didn't carry —
|
||||||
|
silently erasing real `Title`/`Slug`/`Tags`/… another file had, the opposite of what
|
||||||
|
`--ingest` exists to do. This is worse than the `"."` case above: that one only caused
|
||||||
|
churn (the two writes canceled out); this one loses data, and which posts lose which
|
||||||
|
fields depends on filesystem enumeration order
|
||||||
|
- Same shape of fix, `NULL` instead of `"."` as the sentinel: `col = CASE WHEN @col IS NULL
|
||||||
|
THEN col ELSE @col END` in the `SET` list, `(@col IS NOT NULL AND IFNULL(col, '') <> @col)
|
||||||
|
OR ...` in the change-detection
|
||||||
|
- Same narrow rule: only `NULL` (the field's line was never present in this file) is the
|
||||||
|
sentinel. `G()` already distinguishes this from "present but blank" — a dictionary miss is
|
||||||
|
`null`, an empty value after the prefix is `""` — so an explicitly blank field still
|
||||||
|
overwrites
|
||||||
|
- `HasImage` is **not** guarded and remains a known gap: `IngestMode` always computes a
|
||||||
|
concrete `bool` (defaulting `false` when a file has no `Has Image:` line), so there is no
|
||||||
|
way for this function to tell "this format says no image" from "this format doesn't report
|
||||||
|
it at all" without changing the parameter to `bool?` and threading that through
|
||||||
|
`IngestMode`/`LegacyPostsDbImporter`. Fix this the same way if `--ingest` is observed
|
||||||
|
downgrading a post's `HasImage` from `1` to `0`
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
- No existing test suite; use xUnit if adding tests
|
- No existing test suite; use xUnit if adding tests
|
||||||
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
|
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The database every DataAccess call defaults to, exposed so modes can report
|
||||||
|
// which file they actually read when their results are surprising.
|
||||||
|
public static string GetActiveDbPath() => GetDefaultDbPath();
|
||||||
|
|
||||||
private static string GetDefaultDbPath()
|
private static string GetDefaultDbPath()
|
||||||
{
|
{
|
||||||
if (_cachedDbPath != null)
|
if (_cachedDbPath != null)
|
||||||
@@ -2062,48 +2066,63 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
if (rowsInserted == 0)
|
if (rowsInserted == 0)
|
||||||
{
|
{
|
||||||
|
// NULL is this function's sentinel for "this file's record had no line for
|
||||||
|
// that field" (IngestMode's G(key) misses return null; LegacyPostsDbImporter
|
||||||
|
// passes null straight from a NULL source column) -- it does not mean "clear
|
||||||
|
// this field". --ingest's entire reason to exist is reconciling multiple
|
||||||
|
// export files for the same (BlogName, PostID) -- IngestMode normalizes a
|
||||||
|
// "_2"-suffixed duplicate folder onto the same blog name specifically so a
|
||||||
|
// second, differently-formatted file for a post it already has gets merged in.
|
||||||
|
// Files are walked in filesystem enumeration order, not sorted, so which
|
||||||
|
// file's UpsertPostFromTextFile call runs last for a given PostID is
|
||||||
|
// effectively arbitrary. An unconditional SET here would let whichever file
|
||||||
|
// processed last silently null out every column its own record didn't carry,
|
||||||
|
// erasing real content the other file had -- the opposite of "clean up". Each
|
||||||
|
// column is CASE-guarded to keep the existing value when this call's parameter
|
||||||
|
// is NULL, and the change-detection ignores a NULL-vs-real mismatch the same
|
||||||
|
// way, so a partial record converges into the row instead of overwriting it.
|
||||||
string updateSql = @"UPDATE Posts SET
|
string updateSql = @"UPDATE Posts SET
|
||||||
reblogURL = @reblogURL,
|
reblogURL = CASE WHEN @reblogURL IS NULL THEN reblogURL ELSE @reblogURL END,
|
||||||
PostDate = @PostDate,
|
PostDate = CASE WHEN @PostDate IS NULL THEN PostDate ELSE @PostDate END,
|
||||||
PostURL = @PostURL,
|
PostURL = CASE WHEN @PostURL IS NULL THEN PostURL ELSE @PostURL END,
|
||||||
Slug = @Slug,
|
Slug = CASE WHEN @Slug IS NULL THEN Slug ELSE @Slug END,
|
||||||
ReblogKey = @ReblogKey,
|
ReblogKey = CASE WHEN @ReblogKey IS NULL THEN ReblogKey ELSE @ReblogKey END,
|
||||||
ReblogName = @ReblogName,
|
ReblogName = CASE WHEN @ReblogName IS NULL THEN ReblogName ELSE @ReblogName END,
|
||||||
Summary = @Summary,
|
Summary = CASE WHEN @Summary IS NULL THEN Summary ELSE @Summary END,
|
||||||
Quote = @Quote,
|
Quote = CASE WHEN @Quote IS NULL THEN Quote ELSE @Quote END,
|
||||||
Body = @Body,
|
Body = CASE WHEN @Body IS NULL THEN Body ELSE @Body END,
|
||||||
Tags = @Tags,
|
Tags = CASE WHEN @Tags IS NULL THEN Tags ELSE @Tags END,
|
||||||
Link = @Link,
|
Link = CASE WHEN @Link IS NULL THEN Link ELSE @Link END,
|
||||||
PhotoURL = @PhotoURL,
|
PhotoURL = CASE WHEN @PhotoURL IS NULL THEN PhotoURL ELSE @PhotoURL END,
|
||||||
PhotoCaption = @PhotoCaption,
|
PhotoCaption = CASE WHEN @PhotoCaption IS NULL THEN PhotoCaption ELSE @PhotoCaption END,
|
||||||
DownloadedFiles = @DownloadedFiles,
|
DownloadedFiles = CASE WHEN @DownloadedFiles IS NULL THEN DownloadedFiles ELSE @DownloadedFiles END,
|
||||||
AudioCaption = @AudioCaption,
|
AudioCaption = CASE WHEN @AudioCaption IS NULL THEN AudioCaption ELSE @AudioCaption END,
|
||||||
Question = @Question,
|
Question = CASE WHEN @Question IS NULL THEN Question ELSE @Question END,
|
||||||
Answer = @Answer,
|
Answer = CASE WHEN @Answer IS NULL THEN Answer ELSE @Answer END,
|
||||||
Title = @Title,
|
Title = CASE WHEN @Title IS NULL THEN Title ELSE @Title END,
|
||||||
PostType = @PostType,
|
PostType = CASE WHEN @PostType IS NULL THEN PostType ELSE @PostType END,
|
||||||
HasImage = @HasImage,
|
HasImage = @HasImage,
|
||||||
DateModified = @DateModified
|
DateModified = @DateModified
|
||||||
WHERE BlogName = @BlogName AND PostID = @PostID AND (
|
WHERE BlogName = @BlogName AND PostID = @PostID AND (
|
||||||
IFNULL(reblogURL, '') <> IFNULL(@reblogURL, '') OR
|
(@reblogURL IS NOT NULL AND IFNULL(reblogURL, '') <> @reblogURL) OR
|
||||||
IFNULL(PostDate, '') <> IFNULL(@PostDate, '') OR
|
(@PostDate IS NOT NULL AND IFNULL(PostDate, '') <> @PostDate) OR
|
||||||
IFNULL(PostURL, '') <> IFNULL(@PostURL, '') OR
|
(@PostURL IS NOT NULL AND IFNULL(PostURL, '') <> @PostURL) OR
|
||||||
IFNULL(Slug, '') <> IFNULL(@Slug, '') OR
|
(@Slug IS NOT NULL AND IFNULL(Slug, '') <> @Slug) OR
|
||||||
IFNULL(ReblogKey, '') <> IFNULL(@ReblogKey, '') OR
|
(@ReblogKey IS NOT NULL AND IFNULL(ReblogKey, '') <> @ReblogKey) OR
|
||||||
IFNULL(ReblogName, '') <> IFNULL(@ReblogName, '') OR
|
(@ReblogName IS NOT NULL AND IFNULL(ReblogName, '') <> @ReblogName) OR
|
||||||
IFNULL(Summary, '') <> IFNULL(@Summary, '') OR
|
(@Summary IS NOT NULL AND IFNULL(Summary, '') <> @Summary) OR
|
||||||
IFNULL(Quote, '') <> IFNULL(@Quote, '') OR
|
(@Quote IS NOT NULL AND IFNULL(Quote, '') <> @Quote) OR
|
||||||
IFNULL(Body, '') <> IFNULL(@Body, '') OR
|
(@Body IS NOT NULL AND IFNULL(Body, '') <> @Body) OR
|
||||||
IFNULL(Tags, '') <> IFNULL(@Tags, '') OR
|
(@Tags IS NOT NULL AND IFNULL(Tags, '') <> @Tags) OR
|
||||||
IFNULL(Link, '') <> IFNULL(@Link, '') OR
|
(@Link IS NOT NULL AND IFNULL(Link, '') <> @Link) OR
|
||||||
IFNULL(PhotoURL, '') <> IFNULL(@PhotoURL, '') OR
|
(@PhotoURL IS NOT NULL AND IFNULL(PhotoURL, '') <> @PhotoURL) OR
|
||||||
IFNULL(PhotoCaption, '') <> IFNULL(@PhotoCaption, '') OR
|
(@PhotoCaption IS NOT NULL AND IFNULL(PhotoCaption, '') <> @PhotoCaption) OR
|
||||||
IFNULL(DownloadedFiles, '') <> IFNULL(@DownloadedFiles, '') OR
|
(@DownloadedFiles IS NOT NULL AND IFNULL(DownloadedFiles, '') <> @DownloadedFiles) OR
|
||||||
IFNULL(AudioCaption, '') <> IFNULL(@AudioCaption, '') OR
|
(@AudioCaption IS NOT NULL AND IFNULL(AudioCaption, '') <> @AudioCaption) OR
|
||||||
IFNULL(Question, '') <> IFNULL(@Question, '') OR
|
(@Question IS NOT NULL AND IFNULL(Question, '') <> @Question) OR
|
||||||
IFNULL(Answer, '') <> IFNULL(@Answer, '') OR
|
(@Answer IS NOT NULL AND IFNULL(Answer, '') <> @Answer) OR
|
||||||
IFNULL(Title, '') <> IFNULL(@Title, '') OR
|
(@Title IS NOT NULL AND IFNULL(Title, '') <> @Title) OR
|
||||||
IFNULL(PostType, '') <> IFNULL(@PostType, '') OR
|
(@PostType IS NOT NULL AND IFNULL(PostType, '') <> @PostType) OR
|
||||||
IFNULL(HasImage, 0) <> @HasImage
|
IFNULL(HasImage, 0) <> @HasImage
|
||||||
)";
|
)";
|
||||||
|
|
||||||
@@ -2289,7 +2308,11 @@ namespace URLNotesGrabberCORE
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void SetBlogTTFolderPath(string blogName, string? path, string? DBPath = null)
|
// Returns true only when a row's TTFolderPath actually changed. A false means either
|
||||||
|
// the row already held this value or no row matched the name -- callers must not
|
||||||
|
// report a write they did not get, which is how a --updatepaths run could once print
|
||||||
|
// "Updated <blog>" for every metadata file while leaving the column entirely NULL.
|
||||||
|
public static bool SetBlogTTFolderPath(string blogName, string? path, string? DBPath = null)
|
||||||
{
|
{
|
||||||
DBPath ??= GetDefaultDbPath();
|
DBPath ??= GetDefaultDbPath();
|
||||||
try { AddBlog(blogName, false, DBPath); } catch { }
|
try { AddBlog(blogName, false, DBPath); } catch { }
|
||||||
@@ -2302,7 +2325,21 @@ namespace URLNotesGrabberCORE
|
|||||||
cmd.Parameters.AddWithValue("@path", (object?)path ?? DBNull.Value);
|
cmd.Parameters.AddWithValue("@path", (object?)path ?? DBNull.Value);
|
||||||
cmd.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
|
cmd.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||||
cmd.Parameters.AddWithValue("@name", blogName);
|
cmd.Parameters.AddWithValue("@name", blogName);
|
||||||
cmd.ExecuteNonQuery();
|
return cmd.ExecuteNonQuery() > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whether a Blogs row exists under this exact name. BlogName is a BINARY-collated
|
||||||
|
// primary key, so a metadata filename that differs only in case is a different blog
|
||||||
|
// as far as the UPDATE above is concerned -- worth telling the user about.
|
||||||
|
public static bool BlogExists(string blogName, string? DBPath = null)
|
||||||
|
{
|
||||||
|
DBPath ??= GetDefaultDbPath();
|
||||||
|
|
||||||
|
using var connection = new SQLiteConnection("Data Source=" + DBPath);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SQLiteCommand("SELECT 1 FROM Blogs WHERE BlogName = @name", connection);
|
||||||
|
cmd.Parameters.AddWithValue("@name", blogName);
|
||||||
|
return cmd.ExecuteScalar() != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Partial UPDATE used by the correct-apply path. fieldsToUpdate maps
|
// Partial UPDATE used by the correct-apply path. fieldsToUpdate maps
|
||||||
@@ -2376,24 +2413,48 @@ namespace URLNotesGrabberCORE
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static List<(string BlogName, string? TTFolderPath)> GetAllBlogsWithTTFolderPath(string? DBPath = null)
|
// Export targets only: active blogs that actually carry a TTFolderPath.
|
||||||
|
// Blogs is a 144k-row crawl registry and only the few hundred blogs downloaded
|
||||||
|
// locally have a folder, so returning the unset rows made --output print a skip
|
||||||
|
// line for every blog Tumblr has ever handed us.
|
||||||
|
public static List<(string BlogName, string TTFolderPath)> GetAllBlogsWithTTFolderPath(string? DBPath = null)
|
||||||
{
|
{
|
||||||
DBPath ??= GetDefaultDbPath();
|
DBPath ??= GetDefaultDbPath();
|
||||||
var results = new List<(string, string?)>();
|
var results = new List<(string, string)>();
|
||||||
|
|
||||||
using var connection = new SQLiteConnection("Data Source=" + DBPath);
|
using var connection = new SQLiteConnection("Data Source=" + DBPath);
|
||||||
connection.Open();
|
connection.Open();
|
||||||
using var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs WHERE IsActive = 1", connection);
|
using var cmd = new SQLiteCommand(
|
||||||
|
"SELECT BlogName, TRIM(TTFolderPath) FROM Blogs WHERE IsActive = 1 AND IFNULL(TRIM(TTFolderPath), '') <> '' ORDER BY BlogName",
|
||||||
|
connection);
|
||||||
using var reader = cmd.ExecuteReader();
|
using var reader = cmd.ExecuteReader();
|
||||||
while (reader.Read())
|
while (reader.Read())
|
||||||
{
|
results.Add((reader.GetString(0), reader.GetString(1)));
|
||||||
string name = reader.GetString(0);
|
|
||||||
string? path = reader.IsDBNull(1) ? null : reader.GetString(1);
|
|
||||||
results.Add((name, path));
|
|
||||||
}
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Companion counts for the messages --output and --updatepaths print about coverage.
|
||||||
|
public static int CountActiveBlogs(string? DBPath = null)
|
||||||
|
{
|
||||||
|
DBPath ??= GetDefaultDbPath();
|
||||||
|
|
||||||
|
using var connection = new SQLiteConnection("Data Source=" + DBPath);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SQLiteCommand("SELECT COUNT(*) FROM Blogs WHERE IsActive = 1", connection);
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CountBlogsWithTTFolderPath(string? DBPath = null)
|
||||||
|
{
|
||||||
|
DBPath ??= GetDefaultDbPath();
|
||||||
|
|
||||||
|
using var connection = new SQLiteConnection("Data Source=" + DBPath);
|
||||||
|
connection.Open();
|
||||||
|
using var cmd = new SQLiteCommand(
|
||||||
|
"SELECT COUNT(*) FROM Blogs WHERE IFNULL(TRIM(TTFolderPath), '') <> ''", connection);
|
||||||
|
return Convert.ToInt32(cmd.ExecuteScalar());
|
||||||
|
}
|
||||||
|
|
||||||
private static string SafeStr(SQLiteDataReader reader, int ordinal)
|
private static string SafeStr(SQLiteDataReader reader, int ordinal)
|
||||||
{
|
{
|
||||||
return reader.IsDBNull(ordinal) ? string.Empty : reader.GetValue(ordinal)?.ToString() ?? string.Empty;
|
return reader.IsDBNull(ordinal) ? string.Empty : reader.GetValue(ordinal)?.ToString() ?? string.Empty;
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ namespace URLNotesGrabberCORE
|
|||||||
Console.WriteLine($"Reading legacy posts.db: {legacyDbPath}");
|
Console.WriteLine($"Reading legacy posts.db: {legacyDbPath}");
|
||||||
|
|
||||||
int blogsCopied = 0;
|
int blogsCopied = 0;
|
||||||
|
int blogPathsWritten = 0;
|
||||||
|
int blogsWithoutPath = 0;
|
||||||
int postsUpserted = 0;
|
int postsUpserted = 0;
|
||||||
int errors = 0;
|
int errors = 0;
|
||||||
|
|
||||||
@@ -48,7 +50,13 @@ namespace URLNotesGrabberCORE
|
|||||||
if (string.IsNullOrWhiteSpace(blogName)) continue;
|
if (string.IsNullOrWhiteSpace(blogName)) continue;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DataAccess.SetBlogTTFolderPath(blogName, ttFolderPath);
|
// A legacy row whose TTFolderPath was already NULL copies nothing.
|
||||||
|
// Counting it as "copied" is what hid the fact that this import has
|
||||||
|
// never populated a single path.
|
||||||
|
if (string.IsNullOrWhiteSpace(ttFolderPath))
|
||||||
|
blogsWithoutPath++;
|
||||||
|
else if (DataAccess.SetBlogTTFolderPath(blogName, ttFolderPath.Trim()))
|
||||||
|
blogPathsWritten++;
|
||||||
blogsCopied++;
|
blogsCopied++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -58,7 +66,7 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Console.WriteLine($" Blogs copied: {blogsCopied}");
|
Console.WriteLine($" Blogs seen: {blogsCopied}, TTFolderPath written: {blogPathsWritten}, legacy rows with no path: {blogsWithoutPath}");
|
||||||
|
|
||||||
// 2) Copy Posts
|
// 2) Copy Posts
|
||||||
try
|
try
|
||||||
@@ -138,7 +146,8 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"\n========== Legacy import summary ==========");
|
Console.WriteLine($"\n========== Legacy import summary ==========");
|
||||||
Console.WriteLine($"Blogs copied: {blogsCopied}");
|
Console.WriteLine($"Blogs seen: {blogsCopied}");
|
||||||
|
Console.WriteLine($"Paths written: {blogPathsWritten} (legacy rows with no path: {blogsWithoutPath})");
|
||||||
Console.WriteLine($"Posts upserted: {postsUpserted}");
|
Console.WriteLine($"Posts upserted: {postsUpserted}");
|
||||||
Console.WriteLine($"Errors: {errors}");
|
Console.WriteLine($"Errors: {errors}");
|
||||||
return errors == 0 ? 0 : 2;
|
return errors == 0 ? 0 : 2;
|
||||||
|
|||||||
@@ -8,28 +8,51 @@ namespace URLNotesGrabberCORE
|
|||||||
// field order). Reads from TL.db via DataAccess.GetAllPostsForBlog.
|
// field order). Reads from TL.db via DataAccess.GetAllPostsForBlog.
|
||||||
public static class OutputMode
|
public static class OutputMode
|
||||||
{
|
{
|
||||||
public static int Run(IConfiguration config)
|
public static int Run(IConfiguration config, string[]? args = null)
|
||||||
{
|
{
|
||||||
DataAccess.EnsureTTFileHelperColumnsExist();
|
DataAccess.EnsureTTFileHelperColumnsExist();
|
||||||
|
|
||||||
var blogs = DataAccess.GetAllBlogsWithTTFolderPath();
|
string dbPath = DataAccess.GetActiveDbPath();
|
||||||
Console.WriteLine($"Found {blogs.Count} blog(s) to process.");
|
Console.WriteLine($"Database: {Path.GetFullPath(dbPath)}");
|
||||||
|
|
||||||
foreach (var (blogName, ttFolderPath) in blogs)
|
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}");
|
Console.WriteLine($"\nProcessing blog: {blogName}");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ttFolderPath) || !Directory.Exists(ttFolderPath))
|
// 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 does not exist or is not set. Skipping.");
|
Console.WriteLine($" TTFolderPath folder not found: {folder}. Skipping.");
|
||||||
|
missingFolderCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($" TTFolderPath: {ttFolderPath}");
|
Console.WriteLine($" TTFolderPath: {folder}");
|
||||||
|
writtenCount++;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var bakFile in Directory.GetFiles(ttFolderPath, "*.bak"))
|
foreach (var bakFile in Directory.GetFiles(folder, "*.bak"))
|
||||||
File.Delete(bakFile);
|
File.Delete(bakFile);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -37,7 +60,7 @@ namespace URLNotesGrabberCORE
|
|||||||
Console.WriteLine($" Error deleting .bak files: {ex.Message}");
|
Console.WriteLine($" Error deleting .bak files: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
RenameExistingTxtFilesToBak(ttFolderPath);
|
RenameExistingTxtFilesToBak(folder);
|
||||||
|
|
||||||
var posts = DataAccess.GetAllPostsForBlog(blogName);
|
var posts = DataAccess.GetAllPostsForBlog(blogName);
|
||||||
Console.WriteLine($" Found {posts.Count} post(s) for this blog.");
|
Console.WriteLine($" Found {posts.Count} post(s) for this blog.");
|
||||||
@@ -46,7 +69,7 @@ namespace URLNotesGrabberCORE
|
|||||||
foreach (var typeGroup in grouped)
|
foreach (var typeGroup in grouped)
|
||||||
{
|
{
|
||||||
string postType = typeGroup.Key ?? "Unknown";
|
string postType = typeGroup.Key ?? "Unknown";
|
||||||
string outputFilePath = Path.Combine(ttFolderPath, $"{postType}.txt");
|
string outputFilePath = Path.Combine(folder, $"{postType}.txt");
|
||||||
var ordered = typeGroup.OrderBy(p => p.Date).ToList();
|
var ordered = typeGroup.OrderBy(p => p.Date).ToList();
|
||||||
Console.WriteLine($" Writing {ordered.Count} post(s) to {postType}.txt");
|
Console.WriteLine($" Writing {ordered.Count} post(s) to {postType}.txt");
|
||||||
|
|
||||||
@@ -65,10 +88,57 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("\nOutput mode complete.");
|
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;
|
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)
|
private static void RenameExistingTxtFilesToBak(string folderPath)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -343,7 +343,7 @@ namespace URLNotesGrabberCORE
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "--output":
|
case "--output":
|
||||||
exitCode = OutputMode.Run(config);
|
exitCode = OutputMode.Run(config, args.Skip(1).ToArray());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "--revert":
|
case "--revert":
|
||||||
@@ -439,7 +439,9 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
Console.WriteLine("--ingest [blogname]\t Ingest Tumblr .txt exports from appSettings:PathTTRoot into TL.db (all blogs, or single blog if name given)");
|
Console.WriteLine("--ingest [blogname]\t Ingest Tumblr .txt exports from appSettings:PathTTRoot into TL.db (all blogs, or single blog if name given)");
|
||||||
|
|
||||||
Console.WriteLine("--output\t Export posts from TL.db back to .txt files in each blog's TTFolderPath");
|
Console.WriteLine("--output [rootPath]\t Refresh Blogs.TTFolderPath from <root>\\Index (or appSettings:PathTTRoot), then export posts from TL.db back to .txt files in each blog's folder");
|
||||||
|
|
||||||
|
Console.WriteLine("--output --norefresh\t Export without refreshing TTFolderPath first");
|
||||||
|
|
||||||
Console.WriteLine("--revert [blogname]\t Recursively scan the PathInput tree and restore *.bak back to *.txt (current .txt saved as next-free .bkN); optional blogname filters by path substring");
|
Console.WriteLine("--revert [blogname]\t Recursively scan the PathInput tree and restore *.bak back to *.txt (current .txt saved as next-free .bkN); optional blogname filters by path substring");
|
||||||
|
|
||||||
|
|||||||
@@ -4,34 +4,64 @@ namespace URLNotesGrabberCORE
|
|||||||
{
|
{
|
||||||
// Port of ThreeTxtFileHelper/UpdateBlogPaths.cs. Reads .tumblr / .tmblrpriv metadata
|
// Port of ThreeTxtFileHelper/UpdateBlogPaths.cs. Reads .tumblr / .tmblrpriv metadata
|
||||||
// files from a root\Index folder and populates Blogs.TTFolderPath in TL.db.
|
// files from a root\Index folder and populates Blogs.TTFolderPath in TL.db.
|
||||||
|
//
|
||||||
|
// Scan() is the reusable engine: --updatepaths wraps it as a standalone command and
|
||||||
|
// --output calls it as a refresh step, because a TL.db synced between machines cannot
|
||||||
|
// hold one absolute path that is correct on both.
|
||||||
public static class UpdateBlogPathsRunner
|
public static class UpdateBlogPathsRunner
|
||||||
{
|
{
|
||||||
public static int Run(string rootPath)
|
public enum ScanOutcome
|
||||||
|
{
|
||||||
|
Completed,
|
||||||
|
NoRootConfigured,
|
||||||
|
IndexFolderMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScanResult
|
||||||
|
{
|
||||||
|
public ScanOutcome Outcome { get; init; }
|
||||||
|
public string RootPath { get; init; } = string.Empty;
|
||||||
|
public string IndexPath { get; init; } = string.Empty;
|
||||||
|
public int MetadataFiles { get; init; }
|
||||||
|
public int Written { get; init; }
|
||||||
|
public int Unchanged { get; init; }
|
||||||
|
public int NoLocation { get; init; }
|
||||||
|
public int NoMatchingRow { get; init; }
|
||||||
|
public int Errors { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbose: log a line per metadata file. --updatepaths wants that detail; --output
|
||||||
|
// only wants the counts, since a few hundred lines before the export would bury it.
|
||||||
|
public static ScanResult Scan(string? rootPath, bool verbose)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(rootPath))
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
{
|
return new ScanResult { Outcome = ScanOutcome.NoRootConfigured };
|
||||||
Console.WriteLine("UpdateBlogPaths: rootPath is required.");
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
DataAccess.EnsureTTFileHelperColumnsExist();
|
DataAccess.EnsureTTFileHelperColumnsExist();
|
||||||
|
|
||||||
string indexPath = Path.Combine(rootPath, "Index");
|
string indexPath = Path.Combine(rootPath, "Index");
|
||||||
if (!Directory.Exists(indexPath))
|
if (!Directory.Exists(indexPath))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"Index folder not found at: {indexPath}");
|
return new ScanResult
|
||||||
return 1;
|
{
|
||||||
|
Outcome = ScanOutcome.IndexFolderMissing,
|
||||||
|
RootPath = rootPath,
|
||||||
|
IndexPath = indexPath
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Scanning Index folder: {indexPath}");
|
|
||||||
|
|
||||||
var blogFiles = Directory.GetFiles(indexPath, "*.tumblr")
|
var blogFiles = Directory.GetFiles(indexPath, "*.tumblr")
|
||||||
.Concat(Directory.GetFiles(indexPath, "*.tmblrpriv"))
|
.Concat(Directory.GetFiles(indexPath, "*.tmblrpriv"))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
if (verbose)
|
||||||
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
|
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
|
||||||
|
|
||||||
int updatedCount = 0;
|
int updatedCount = 0;
|
||||||
|
int unchangedCount = 0;
|
||||||
|
int noLocationCount = 0;
|
||||||
|
int noRowCount = 0;
|
||||||
|
int errorCount = 0;
|
||||||
|
|
||||||
foreach (var blogFile in blogFiles)
|
foreach (var blogFile in blogFiles)
|
||||||
{
|
{
|
||||||
@@ -44,27 +74,91 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
if (root.TryGetProperty("FileDownloadLocation", out JsonElement locationElement))
|
if (root.TryGetProperty("FileDownloadLocation", out JsonElement locationElement))
|
||||||
{
|
{
|
||||||
string? fileDownloadLocation = locationElement.GetString();
|
string? fileDownloadLocation = locationElement.GetString()?.Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(fileDownloadLocation))
|
if (!string.IsNullOrWhiteSpace(fileDownloadLocation))
|
||||||
{
|
{
|
||||||
DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation);
|
// Report the database's answer, not the fact that the file parsed.
|
||||||
|
if (DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation))
|
||||||
|
{
|
||||||
updatedCount++;
|
updatedCount++;
|
||||||
|
if (verbose)
|
||||||
Console.WriteLine($"Updated {blogName}: {fileDownloadLocation}");
|
Console.WriteLine($"Updated {blogName}: {fileDownloadLocation}");
|
||||||
}
|
}
|
||||||
|
else if (DataAccess.BlogExists(blogName))
|
||||||
|
{
|
||||||
|
unchangedCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noRowCount++;
|
||||||
|
Console.WriteLine($"No Blogs row named '{blogName}' -- path not stored (name may differ in case)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
noLocationCount++;
|
||||||
|
if (verbose)
|
||||||
|
Console.WriteLine($"Empty FileDownloadLocation in {blogFile}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noLocationCount++;
|
||||||
|
if (verbose)
|
||||||
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
|
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
errorCount++;
|
||||||
Console.WriteLine($"Error processing {blogFile}: {ex.Message}");
|
Console.WriteLine($"Error processing {blogFile}: {ex.Message}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"\nUpdated {updatedCount} blogs with TTFolderPath");
|
return new ScanResult
|
||||||
return 0;
|
{
|
||||||
|
Outcome = ScanOutcome.Completed,
|
||||||
|
RootPath = rootPath,
|
||||||
|
IndexPath = indexPath,
|
||||||
|
MetadataFiles = blogFiles.Count,
|
||||||
|
Written = updatedCount,
|
||||||
|
Unchanged = unchangedCount,
|
||||||
|
NoLocation = noLocationCount,
|
||||||
|
NoMatchingRow = noRowCount,
|
||||||
|
Errors = errorCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Run(string rootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine("UpdateBlogPaths: rootPath is required.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
string indexPath = Path.Combine(rootPath, "Index");
|
||||||
|
Console.WriteLine($"Scanning Index folder: {indexPath}");
|
||||||
|
|
||||||
|
var result = Scan(rootPath, verbose: true);
|
||||||
|
|
||||||
|
if (result.Outcome == ScanOutcome.IndexFolderMissing)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Index folder not found at: {result.IndexPath}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"\n========== UpdateBlogPaths summary ==========");
|
||||||
|
Console.WriteLine($"Metadata files: {result.MetadataFiles}");
|
||||||
|
Console.WriteLine($"TTFolderPath written: {result.Written}");
|
||||||
|
Console.WriteLine($"Already correct: {result.Unchanged}");
|
||||||
|
Console.WriteLine($"No FileDownloadLocation: {result.NoLocation}");
|
||||||
|
Console.WriteLine($"No matching blog row: {result.NoMatchingRow}");
|
||||||
|
Console.WriteLine($"Errors: {result.Errors}");
|
||||||
|
|
||||||
|
Console.WriteLine($"\nBlogs now holding a TTFolderPath: {DataAccess.CountBlogsWithTTFolderPath()}");
|
||||||
|
|
||||||
|
return result.Errors == 0 ? 0 : 2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user