feat: merge ThreeTxtFileHelper into URLNotesGrabberCORE

Folds the standalone ThreeTxtFileHelper tool into URLNotesGrabberCORE so
text-file ingest/output/correct lives alongside the API scraper. Adds
new flags -ingest, -output, -correct (with -apply), -updatepaths, and a
one-time -importposts <posts.db> migration.

Schema: Blogs.TTFolderPath and Posts.PostType are added by an idempotent
migration. On (BlogName, PostID) collisions, content columns are
overwritten while engagement columns (ByLikes, RootBlogName, RootURL,
HasNotesGathered, NotFound, NotesGatheredDateTime, Likes*) are preserved.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
jim
2026-05-18 12:43:17 -05:00
co-authored by Claude Opus 4.7
parent 5781e121d2
commit 3aff849216
10 changed files with 1419 additions and 1 deletions
+513
View File
@@ -1786,6 +1786,519 @@ namespace URLNotesGrabberCORE
}
}
#endregion Updates
#region TTFileHelper
// Idempotent migration: adds Blogs.TTFolderPath and Posts.PostType if missing.
// Mirrors the EnsureBlogsLikesColumnsExist pattern (PRAGMA table_info + ALTER TABLE ADD COLUMN).
public static void EnsureTTFileHelperColumnsExist(string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
SQLiteConnection connection = new SQLiteConnection("Data Source=" + DBPath);
try
{
connection.Open();
bool ttFolderPathExists = false;
using (SQLiteCommand command = new SQLiteCommand("PRAGMA table_info(Blogs);", connection))
using (SQLiteDataReader reader = command.ExecuteReader())
{
while (reader.Read())
{
string columnName = reader.GetString(1);
if (columnName.Equals("TTFolderPath", StringComparison.OrdinalIgnoreCase)) ttFolderPathExists = true;
}
}
if (!ttFolderPathExists)
{
using (SQLiteCommand cmd = new SQLiteCommand("ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;", connection))
cmd.ExecuteNonQuery();
Console.WriteLine("[Migration] Added TTFolderPath column to Blogs table");
}
bool postTypeExists = false;
using (SQLiteCommand command = new SQLiteCommand("PRAGMA table_info(Posts);", connection))
using (SQLiteDataReader reader = command.ExecuteReader())
{
while (reader.Read())
{
string columnName = reader.GetString(1);
if (columnName.Equals("PostType", StringComparison.OrdinalIgnoreCase)) postTypeExists = true;
}
}
if (!postTypeExists)
{
using (SQLiteCommand cmd = new SQLiteCommand("ALTER TABLE Posts ADD COLUMN PostType TEXT;", connection))
cmd.ExecuteNonQuery();
Console.WriteLine("[Migration] Added PostType column to Posts table");
}
}
catch (Exception ex)
{
Console.WriteLine($"Error ensuring TTFileHelper columns: {ex.Message}");
}
finally
{
connection.Close();
}
}
// INSERT-or-UPDATE for a post arriving from a Tumblr text-file export.
// On collision, only content columns + PostType + DateModified are updated;
// engagement columns (ByLikes, RootBlogName, RootURL, HasNotesGathered, NotFound,
// NotesGatheredDateTime) are preserved.
public static void UpsertPostFromTextFile(
string blogName,
string postID,
string? reblogURL,
string? postDate,
string? postURL,
string? slug,
string? reblogKey,
string? reblogName,
string? summary,
string? quote,
string? body,
string? tags,
string? link,
string? photoURL,
string? photoCaption,
string? downloadedFiles,
string? audioCaption,
string? question,
string? answer,
string? title,
string? postType,
bool hasImage,
string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
try { AddBlog(blogName, false, DBPath); } catch { }
SQLiteConnection connection;
bool ownsConnection;
lock (_importSessionLock)
{
if (_importConnection != null) { connection = _importConnection; ownsConnection = false; }
else { connection = new SQLiteConnection("Data Source=" + DBPath); ownsConnection = true; }
}
string now = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
try
{
if (ownsConnection) connection.Open();
string insertSql = @"INSERT INTO Posts (
BlogName, PostID, reblogURL, PostDate, PostURL, Slug,
ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link,
PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption,
Question, Answer, Title, PostType,
DateModified, DateCreated, RootBlogName, RootURL,
HasImage, ByLikes
) VALUES (
@BlogName, @PostID, @reblogURL, @PostDate, @PostURL, @Slug,
@ReblogKey, @ReblogName, @Summary, @Quote, @Body, @Tags, @Link,
@PhotoURL, @PhotoCaption, @DownloadedFiles, @AudioCaption,
@Question, @Answer, @Title, @PostType,
@DateModified, @DateCreated, '.', '.', @HasImage, 0)";
int rowsInserted = 0;
try
{
using (var cmd = new SQLiteCommand(insertSql, connection))
{
cmd.Parameters.AddWithValue("@BlogName", blogName);
cmd.Parameters.AddWithValue("@PostID", postID);
cmd.Parameters.AddWithValue("@reblogURL", (object?)reblogURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostDate", (object?)postDate ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostURL", (object?)postURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ReblogKey", (object?)reblogKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ReblogName", (object?)reblogName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Summary", (object?)summary ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Quote", (object?)quote ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Body", (object?)body ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Tags", (object?)tags ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Link", (object?)link ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PhotoURL", (object?)photoURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PhotoCaption", (object?)photoCaption ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DownloadedFiles", (object?)downloadedFiles ?? DBNull.Value);
cmd.Parameters.AddWithValue("@AudioCaption", (object?)audioCaption ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Question", (object?)question ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Answer", (object?)answer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Title", (object?)title ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostType", (object?)postType ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DateModified", now);
cmd.Parameters.AddWithValue("@DateCreated", now);
cmd.Parameters.AddWithValue("@HasImage", hasImage ? 1 : 0);
rowsInserted = cmd.ExecuteNonQuery();
}
}
catch (Exception insertEx)
{
string msg = insertEx.Message ?? string.Empty;
bool isUniqueViolation = msg.Contains("UNIQUE constraint failed");
if (!isUniqueViolation)
{
Console.WriteLine($"[UpsertPostFromTextFile] INSERT failed for {blogName}/{postID}: {msg}");
}
}
if (rowsInserted == 0)
{
string updateSql = @"UPDATE Posts SET
reblogURL = @reblogURL,
PostDate = @PostDate,
PostURL = @PostURL,
Slug = @Slug,
ReblogKey = @ReblogKey,
ReblogName = @ReblogName,
Summary = @Summary,
Quote = @Quote,
Body = @Body,
Tags = @Tags,
Link = @Link,
PhotoURL = @PhotoURL,
PhotoCaption = @PhotoCaption,
DownloadedFiles = @DownloadedFiles,
AudioCaption = @AudioCaption,
Question = @Question,
Answer = @Answer,
Title = @Title,
PostType = @PostType,
HasImage = @HasImage,
DateModified = @DateModified
WHERE BlogName = @BlogName AND PostID = @PostID";
using (var cmd = new SQLiteCommand(updateSql, connection))
{
cmd.Parameters.AddWithValue("@BlogName", blogName);
cmd.Parameters.AddWithValue("@PostID", postID);
cmd.Parameters.AddWithValue("@reblogURL", (object?)reblogURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostDate", (object?)postDate ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostURL", (object?)postURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Slug", (object?)slug ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ReblogKey", (object?)reblogKey ?? DBNull.Value);
cmd.Parameters.AddWithValue("@ReblogName", (object?)reblogName ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Summary", (object?)summary ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Quote", (object?)quote ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Body", (object?)body ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Tags", (object?)tags ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Link", (object?)link ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PhotoURL", (object?)photoURL ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PhotoCaption", (object?)photoCaption ?? DBNull.Value);
cmd.Parameters.AddWithValue("@DownloadedFiles", (object?)downloadedFiles ?? DBNull.Value);
cmd.Parameters.AddWithValue("@AudioCaption", (object?)audioCaption ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Question", (object?)question ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Answer", (object?)answer ?? DBNull.Value);
cmd.Parameters.AddWithValue("@Title", (object?)title ?? DBNull.Value);
cmd.Parameters.AddWithValue("@PostType", (object?)postType ?? DBNull.Value);
cmd.Parameters.AddWithValue("@HasImage", hasImage ? 1 : 0);
cmd.Parameters.AddWithValue("@DateModified", now);
cmd.ExecuteNonQuery();
}
}
}
finally
{
if (ownsConnection) connection.Close();
}
}
public static List<TTPostRecord> GetAllPostsForBlog(string blogName, string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
var results = new List<TTPostRecord>();
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug,
ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link,
PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption,
Question, Answer, Title, PostType,
HasImage, DateCreated, DateModified
FROM Posts WHERE BlogName = @BlogName";
using var cmd = new SQLiteCommand(sql, connection);
cmd.Parameters.AddWithValue("@BlogName", blogName);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
results.Add(new TTPostRecord
{
BlogName = SafeStr(reader, 0),
PostId = SafeStr(reader, 1),
ReblogUrl = SafeNullableStr(reader, 2),
Date = SafeNullableStr(reader, 3),
PostUrl = SafeNullableStr(reader, 4),
Slug = SafeNullableStr(reader, 5),
ReblogKey = SafeNullableStr(reader, 6),
ReblogName = SafeNullableStr(reader, 7),
Summary = SafeNullableStr(reader, 8),
Quote = SafeNullableStr(reader, 9),
Body = SafeNullableStr(reader, 10),
Tags = SafeNullableStr(reader, 11),
Link = SafeNullableStr(reader, 12),
PhotoUrl = SafeNullableStr(reader, 13),
PhotoCaption = SafeNullableStr(reader, 14),
DownloadedFiles = SafeNullableStr(reader, 15),
AudioCaption = SafeNullableStr(reader, 16),
Question = SafeNullableStr(reader, 17),
Answer = SafeNullableStr(reader, 18),
Title = SafeNullableStr(reader, 19),
PostType = SafeNullableStr(reader, 20),
HasImage = SafeNullableStr(reader, 21),
CreatedDate = SafeNullableStr(reader, 22),
ModifiedDate = SafeNullableStr(reader, 23)
});
}
return results;
}
public static TTPostRecord? GetPost(string blogName, string postId, string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug,
ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link,
PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption,
Question, Answer, Title, PostType,
HasImage, DateCreated, DateModified
FROM Posts WHERE BlogName = @BlogName AND PostID = @PostID";
using var cmd = new SQLiteCommand(sql, connection);
cmd.Parameters.AddWithValue("@BlogName", blogName);
cmd.Parameters.AddWithValue("@PostID", postId);
using var reader = cmd.ExecuteReader();
if (!reader.Read()) return null;
return new TTPostRecord
{
BlogName = SafeStr(reader, 0),
PostId = SafeStr(reader, 1),
ReblogUrl = SafeNullableStr(reader, 2),
Date = SafeNullableStr(reader, 3),
PostUrl = SafeNullableStr(reader, 4),
Slug = SafeNullableStr(reader, 5),
ReblogKey = SafeNullableStr(reader, 6),
ReblogName = SafeNullableStr(reader, 7),
Summary = SafeNullableStr(reader, 8),
Quote = SafeNullableStr(reader, 9),
Body = SafeNullableStr(reader, 10),
Tags = SafeNullableStr(reader, 11),
Link = SafeNullableStr(reader, 12),
PhotoUrl = SafeNullableStr(reader, 13),
PhotoCaption = SafeNullableStr(reader, 14),
DownloadedFiles = SafeNullableStr(reader, 15),
AudioCaption = SafeNullableStr(reader, 16),
Question = SafeNullableStr(reader, 17),
Answer = SafeNullableStr(reader, 18),
Title = SafeNullableStr(reader, 19),
PostType = SafeNullableStr(reader, 20),
HasImage = SafeNullableStr(reader, 21),
CreatedDate = SafeNullableStr(reader, 22),
ModifiedDate = SafeNullableStr(reader, 23)
};
}
// Find a post by PostID alone (matches ThreeTxtFileHelper's correction-mode
// lookup, which does not scope by blog name). Returns the first match if any.
public static TTPostRecord? GetPostByIdAnyBlog(string postId, string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
string sql = @"SELECT BlogName, PostID, reblogURL, PostDate, PostURL, Slug,
ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link,
PhotoURL, PhotoCaption, DownloadedFiles, AudioCaption,
Question, Answer, Title, PostType,
HasImage, DateCreated, DateModified
FROM Posts WHERE PostID = @PostID LIMIT 1";
using var cmd = new SQLiteCommand(sql, connection);
cmd.Parameters.AddWithValue("@PostID", postId);
using var reader = cmd.ExecuteReader();
if (!reader.Read()) return null;
return new TTPostRecord
{
BlogName = SafeStr(reader, 0),
PostId = SafeStr(reader, 1),
ReblogUrl = SafeNullableStr(reader, 2),
Date = SafeNullableStr(reader, 3),
PostUrl = SafeNullableStr(reader, 4),
Slug = SafeNullableStr(reader, 5),
ReblogKey = SafeNullableStr(reader, 6),
ReblogName = SafeNullableStr(reader, 7),
Summary = SafeNullableStr(reader, 8),
Quote = SafeNullableStr(reader, 9),
Body = SafeNullableStr(reader, 10),
Tags = SafeNullableStr(reader, 11),
Link = SafeNullableStr(reader, 12),
PhotoUrl = SafeNullableStr(reader, 13),
PhotoCaption = SafeNullableStr(reader, 14),
DownloadedFiles = SafeNullableStr(reader, 15),
AudioCaption = SafeNullableStr(reader, 16),
Question = SafeNullableStr(reader, 17),
Answer = SafeNullableStr(reader, 18),
Title = SafeNullableStr(reader, 19),
PostType = SafeNullableStr(reader, 20),
HasImage = SafeNullableStr(reader, 21),
CreatedDate = SafeNullableStr(reader, 22),
ModifiedDate = SafeNullableStr(reader, 23)
};
}
public static void SetBlogTTFolderPath(string blogName, string? path, string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
try { AddBlog(blogName, false, DBPath); } catch { }
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
using var cmd = new SQLiteCommand(
"UPDATE Blogs SET TTFolderPath = @path, DateModified = @modified WHERE BlogName = @name",
connection);
cmd.Parameters.AddWithValue("@path", (object?)path ?? DBNull.Value);
cmd.Parameters.AddWithValue("@modified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
cmd.Parameters.AddWithValue("@name", blogName);
cmd.ExecuteNonQuery();
}
// Partial UPDATE used by the correct-apply path. fieldsToUpdate maps
// ThreeTxtFileHelper prefix names ("Reblog URL", "Body", etc.) to non-empty
// values pulled from a BAK file. Only those columns + DateModified are written;
// other content columns and all engagement columns are left intact.
// Returns true if a row was matched (and therefore updated).
public static bool UpdatePostContentFields(string blogName, string postId, IDictionary<string, string> fieldsToUpdate, string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
var setClauses = new List<string>();
var parameters = new List<(string Name, object Value)>();
foreach (var kvp in fieldsToUpdate)
{
if (string.IsNullOrWhiteSpace(kvp.Value)) continue;
string? column = MapPrefixToColumn(kvp.Key);
if (column == null) continue;
string paramName = "@p" + parameters.Count;
setClauses.Add($"{column} = {paramName}");
parameters.Add((paramName, kvp.Value));
}
if (setClauses.Count == 0) return false;
setClauses.Add("DateModified = @DateModified");
parameters.Add(("@DateModified", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")));
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
string sql = $"UPDATE Posts SET {string.Join(", ", setClauses)} WHERE BlogName = @BlogName AND PostID = @PostID";
using var cmd = new SQLiteCommand(sql, connection);
foreach (var (name, value) in parameters)
cmd.Parameters.AddWithValue(name, value);
cmd.Parameters.AddWithValue("@BlogName", blogName);
cmd.Parameters.AddWithValue("@PostID", postId);
return cmd.ExecuteNonQuery() > 0;
}
private static string? MapPrefixToColumn(string prefix)
{
return prefix.ToLowerInvariant() switch
{
"reblog url" => "reblogURL",
"date" => "PostDate",
"has image" => "HasImage",
"post url" => "PostURL",
"slug" => "Slug",
"reblog key" => "ReblogKey",
"reblog name" => "ReblogName",
"summary" => "Summary",
"quote" => "Quote",
"body" => "Body",
"tags" => "Tags",
"link" => "Link",
"photo url" => "PhotoURL",
"photo caption" => "PhotoCaption",
"downloaded files" => "DownloadedFiles",
"audio caption" => "AudioCaption",
"question" => "Question",
"answer" => "Answer",
"title" => "Title",
"post id" => null,
_ => null
};
}
public static List<(string BlogName, string? TTFolderPath)> GetAllBlogsWithTTFolderPath(string? DBPath = null)
{
DBPath ??= GetDefaultDbPath();
var results = new List<(string, string?)>();
using var connection = new SQLiteConnection("Data Source=" + DBPath);
connection.Open();
using var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs", connection);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
string name = reader.GetString(0);
string? path = reader.IsDBNull(1) ? null : reader.GetString(1);
results.Add((name, path));
}
return results;
}
private static string SafeStr(SQLiteDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? string.Empty : reader.GetValue(ordinal)?.ToString() ?? string.Empty;
}
private static string? SafeNullableStr(SQLiteDataReader reader, int ordinal)
{
return reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal)?.ToString();
}
#endregion TTFileHelper
}
// Mirrors ThreeTxtFileHelper.PostData so the ported ingest/output/correct logic
// can read/write rows without an ORM. All content fields are nullable except keys.
public class TTPostRecord
{
public string BlogName { get; set; } = "";
public string PostId { get; set; } = "";
public string? ReblogUrl { get; set; }
public string? Date { get; set; }
public string? HasImage { get; set; }
public string? PostUrl { get; set; }
public string? Slug { get; set; }
public string? ReblogKey { get; set; }
public string? ReblogName { get; set; }
public string? Summary { get; set; }
public string? Quote { get; set; }
public string? Body { get; set; }
public string? Tags { get; set; }
public string? Link { get; set; }
public string? PhotoUrl { get; set; }
public string? PhotoCaption { get; set; }
public string? DownloadedFiles { get; set; }
public string? AudioCaption { get; set; }
public string? Question { get; set; }
public string? Answer { get; set; }
public string? Title { get; set; }
public string? PostType { get; set; }
public string? CreatedDate { get; set; }
public string? ModifiedDate { get; set; }
}
public class ApiKeyConfig