Author SHA1 Message Date
jimandClaude Opus 5 8f4177a0c9 feat: fold the TTFolderPath refresh into --output
TL.db syncs between machines whose absolute paths differ, so a single
TTFolderPath column cannot be correct on both at once -- the stored paths are
only trustworthy on the machine that wrote them. That made --updatepaths a
mandatory prelude to every --output rather than the one-time setup step it
looks like.

--output now refreshes the column from the TumblThree Index metadata before
exporting. The root comes from the first non-flag argument, else
appSettings:PathTTRoot. With no root available it says so and exports whatever
TL.db already holds; a root whose Index folder is missing is a hard stop, since
silently exporting stale paths is the failure this change exists to prevent.
--norefresh skips the refresh for a pure export.

The scan is extracted from UpdateBlogPathsRunner.Run into a reusable Scan() that
returns counts instead of only printing them, so --updatepaths keeps its
per-file detail while --output prints a single summary line rather than a few
hundred lines ahead of the export.

Verified against a throwaway database: a stale cross-machine path is repaired
and the export lands in the correct local folder; no configured root warns and
continues (exit 0); a missing Index folder stops (exit 1); --norefresh skips the
refresh and exports (exit 0).

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 12:09:55 -05:00
jimandClaude Opus 5 721224bc13 fix: make --output and --updatepaths tell the truth about TTFolderPath
--output iterated all 156k active Blogs rows and printed a "does not exist or is
not set" skip line for each, which is nearly every blog in the crawl registry --
only the few hundred downloaded locally ever have a folder. The signal was
buried in six figures of noise.

GetAllBlogsWithTTFolderPath now selects only active blogs carrying a non-empty
path, so --output processes export targets and nothing else. When none exist it
says so once, names the database it read, points at --updatepaths, and returns
non-zero instead of reporting success. A stored path this machine cannot see is
now reported separately from an unset one, with the path shown, because the two
are fixed in different places. Paths are trimmed before Directory.Exists, which
stray whitespace in a .tumblr FileDownloadLocation would otherwise defeat.

Both writers counted optimistically. UpdateBlogPathsRunner printed its
per-blog success line and incremented its total from the metadata file parsing,
never checking whether the UPDATE matched a row; LegacyPostsDbImporter counted a
blog as copied even when the legacy TTFolderPath was NULL. Either could report
full success having written nothing -- which is consistent with TL.db holding
zero populated paths across all 156,492 active blogs despite 20,679 posts having
merged. SetBlogTTFolderPath now returns whether a row changed, and both callers
report written / already-correct / no-matching-row separately.

Verified against a throwaway database: no-paths case, export case (stale .txt
rotated to .bak, per-PostType files, date-sorted), missing-folder case,
--updatepaths honest counts, and an idempotent rerun reporting already-correct.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 12:02:49 -05:00
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
jim 6136901cc7 Merge branch 'claude/sqlite-modified-date-logic-3d9bee' into master 2026-08-05 09:17:48 -05:00
jimandClaude Opus 5 83e35a2323 fix: stop "." export sentinel from clobbering real post content
ReblogRecord (TraverseDirectory's .txt-export parser) and the --likes API
path both default every content field to the literal "." when their source
has no value for that field, then pass it straight into UpdatePost. A blog
with two export folders in different field formats -- a duplicate "_2"
folder, or an export whose field set changed over time -- sends one record
with real Title/Tags/Slug and another with those fields "." because that
format never had a line for them. Re-importing both on every run flipped
the row back and forth forever: net content never changed, but
DateModified moved on every pass since each write really did change a
column relative to the other write, just not relative to the true value.

Every content column in UpdatePost's SET list is now guarded the same way
RootBlogName/RootURL already were -- a "." parameter leaves the existing
value alone instead of overwriting it -- and the change-detection WHERE
clause carries the same exception, so a "."-only difference no longer
fires the UPDATE at all. Deliberately narrow: only the literal "." is the
sentinel, so an explicit empty string from a real record still overwrites.

Verified against a throwaway DB using the exact SQL text and parameter
binding from UpdatePost, reproducing the an-angry-wolf/adore-blk scenario
found in the live DB: re-importing conflicting "." records now writes zero
rows and leaves DateModified untouched, while a genuine content change
still fires and still moves it.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-05 09:17:20 -05:00
jim f0ccac6503 Merge GitTea/master into master 2026-08-03 08:58:07 -05:00
jimandClaude Opus 5 e1d2eb48c2 fix: only bump DateModified when a value actually changed
Seven UPDATE statements wrote DateModified unconditionally, so re-crawling
or re-ingesting identical content marked Blogs, Posts and Notes rows as
modified. Each now carries a WHERE guard covering every column in its SET
list, so SQLite matches zero rows on a no-op.

Guarded: AddPost's insert-failure fallback and blog stamp, AddNote's blog
stamp, UpdateBlogLikesNewestTimestamp, UpdateNoteReplyText,
UpsertPostFromTextFile, SetBlogTTFolderPath, UpdatePostContentFields.

Also:
- Blogs.DateAdded is no longer rewritten when a new post arrives for a
  known blog. A new post is not a new blog, and rewriting the column both
  destroyed the registration date and made every insert look like a change.
- Posts.NotesGatheredDateTime is crawl bookkeeping that moves on every
  pass, so it no longer moves DateModified on its own. It is still written
  each pass, but the timestamp is wrapped in a CASE on the pre-UPDATE
  HasNotesGathered value so only the flag flipping counts.

These statements now return 0 rows for "found but unchanged" as well as
"not found"; CorrectMode's postsUpdated tally consequently counts rows
actually changed, matching what its dry-run diff reports.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-03 08:53:10 -05:00
jim 3c85a05afc Merge branch 'master' into claude/jovial-mayer-77c2b5 2026-07-29 16:26:50 -05:00
jim 60912c882d fix: stop AddAPICount from throwing on missing DateCreated column
The INSERT named a DateCreated column that DailyAPICount (Date, APICount)
never had, so every call threw "no such column: DateCreated" into an
empty catch block. Today's row was never created and the tally sat idle
since 2026-04-13. Drop the column from the INSERT, and report the three
silent failure points (insert error, missing row after insert, update
matching zero rows) instead of swallowing them.
2026-07-29 16:26:08 -05:00
jim 8a4ab2402d docs: record the IsActive no-write rule in AGENTS.md 2026-07-29 16:04:08 -05:00
jimandClaude Opus 5 05ec465f74 feat: honor optional Posts.IsActive and Notes.IsActive
Both columns carry the meaning Blogs.IsActive has: 0 = removed by another
tool, anything else (including NULL) = live. Neither exists in the live
TL.db yet, and both are added from outside this crawler, so the code has
to work on databases either side of the change - naming a missing column
is a hard SQLite error.

HasIsActiveColumn asks PRAGMA table_info once per table per database path
and caches it; AndIsActive/WhereIsActive return "COALESCE(IsActive, 1) = 1"
or an empty string. Every read that selects posts or notes now carries the
filter: GetPosts (both branches, including the per-blog count subquery),
GetReplies, GetRepliesWithMissingText, GetRepliesWithFilledText,
GetAllPostTextColumns, GetAllPostsForBlog, GetPost, GetPostByIdAnyBlog,
and the engagement queries that count or join Notes - GetBlogs,
GetBlogsAll and both note-joining variants of GetBlogsForLikes.

The LEFT JOIN Notes in GetPosts is left alone on purpose: nothing is
selected from it and it can neither add nor remove a row.
LegacyPostsDbImporter is left alone too - it reads a foreign legacy
schema.

Writes were already safe and are documented rather than changed: no
INSERT column list names IsActive, no UPDATE sets it, MapPrefixToColumn
cannot map to it, and there is no INSERT OR REPLACE on Posts or Notes for
a column default to be reset by. Re-crawling a removed row refreshes its
content and leaves the flag at 0. As with Blogs, exclusion belongs at
selection, so the update paths stay keyed on rows the caller already
chose.

Verified against three synthetic databases - no IsActive columns, columns
present with a removed post and its notes, and columns present but NULL -
by running every affected reader: the queries are valid in all three, the
removed rows drop out only where the columns exist, NULL reads as live,
and AddPost/AddNote/UpsertPostFromTextFile/UpdatePostContentFields leave
an IsActive = 0 row at 0.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 14:21:09 -05:00
jimandClaude Opus 5 eded5271ea docs: revise TL.db notes for Rolodex's use of Blogs.IsActive
Replaces the Blogs.IsDeleted section. Rolodex adds no column of its own;
it reuses the crawler's existing IsActive flag, so removing a blog in the
UI also stops it being collected.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:56:18 -05:00
jimandClaude Opus 5 a14debd5ed docs: track TL.db schema notes in the repo
TL.db.md documents the live schema: the three content tables and their
row counts, the '.' placeholder convention the crawler writes instead of
NULL, the two incompatible date formats in Blogs.DateAdded, and the
access paths that matter on the 1.19M-row Notes table.

It also covers Blogs.IsDeleted, which Rolodex adds by ALTER TABLE and
this crawler must not write.

The file was sitting untracked next to the database it describes.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:54:20 -05:00
jimandClaude Opus 5 d6637266b7 fix: exclude inactive blogs from blog selection queries
Blogs.IsActive was honored only by GetBlogs and GetBlogsAll, so a blog
with IsActive = 0 was still selected for likes crawling and for output
mode. Add the filter to every remaining query that selects blog records:

- GetBlogsForLikes, all three variants (specific blog, ignoreCooldown,
  cooldown) - this is the selector that spends API quota
- GetAllBlogsWithTTFolderPath

Writes are deliberately untouched. The UPDATE statements are keyed on a
blog the caller already selected; filtering them would let the crawler
fetch a blog, pay the API cost, then fail to persist its cursor and
re-fetch the same pages on every run. Exclusion belongs at selection.

LegacyPostsDbImporter is also untouched: it reads a foreign legacy
schema that may not have the column.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-29 09:51:24 -05:00
jimandClaude Opus 4.8 2a02811003 Merge branch 'claude/rate-limit-behavior-474ecf'
Strip only a trailing numeric suffix from blog folder names, fixing
zomb-eh_10 importing as blog 'zomb-eh0'.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 16:40:16 -05:00
jimandClaude Opus 4.8 a73b597381 fix: strip only trailing numeric suffix from blog folder names
NormalizeBlogFolderName removed "_1".."_9" as unanchored substrings, so a
folder suffixed past a single digit lost the wrong characters: "_10" hit
the "_1" rule and left the trailing "0" welded to the name, importing
zomb-eh_10 as blog "zomb-eh0". That name does not exist on Tumblr, so
every post imported under it 404s on --collect forever.

Anchor the strip to a trailing _<digits> instead. This also fixes blogs
whose real name contains "_1" (some_1blog no longer becomes someblog) and
folders suffixed "_0", which were not stripped at all.

Verified against the live folder tree: zomb-eh_10 is the only existing
folder whose normalized name changes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:58:35 -05:00
jim f9e1d2100b Merge remote master into local master 2026-07-22 12:12:33 -05:00
jimandClaude Opus 4.8 3e2b287737 Merge branch 'claude/rate-limit-behavior-474ecf'
Retry transient CDN failures instead of failing the post; throttle
--collect and --likes to 60/min.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:10:54 -05:00
jimandClaude Opus 4.8 003a504d5e fix: retry transient CDN failures instead of failing the post
A non-JSON response body (CDN 403/5xx HTML, empty body, transport error)
never reached the Tumblr API, so it says nothing about the post being
fetched. These were recorded as FAILURE, which consumed the post's single
attempt for the pass and cleared the API key's rate-limit flag on the way
through.

Classify them as Root.transientFailure and retry in place (1s/4s/10s)
before skipping. Skipped posts stay unmarked in the DB so a later launch
retries them. Ten consecutive transient failures now aborts the pass
rather than skipping post-by-post against an edge refusing all traffic.

Also:
- MarkAvailable() only on a response that reached the API, and it is now
  a no-op when the key was not flagged (was writing to the DB and logging
  on every single call)
- Only a real 429 counts as a rate limit; stop inferring one from
  X-RateLimit-* headers, which Tumblr sends on every response
- Limiters pace with AcquireAsync instead of AttemptAcquire, which did
  not wait and aborted the run once a window was saturated
- Throttle --collect and --likes from 300/min to 60/min
- Log one line per transient failure instead of the HTML body and stack
  trace; keep full detail only for a 2xx that fails to parse
- --collect returns exit 3 when a pass ends incomplete

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-22 12:10:49 -05:00
jim 16147b273e docs: document default-mode .txt ingest field parsing in AGENTS.md
Note TraverseDirectory's recognized field prefixes, multi-line
Body/Downloaded files continuation, and how RootURL now gets
populated from both the .txt Reblog root url line and the API-based
--likes flow.
2026-07-16 10:43:16 -05:00
jim 5361bb78b8 Merge remote master into txt-validation branch 2026-07-16 10:42:28 -05:00
jim 21a5525094 Capture multi-line Body/Downloaded files in default .txt ingest mode
TraverseDirectory only ever read the single line immediately after
"Body:"/"Downloaded files:", silently dropping every continuation
line (multi-paragraph HTML bodies, multiple downloaded filenames).
Switch to an indexed line scan so those two fields collect lines
until the next recognized field prefix, matching how IngestMode.cs
already handles multi-line values.
2026-07-16 10:23:16 -05:00
jim 33839930e8 Parse Reblog root url in default .txt ingest mode
TraverseDirectory (the no-args ingest path) never read the "Reblog
root url:" line, so RootURL stayed unset even though AddPost/UpdatePost
already support it via the API-based --likes flow. New scraper output
now includes this field; wire it through both AddPost call sites.
2026-07-16 10:20:50 -05:00
jim f541ec4260 fix: correctly detect rate-limit state for single-key API pools
IsAllRateLimited() short-circuited true for any pool with 0 or 1
keys, with minRetrySeconds left at 0 regardless of whether that key
was actually rate-limited. SleepUntilAnyAvailable() checks
"minRetry <= 0" to decide whether to skip sleeping, so with exactly
one key it always skipped the wait and let callers hammer the API
again immediately after a 429, even mid-cooldown.

The per-key loop already computes this correctly for any key count;
the special case only needs to cover the true no-keys edge case,
where there's nothing to wait on.
2026-06-30 21:22:12 -05:00
jim f549f020e1 refactor: standardize SQLiteConnection disposal via using; guard config
Replace the try/finally { connection.Close(); } pattern used across
most of DataAccess.cs with using declarations, so disposal happens
automatically and can't be skipped by a future edit that adds an
early return before the finally. Left the shared-connection
(ownsConnection) call sites alone since those intentionally outlive
a single method call.

Also drop a stray unused `using static ... JSType` import, and make
a missing ContainsList config setting fail with a clear
InvalidOperationException instead of a NullReferenceException from
Split(',') on null.
2026-06-30 20:54:18 -05:00
jim 0ff80a0fd3 fix: parameterize AddPost fallback UPDATE, guard args indexing
Posts.hasImage/DateModified fallback update built its WHERE clause via
raw string concatenation of blogName/postID, unlike every other query
in this method — a blog name containing a single quote would break or
inject into the query. Switch it to parameters.

--parse, --blogsO, and --bop indexed args[1..3] before checking
args.Length, so a missing argument threw IndexOutOfRangeException
instead of hitting the intended usage message.
2026-06-30 20:41:51 -05:00
jimandClaude Opus 4.8 4df73367fb BREAKING: switch all multi-char commands to POSIX --double-dash
Rename every multi-character option/command from single-dash to double-dash (--likes, --collect, --force, etc.) to follow the POSIX long-option convention. Single-character short options (-h, -V, -?) keep their single dash, as POSIX prescribes.

Breaking: existing invocations/scripts using single-dash forms now report Unknown Command and must be updated. Run profile (launchSettings.json) and CLI docs (copilot-instructions.md) updated to match.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 15:26:38 -05:00
jimandClaude Opus 4.8 a437fa87d3 Document -post and -bop commands in --help
These two commands were handled by the switch but never listed in help.
--help now covers every command the program accepts.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 15:19:51 -05:00
jimandClaude Opus 4.8 32a1583efd Document exit-status codes in --help output
The new 0/1/2 exit codes had no footprint in --help; add an Exit status
line so the documented behavior matches what the program now returns.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 15:18:25 -05:00
jimandClaude Opus 4.8 03676432bd Add POSIX-friendly CLI handling: --, --help/--version, exit codes
Keep the existing single-dash switch style and case-insensitive matching,
but add the cheap, non-breaking POSIX wins:

- `--` end-of-options: tokens after a bare `--` are treated as literal operands
- `--help`/`-h` (alongside `-?`) and `-V`/`--version`
- Main returns a real exit code: 2 for usage errors, propagates handler
  return codes, and a top-level catch yields a quiet 1 on unhandled errors

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-09 15:12:10 -05:00
jim 8d6b9212c1 Remove binaries, batch script; add DB schema verifier
Removed outdated binary files and the `run_500_times.bat` script, which automated repetitive runs of `URLNotesGrabberCORE`. The batch script is no longer needed or has been replaced.

Added `verify-db-schema.sql`, a new script to verify and align the SQLite database schema (`TL.db`) with the application's expected schema. The script includes:
- A verification section to identify missing/extra columns or tables.
- An optional fix section with `ALTER TABLE` statements to add missing columns.

The SQL script ensures database compatibility while preserving data integrity and avoiding destructive operations.
2026-06-03 15:54:22 -05:00
jimandClaude Opus 4.8 18f172fe96 Make -collect 0 a resumable, single-pass full re-check
Mode 0 (full re-check) previously reset its cutoff to now on every
launch, so an interrupted run restarted from scratch, and a post that
kept returning a non-Success/non-NotFound status could loop forever.

- Add single-row CollectRunState table + accessors (EnsureCollectRunStateTableExists,
  GetCollectRunState, BeginCollectRun, CompleteCollectRun) mirroring the
  ApiKeyPoolMeta pattern, to persist a frozen run cutoff and completion flag.
- -collect 0 with no explicit date is now a managed run: resume against the
  stored cutoff if a run is in progress, else start a new run; mark complete
  when the pass finishes so the next launch starts fresh. Explicit-date and
  mode 1 behavior unchanged.
- CollectNotes makes a single attempt pass via an in-process attempted set;
  FAILURE/UNKNOWN are logged once, TooManyRequests/no-lease aborts without
  completing so a later launch resumes.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-03 15:48:26 -05:00
jimandClaude Opus 4.8 b576a9cdf3 fix: make -revert scan the PathInput tree like the no-parameter run
-revert was DB-driven, searching each blog's Blogs.TTFolderPath
non-recursively for *.bak. That tree differs from the no-parameter run,
which recursively walks PathInput. Rewrite RevertMode to recursively walk
PathInput (filesystem-only, no DB), with the optional [blogname] argument
now filtering by path substring. Restore mechanics unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-05-28 16:30:26 -05:00
jimandClaude Opus 4.8 5973920894 feat: add -revert mode to restore *.bak back to *.txt
Inverse of -output. For each blog with a TTFolderPath, restores every
*.bak over its *.txt, first preserving the current *.txt as the
next-free *.bkN, then consuming the *.bak. Confirms before running and
supports an optional single-blog filter.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-05-28 16:11:35 -05:00
jim 494d6aa2d4 Shortened sleep 2026-05-19 16:48:44 -05:00
jim 18c5ac5401 fix: correct foreach syntax for .NET 8 compatibility 2026-05-19 16:27:38 -05:00
jim 21e848efb7 feat: add [X remaining] progress counter to likes mode output 2026-05-19 15:48:18 -05:00
jim 24c5449e0c No longer copies db to output directory 2026-05-18 21:32:01 -05:00
jimandClaude Opus 4.7 c55569eadf chore: log folder transitions during -ingest
The every-50-file progress line wasn't enough to know which blog was
currently being processed on a long run. Now -ingest prints a
"entering folder: <name>" line whenever the source folder changes, and
the every-50 progress line also prefixes the folder name.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-18 15:40:27 -05:00
jimandClaude Opus 4.7 cf3f97ddc4 feat: add single-blog filter to -ingest
`-ingest <blogname>` now restricts the run to one blog's folder, mirroring
the existing -parse <blogname> ergonomics. `-ingest` with no arg still
processes every blog under appSettings:PathTTRoot (or PathInput fallback).

Breaking vs 3aff849: the first positional arg is interpreted as a blog
name, not a path. Configure the root via appSettings:PathTTRoot.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-18 15:36:42 -05:00
jimandClaude Opus 4.7 3aff849216 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]>
2026-05-18 12:43:17 -05:00
jim 5781e121d2 Merge branch 'claude/gifted-dhawan-7e5fc7' 2026-05-16 14:02:07 -05:00
jimandClaude Sonnet 4.6 e27190e4f9 add incremental refresh + cooldown to -likes mode
After backfill completes for a blog, -likes can now pick up only newer
likes instead of being a one-shot pull. Tracks a per-blog
liked_timestamp high-water mark and stops the refresh walk once it
crosses the stored mark. A configurable cooldown (LikesRefreshCooldownDays,
default 7) gates which blogs are re-checked on each run. -force bypasses
the cooldown.

