2 Commits
Author SHA1 Message Date
jim ef6629d86a Merge branch 'claude/sqlite-modified-date-logic-3d9bee' into master 2026-08-05 09:36:38 -05:00
jimandClaude Opus 5 6320e2c0c9 fix: stop --ingest's NULL sentinel from clobbering post content
UpsertPostFromTextFile (the persistence layer under --ingest) uses NULL as
its "this file's record had no line for that field" sentinel -- the direct
analog of the "." convention just fixed in UpdatePost. IngestMode strips a
trailing "_N" off the folder name before it ever reaches this function, so
a duplicate export folder deliberately collapses onto the same BlogName --
reconciling multiple differently-formatted files for one post is the whole
point of --ingest. Files are walked in raw filesystem enumeration order,
never sorted, so which file's call lands last for a given (BlogName,
PostID) is arbitrary.

The UPDATE branch set every column unconditionally, so whichever file
processed last for a PostID nulled out every field its own record didn't
carry, silently erasing real content another file had. Worse than the "."
case: that one only caused churn (two writes cancelling out); this one
loses data, in an order that depends on filesystem enumeration.

Every content column is now guarded the same way, 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. Narrow the same way: only a missing line (NULL) is the
sentinel -- G() already distinguishes that from present-but-blank (""), so
an explicit empty field still overwrites.

HasImage is deliberately left unguarded and documented as a known gap:
IngestMode always computes a concrete bool, defaulting false when a file
has no "Has Image:" line, so this function can't currently tell "no image"
from "not reported" without changing the parameter to bool? and threading
that through IngestMode/LegacyPostsDbImporter too.

Verified against a throwaway DB using the exact SQL text and parameter
binding: a full-format record's real Title/Slug/Tags now survive a
same-PostID partial record whose format doesn't carry those fields, in
both file orders, while a genuine content change and an explicit empty
value still write and still move DateModified.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 09:36:17 -05:00
2 changed files with 83 additions and 38 deletions
+30
View File
@@ -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
+53 -38
View File
@@ -2062,48 +2062,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
)"; )";