# 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 ### `Notes` Stores Integer IDs, Not Names As of 2026-08-07 `Notes.RootBlogName`, `NoteBlogName` and `Type` are gone, replaced by `RootBlogId`, `NoteBlogId` and `TypeId`. Blog IDs resolve through `Blogs.BlogId`, and types through the `NoteTypes` lookup table. There is no compatibility view: naming an old column is a hard SQLite error, so unlike `IsActive` this is a hard cut with no runtime probe. Full detail in `URLNotesGrabberCORE/TL.db.md`. - **`Blogs.BlogId` is the only blog-ID authority (since 2026-09-28).** IDs used to live in a `BlogNames` table with an unmaintained copy in `Blogs.BlogId`. The copy drifted and hid 12k blogs from `GetBlogs`, so `retire-blognames.sql` moved the authority into `Blogs` and **dropped `BlogNames` entirely**. There is no compatibility view, so naming it is `no such table`. Do not recreate it - **Joining `Notes` to `Blogs`**: `FROM Blogs B INNER JOIN Notes N ON N.NoteBlogId = B.BlogId` - **Joining `Notes` to `Posts` also goes through `Blogs`**, since `Posts` has only `BlogName`: `Posts P JOIN Blogs RB ON RB.BlogName = P.BlogName JOIN Notes N ON N.RootBlogId = RB.BlogId` (`GetRepliesWithFilledText`) - **Resolve a name by filtering `Blogs`, never by scanning `Notes`**: `WHERE NoteBlogId = (SELECT BlogId FROM Blogs WHERE BlogName = @name)`. The subquery is a primary-key probe and does not show against the 1.2M-row table - **`AddNote` registers both blogs *and* the note type** before inserting, all in one transaction. `RegisterBlog` does `INSERT OR IGNORE` into `Blogs`, then assigns `BlogId = MAX(BlogId) + 1` where it is NULL. Unlike `AddBlog`, it does not skip `deact` names, because a note by a deactivated blog still needs an ID. `NoteTypes` is a table rather than a `CHECK` constraint precisely so an unseen type is an `INSERT`. Without that registration a type would resolve to `NULL` and fail the `NOT NULL` on `TypeId`, losing the note - **Assigning a `BlogId` is bookkeeping and must not move `DateModified`** - **`Blogs.BlogId` is NULL on ~166k of ~199k rows**, every blog that has never appeared in a note. An inner join on it silently drops them. Correct for engagement queries, wrong for anything listing the registry. `ix_Blogs_BlogId` is `UNIQUE`, which allows many NULLs - **IDs are stable and must never be renumbered.** They are stored in 1.2M `Notes` rows. Triggers `trg_Blogs_BlogId_NoDelete` and `trg_Blogs_BlogId_Immutable` abort any `DELETE` of a `Blogs` row that has a `BlogId`, and any change to its `BlogId` or `BlogName`. A blog renamed upstream gets a new row. Remove a blog with `IsActive = 0`. These triggers are also what make `MAX(BlogId) + 1` safe: no ID can ever be freed for reuse - Prefer `TypeId = (SELECT TypeId FROM NoteTypes WHERE Type = 'reply')` over a hardcoded ID. A negated `TypeId NOT IN (SELECT …)` is only correct because `TypeId` is `NOT NULL` - Duplicate-key detection uses `IsNotesDuplicateKey`, which matches the constraint and the table rather than an exact column list. The old literal string comparison broke silently on this rename — do not reintroduce one ### `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 (