The migration adds three columns to Blogs (LikesNewestTimestamp,
LikesLastRefreshed, LikesLastNewCount) and does a one-time reset of all
likes tracking state so the new high-water mark starts from a clean
baseline. Existing ByLikes posts remain; the UNIQUE constraint absorbs
re-inserts during the first re-backfill.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-16 14:00:45 -05:00
jim 7c667bd579 Merge branch 'claude/eager-hugle-2f3520' 2026-05-15 11:53:34 -05:00
jimandClaude Opus 4.7 2634ff8967 speed up no-args directory import with shared connection + lazy blog cache
AddBlog/AddPost/UpdatePost/UpdatePostSetDate now reuse a single SQLiteConnection
when an import session is active, instead of opening and closing one per call.
AddBlog also short-circuits on an in-memory HashSet of blog names already
attempted this run. Other entry points are unaffected since they never call
BeginImportSession.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-15 11:51:57 -05:00
jim 69ce36a1f5 sleep call on finding note 2026-05-15 11:49:54 -05:00
jim 52293f2021 Merge branch 'claude/goofy-antonelli-73ea26' 2026-05-11 09:09:25 -05:00
jimandClaude Opus 4.7 2b86ce9119 collapse GetPosts SQL log to a single line
Per-iteration call in CollectNotes was dumping ~30 lines of newline-padded
SQL to the console. Replace with a whitespace-collapsed single-line print.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-11 09:09:24 -05:00
jim a5c118ce3b Merge branch 'claude/goofy-antonelli-73ea26' 2026-05-11 09:03:12 -05:00
jimandClaude Opus 4.7 47497d02bf tweak GetPosts beforeDate filter and broaden GetBlogsForLikes
- GetPosts: match NotesGatheredDateTime = 0 instead of IS NULL for the
  beforeDate cutoff.
- GetBlogsForLikes: drop the EXISTS-Posts predicate so blogs with notes
  but no posts rows are still eligible for likes collection.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-11 09:03:01 -05:00
jimandClaude Opus 4.7 5ae1d4feb8 fix: skip API calls when all keys are rate-limited in -collect mode
Pre-flight check on the CollectNotes loop now sleeps until at least one
key recovers instead of issuing a wasted 429-bound request per iteration.
Extracts the countdown into ApiKeyPool.SleepUntilAnyAvailable (30s refresh)
and reuses it in CollectLikes and GrabNotes.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-11 08:37:47 -05:00
jim 2034b43e83 Merge branch 'claude/wizardly-antonelli-9f2795' 2026-05-09 20:24:53 -05:00
jimandClaude Opus 4.7 4f00adcc05 fix: -collect mode now processes all posts instead of exiting after first
Changed CollectNotes from async void to async Task and await it with
.GetAwaiter().GetResult() to match the pattern used by -replies and -likes.
Previously the method would return immediately after the first await,
causing Main to exit before the loop could process more than one post.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-09 20:24:50 -05:00
jim bcbaea8c48 Merge branch 'claude/wizardly-antonelli-9f2795' 2026-05-08 21:42:39 -05:00
jimandClaude Opus 4.7 349b465f8a fix: release 404 posts from -replies queue
Tumblr API 404s were leaving Posts.NotFound=0 and Notes.replyText='.',
so GetRepliesWithFilledText kept re-selecting the same dead post and
the loop spun forever. Mark NotFound=1 and replies '?' on 404.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-08 21:42:30 -05:00
jim acc5e885a8 feat: require PathDB config in appsettings.json 2026-05-08 16:24:43 -05:00
jimandClaude Sonnet 4.6 b68a9833d8 chore: format remaining blog/post log output as <blog>.tumblr.com/post/<id>
Apply the URL format to the remaining 6 console log lines that still
referenced posts in <blog>/<id> form (404 logs, max-page-limit log,
DumpReplies console output).

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 16:16:29 -05:00
jim 89f2addf01 Merge branch 'claude/awesome-edison-d12b61' 2026-05-07 16:13:30 -05:00
jimandClaude Sonnet 4.6 b963489741 chore: format blog/post log output as <blog>.tumblr.com/post/<id>
Console-friendly URL format for log lines that reference a specific
post — easier to copy/paste into a browser when investigating output.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 16:12:00 -05:00
jim d18ab92019 Merge branch 'claude/awesome-edison-d12b61' 2026-05-07 15:58:13 -05:00
jimandClaude Sonnet 4.6 4737baa288 fix: keep '.' as needs-processing sentinel, normalize real-dot replies
Previous fix removed '.' from the GetRepliesWithFilledText SELECT,
which broke processing of legacy null-substitute rows that need to be
re-fetched.

Restored '.' in the SELECT. To avoid the infinite loop when an actual
API reply is the literal string ".", normalize it to ". " (dot +
trailing space) inside UpdateNoteReplyText so the stored value no
longer matches the sentinel.

Also extended the fan-out UPDATE guard to match the SELECT criteria
(NULL / '' / '.') so legacy '.' rows in other reblog copies can be
filled in too. The guard still refuses to overwrite '?' or
already-fetched text.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 15:56:53 -05:00
jim 51c40fe2bd Merge branch 'claude/awesome-edison-d12b61' 2026-05-07 15:49:40 -05:00
jimandClaude Sonnet 4.6 51e2a8a1e1 fix: stop infinite loop when reply text is a literal dot
'.' was used as a sentinel for 'not yet fetched' in the SELECT query,
but it is also valid reply text. This caused any post where a reply
text was literally '.' to stay in the work queue forever.

Also fixes the fan-out UPDATE guard: previously it used
IFNULL(replyText, '.') <> @newValue, which would overwrite '?'
(confirmed-empty) rows with '.' when processing a dot reply elsewhere
in the reblog chain, pulling completed posts back into the queue and
causing the remaining counter to increase.

Changes:
- Remove OR replyText = '.' from GetRepliesWithFilledText SELECT
- Restrict UpdateNoteReplyText fan-out to NULL/'' rows only
- Use '?' not '.' as null-coalesce fallback in UpdateNoteReplyText

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-07 15:49:35 -05:00
jim 0213478a40 Merge branch 'claude/epic-bardeen-cbbb7e' into master 2026-05-07 15:02:48 -05:00
jimandClaude Opus 4.7 081528a81a fix: mark replies '?' after pagination yields no reply notes
Previously, the '?' marker only fired when page 1 returned empty notes.
Posts whose page 1 contained only likes/reblogs and whose page 2 came
back empty were never marked, so GetRepliesWithFilledText kept reselecting
them every iteration. Now mark after the pagination loop whenever
rowsUpdated==0 and at least one page returned 200 OK.

UpdateAllNoteReplyTextForPost now returns int so the caller can fold
the count into emptyReplyCount/rowsUpdated for accurate Done logging.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
2026-05-07 15:01:04 -05:00
jim f2539dc9d0 fix: mark post's replies '?' on confirmed empty notes response
When the API responds 200 OK with notes:[] on the first page,
the post has no conversational notes per Tumblr. Stamp all the
post's reply rows '?' so the per-iteration requery stops handing
this post back forever. Only fires for first-page 200 OK so a
later-page empty (end of pagination) doesn't poison good rows.
2026-05-07 14:25:00 -05:00
jim 2438997c7c chore: untrack and gitignore .claude local settings + worktrees
Local Claude Code config; should not be in the repo.
2026-05-07 14:20:41 -05:00
jim ac79727040 feat: fan reply updates across reblog chains, requery per post
A reply by a given blog at a given timestamp is the same reply
across the original post and every reblog of it. Drop PostID from
the UpdateNoteReplyText WHERE clause so a single API hit fills in
the replyText on every matching row at once.

Pair that with a per-iteration requery (limit 1) of the work list
so posts whose replies were already filled in as a side-effect of
a previous post's update are skipped without burning an API call.
2026-05-07 14:19:54 -05:00
jim 64a914c14d fix: tolerate ~5s timestamp drift in UpdateNoteReplyText
The API returns reply timestamps that are ~1s ahead of what -collect
originally stored, so an exact TimeStamp match in the UPDATE was
hitting zero rows for every note - the API call worked, the reply
text came back, but nothing landed in the DB. Match within +-5s
instead. Also return rowsAffected from UpdateNoteReplyText and
report it separately from notes-seen in the summary so misses are
visible.
2026-05-07 14:10:37 -05:00
jim 1c7b6a1e86 fix: start replies fetch from newest, ignore stale DB timestamp
The DB-derived MAX(N.timestamp)+1 for a post is unreliable - many notes
were stored with the same timestamp (likely crawl time, not the actual
note timestamp), so passing it as before_timestamp excluded all real
replies and returned an empty notes array. Diagnostics showed two
unrelated posts coming back with identical totals (1625/1021/603) and
zero notes.

Now we start with no before_timestamp (newest page) and let pagination
walk backward via each page's last-note timestamp.
2026-05-07 14:00:48 -05:00
jim 1da8a702ba fix: pull reply_text from Tumblr in -replies mode
Wrap FetchAndStoreReplyText in a per-post pagination loop (up to 10
pages, advancing before_timestamp via the last note's timestamp) so
posts with >50 notes are fully walked. Log raw response (meta + first
500 chars of JSON) when a page returns no notes so empty results are
diagnosable. Stop blanket-marking every reply on a post with '?' on
the first empty response - rows stay '.' and remain retry-eligible;
only individual replies that come back with empty reply_text are
marked '?'.
2026-05-07 13:54:54 -05:00
jim b70f356d24 SQLite DB Project File 2026-05-06 16:51:56 -05:00
jimandClaude Haiku 4.5 539994b742 fix: emit per-key colored output in -replies mode on successful API calls
Call ApiKeyPool.MarkAvailable(key) after successful reply fetch in FetchAndStoreReplyText, matching behavior in CollectLikes. This produces the colored '[Pool] Key#N (...) rate-limit cleared' line for each API call, allowing operators to visually track which API key is being used per request.

Co-Authored-By: Claude Haiku 4.5 <[email protected]>
2026-05-06 15:27:23 -05:00
jim c39d555d6e 2026-05-06 14:50:30 -05:00
jim a7d175a5a8 feat: enhance replies command progress output 2026-05-06 10:32:49 -05:00
jim 0adaef62b1 fix: update GetRepliesWithFilledText to join Posts table and filter NotFound 2026-05-06 09:50:36 -05:00
jim 1507086269 fix: Correct CollectMissingReplyText to use API key rate limiting 2026-05-06 09:16:36 -05:00
jim 9c77ecce4b fix: color no-replies messages yellow for visual distinction 2026-05-05 18:07:19 -05:00
jim 78e3a070b2 fix: make reply text collection process all remaining posts without limit 2026-05-05 17:57:23 -05:00
jim 9589a9c090 feat: add green color for replies and cyan for reblog comments 2026-05-05 17:43:48 -05:00
jim abdfe30203 Uses mode=conversation to fetch only notes with text
 Captures replies and reblogs with comment
 Ignores rollup_notes field (as requested)
 Maintains rate limiting and error handling
 Console output shows both reply and reblog comment
2026-05-05 11:49:10 -05:00
jim 354bb9cc8e Additional fixes 2026-05-05 00:04:40 -05:00
jim 5e62b58261 feat: add API key color assignment, auto-persist, AGENTS.md 2026-05-04 10:33:46 -05:00
jim 34e5745e99 Fixed bugs on API key pool 2026-05-03 13:59:51 -05:00
jim 6cec7afb53 Changes Summary
appsettings.json — Added PoolEnabled to each API section:
- TumblrApi → true
- TumblrApi3 → false
- TumblrApi4 → true
DataAccess.cs — Added:
- ApiKeyConfig class — holds credentials + metadata per key
- ApiKeyPool class — manages pool with round-robin rotation, SQLite-backed state (ApiKeyPoolState, ApiKeyPoolMeta tables)
  - GetCurrentKey() — returns next key, skipping rate-limited ones, falls back to earliest-recovery if all are throttled
  - MarkRateLimited(key, retryUntil) / MarkAvailable(key) — persists state
  - Initialize() — discovers pool-enabled keys, detects single-key override mode
- Refactored APIAccess.GrabNotes(), GrabPostWithReplies(), GrabLikes() to accept ApiKeyConfig param and log [Key#N]
Program.cs — Updated:
- Tracks apiExplicitlySet flag from -api/-api3/-api4
- Initializes ApiKeyPool at startup (pool mode or single-key override)
- All 3 API callers updated to use pool rotation + 429 handling
Startup Output
- Pool mode: [Pool] Active keys: Key#1=TumblrApi, Key#2=TumblrApi4
- Single-key: [Pool] Single-key mode: TumblrApi3
2026-05-03 13:31:28 -05:00
jim de96c7c1c5 Updated db location logic 2026-05-03 08:41:21 -05:00
jim 059c9cd527 Improve import speed, add URL dump, and CLI options
- Add SQLite import mode pragmas for faster bulk inserts
- Implement -urldump command to extract all URLs from posts
- Add -start [blogname] CLI option for partial traversal
- Support toggling file logging and record import logging via config
- Only update DB rows if values change to reduce writes
- Add DateCreated fields to relevant tables
- Enhance logging and output formatting
- Refactor directory traversal for better control and reporting
2026-04-15 15:14:43 -05:00
jim 390e1284cb Add support for selecting Tumblr API credentials via CLI
Allows switching between multiple Tumblr API credential sets at runtime using new command-line arguments (-api3, -api4, -api [section]). API keys are now read from the selected section in appsettings.json, with validation for required keys. Also updates the SQL query for selecting blogs needing likes to use more precise filtering and ordering. Usage/help output is updated to reflect new options.
2026-04-03 13:23:38 -05:00
jim eb86bc8f66 Add byLikes tracking to blogs and posts in DataAccess
Introduce byLikes parameter to AddBlog, AddPost, and UpdatePost methods, updating SQL logic and schema to store this flag. Ensure all relevant calls and SQL statements handle the new ByLikes column, allowing tracking of whether entries were added "by likes." Also standardize hasImage boolean handling in SQL.

Add Tumblr likes fetching and DB tracking support

Implemented -likes command to fetch/process Tumblr blog likes.
Added DB columns and logic to track likes progress (LikesPulled, LikesCursor).
Integrated API call, pagination, and rate-limit handling for likes.
Extended AddBlog/AddPost/UpdatePost for likes-related fields.
Added DateModified tracking to DB operations.
Improved error handling and updated launch/app settings.
2026-04-02 10:57:13 -05:00
jim 4e87daf2b2 . 2026-03-26 00:35:24 -05:00
jim 15b484bdf8 Update SQL logic, add project file, remove TLDB.7z
Added RERUN.sqbpro with SQL queries and DB settings.
Enhanced DataAccess.cs filtering for 'zomb-eh' and enabled SQL debug output.
Commented out Tumblr URL output in Program.cs for cleaner logs.
Deleted TLDB.7z binary archive.
2026-02-27 11:44:58 -06:00
jim bc9985a6f0 Add support for fetching and storing reply text
- Add database migration and update logic for replyText column in Notes
- Implement batch collection of missing reply text via Tumblr API
- Add new API integration to fetch reply text for replies
- Enhance DataAccess with methods to query/update replyText
- Update CLI: -replies now collects and stores reply text
- Improve logging (archive logs/), error handling, and output
- Remove TL.db from source control
2026-02-12 23:12:54 -06:00
jim 0e5fb124a0 Enhance filtering, SQL, and error handling logic
- Added `beforeDate` parameter to filter posts by `NotesGatheredDateTime`.
- Improved SQL queries with additional joins and conditions.
- Refactored exception handling for better resource cleanup.
- Enhanced string comparisons to support case-insensitivity.
- Added `GetReplies` method for fetching replies.
- Improved handling of `ReblogRecord` data and filtering logic.
- Updated `-collect` command to support optional date filtering.
- Adjusted `launchSettings.json` for testing with specific parameters.
- Improved logging and error reporting in API and database operations.
2025-12-11 23:19:19 -06:00
jim e55d2b0e29 DataAccess: use INSERT OR IGNORE for Notes to handle duplicate entries; improve rate limit handling in API responses 2025-11-25 10:59:00 -06:00
25 changed files with 10256 additions and 727 deletions
+9 -9
View File
@@ -22,18 +22,18 @@ This document provides essential context for AI agents working with URLNotesGrab
```powershell ```powershell
dotnet build dotnet build
dotnet run # Process all files in input directory dotnet run # Process all files in input directory
dotnet run -- -parse [blogname] # Process specific blog dotnet run -- --parse [blogname] # Process specific blog
dotnet run -- -test [blogname] [postID] # Test API for specific post dotnet run -- --test [blogname] [postID] # Test API for specific post
``` ```
### Command-Line Interface ### Command-Line Interface
- `-parse [blogname]`: Parse text files for specific blog - `--parse [blogname]`: Parse text files for specific blog
- `-test [blogname] [postID]`: Test API note collection - `--test [blogname] [postID]`: Test API note collection
- `-posts`: Export post blogs to file - `--posts`: Export post blogs to file
- `-blogs`: Export blog list to file - `--blogs`: Export blog list to file
- `-collect`: Collect notes for all posts in DB - `--collect`: Collect notes for all posts in DB
- `-blogsR`: Export reply blogs to file - `--blogsR`: Export reply blogs to file
- `-blogsO [start] [stop]`: Export blogs within range - `--blogsO [start] [stop]`: Export blogs within range
## Project Conventions ## Project Conventions
+10
View File
@@ -361,3 +361,13 @@ MigrationBackup/
# Fody - auto-generated XML schema # Fody - auto-generated XML schema
FodyWeavers.xsd FodyWeavers.xsd
/URLNotesGrabberCORE/TL.db
# Claude Code local settings + worktrees
.claude/settings.local.json
.claude/worktrees/
/URLNotesGrabberCORE/TL.db
/URLNotesGrabberCORE/tl.db-shm
/URLNotesGrabberCORE/tl.db-wal
/U/jim/documents/web copies/blogs
+172
View File
@@ -0,0 +1,172 @@
# AGENTS.md
## Project Overview
- **Primary Language**: C# (.NET 8 Console Application)
- **Key Libraries**: RestSharp, Newtonsoft.Json, System.Data.SQLite, Microsoft.Extensions.Configuration
- **Purpose**: Tumblr API data harvester for collecting notes, posts, likes, and replies, storing results in SQLite.
## Architectural Patterns
- CLI entry point in `Program.cs` with workflow orchestration
- `DataAccess.cs`: Database operations, `ApiKeyPool` (API key management), `APIAccess` (Tumblr client)
- `ResponseNotes.cs`: Tumblr API response models
- Round-robin API key rotation with rate-limit tracking
- Automatic console color assignment per API key for output differentiation
- No-argument mode (`Program.TraverseDirectory`) ingests `.txt` blog export files into `Posts` via `DataAccess.AddPost`. Recognized field prefixes live in `TraverseDirectoryFieldPrefixes`; `Body:` and `Downloaded files:` collect every following line up to the next recognized prefix (multi-line values). `RootURL` is populated from a `Reblog root url:` line the same way it's populated from the API-based `--likes` flow — both paths converge on `DataAccess.AddPost`'s `rootURL` parameter, which `UpdatePost` only overwrites when the incoming value is non-empty (existing `RootURL` is preserved otherwise)
## Developer Guidelines
### Code Formatting
- 4-space indentation, no tabs, match existing C# style
- PascalCase for public members, camelCase for locals
- Minimize code comments unless explicitly requested
- Use only existing project libraries; no new dependencies without confirmation
- Match accessibility modifiers (`public` for models, `internal` for helpers)
### Error Handling
- Wrap file/network operations in `try-catch`
- Log non-critical errors (e.g., config write failures) with `[Warning]` prefix
- Preserve console color state: use save/restore pattern for temporary color changes
- API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()`
### API Failure Classification
Tumblr sits behind a CDN that returns HTML error pages (403, 5xx) which never reach the API. These
say nothing about the item being fetched, so they must not be recorded as per-item failures.
- A response body that will not parse as JSON did not come from the API. Flag it with
`Root.transientFailure`, never as `FAILURE`
- Transient failures retry in place (`TransientBackoffSeconds`) before the item is skipped; a skipped
item stays unmarked in the DB so a later launch retries it
- `MaxConsecutiveTransient` consecutive transient failures aborts the pass rather than skipping
item-by-item against an edge that is refusing all traffic
- Only call `ApiKeyPool.MarkAvailable()` on a response that actually reached the API. A transport or
CDN failure says nothing about the key's standing and must not clear its flag
- Only a real HTTP 429 (or `meta.status == 429`) counts as a rate limit. Do not infer one from the
presence of `X-RateLimit-*` headers, which Tumblr sends on every response
- Rate limiters must pace with `await AcquireAsync()`. `AttemptAcquire()` does not wait, so a
saturated window aborts the run instead of throttling it
- Long-running commands return exit 3 when a pass ends incomplete (rate-limit pause, breaker trip, or
skipped items), so a caller can distinguish that from a clean run
### `IsActive` Is Not Ours To Write
`Blogs.IsActive`, `Posts.IsActive` and `Notes.IsActive` are removal flags set by other tools
(Rolodex). `0` means removed; anything else, including `NULL`, means live. Full detail in
`URLNotesGrabberCORE/TL.db.md`.
- **Never write any `IsActive` column.** Not in an `INSERT` column list, not in an `UPDATE`,
and never via `INSERT OR REPLACE` on these tables — that resets the column default and
un-removes the row. Re-crawling a removed row must refresh its content and leave the flag
where it was
- **Filter at selection, not at write.** Every query that *selects* posts, notes or blogs
excludes removed rows. Update statements stay keyed on a row the caller already selected;
filtering them would spend API quota and then fail to persist the result
- `Posts.IsActive` and `Notes.IsActive` are **optional** — they are absent from databases
that predate them, and naming a missing column is a hard SQLite error. Compose the filter
with `AndIsActive`/`WhereIsActive` in `DataAccess.cs`, which return
`COALESCE(IsActive, 1) = 1` only when `HasIsActiveColumn` finds the column. `Blogs.IsActive`
is not optional and is filtered directly
- Do not add these columns from this app, and do not add them to the missing-column list in
`verify-db-schema.sql`
### `DateModified` Tracks Real Changes Only
`Blogs.DateModified`, `Posts.DateModified` and `Notes.DateModified` must move only when a
column beside `DateModified` itself actually changed. Re-crawling or re-ingesting identical
content has to leave the row — and its timestamp — untouched, or downstream consumers cannot
tell a refreshed row from a rewritten one.
- Enforce it in the `WHERE` clause, not in C#. Every `UPDATE` that sets `DateModified` ends
with an `AND (<col> <> @param OR ...)` term covering every column in its `SET` list, so
SQLite matches zero rows on a no-op and never writes
- Compare NULL-safely: `IFNULL(col, '') <> IFNULL(@param, '')` for text,
`IFNULL(col, 0) <> @param` for integer flags. A bare `col <> @param` is NULL on a NULL
column and silently skips the row that most needs writing
- Where NULL is not equivalent to the default, spell it out. The `HasBeenOutput = 0` stamps
use `(HasBeenOutput IS NULL OR HasBeenOutput <> 0)` because the selection queries test
`HasBeenOutput = 0`, which a NULL would never match
- Dynamic `SET` lists (`UpdatePostContentFields`) build the guard alongside the assignments
so the two lists cannot drift apart
- These statements now return 0 rows for "found but unchanged" as well as "not found".
Callers that read `ExecuteNonQuery()` must not treat 0 as "row missing"
**`Posts.NotesGatheredDateTime` is crawl bookkeeping, not content.** It moves on every
`-collect` pass and says nothing about the post, so it must never move `DateModified` on its
own. `UpdatePostMarkNotesCollected` still writes it every pass but wraps the timestamp in
`DateModified = CASE WHEN IFNULL(HasNotesGathered, 0) <> 1 THEN @dateModified ELSE
DateModified END` — SQLite evaluates `SET` expressions against the pre-`UPDATE` row, so only
the flag flipping counts as a modification. Use this shape for any column that has to be
refreshed unconditionally without being a change. `Blogs.LikesLastRefreshed` is the
deliberate exception: a refresh pass is treated as a real event on the blog row.
**`Blogs.DateAdded` is write-once.** `AddBlog`'s `INSERT` is the only place that sets it. A
new post arriving for a known blog reopens `HasBeenOutput` but must leave `DateAdded` alone —
a new post is not a new blog, and rewriting the column both destroys the registration date
and makes every insert look like a change.
**`"."` in a `Posts` content field means "not supplied", not "empty".** `ReblogRecord`
(`TraverseDirectory`'s parser for the local `.txt` export tree) and the `--likes` API path
both default every content field to the literal string `"."` when their source has no value
for it, then pass that straight to `UpdatePost`. A blog with two export folders in different
field formats (a duplicate `_2` folder, or a Tumblr export whose field set changed over time)
sends one record with a real `Title`/`Tags`/`Slug` and another with those fields `"."`
because that format never had a line for them — and without a guard, re-importing both on
every run flips the row back and forth forever, bumping `DateModified` on every pass even
though the true content never changes.
- Every content column in `UpdatePost`'s `SET` list is guarded the same way `RootBlogName`/
`RootURL` already were: `col = CASE WHEN @col = '.' THEN col ELSE @col END`. A `"."`
parameter leaves the existing value alone instead of overwriting it
- The change-detection `WHERE` clause carries the same exception —
`(@col <> '.' AND IFNULL(col, '') <> @col) OR ...` — so a `"."`-only difference does not
make the statement fire at all, and `DateModified` stays put
- Deliberately narrow: only the literal `"."` is the sentinel. An explicit empty string from
a real record still overwrites, same as before this fix. `postID`, `BlogName`, `hasImage`,
`ByLikes` are not part of this convention and are unaffected
- If a new content field is added to `Posts`/`UpdatePost`, decide explicitly whether its
source can legitimately supply `"."` as "field absent" before deciding whether it needs
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
- No existing test suite; use xUnit if adding tests
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
- Avoid testing one-off CLI workflows
### Git Commit Messages
- Imperative mood ("Add feature" not "Added feature")
- Prefix with type: `feat:`, `fix:`, `chore:`, `docs:`
- Keep messages under 72 characters
- Never commit sensitive data (API keys/tokens)
### API Key Color Rules
- Unconfigured keys auto-assign colors from a preset palette
- Auto-assigned colors persist to `appsettings.json`
- All output for an active key uses its assigned color
- Temporary color changes (e.g., errors) must restore the key's color afterward
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="C:/Users/jim/Nextcloud/C#/URLNotesGrabberCORE/URLNotesGrabberCORE/TL.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="4305"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="Posts" custom_title="0" dock_id="4" table="4,5:mainPosts"/><dock_state state="000000ff00000000fd00000001000000020000077200000379fc0100000006fb000000160064006f0063006b00420072006f00770073006500310100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500320100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500330100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500350100000000000005f40000000000000000fb000000160064006f0063006b00420072006f00770073006500340100000000000007720000011700fffffffb000000160064006f0063006b00420072006f00770073006500340100000000000005f40000000000000000000007720000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="ApiKeyPoolMeta" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="29"/><column index="2" value="64"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="Blogs" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="257"/><column index="2" value="95"/><column index="3" value="54"/><column index="4" value="156"/><column index="5" value="51"/><column index="6" value="71"/><column index="7" value="85"/><column index="8" value="156"/><column index="9" value="156"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="Posts" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort><column index="28" mode="1"/></sort><column_widths><column index="1" value="241"/><column index="2" value="148"/><column index="3" value="126"/><column index="4" value="300"/><column index="5" value="75"/><column index="6" value="187"/><column index="7" value="159"/><column index="8" value="75"/><column index="9" value="300"/><column index="10" value="300"/><column index="11" value="78"/><column index="12" value="249"/><column index="13" value="300"/><column index="14" value="53"/><column index="15" value="300"/><column index="16" value="300"/><column index="17" value="41"/><column index="18" value="75"/><column index="19" value="96"/><column index="20" value="300"/><column index="21" value="96"/><column index="22" value="300"/><column index="23" value="300"/><column index="24" value="42"/><column index="25" value="60"/><column index="26" value="218"/><column index="27" value="920"/><column index="28" value="156"/><column index="29" value="156"/></column_widths><filter_values><column index="24" value="=1"/><column index="28" value="&gt;2026-05-06 20:00:01"/></filter_values><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1">UPDATE Posts
SET HasNotesGathered = 0
WHERE (BlogName, PostID) IN (
SELECT p.BlogName, p.PostID
FROM Posts p
WHERE p.HasNotesGathered = 1
AND P.notesGatheredDatetime &lt; 1774294520
AND EXISTS (
SELECT 1
FROM Notes n
WHERE n.PostID = p.PostID
AND n.RootBlogName = p.BlogName
--AND n.Type NOT IN ('reblog', 'reply')
)
ORDER BY P.PostDate ASC
--LIMIT 500
);</sql><sql name="Mark Blogs">select *
from Blogs
--update blogs set HasBeenOutput = 1
where HasBeenOutput = 0
AND
blogname in
(
'teaberrybee',
'reddevilgoddesstoo',
'waywardog13',
'wzjustbrowsing-blog',
'lewerta',
'nudenymph',
'caylachief'
)</sql><sql name="New Notes">select RootBlogName, PostID, NoteBlogName || '.tumblr.com' as NoteBlogName, DatetimeCrawled, TimeStamp, type, RootBlogName || '.tumblr.com/post/' || postid, datetime(timestamp, 'unixepoch')
from Notes
where
DatetimeCrawled &gt; '2026-05-14 02:50:05' --and type like 'r%'
order by DatetimeCrawled desc</sql><sql name="Pull Blogs*">SELECT distinct␍
'''' || blogname || ''',',
blogs.*
, blogname || '.tumblr.com'
FROM
Blogs
inner JOIN
Notes on notes.noteBlogName = blogs.BlogName
WHERE
HasBeenOutput = 0 and type = 'reblog'
order by
Notes.Type desc,
DateAdded desc
LIMIT 100;</sql><sql name="SQL 7">WITH ReplyCounts AS (
SELECT
NoteBlogName,
COUNT(DISTINCT replyText) AS DistinctReplyCount
FROM Notes
where replyText &lt;&gt; '.'
GROUP BY NoteBlogName
)
SELECT
n.RootBlogName || '.tumblr.com/post/' || n.PostID AS PostURL, postid,
n.NoteBlogName,
n.replyText,
c.DistinctReplyCount
FROM Notes n
JOIN ReplyCounts c ON n.NoteBlogName = c.NoteBlogName
where replyText &lt;&gt; '.' and type &lt;&gt; 'reply'
--AND N.NoteBlogName NOT IN ( 'roadblocker21', 'thesaddemon666', 'edwardabbeyhoffman', 'tattedsoldier20', 'zomb-eh', 'animalistic13', 'indken', 'maccloud1592',
--'moss-wizard', 'supertrucker12682', 'exploringthrupics', 'padeyepete' )
order by c.DistinctReplyCount desc, n.NoteBlogName, n.DateModified desc, replyText, RootBlogName, PostID</sql><current_tab id="3"/></tab_sql></sqlb_project>
BIN
View File
Binary file not shown.
+10 -5
View File
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="D:/NextCloud/C#/URLNotesGrabberCORE/URLNotesGrabberCORE/bin/Debug/net8.0/TL.db" readonly="1" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="3571"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="Blogs" custom_title="0" dock_id="1" table="4,5:mainBlogs"/><dock_state state="000000ff00000000fd0000000100000002000005f40000031dfc0100000001fb000000160064006f0063006b00420072006f00770073006500310100000000000005f4000000fb00ffffff000002130000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="Blogs" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort><column index="0" mode="1"/></sort><column_widths><column index="1" value="257"/><column index="2" value="48"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1*">SELECT <?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="" readonly="1" foreign_keys="" case_sensitive_like="" temp_store="" wal_autocheckpoint="" synchronous=""/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="3571"/><column_width id="4" width="0"/></tab_structure><tab_browse><table title="." custom_title="0" dock_id="4" table="0,0:"/><dock_state state="000000ff00000000fd0000000100000002000005f40000030ffc0100000002fb000000160064006f0063006b00420072006f00770073006500310100000000000005f40000000000000000fb000000160064006f0063006b00420072006f00770073006500340100000000ffffffff0000011700ffffff000005f40000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings/></tab_browse><tab_sql><sql name="SQL 1">SELECT
BlogName || '.tumblr.com/post/' || postID, BlogName || '.tumblr.com/post/' || postID,
datetime(NotesGatheredDateTime, 'unixepoch'), * datetime(NotesGatheredDateTime, 'unixepoch'), *
@@ -9,11 +9,16 @@ WHERE
ORDER BY ORDER BY
postdate desc</sql><sql name="SQL 2*">SELECT postdate desc</sql><sql name="SQL 2*">SELECT
datetime(TimeStamp, 'unixepoch'), datetime(TimeStamp, 'unixepoch'),
RootBlogName || '.tumblr.com/post/' || postid, RootBlogName || '.tumblr.com/post/' || N.postid,
*, *,
NoteBlogName || '.tumblr.com' NoteBlogName || '.tumblr.com'
FROM FROM
Notes␍ Notes N
WHERE RootBlogName NOT IN ('xlittle-ghost', 'glimmerin-darlin') inner JOIN
Posts P on P.PostID = N.PostID and P.BlogName = N.RootBlogName
WHERE RootBlogName NOT IN ('xlittle-ghost', 'glimmerin-darlin', 'vvenus-child')␍
and type like 'r%'␍
and RootBlogName = 'zomb-eh'␍
and P.HasImage = 1
ORDER BY ORDER BY
TimeStamp desc</sql><current_tab id="0"/></tab_sql></sqlb_project> TimeStamp desc</sql><current_tab id="1"/></tab_sql></sqlb_project>
+277
View File
@@ -0,0 +1,277 @@
using Microsoft.Extensions.Configuration;
namespace URLNotesGrabberCORE
{
// Port of ThreeTxtFileHelper RunCorrectionMode (dry-run) and RunFullCorrectionMode (apply).
// Scans a BAK directory of .txt files, parses posts with multi-line field support,
// and either reports or applies content-column corrections to TL.db.Posts.
// Apply path only writes non-empty values (mirrors original ThreeTxtFileHelper semantics)
// and never touches engagement columns.
public static class CorrectMode
{
public static int Run(IConfiguration config, string[] args, bool applyChanges)
{
DataAccess.EnsureTTFileHelperColumnsExist();
// Resolve BAK path: explicit arg > appSettings:PathTTBackup > derive from PathTTRoot/PathInput
string? bakRootPath = args.Length > 0 ? args[0] : config["appSettings:PathTTBackup"];
if (string.IsNullOrWhiteSpace(bakRootPath))
{
string? root = config["appSettings:PathTTRoot"];
if (string.IsNullOrWhiteSpace(root)) root = config["appSettings:PathInput"];
if (!string.IsNullOrWhiteSpace(root))
bakRootPath = root.TrimEnd('\\', '/') + "_BAK\\";
}
if (string.IsNullOrWhiteSpace(bakRootPath) || !Directory.Exists(bakRootPath))
{
Console.WriteLine($"BAK directory not found: {bakRootPath}");
return 1;
}
Console.WriteLine($"BAK source path: {bakRootPath}");
Console.WriteLine(applyChanges
? "Correction mode: APPLY - non-empty fields from BAK overwrite DB columns\n"
: "Correction mode: Dry-run - reports multi-line field updates available\n");
string prefixesPath = config["appSettings:PathPrefixes"] ?? "prefixes.txt";
if (!Path.IsPathRooted(prefixesPath))
prefixesPath = Path.Combine(AppContext.BaseDirectory, prefixesPath);
if (!File.Exists(prefixesPath))
{
Console.WriteLine($"Prefixes file not found: {prefixesPath}");
return 1;
}
var allowedPrefixes = new HashSet<string>(File.ReadLines(prefixesPath), StringComparer.OrdinalIgnoreCase);
if (applyChanges)
{
Console.Write("WARNING: This will overwrite non-empty fields in matching posts from BAK files. Continue? (yes/no): ");
string? response = Console.ReadLine();
if (string.IsNullOrWhiteSpace(response) || !response.Equals("yes", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine("Operation cancelled.");
return 0;
}
}
var bakTxtFiles = new List<string>();
try
{
foreach (var dir in Directory.GetDirectories(bakRootPath, "*", SearchOption.AllDirectories))
bakTxtFiles.AddRange(Directory.GetFiles(dir, "*.txt"));
bakTxtFiles.AddRange(Directory.GetFiles(bakRootPath, "*.txt"));
}
catch (Exception ex)
{
Console.WriteLine($"Error scanning BAK directory: {ex.Message}");
return 1;
}
Console.WriteLine($"Found {bakTxtFiles.Count} file(s) in BAK directory\n");
int totalPostsFound = 0;
int postsWithUpdates = 0;
int postsUpdated = 0;
int postsNotFound = 0;
var correctionLog = new List<string>();
var updateLog = new List<string>();
foreach (string bakFile in bakTxtFiles)
{
Console.WriteLine($"Processing BAK file: {Path.GetFileName(bakFile)}");
try
{
var bakPosts = ParsePostsFromFile(bakFile, allowedPrefixes);
Console.WriteLine($" Found {bakPosts.Count} post(s) in this file");
foreach (var (postId, bakData) in bakPosts)
{
totalPostsFound++;
var dbPost = DataAccess.GetPostByIdAnyBlog(postId);
if (dbPost == null)
{
postsNotFound++;
continue;
}
if (applyChanges)
{
bool updated = DataAccess.UpdatePostContentFields(dbPost.BlogName, dbPost.PostId, bakData);
if (updated)
{
postsUpdated++;
updateLog.Add($"Post ID: {postId} - Updated from {Path.GetFileName(bakFile)}");
}
}
else
{
var updateList = BuildDryRunDiff(bakData, dbPost);
if (updateList.Count > 0)
{
postsWithUpdates++;
correctionLog.Add($"\nPost ID: {postId}");
correctionLog.Add($" File: {Path.GetFileName(bakFile)}");
correctionLog.Add($" Fields to update:");
correctionLog.AddRange(updateList);
}
}
}
}
catch (Exception ex)
{
Console.WriteLine($" ERROR processing file: {ex.Message}");
}
}
if (applyChanges)
{
Console.WriteLine($"\n========== CORRECTION COMPLETE ==========");
Console.WriteLine($"Total posts found in BAK files: {totalPostsFound}");
Console.WriteLine($"Posts updated in database: {postsUpdated}");
Console.WriteLine($"Posts not found in database: {postsNotFound}");
string logPath = config["appSettings:PathCorrectionApplied"] ?? "correction_applied.txt";
try
{
var logLines = new List<string>
{
$"Correction Applied: {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
$"Total posts found in BAK files: {totalPostsFound}",
$"Posts updated in database: {postsUpdated}",
$"Posts not found in database: {postsNotFound}",
"",
"Updated Posts:"
};
logLines.AddRange(updateLog);
File.WriteAllLines(logPath, logLines);
Console.WriteLine($"Update log saved to: {logPath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error writing log file: {ex.Message}");
}
}
else
{
Console.WriteLine($"\n========== CORRECTION REPORT (DRY RUN) ==========");
Console.WriteLine($"Total posts found in BAK files: {totalPostsFound}");
Console.WriteLine($"Posts with multi-line field updates available: {postsWithUpdates}");
if (correctionLog.Count > 0)
{
string logPath = config["appSettings:PathCorrectionReport"] ?? "correction_report.txt";
try
{
File.WriteAllLines(logPath, correctionLog);
Console.WriteLine($"\nDetailed report saved to: {logPath}");
}
catch (Exception ex)
{
Console.WriteLine($"Error writing report file: {ex.Message}");
}
}
else
{
Console.WriteLine("\nNo multi-line field updates found.");
}
Console.WriteLine("\nDry-run complete. No database changes were made.");
Console.WriteLine("If updates look correct, re-run with `-correct -apply` to apply changes.");
}
return 0;
}
private static List<string> BuildDryRunDiff(Dictionary<string, string> bakData, TTPostRecord dbPost)
{
var updates = new List<string>();
foreach (var (fieldName, bakValue) in bakData)
{
if (string.IsNullOrWhiteSpace(bakValue)) continue;
string? currentValue = fieldName.ToLowerInvariant() switch
{
"reblog url" => dbPost.ReblogUrl,
"date" => dbPost.Date,
"has image" => dbPost.HasImage,
"post url" => dbPost.PostUrl,
"slug" => dbPost.Slug,
"reblog key" => dbPost.ReblogKey,
"reblog name" => dbPost.ReblogName,
"summary" => dbPost.Summary,
"quote" => dbPost.Quote,
"body" => dbPost.Body,
"tags" => dbPost.Tags,
"link" => dbPost.Link,
"photo url" => dbPost.PhotoUrl,
"photo caption" => dbPost.PhotoCaption,
"downloaded files" => dbPost.DownloadedFiles,
"audio caption" => dbPost.AudioCaption,
"question" => dbPost.Question,
"answer" => dbPost.Answer,
"title" => dbPost.Title,
_ => null
};
if (bakValue != currentValue && bakValue.Contains('\n'))
updates.Add($" {fieldName}: [MULTILINE]");
}
return updates;
}
private static Dictionary<string, Dictionary<string, string>> ParsePostsFromFile(string filePath, HashSet<string> allowedPrefixes)
{
var posts = new Dictionary<string, Dictionary<string, string>>(StringComparer.OrdinalIgnoreCase);
var lines = File.ReadAllLines(filePath);
int lineIndex = 0;
string currentPostId = "";
var currentPostData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
while (lineIndex < lines.Length)
{
string line = lines[lineIndex];
string searchText = line.Length > 25 ? line.Substring(0, 25) : line;
int colonIndex = searchText.IndexOf(": ");
if (colonIndex > 0)
{
string prefix = line.Substring(0, colonIndex).Trim();
if (!string.IsNullOrWhiteSpace(prefix) && allowedPrefixes.Contains(prefix))
{
if (string.Equals(prefix, "Post ID", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrWhiteSpace(currentPostId) && currentPostData.Count > 0)
posts[currentPostId] = new Dictionary<string, string>(currentPostData, StringComparer.OrdinalIgnoreCase);
currentPostId = line.Substring(colonIndex + 2).Trim();
currentPostData.Clear();
lineIndex++;
continue;
}
var valueLines = new List<string> { line.Substring(colonIndex + 2).Trim() };
int nextLineIndex = lineIndex + 1;
while (nextLineIndex < lines.Length)
{
string nextLine = lines[nextLineIndex];
string nextSearch = nextLine.Length > 25 ? nextLine.Substring(0, 25) : nextLine;
int nextColon = nextSearch.IndexOf(": ");
if (nextColon > 0)
{
string nextPrefix = nextLine.Substring(0, nextColon).Trim();
if (!string.IsNullOrWhiteSpace(nextPrefix) && allowedPrefixes.Contains(nextPrefix))
break;
}
valueLines.Add(nextLine);
nextLineIndex++;
}
currentPostData[prefix] = string.Join("\n", valueLines);
lineIndex = nextLineIndex;
continue;
}
}
lineIndex++;
}
if (!string.IsNullOrWhiteSpace(currentPostId) && currentPostData.Count > 0)
posts[currentPostId] = new Dictionary<string, string>(currentPostData, StringComparer.OrdinalIgnoreCase);
return posts;
}
}
}
File diff suppressed because it is too large Load Diff
+219
View File
@@ -0,0 +1,219 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Configuration;
namespace URLNotesGrabberCORE
{
// Port of ThreeTxtFileHelper RunIngestMode. Scans a root folder for .txt files,
// parses Tumblr-export fields (multi-line aware, prefix-driven), upserts each
// post into TL.db.Posts via DataAccess.UpsertPostFromTextFile.
public static class IngestMode
{
public static int Run(IConfiguration config, string[] args)
{
string? targetBlog = args.Length > 0 ? args[0]?.Trim() : null;
if (string.IsNullOrWhiteSpace(targetBlog)) targetBlog = null;
string? rootPath = config["appSettings:PathTTRoot"];
if (string.IsNullOrWhiteSpace(rootPath))
rootPath = config["appSettings:PathInput"];
if (string.IsNullOrWhiteSpace(rootPath))
{
Console.WriteLine("Ingest: no root path configured. Set appSettings:PathTTRoot or appSettings:PathInput.");
return 1;
}
if (!Directory.Exists(rootPath))
{
Console.WriteLine($"Directory not found: {rootPath}");
return 1;
}
DataAccess.EnsureTTFileHelperColumnsExist();
string prefixesPath = config["appSettings:PathPrefixes"] ?? "prefixes.txt";
if (!Path.IsPathRooted(prefixesPath))
prefixesPath = Path.Combine(AppContext.BaseDirectory, prefixesPath);
if (!File.Exists(prefixesPath))
{
Console.WriteLine($"Prefixes file not found: {prefixesPath}");
return 1;
}
var allowedPrefixes = new HashSet<string>(File.ReadLines(prefixesPath), StringComparer.OrdinalIgnoreCase);
Console.WriteLine($"Loaded {allowedPrefixes.Count} prefixes from {prefixesPath}");
Console.WriteLine($"========== Ingest Settings ==========");
Console.WriteLine($"Root path: {rootPath}");
Console.WriteLine($"Blog filter: {(targetBlog == null ? "(all blogs)" : targetBlog)}");
Console.WriteLine($"=====================================");
var txtFiles = new List<string>();
try
{
var dirs = Directory.GetDirectories(rootPath, "*", SearchOption.AllDirectories);
Console.WriteLine($"Found {dirs.Length} directories under root.");
foreach (var dir in dirs)
{
try { txtFiles.AddRange(Directory.GetFiles(dir, "*.txt")); }
catch (Exception ex) { Console.WriteLine($" Skipping {dir}: {ex.Message}"); }
}
txtFiles.AddRange(Directory.GetFiles(rootPath, "*.txt"));
}
catch (Exception ex)
{
Console.WriteLine($"Error scanning root: {ex.Message}");
return 1;
}
Console.WriteLine($"Processing {txtFiles.Count} .txt file(s)...");
int filesProcessed = 0;
int filesSkipped = 0;
int postsTouched = 0;
string? lastBlogFolder = null;
try
{
DataAccess.EnableImportModePragmas();
DataAccess.BeginImportSession();
foreach (string file in txtFiles)
{
try
{
string rawBlogName = Path.GetFileName(Path.GetDirectoryName(file) ?? "unknown");
string blogName = Regex.Replace(rawBlogName, @"_\d+$", "");
string postType = Path.GetFileNameWithoutExtension(file);
if (targetBlog != null && !string.Equals(blogName, targetBlog, StringComparison.OrdinalIgnoreCase))
{
filesSkipped++;
continue;
}
filesProcessed++;
if (lastBlogFolder != rawBlogName)
{
Console.WriteLine($"[{filesProcessed}/{txtFiles.Count}] >> entering folder: {rawBlogName}");
lastBlogFolder = rawBlogName;
}
else if (filesProcessed % 50 == 0)
{
Console.WriteLine($"[{filesProcessed}/{txtFiles.Count}] {rawBlogName}/{Path.GetFileName(file)}");
}
string currentPostId = "";
var currentPostData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
void Flush()
{
if (!string.IsNullOrWhiteSpace(currentPostId) && currentPostData.Count > 0)
{
UpsertPostFromParsedData(blogName, currentPostId, postType, currentPostData);
postsTouched++;
}
}
var lines = File.ReadAllLines(file);
int lineIndex = 0;
while (lineIndex < lines.Length)
{
string line = lines[lineIndex];
string searchText = line.Length > 25 ? line.Substring(0, 25) : line;
int colonIndex = searchText.IndexOf(": ");
if (colonIndex > 0)
{
string prefix = line.Substring(0, colonIndex).Trim();
if (!string.IsNullOrWhiteSpace(prefix) && allowedPrefixes.Contains(prefix))
{
if (string.Equals(prefix, "Post ID", StringComparison.OrdinalIgnoreCase))
{
Flush();
currentPostId = line.Substring(colonIndex + 2).Trim();
currentPostData.Clear();
lineIndex++;
continue;
}
var valueLines = new List<string> { line.Substring(colonIndex + 2).Trim() };
int nextLineIndex = lineIndex + 1;
while (nextLineIndex < lines.Length)
{
string nextLine = lines[nextLineIndex];
string nextSearch = nextLine.Length > 25 ? nextLine.Substring(0, 25) : nextLine;
int nextColon = nextSearch.IndexOf(": ");
if (nextColon > 0)
{
string nextPrefix = nextLine.Substring(0, nextColon).Trim();
if (!string.IsNullOrWhiteSpace(nextPrefix) && allowedPrefixes.Contains(nextPrefix))
break;
}
valueLines.Add(nextLine);
nextLineIndex++;
}
currentPostData[prefix] = string.Join("\n", valueLines);
lineIndex = nextLineIndex;
continue;
}
}
lineIndex++;
}
Flush();
}
catch (Exception ex)
{
Console.WriteLine($" ERROR processing file {file}: {ex.Message}");
}
}
}
finally
{
DataAccess.EndImportSession();
DataAccess.RestoreImportModePragmas();
}
Console.WriteLine($"\nIngest complete. Files processed: {filesProcessed}. Files skipped (blog filter): {filesSkipped}. Posts touched: {postsTouched}.");
return 0;
}
private static void UpsertPostFromParsedData(string blogName, string postId, string postType, Dictionary<string, string> data)
{
string? G(string key) => data.TryGetValue(key, out var v) ? v : null;
string? hasImageStr = G("Has Image");
bool hasImage = !string.IsNullOrWhiteSpace(hasImageStr)
&& (hasImageStr.Equals("true", StringComparison.OrdinalIgnoreCase)
|| hasImageStr == "1"
|| hasImageStr.Equals("yes", StringComparison.OrdinalIgnoreCase));
DataAccess.UpsertPostFromTextFile(
blogName: blogName,
postID: postId,
reblogURL: G("reblog URL"),
postDate: G("Date"),
postURL: G("Post URL"),
slug: G("Slug"),
reblogKey: G("Reblog Key"),
reblogName: G("Reblog Name"),
summary: G("Summary"),
quote: G("Quote"),
body: G("Body"),
tags: G("Tags"),
link: G("Link"),
photoURL: G("Photo URL"),
photoCaption: G("Photo Caption"),
downloadedFiles: G("Downloaded Files"),
audioCaption: G("Audio Caption"),
question: G("Question"),
answer: G("Answer"),
title: G("Title"),
postType: postType,
hasImage: hasImage);
}
}
}
@@ -0,0 +1,156 @@
using System.Data.SQLite;
namespace URLNotesGrabberCORE
{
// One-time migration: opens a legacy ThreeTxtFileHelper posts.db, copies its
// Blog + PostData rows into the merged TL.db via DataAccess.
// Conflict rule on (BlogName, PostId): ThreeTxtFileHelper wins on the 22 content
// columns + PostType + DateModified (handled inside UpsertPostFromTextFile).
// Engagement columns in TL.db (ByLikes, RootBlogName, RootURL, HasNotesGathered,
// NotFound, NotesGatheredDateTime) are preserved.
public static class LegacyPostsDbImporter
{
public static int Run(string legacyDbPath)
{
if (string.IsNullOrWhiteSpace(legacyDbPath))
{
Console.WriteLine("LegacyPostsDbImporter: path to legacy posts.db is required.");
return 1;
}
if (!File.Exists(legacyDbPath))
{
Console.WriteLine($"Legacy posts.db not found at: {legacyDbPath}");
return 1;
}
DataAccess.EnsureTTFileHelperColumnsExist();
Console.WriteLine($"Reading legacy posts.db: {legacyDbPath}");
int blogsCopied = 0;
int blogPathsWritten = 0;
int blogsWithoutPath = 0;
int postsUpserted = 0;
int errors = 0;
try
{
using var src = new SQLiteConnection("Data Source=" + legacyDbPath + ";Read Only=True;");
src.Open();
// 1) Copy Blogs (BlogName + TTFolderPath)
using (var cmd = new SQLiteCommand("SELECT BlogName, TTFolderPath FROM Blogs", src))
using (var reader = cmd.ExecuteReader())
{
while (reader.Read())
{
string blogName = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
string? ttFolderPath = reader.IsDBNull(1) ? null : reader.GetString(1);
if (string.IsNullOrWhiteSpace(blogName)) continue;
try
{
// 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++;
}
catch (Exception ex)
{
errors++;
Console.WriteLine($" Blog copy failed for '{blogName}': {ex.Message}");
}
}
}
Console.WriteLine($" Blogs seen: {blogsCopied}, TTFolderPath written: {blogPathsWritten}, legacy rows with no path: {blogsWithoutPath}");
// 2) Copy Posts
try
{
DataAccess.EnableImportModePragmas();
DataAccess.BeginImportSession();
string sql = @"SELECT BlogName, PostId, ReblogUrl, Date, HasImage, PostUrl, Slug,
ReblogKey, ReblogName, Summary, Quote, Body, Tags, Link,
PhotoUrl, PhotoCaption, DownloadedFiles, AudioCaption,
Question, Answer, Title, PostType
FROM Posts";
using var cmd = new SQLiteCommand(sql, src);
using var reader = cmd.ExecuteReader();
while (reader.Read())
{
try
{
string blogName = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
string postId = reader.IsDBNull(1) ? string.Empty : reader.GetString(1);
if (string.IsNullOrWhiteSpace(blogName) || string.IsNullOrWhiteSpace(postId)) continue;
string? hasImageRaw = reader.IsDBNull(4) ? null : reader.GetValue(4)?.ToString();
bool hasImage = !string.IsNullOrWhiteSpace(hasImageRaw)
&& (hasImageRaw.Equals("true", StringComparison.OrdinalIgnoreCase)
|| hasImageRaw == "1"
|| hasImageRaw.Equals("yes", StringComparison.OrdinalIgnoreCase));
DataAccess.UpsertPostFromTextFile(
blogName: blogName,
postID: postId,
reblogURL: reader.IsDBNull(2) ? null : reader.GetString(2),
postDate: reader.IsDBNull(3) ? null : reader.GetString(3),
postURL: reader.IsDBNull(5) ? null : reader.GetString(5),
slug: reader.IsDBNull(6) ? null : reader.GetString(6),
reblogKey: reader.IsDBNull(7) ? null : reader.GetString(7),
reblogName: reader.IsDBNull(8) ? null : reader.GetString(8),
summary: reader.IsDBNull(9) ? null : reader.GetString(9),
quote: reader.IsDBNull(10) ? null : reader.GetString(10),
body: reader.IsDBNull(11) ? null : reader.GetString(11),
tags: reader.IsDBNull(12) ? null : reader.GetString(12),
link: reader.IsDBNull(13) ? null : reader.GetString(13),
photoURL: reader.IsDBNull(14) ? null : reader.GetString(14),
photoCaption: reader.IsDBNull(15) ? null : reader.GetString(15),
downloadedFiles: reader.IsDBNull(16) ? null : reader.GetString(16),
audioCaption: reader.IsDBNull(17) ? null : reader.GetString(17),
question: reader.IsDBNull(18) ? null : reader.GetString(18),
answer: reader.IsDBNull(19) ? null : reader.GetString(19),
title: reader.IsDBNull(20) ? null : reader.GetString(20),
postType: reader.IsDBNull(21) ? null : reader.GetString(21),
hasImage: hasImage);
postsUpserted++;
if (postsUpserted % 500 == 0)
Console.WriteLine($" ... {postsUpserted} posts upserted");
}
catch (Exception ex)
{
errors++;
if (errors < 20)
Console.WriteLine($" Post upsert error: {ex.Message}");
}
}
}
finally
{
DataAccess.EndImportSession();
DataAccess.RestoreImportModePragmas();
}
Console.WriteLine($" Posts upserted: {postsUpserted}");
}
catch (Exception ex)
{
Console.WriteLine($"Fatal error reading legacy posts.db: {ex.Message}");
return 1;
}
Console.WriteLine($"\n========== Legacy import summary ==========");
Console.WriteLine($"Blogs seen: {blogsCopied}");
Console.WriteLine($"Paths written: {blogPathsWritten} (legacy rows with no path: {blogsWithoutPath})");
Console.WriteLine($"Posts upserted: {postsUpserted}");
Console.WriteLine($"Errors: {errors}");
return errors == 0 ? 0 : 2;
}
}
}
+206
View File
@@ -0,0 +1,206 @@
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.");
var grouped = posts.GroupBy(p => p.PostType ?? "Unknown");
foreach (var typeGroup in grouped)
{
string postType = typeGroup.Key ?? "Unknown";
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]}");
}
}
}
}
File diff suppressed because it is too large Load Diff
@@ -2,7 +2,7 @@
"profiles": { "profiles": {
"URLNotesGrabberCORE": { "URLNotesGrabberCORE": {
"commandName": "Project", "commandName": "Project",
"commandLineArgs": "-collect 0" "commandLineArgs": "--collect 1 --api4"
} }
} }
} }
+60 -2
View File
@@ -5,12 +5,12 @@ using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace URLNotesGrabberCORE namespace URLNotesGrabberCORE
{ {
internal class ResponseNotes internal class ResponseNotes
{ {
} }
// Root myDeserializedClass = JsonConvert.DeserializeObject<Root>(myJsonResponse); // Root myDeserializedClass = JsonConvert.DeserializeObject<Root>(myJsonResponse);
public class AvatarUrl public class AvatarUrl
{ {
@@ -42,7 +42,7 @@ namespace URLNotesGrabberCORE
public class Note public class Note
{ {
public string type { get; set; } public string type { get; set; }
public int timestamp { get; set; } public long timestamp { get; set; }
public string blog_name { get; set; } public string blog_name { get; set; }
public string blog_uuid { get; set; } public string blog_uuid { get; set; }
public string blog_url { get; set; } public string blog_url { get; set; }
@@ -51,6 +51,7 @@ namespace URLNotesGrabberCORE
public AvatarUrl avatar_url { get; set; } public AvatarUrl avatar_url { get; set; }
public string post_id { get; set; } public string post_id { get; set; }
public string reblog_parent_blog_name { get; set; } public string reblog_parent_blog_name { get; set; }
public string reply_text { get; set; }
} }
public class QueryParams public class QueryParams
@@ -58,6 +59,8 @@ namespace URLNotesGrabberCORE
public string mode { get; set; } public string mode { get; set; }
public string id { get; set; } public string id { get; set; }
public string before_timestamp { get; set; } public string before_timestamp { get; set; }
public string before { get; set; }
public string after { get; set; }
} }
public class Response public class Response
@@ -82,5 +85,60 @@ namespace URLNotesGrabberCORE
public int retryInSeconds { get; set; } public int retryInSeconds { get; set; }
public string rawJson { get; set; } public string rawJson { get; set; }
// The request never reached the Tumblr API (transport error, or an edge/CDN response with a
// non-JSON body). Says nothing about the post, so the caller should retry rather than fail it.
public bool transientFailure { get; set; }
}
// Classes for Posts API endpoint (for reply_text)
public class PostsResponse
{
public List<Post> posts { get; set; }
}
public class Post
{
public long id { get; set; }
public List<NoteDetail> notes { get; set; }
}
public class NoteDetail
{
public long timestamp { get; set; }
public string type { get; set; }
public string blog_name { get; set; }
public string reply_text { get; set; }
}
public class PostsRoot
{
public Meta meta { get; set; }
[JsonConverter(typeof(EmptyArrayOrObjectConverter<PostsResponse>))]
public PostsResponse response { get; set; }
public string statusCode { get; set; }
public int retryInSeconds { get; set; }
public string rawJson { get; set; }
}
public class LikesResponse
{
public List<dynamic> liked_posts { get; set; }
public int liked_count { get; set; }
public Links _links { get; set; }
}
public class LikesRoot
{
public Meta meta { get; set; }
[JsonConverter(typeof(EmptyArrayOrObjectConverter<LikesResponse>))]
public LikesResponse response { get; set; }
public string statusCode { get; set; }
public int retryInSeconds { get; set; }
public string rawJson { get; set; }
} }
} }
+110
View File
@@ -0,0 +1,110 @@
using Microsoft.Extensions.Configuration;
namespace URLNotesGrabberCORE
{
// Inverse of OutputMode. Recursively walks the PathInput tree (the same directory tree the
// no-parameter run uses) and restores every *.bak back to its *.txt, first preserving the
// current *.txt as the next-free *.bkN. Consumes the *.bak (File.Move). Filesystem-only;
// does not read the DB. An optional blogname argument filters by path substring.
public static class RevertMode
{
public static int Run(IConfiguration config, string? blogFilter = null)
{
string? root = config["appSettings:PathInput"];
if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
{
Console.WriteLine($"PathInput is not set or does not exist: '{root}'. Nothing to revert.");
return 0;
}
Console.WriteLine($"Searching for .bak files under: {root}");
// Recursively collect every *.bak, optionally filtered by path substring (blogname).
var bakFiles = EnumerateBakFiles(root)
.Where(f => string.IsNullOrWhiteSpace(blogFilter)
|| f.IndexOf(blogFilter, StringComparison.OrdinalIgnoreCase) >= 0)
.ToList();
if (bakFiles.Count == 0)
{
Console.WriteLine("No .bak files found. Nothing to revert.");
return 0;
}
Console.Write($"WARNING: This will restore {bakFiles.Count} .bak file(s) over their .txt files. " +
$"Current .txt files are preserved as the next-free .bkN. Continue? (yes/no): ");
string? response = Console.ReadLine();
if (string.IsNullOrWhiteSpace(response) || !response.Equals("yes", StringComparison.OrdinalIgnoreCase))
{
Console.WriteLine("Operation cancelled.");
return 0;
}
int restored = 0, backedUp = 0;
foreach (var bakFile in bakFiles)
{
try
{
string txtPath = Path.ChangeExtension(bakFile, ".txt");
if (File.Exists(txtPath))
{
string bkPath = NextFreeBkPath(txtPath);
File.Move(txtPath, bkPath);
backedUp++;
Console.WriteLine($" Backed up {Path.GetFileName(txtPath)} -> {Path.GetFileName(bkPath)}");
}
File.Move(bakFile, txtPath);
restored++;
Console.WriteLine($" Restored {bakFile} -> {Path.GetFileName(txtPath)}");
}
catch (Exception ex)
{
Console.WriteLine($" Error reverting {bakFile}: {ex.Message}");
}
}
Console.WriteLine($"\nRevert mode complete. Restored {restored} file(s); backed up {backedUp} current .txt file(s).");
return 0;
}
// Recursively yields every *.bak path under root. Per-directory try/catch so an
// inaccessible folder doesn't abort the whole walk (mirrors TraverseDirectory).
private static IEnumerable<string> EnumerateBakFiles(string path)
{
string[] subDirs;
try { subDirs = Directory.GetDirectories(path); }
catch (Exception ex)
{
Console.WriteLine($" Skipping '{path}': {ex.Message}");
yield break;
}
foreach (var dir in subDirs)
foreach (var bak in EnumerateBakFiles(dir))
yield return bak;
string[] bakFiles;
try { bakFiles = Directory.GetFiles(path, "*.bak"); }
catch (Exception ex)
{
Console.WriteLine($" Skipping files in '{path}': {ex.Message}");
yield break;
}
foreach (var bak in bakFiles)
yield return bak;
}
// Returns the lowest unused .bkN path for a given .txt file (.bk1, .bk2, ...).
private static string NextFreeBkPath(string txtFile)
{
for (int n = 1; ; n++)
{
string candidate = Path.ChangeExtension(txtFile, $".bk{n}");
if (!File.Exists(candidate)) return candidate;
}
}
}
}
Binary file not shown.
+333
View File
@@ -0,0 +1,333 @@
# `TL.db` — schema notes
The SQLite database behind **URLNotesGrabberCORE** and its sibling crawlers, and the one
[Rolodex](https://git.basso.land/jim/Rolodex) reads.
Everything below was read out of the live file, not inferred from code. Counts are as of
**2026-07-29**; re-run the queries at the bottom to refresh them.
- Journal mode: **WAL**`TL.db-wal` and `TL.db-shm` live beside the file and are part of
the database. Copying `TL.db` alone gives you whatever was last checkpointed, not the
current state.
- Page size: 4096.
---
## The three content tables
| Table | Rows | What it is |
|---|--:|---|
| `Blogs` | 144,367 | The crawl registry — one row per known blog, plus crawl-state flags |
| `Posts` | 14,589 | Stored post content. Only 3,602 blogs actually have any |
| `Notes` | 1,189,604 | The engagement graph: `NoteBlogName` acted on `(RootBlogName, PostID)` |
The engagement graph is the interesting part. 31,888 distinct blogs appear as engagers —
far more than the 3,602 that have stored posts — which is what makes this a social graph
rather than a post archive.
### `Blogs`
```sql
CREATE TABLE "Blogs" (
"BlogName" TEXT,
"HasBeenOutput" INTEGER DEFAULT 0,
"IsActive" INTEGER DEFAULT 1,
"DateAdded" TEXT NOT NULL DEFAULT '12/24/25',
"ByLikes" INTEGER NOT NULL DEFAULT 0,
"LikesPulled" INTEGER NOT NULL DEFAULT 0,
"LikesCursor" INTEGER DEFAULT 0,
"DateModified" TEXT,
"DateCreated" TEXT,
LikesNewestTimestamp INTEGER DEFAULT 0,
LikesLastRefreshed INTEGER DEFAULT 0,
LikesLastNewCount INTEGER DEFAULT 0,
TTFolderPath TEXT,
PRIMARY KEY("BlogName")
);
```
`BlogName` is the primary key, so it is the only indexed way in. There is no index on any
flag or date — filtering or sorting on those scans all 144k rows, which is affordable
here and is not on `Notes`.
Flag distribution: `IsActive = 1` on 144,366 of 144,367 rows, `HasBeenOutput = 1` on
5,369, `ByLikes = 1` on 2. `IsActive` carries a second meaning as of Rolodex — see
[`Blogs.IsActive`](#blogsisactive--now-written-by-two-applications) below.
The columns after `DateCreated` were added later by `ALTER TABLE`, which is why they carry
no quoting in the stored DDL. That is the normal way this schema grows.
**`DateAdded` is not written consistently.** 126,423 rows hold ISO `yyyy-MM-dd HH:mm:ss`;
17,944 hold US-format `M/d/yy` from a bulk import. As text those two sort into different
parts of the table, so anything ordering or range-filtering on this column has to
normalise first — see `DateSql` in Rolodex.
### `Posts`
```sql
CREATE TABLE "Posts" (
"BlogName" TEXT,
"PostID" INTEGER,
"HasNotesGathered" INTEGER DEFAULT 0,
"reblogURL" TEXT,
"NotFound" INTEGER DEFAULT 0,
"PostDate" TEXT,
"NotesGatheredDateTime" INTEGER NOT NULL DEFAULT 1729746000,
"HasImage" INTEGER NOT NULL DEFAULT 0,
"PostURL" TEXT,
"Slug" TEXT,
"ReblogKey" TEXT,
"ReblogName" TEXT,
"Summary" TEXT,
"Quote" TEXT,
"Body" TEXT,
"Tags" TEXT,
"Link" TEXT,
"PhotoURL" TEXT,
"PhotoCaption" TEXT,
"DownloadedFiles" TEXT,
"AudioCaption" TEXT,
"Question" TEXT,
"Answer" TEXT,
"Title" TEXT,
"ByLikes" INTEGER NOT NULL DEFAULT 0,
"RootBlogName" TEXT,
"RootURL" TEXT,
"DateModified" TEXT,
"DateCreated" TEXT,
PostType TEXT,
PRIMARY KEY("BlogName","PostID")
);
```
**The key is `(BlogName, PostID)`, not `PostID`.** This matters more than it looks: 325
post IDs exist under more than one blog, so an ID on its own is both ambiguous *and*
unindexed. Any lookup should carry the blog name, and a batch lookup should group by blog
so it stays on the leading column of the key.
Notable:
- **`PostType` is `NULL` on all 14,589 rows.** The column exists but nothing has ever
populated it. Treat it as unpopulated rather than as a type discriminator.
- `HasImage = 1` on 14,268 rows — nearly all of them. It records that the post *had* a
picture, not that a usable URL was kept, so it is not a reliable predictor that anything
will render.
- `PhotoURL` is largely unused; in practice the image markup lives inside `Body`.
- `NotFound = 1` on 4,663 rows — posts that have since been deleted upstream.
- The content columns (`Body`, `Quote`, `Question`, `Answer`, …) are the heavy ones. List
views should not select them.
### `Notes`
```sql
CREATE TABLE "Notes" (
"RootBlogName" TEXT,
"PostID" INTEGER,
"NoteBlogName" TEXT,
"TimeStamp" INTEGER,
"Type" TEXT,
"replyText" TEXT DEFAULT '.',
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
"DateModified" TEXT,
"DateCreated" TEXT,
PRIMARY KEY("RootBlogName","PostID","TimeStamp","Type","NoteBlogName")
);
CREATE INDEX "Notes_idx_06e01ae3" ON "Notes" ("TimeStamp" DESC);
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
```
One row per engagement event. `TimeStamp` is **unix seconds** — unlike every date column
elsewhere in the schema, which are text.
| `Type` | Rows | Share |
|---|--:|--:|
| `like` | 947,955 | 79.7% |
| `reblog` | 224,323 | 18.9% |
| `reply` | 15,201 | 1.3% |
| `posted` | 2,106 | 0.2% |
| `post_attribution` | 19 | — |
At 1.19M rows this is the table that dictates how the whole database has to be queried:
- **Nothing should run an unbounded `SELECT` or a bare `COUNT(*)` here.** A count scans
the lot on every call.
- The only fast access paths are the primary key's leading columns (`RootBlogName`, then
`PostID`) and `ix_NoteBlogName01` on `NoteBlogName`. "Notes received by a blog" and
"notes given by a blog" are both cheap; almost nothing else is.
- Ordering by anything but `TimeStamp` is a full sort of whatever the filters leave.
- `replyText` is `'.'` on 1,174,706 rows — only `reply` notes carry real text.
### Referential integrity
There are no foreign keys, and the tables do not perfectly agree:
- 4 `Posts` rows name a blog with no `Blogs` row.
- 15 of the 31,888 distinct engagers have no `Blogs` row.
So a name appearing in `Notes` or `Posts` is not a guarantee that the registry knows about
it. Joins from those tables back to `Blogs` should tolerate a miss.
---
## The `'.'` placeholder convention
**The crawler writes a single dot into text columns it has no value for, rather than
`NULL`.** This is the single most surprising thing about the schema and it affects every
consumer.
| Column | `'.'` rows |
|---|--:|
| `Notes.replyText` | 1,174,706 |
| `Posts.Title` | 13,144 |
| `Posts.Body` | 172 |
Any query whose output reaches a human should collapse it:
```sql
NULLIF(NULLIF(SomeColumn, '.'), '') AS SomeColumn
```
Empty string turns up too, hence the double `NULLIF`. Not every column is affected —
`Blogs.TTFolderPath` and `Blogs.DateModified` currently have zero dot rows — but new
columns tend to acquire them, so treat cleaning as the default for any text column
rendered to a user.
---
## Supporting tables
Crawler bookkeeping. Rolodex ignores all of these.
| Table | Rows | What it is |
|---|--:|---|
| `DailyAPICount` | 133 | `(Date TEXT PK, APICount INTEGER)` — per-day API call tally against the rate limit |
| `ApiKeyPoolState` | 2 | `(KeyName TEXT PK, RetryUntil INTEGER)` — per-key backoff; `RetryUntil` is unix seconds |
| `ApiKeyPoolMeta` | 1 | `(Id PK CHECK (Id = 1), LastIndex)` — round-robin cursor. Singleton by check constraint |
| `CollectRunState` | 1 | `(Id PK CHECK (Id = 1), RunCutoff, RunComplete, RunStarted, RunCompletedAt)` — resume state for an interrupted collection run. Also a singleton |
---
## `Blogs.IsActive` — now written by two applications
`IsActive` has always been the crawler's work-selection flag. `GetBlogs` in
`DataAccess.cs` joins on it to decide what to collect:
```sql
SELECT NoteBlogName, count(*) FROM notes
INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName
WHERE blogs.IsActive = @isActive AND ...
```
Nothing inside the crawler *writes* it — it is an input, set from outside.
**Rolodex is now one of the things that sets it.** Removing a blog through the Rolodex UI
runs exactly this:
```sql
UPDATE Blogs SET IsActive = 0 WHERE BlogName = ?;
```
Rolodex adds no column and changes no schema. It reuses this flag because the two meanings
were judged to be one decision: a blog you do not want in the browsing UI is a blog you do
not want to keep crawling. Removal therefore stops collection, and the Rolodex
confirmation screen says so before anyone commits.
- `1` (or absent/NULL) — live. Crawled, and visible in Rolodex.
- `0` — removed. Not crawled, hidden from the Rolodex registry, dashboard counts and
engagement rollups.
Restoring is the same `UPDATE` with a `1`. Nothing is destroyed either way: the blog's
`Posts` and `Notes` rows are never touched, and Rolodex deliberately keeps showing them
under its Posts and Notes pages. Removing a blog hides the blog, not what it collected.
### What other tools need to know
1. **Setting `IsActive = 0` now also hides the blog from Rolodex**, and setting it back to
`1` makes it reappear. If another tool deactivates blogs in bulk, it is also removing
them from the browsing UI — which may be exactly right, but it is no longer a
crawler-only decision.
2. **Re-crawling a removed blog will not bring it back**, since nothing in the crawler
writes the flag. An `INSERT OR REPLACE` on the `Blogs` row *would*, by resetting it to
the column default of `1`. Prefer an `UPDATE` of the specific columns, or
`INSERT … ON CONFLICT DO UPDATE SET` naming only the columns being refreshed.
3. **NULL is treated as live.** The column is `INTEGER DEFAULT 1` with no `NOT NULL`, so
Rolodex reads it through `COALESCE(IsActive, 1)`. A NULL therefore leaves the blog
visible rather than stranding it outside both the registry and the removed list, where
no screen could reach it. Write `0` or `1`, not NULL.
4. **Backing the feature out is a configuration change, not a migration.** Because there is
no Rolodex-owned column, setting `Rolodex__EnableBlogDeletion=false` is the whole of it;
there is nothing to drop. Any blogs already at `IsActive = 0` simply go back to being
ordinary inactive blogs.
---
## `Posts.IsActive` and `Notes.IsActive` — optional, and not in this database yet
The same flag is being extended to the two content tables, with the same meaning: `0` is
removed, anything else — including `NULL` — is live. **Neither column exists in the live
`TL.db` as of 2026-07-29**; the DDL quoted above for `Posts` and `Notes` is complete. Like
`Blogs.IsActive`, they are written from outside this crawler.
The crawler therefore treats both as optional, and as nothing it owns:
- **It never writes them.** No `INSERT` column list names `IsActive`, no `UPDATE` sets it,
and `MapPrefixToColumn` — the only place a column name is chosen at runtime — cannot map
to it. Re-crawling a removed post or note refreshes its content and leaves the flag at
`0`. There is no `INSERT OR REPLACE` on `Posts` or `Notes` for a default to be reset by.
- **It filters on them only when they exist.** `HasIsActiveColumn` in `DataAccess.cs` asks
`PRAGMA table_info` once per table per database path and caches the answer; the filter
is `COALESCE(IsActive, 1) = 1`, and it is dropped entirely when the column is absent.
Naming a missing column is a hard SQLite error, so this is what lets one build run
against databases on both sides of the change. The cache lives for the process — adding
the columns to a live database takes effect on the next run.
Every read that selects posts or notes carries the filter: `GetPosts`, `GetReplies`,
`GetRepliesWithMissingText`, `GetRepliesWithFilledText`, `GetAllPostTextColumns`,
`GetAllPostsForBlog`, `GetPost`, `GetPostByIdAnyBlog`, and the engagement queries that
count or join `Notes` (`GetBlogs`, `GetBlogsAll`, `GetBlogsForLikes`). The one deliberate
omission is the `LEFT JOIN Notes` in `GetPosts`: nothing is selected from it and it can
neither add nor remove a row, so filtering it would buy nothing.
`LegacyPostsDbImporter` is unfiltered too — it reads a foreign legacy database whose
`Posts` table is not this schema.
Two consequences worth stating plainly, both inherited from how `Blogs.IsActive` is
handled:
1. **Removal hides a row; it does not freeze it.** The write paths are keyed on a post the
caller already selected, so an ingest or a correction run still overwrites the content
of a removed post. Only selection is filtered.
2. **`NULL` is live.** Write `0` or `1`, not `NULL`, but a `NULL` leaves the row visible
rather than stranding it.
---
## Reproducing the numbers
```sql
SELECT 'Blogs', COUNT(*) FROM Blogs
UNION ALL SELECT 'Posts', COUNT(*) FROM Posts
UNION ALL SELECT 'Notes', COUNT(*) FROM Notes;
-- note type mix
SELECT Type, COUNT(*) FROM Notes GROUP BY Type ORDER BY 2 DESC;
-- the two date shapes in Blogs.DateAdded
SELECT CASE WHEN DateAdded LIKE '____-__-__%' THEN 'ISO' ELSE 'US' END, COUNT(*)
FROM Blogs GROUP BY 1;
-- post IDs that are ambiguous without a blog name
SELECT COUNT(*) FROM (
SELECT PostID FROM Posts GROUP BY PostID HAVING COUNT(DISTINCT BlogName) > 1);
-- rows that reference a blog the registry does not have
SELECT COUNT(*) FROM Posts p
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogName = p.BlogName);
```
Open the file read-only so an inspection can never disturb a running crawl:
```bash
sqlite3 "file:TL.db?mode=ro" ".schema"
```
Binary file not shown.
@@ -29,6 +29,9 @@
<CopyToOutputDirectory>Always</CopyToOutputDirectory> <CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None> </None>
<None Update="TL.db"> <None Update="TL.db">
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
</None>
<None Update="prefixes.txt">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
</ItemGroup> </ItemGroup>
@@ -0,0 +1,164 @@
using System.Text.Json;
namespace URLNotesGrabberCORE
{
// Port of ThreeTxtFileHelper/UpdateBlogPaths.cs. Reads .tumblr / .tmblrpriv metadata
// 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 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))
return new ScanResult { Outcome = ScanOutcome.NoRootConfigured };
DataAccess.EnsureTTFileHelperColumnsExist();
string indexPath = Path.Combine(rootPath, "Index");
if (!Directory.Exists(indexPath))
{
return new ScanResult
{
Outcome = ScanOutcome.IndexFolderMissing,
RootPath = rootPath,
IndexPath = indexPath
};
}
var blogFiles = Directory.GetFiles(indexPath, "*.tumblr")
.Concat(Directory.GetFiles(indexPath, "*.tmblrpriv"))
.ToList();
if (verbose)
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
int updatedCount = 0;
int unchangedCount = 0;
int noLocationCount = 0;
int noRowCount = 0;
int errorCount = 0;
foreach (var blogFile in blogFiles)
{
try
{
string blogName = Path.GetFileNameWithoutExtension(blogFile);
string jsonContent = File.ReadAllText(blogFile);
using JsonDocument doc = JsonDocument.Parse(jsonContent);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("FileDownloadLocation", out JsonElement locationElement))
{
string? fileDownloadLocation = locationElement.GetString()?.Trim();
if (!string.IsNullOrWhiteSpace(fileDownloadLocation))
{
// Report the database's answer, not the fact that the file parsed.
if (DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation))
{
updatedCount++;
if (verbose)
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
{
noLocationCount++;
if (verbose)
Console.WriteLine($"Empty FileDownloadLocation in {blogFile}");
}
}
else
{
noLocationCount++;
if (verbose)
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
}
}
catch (Exception ex)
{
errorCount++;
Console.WriteLine($"Error processing {blogFile}: {ex.Message}");
}
}
return new ScanResult
{
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;
}
}
}
+31 -4
View File
@@ -1,4 +1,4 @@
{ {
"appSettings": { "appSettings": {
"PathInput": "u:\\jim\\Documents\\Web Copies\\blogs\\", "PathInput": "u:\\jim\\Documents\\Web Copies\\blogs\\",
"PathOutput": "u:\\jim\\Documents\\Web Copies\\blogs\\GetNotes.txt", "PathOutput": "u:\\jim\\Documents\\Web Copies\\blogs\\GetNotes.txt",
@@ -6,13 +6,40 @@
"PathOutputPosts": "u:\\jim\\Documents\\Web Copies\\blogs\\GetPosts.txt", "PathOutputPosts": "u:\\jim\\Documents\\Web Copies\\blogs\\GetPosts.txt",
"PathOutputBlogs": "u:\\jim\\Documents\\Web Copies\\blogs\\GetBlogs.txt", "PathOutputBlogs": "u:\\jim\\Documents\\Web Copies\\blogs\\GetBlogs.txt",
"PathOutputReplies": "u:\\jim\\Documents\\Web Copies\\blogs\\GetReplies.txt", "PathOutputReplies": "u:\\jim\\Documents\\Web Copies\\blogs\\GetReplies.txt",
"PathDB": "u:\\jim\\Documents\\Web Copies\\blogs\\TL.db", "PathOutputUrls": "u:\\jim\\Documents\\Web Copies\\blogs\\GetUrls.txt",
"ContainsList": "zombaee,zomb-eh,ahzombae,thebugandme,lovingbabybug,swarthyvillain,h4rdspot" "PathDB": "..\\..\\..\\tl.db",
"ContainsList": "zombaee,zomb-eh,ahzombae,thebugandme,lovingbabybug,h4rdspot",
"PostIDToExclude": "730084470076686336,726309940037287936,639974102120235008,184045126218",
"EnableFileLogging": false,
"LogTraversalRecordImports": false,
"LikesRefreshCooldownDays": 7,
"PathTTRoot": "",
"PathTTBackup": "",
"PathPrefixes": "prefixes.txt",
"PathCorrectionReport": "correction_report.txt",
"PathCorrectionApplied": "correction_applied.txt"
}, },
"TumblrApi": { "TumblrApi": {
"ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3", "ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3",
"ConsumerSecret": "sA8BwNVTVKqBRRJmHbAD6NuyKPJ3bb9cei2bYhMgqT8cLX8tSG", "ConsumerSecret": "sA8BwNVTVKqBRRJmHbAD6NuyKPJ3bb9cei2bYhMgqT8cLX8tSG",
"OAuthToken": "HPJI6IijHoKN6WzBumG7KjS7g01iCu07jQsulpueKbWN1ZJ35J", "OAuthToken": "HPJI6IijHoKN6WzBumG7KjS7g01iCu07jQsulpueKbWN1ZJ35J",
"OAuthTokenSecret": "ajkN0Z1kKrsJaIXZEDI8zLsjRSQxFkfgO1i5k78FuQYCQjSY7w" "OAuthTokenSecret": "ajkN0Z1kKrsJaIXZEDI8zLsjRSQxFkfgO1i5k78FuQYCQjSY7w",
"PoolEnabled": true,
"Color": "Cyan"
},
"TumblrApi3": {
"ConsumerKey": "Jmoh13AS9hKBYsf939uENQsiWUuZJLFT3do4YK9P0bpspVuxkH",
"ConsumerSecret": "8LcHjuqpOS9gZZPaML1joT330w5NqyGdreSqSBazdAWCADzcms",
"OAuthToken": "Gm2esFTeb5LKsb4A2YJGrF2Udycfc0AF8afof2qNGyzNSq1qWM",
"OAuthTokenSecret": "Kfokor80s5fVue91OmNHidCdILZ9AnHtRplrct2P6Q75jgNTLa",
"PoolEnabled": false
},
"TumblrApi4": {
"ConsumerKey": "gOuydIEENkRmEvvuf57R69yPRb39FC0Egb9p6ntxWuCuFzlsV3",
"ConsumerSecret": "0my913slXAHgra4mEEYV98stJ3wXGjPTWJeviiZXwJ2AqzYtIF ",
"OAuthToken": "JHk08YufSrsetTR1Ekuh10cPOzC6rjqU5WwVu3uV4vvLzp0w8w",
"OAuthTokenSecret": "1oryr4WMBxuDrt4vL4sJQs5qNagkH3KxwIf61M8SfvklTrhHSb",
"PoolEnabled": true,
"Color": "Yellow"
} }
} }
+20
View File
@@ -0,0 +1,20 @@
Post ID
reblog URL
Date
Has Image
Post URL
Slug
Reblog Key
Reblog Name
Summary
Quote
Body
Tags
Link
Photo URL
Photo Caption
Downloaded Files
Audio Caption
Question
Answer
Title
+4005
View File
File diff suppressed because one or more lines are too long
+202
View File
@@ -0,0 +1,202 @@
-- ============================================================================
-- verify-db-schema.sql
--
-- Purpose: Verify that a TL.db (e.g. a restored backup) has every column the
-- current URLNotesGrabberCORE code expects. The app has NO startup
-- migration: missing columns only get added when specific modes run,
-- and a referenced-but-missing column causes a "no such column" crash.
--
-- How to use (DB Browser for SQLite):
-- 1. File > Open Database -> pick the restored backup.
-- 2. Execute SQL tab. Run SECTION 1 (it is read-only).
-- * Zero rows from every query = schema is fully aligned, you're done.
-- * Rows in "MISSING COLUMNS" = copy the run_this_to_fix text.
-- 3. If columns are missing: KEEP A COPY OF THE BACKUP FIRST, then go to
-- SECTION 2, uncomment ONLY the ALTER lines that match the report, and run.
-- 4. Re-run SECTION 1 to confirm zero rows.
--
-- This script never UPDATEs/DELETEs/DROPs. In particular it deliberately does
-- NOT replicate the likes-reset that the app's -likes migration performs
-- (DataAccess.cs:375), so existing likes high-water marks are preserved.
-- ============================================================================
-- ============================================================================
-- SECTION 1 -- VERIFICATION (read-only)
-- ============================================================================
-- Expected schema for the current code version.
-- alter_stmt is a runnable ALTER for additively-fixable columns; for base
-- columns it is a 'MANUAL REVIEW' note (a missing base column means the backup
-- predates the table's creation or is damaged -- do not blindly auto-add).
WITH expected(tbl, col, alter_stmt) AS (
VALUES
-- Posts (base columns: manual review if missing)
('Posts','BlogName', 'MANUAL REVIEW - base/PK column missing'),
('Posts','PostID', 'MANUAL REVIEW - base/PK column missing'),
('Posts','HasNotesGathered', 'MANUAL REVIEW - base column missing'),
('Posts','reblogURL', 'MANUAL REVIEW - base column missing'),
('Posts','NotFound', 'MANUAL REVIEW - base column missing'),
('Posts','PostDate', 'MANUAL REVIEW - base column missing'),
('Posts','NotesGatheredDateTime', 'MANUAL REVIEW - base column missing'),
('Posts','HasImage', 'MANUAL REVIEW - base column missing'),
('Posts','PostURL', 'MANUAL REVIEW - base column missing'),
('Posts','Slug', 'MANUAL REVIEW - base column missing'),
('Posts','ReblogKey', 'MANUAL REVIEW - base column missing'),
('Posts','ReblogName', 'MANUAL REVIEW - base column missing'),
('Posts','Summary', 'MANUAL REVIEW - base column missing'),
('Posts','Quote', 'MANUAL REVIEW - base column missing'),
('Posts','Body', 'MANUAL REVIEW - base column missing'),
('Posts','Tags', 'MANUAL REVIEW - base column missing'),
('Posts','Link', 'MANUAL REVIEW - base column missing'),
('Posts','PhotoURL', 'MANUAL REVIEW - base column missing'),
('Posts','PhotoCaption', 'MANUAL REVIEW - base column missing'),
('Posts','DownloadedFiles', 'MANUAL REVIEW - base column missing'),
('Posts','AudioCaption', 'MANUAL REVIEW - base column missing'),
('Posts','Question', 'MANUAL REVIEW - base column missing'),
('Posts','Answer', 'MANUAL REVIEW - base column missing'),
('Posts','Title', 'MANUAL REVIEW - base column missing'),
('Posts','ByLikes', 'MANUAL REVIEW - base column missing'),
('Posts','RootBlogName', 'MANUAL REVIEW - base column missing'),
('Posts','RootURL', 'MANUAL REVIEW - base column missing'),
('Posts','DateModified', 'MANUAL REVIEW - base column missing'),
('Posts','DateCreated', 'MANUAL REVIEW - base column missing'),
-- Posts (additive migration column, auto-fixable)
('Posts','PostType', 'ALTER TABLE Posts ADD COLUMN PostType TEXT;'),
-- Blogs (base columns: manual review if missing)
('Blogs','BlogName', 'MANUAL REVIEW - base/PK column missing'),
('Blogs','HasBeenOutput', 'MANUAL REVIEW - base column missing'),
('Blogs','IsActive', 'MANUAL REVIEW - base column missing'),
('Blogs','DateAdded', 'MANUAL REVIEW - base column missing'),
('Blogs','ByLikes', 'MANUAL REVIEW - base column missing'),
('Blogs','DateModified', 'MANUAL REVIEW - base column missing'),
('Blogs','DateCreated', 'MANUAL REVIEW - base column missing'),
-- Blogs (additive migration columns, auto-fixable)
('Blogs','LikesPulled', 'ALTER TABLE Blogs ADD COLUMN LikesPulled INTEGER DEFAULT 0;'),
('Blogs','LikesCursor', 'ALTER TABLE Blogs ADD COLUMN LikesCursor INTEGER DEFAULT 0;'),
('Blogs','LikesNewestTimestamp', 'ALTER TABLE Blogs ADD COLUMN LikesNewestTimestamp INTEGER DEFAULT 0;'),
('Blogs','LikesLastRefreshed', 'ALTER TABLE Blogs ADD COLUMN LikesLastRefreshed INTEGER DEFAULT 0;'),
('Blogs','LikesLastNewCount', 'ALTER TABLE Blogs ADD COLUMN LikesLastNewCount INTEGER DEFAULT 0;'),
('Blogs','TTFolderPath', 'ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;'),
-- Notes (base columns: manual review if missing)
('Notes','RootBlogName', 'MANUAL REVIEW - base/PK column missing'),
('Notes','PostID', 'MANUAL REVIEW - base/PK column missing'),
('Notes','NoteBlogName', 'MANUAL REVIEW - base/PK column missing'),
('Notes','TimeStamp', 'MANUAL REVIEW - base/PK column missing'),
('Notes','Type', 'MANUAL REVIEW - base/PK column missing'),
('Notes','DatetimeCrawled', 'MANUAL REVIEW - base column missing'),
('Notes','DateModified', 'MANUAL REVIEW - base column missing'),
('Notes','DateCreated', 'MANUAL REVIEW - base column missing'),
-- Notes (additive migration column, auto-fixable)
('Notes','replyText', 'ALTER TABLE Notes ADD COLUMN replyText TEXT DEFAULT ''.'';'),
-- DailyAPICount (base columns)
('DailyAPICount','Date', 'MANUAL REVIEW - base/PK column missing'),
('DailyAPICount','APICount', 'MANUAL REVIEW - base column missing'),
-- ApiKeyPoolState (created at runtime by EnsureApiKeyPoolTables; auto-fixable by re-running app, but safe to add)
('ApiKeyPoolState','KeyName', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
('ApiKeyPoolState','RetryUntil', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
('ApiKeyPoolMeta','Id', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
('ApiKeyPoolMeta','LastIndex', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables')
),
actual(tbl, col) AS (
SELECT 'Posts', name FROM pragma_table_info('Posts')
UNION ALL SELECT 'Blogs', name FROM pragma_table_info('Blogs')
UNION ALL SELECT 'Notes', name FROM pragma_table_info('Notes')
UNION ALL SELECT 'DailyAPICount', name FROM pragma_table_info('DailyAPICount')
UNION ALL SELECT 'ApiKeyPoolState', name FROM pragma_table_info('ApiKeyPoolState')
UNION ALL SELECT 'ApiKeyPoolMeta', name FROM pragma_table_info('ApiKeyPoolMeta')
)
-- 1a. MISSING COLUMNS: columns the code needs that the DB does not have.
-- Zero rows = good. Otherwise copy run_this_to_fix into SECTION 2.
SELECT
e.tbl AS table_name,
e.col AS missing_column,
e.alter_stmt AS run_this_to_fix
FROM expected e
LEFT JOIN actual a
ON a.tbl = e.tbl AND lower(a.col) = lower(e.col)
WHERE a.col IS NULL
ORDER BY (e.alter_stmt LIKE 'ALTER%') DESC, e.tbl, e.col;
-- 1b. MISSING TABLES: expected tables that don't exist at all in this DB.
-- Zero rows = good.
WITH expected_tables(tbl) AS (
VALUES ('Posts'),('Blogs'),('Notes'),('DailyAPICount'),
('ApiKeyPoolState'),('ApiKeyPoolMeta')
)
SELECT et.tbl AS missing_table
FROM expected_tables et
WHERE NOT EXISTS (
SELECT 1 FROM sqlite_master
WHERE type = 'table' AND lower(name) = lower(et.tbl)
)
ORDER BY et.tbl;
-- 1c. EXTRA / UNEXPECTED COLUMNS: present in the DB but not in the expected
-- list above. Informational only -- e.g. a NEWER backup, or a column this
-- script's expected-list hasn't been updated for. Not an error by itself.
-- Posts.IsActive and Notes.IsActive are listed here and NOT in 1a on
-- purpose: they are written by other tools, the app only reads them when
-- present, and it must not be told to add them. See TL.db.md.
WITH expected(tbl, col) AS (
VALUES
('Posts','BlogName'),('Posts','PostID'),('Posts','HasNotesGathered'),('Posts','reblogURL'),
('Posts','NotFound'),('Posts','PostDate'),('Posts','NotesGatheredDateTime'),('Posts','HasImage'),
('Posts','PostURL'),('Posts','Slug'),('Posts','ReblogKey'),('Posts','ReblogName'),('Posts','Summary'),
('Posts','Quote'),('Posts','Body'),('Posts','Tags'),('Posts','Link'),('Posts','PhotoURL'),
('Posts','PhotoCaption'),('Posts','DownloadedFiles'),('Posts','AudioCaption'),('Posts','Question'),
('Posts','Answer'),('Posts','Title'),('Posts','ByLikes'),('Posts','RootBlogName'),('Posts','RootURL'),
('Posts','DateModified'),('Posts','DateCreated'),('Posts','PostType'),('Posts','IsActive'),
('Blogs','BlogName'),('Blogs','HasBeenOutput'),('Blogs','IsActive'),('Blogs','DateAdded'),
('Blogs','ByLikes'),('Blogs','DateModified'),('Blogs','DateCreated'),('Blogs','LikesPulled'),
('Blogs','LikesCursor'),('Blogs','LikesNewestTimestamp'),('Blogs','LikesLastRefreshed'),
('Blogs','LikesLastNewCount'),('Blogs','TTFolderPath'),
('Notes','RootBlogName'),('Notes','PostID'),('Notes','NoteBlogName'),('Notes','TimeStamp'),
('Notes','Type'),('Notes','DatetimeCrawled'),('Notes','DateModified'),('Notes','DateCreated'),
('Notes','replyText'),('Notes','IsActive'),
('DailyAPICount','Date'),('DailyAPICount','APICount'),
('ApiKeyPoolState','KeyName'),('ApiKeyPoolState','RetryUntil'),
('ApiKeyPoolMeta','Id'),('ApiKeyPoolMeta','LastIndex')
),
actual(tbl, col) AS (
SELECT 'Posts', name FROM pragma_table_info('Posts')
UNION ALL SELECT 'Blogs', name FROM pragma_table_info('Blogs')
UNION ALL SELECT 'Notes', name FROM pragma_table_info('Notes')
UNION ALL SELECT 'DailyAPICount', name FROM pragma_table_info('DailyAPICount')
UNION ALL SELECT 'ApiKeyPoolState', name FROM pragma_table_info('ApiKeyPoolState')
UNION ALL SELECT 'ApiKeyPoolMeta', name FROM pragma_table_info('ApiKeyPoolMeta')
)
SELECT a.tbl AS table_name, a.col AS unexpected_column
FROM actual a
LEFT JOIN expected e
ON e.tbl = a.tbl AND lower(e.col) = lower(a.col)
WHERE e.col IS NULL
ORDER BY a.tbl, a.col;
-- ============================================================================
-- SECTION 2 -- FIX (opt-in, additive only)
--
-- Run ONLY the lines that query 1a flagged with an ALTER statement.
-- KEEP A COPY OF THE BACKUP FIRST. SQLite has no "ADD COLUMN IF NOT EXISTS",
-- so running an ALTER for a column that already exists throws a harmless
-- "duplicate column name" error and changes nothing -- just run the flagged
-- subset. These are the 8 additive migration columns and nothing else; the
-- likes high-water-mark reset is intentionally NOT included.
-- ============================================================================
-- ALTER TABLE Posts ADD COLUMN PostType TEXT;
-- ALTER TABLE Blogs ADD COLUMN LikesPulled INTEGER DEFAULT 0;
-- ALTER TABLE Blogs ADD COLUMN LikesCursor INTEGER DEFAULT 0;
-- ALTER TABLE Blogs ADD COLUMN LikesNewestTimestamp INTEGER DEFAULT 0;
-- ALTER TABLE Blogs ADD COLUMN LikesLastRefreshed INTEGER DEFAULT 0;
-- ALTER TABLE Blogs ADD COLUMN LikesLastNewCount INTEGER DEFAULT 0;
-- ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;
-- ALTER TABLE Notes ADD COLUMN replyText TEXT DEFAULT '.';
+1
View File
@@ -0,0 +1 @@
ollama launch opencode --model qwen-coder-32k