Compare commits
110
Commits
64ef0e9079
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b31d5842cc | ||
|
|
c3cf89c3f1 | ||
|
|
ab36085ba8 | ||
|
|
4d37999f8e | ||
|
|
387c023900 | ||
|
|
a9bd5a4c37 | ||
|
|
70b32dfc89 | ||
|
|
8f4177a0c9 | ||
|
|
a2763d0026 | ||
|
|
721224bc13 | ||
|
|
ef6629d86a | ||
|
|
6320e2c0c9 | ||
|
|
6136901cc7 | ||
|
|
83e35a2323 | ||
|
|
f0ccac6503 | ||
|
|
e1d2eb48c2 | ||
|
|
3c85a05afc | ||
|
|
60912c882d | ||
|
|
8a4ab2402d | ||
|
|
05ec465f74 | ||
|
|
eded5271ea | ||
|
|
a14debd5ed | ||
|
|
d6637266b7 | ||
|
|
2a02811003 | ||
|
|
a73b597381 | ||
|
|
f9e1d2100b | ||
|
|
3e2b287737 | ||
|
|
003a504d5e | ||
|
|
16147b273e | ||
|
|
5361bb78b8 | ||
|
|
21a5525094 | ||
|
|
33839930e8 | ||
|
|
f541ec4260 | ||
|
|
f549f020e1 | ||
|
|
0ff80a0fd3 | ||
|
|
4df73367fb | ||
|
|
a437fa87d3 | ||
|
|
32a1583efd | ||
|
|
03676432bd | ||
|
|
8d6b9212c1 | ||
|
|
18f172fe96 | ||
|
|
b576a9cdf3 | ||
|
|
5973920894 | ||
|
|
494d6aa2d4 | ||
|
|
18c5ac5401 | ||
|
|
21e848efb7 | ||
|
|
24c5449e0c | ||
|
|
c55569eadf | ||
|
|
cf3f97ddc4 | ||
|
|
3aff849216 | ||
|
|
5781e121d2 | ||
|
|
e27190e4f9 | ||
|
|
7c667bd579 | ||
|
|
2634ff8967 | ||
|
|
69ce36a1f5 | ||
|
|
52293f2021 | ||
|
|
2b86ce9119 | ||
|
|
a5c118ce3b | ||
|
|
47497d02bf | ||
|
|
5ae1d4feb8 | ||
|
|
2034b43e83 | ||
|
|
4f00adcc05 | ||
|
|
bcbaea8c48 | ||
|
|
349b465f8a | ||
|
|
acc5e885a8 | ||
|
|
b68a9833d8 | ||
|
|
89f2addf01 | ||
|
|
b963489741 | ||
|
|
d18ab92019 | ||
|
|
4737baa288 | ||
|
|
51c40fe2bd | ||
|
|
51e2a8a1e1 | ||
|
|
0213478a40 | ||
|
|
081528a81a | ||
|
|
f2539dc9d0 | ||
|
|
2438997c7c | ||
|
|
ac79727040 | ||
|
|
64a914c14d | ||
|
|
1c7b6a1e86 | ||
|
|
1da8a702ba | ||
|
|
b70f356d24 | ||
|
|
539994b742 | ||
|
|
c39d555d6e | ||
|
|
a7d175a5a8 | ||
|
|
0adaef62b1 | ||
|
|
1507086269 | ||
|
|
9c77ecce4b | ||
|
|
78e3a070b2 | ||
|
|
9589a9c090 | ||
|
|
abdfe30203 | ||
|
|
354bb9cc8e | ||
|
|
5e62b58261 | ||
|
|
34e5745e99 | ||
|
|
6cec7afb53 | ||
|
|
de96c7c1c5 | ||
|
|
059c9cd527 | ||
|
|
390e1284cb | ||
|
|
eb86bc8f66 | ||
|
|
4e87daf2b2 | ||
|
|
15b484bdf8 | ||
|
|
bc9985a6f0 | ||
|
|
0e5fb124a0 | ||
|
|
e55d2b0e29 | ||
|
|
30f127cca4 | ||
|
|
3d8c5a2823 | ||
|
|
fdfe85d118 | ||
|
|
de51068d0a | ||
|
|
2640854f79 | ||
|
|
81e345b36f | ||
|
|
44d97e54fb |
Binary file not shown.
@@ -22,18 +22,18 @@ This document provides essential context for AI agents working with URLNotesGrab
|
||||
```powershell
|
||||
dotnet build
|
||||
dotnet run # Process all files in input directory
|
||||
dotnet run -- -parse [blogname] # Process specific blog
|
||||
dotnet run -- -test [blogname] [postID] # Test API for specific post
|
||||
dotnet run -- --parse [blogname] # Process specific blog
|
||||
dotnet run -- --test [blogname] [postID] # Test API for specific post
|
||||
```
|
||||
|
||||
### Command-Line Interface
|
||||
- `-parse [blogname]`: Parse text files for specific blog
|
||||
- `-test [blogname] [postID]`: Test API note collection
|
||||
- `-posts`: Export post blogs to file
|
||||
- `-blogs`: Export blog list to file
|
||||
- `-collect`: Collect notes for all posts in DB
|
||||
- `-blogsR`: Export reply blogs to file
|
||||
- `-blogsO [start] [stop]`: Export blogs within range
|
||||
- `--parse [blogname]`: Parse text files for specific blog
|
||||
- `--test [blogname] [postID]`: Test API note collection
|
||||
- `--posts`: Export post blogs to file
|
||||
- `--blogs`: Export blog list to file
|
||||
- `--collect`: Collect notes for all posts in DB
|
||||
- `--blogsR`: Export reply blogs to file
|
||||
- `--blogsO [start] [stop]`: Export blogs within range
|
||||
|
||||
## Project Conventions
|
||||
|
||||
|
||||
+11
-1
@@ -360,4 +360,14 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# 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
|
||||
|
||||
Vendored
+8
-4
@@ -1,11 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"args": ["-pn"], // <--- ADD OR MODIFY THIS LINE
|
||||
"args": [
|
||||
"-test 12h45m 724733451895455700"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "C#: Launch Startup Project",
|
||||
"type": "dotnet",
|
||||
"request": "launch"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "URLNotesGrabberCORE/URLNotesGrabberCORE.sln"
|
||||
}
|
||||
Vendored
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
# 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` resolving through the `BlogNames` and `NoteTypes`
|
||||
lookup tables. 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`.
|
||||
|
||||
- **Joining `Notes` to `Blogs` goes through `Blogs.BlogId`**, not `BlogNames`:
|
||||
`FROM Blogs B INNER JOIN Notes N ON N.NoteBlogId = B.BlogId`. Routing it through
|
||||
`BlogNames` adds a hop and ends in the text comparison the migration removed
|
||||
- **Joining `Notes` to `Posts` is the opposite** — `Posts` has only `BlogName`, so it must
|
||||
go through `BlogNames` (`GetRepliesWithFilledText`). This is the only such join
|
||||
- **Resolve a name by filtering the lookup, never by scanning `Notes`**:
|
||||
`WHERE NoteBlogId = (SELECT BlogId FROM BlogNames WHERE BlogName = @name)`. The subquery
|
||||
is a unique-index probe on 20k rows and does not show against the 1.18M-row table
|
||||
- **`AddNote` registers both blog names *and* the note type** with `INSERT OR IGNORE`
|
||||
before inserting, all in one transaction. `NoteTypes` is a table rather than a `CHECK`
|
||||
constraint precisely so an unseen type is an `INSERT`; without that registration it
|
||||
would resolve to `NULL` and fail the `NOT NULL` on `TypeId`, losing the note
|
||||
- **`Blogs.BlogId` is NULL on 168,202 of 188,620 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
|
||||
- **IDs are stable and must never be renumbered.** They are stored in 1.18M `Notes` rows.
|
||||
A blog renamed upstream gets a new `BlogNames` row, not an edited one
|
||||
- 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 (<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
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="D:/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="4486"/><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="000000ff00000000fd0000000100000002000005470000029afc0100000006fb000000160064006f0063006b00420072006f00770073006500310100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500320100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500330100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500350100000000000005f40000000000000000fb000000160064006f0063006b00420072006f00770073006500340100000000000005470000011100fffffffb000000160064006f0063006b00420072006f00770073006500340100000000000005f40000000000000000000005470000000000000004000000040000000800000008fc00000000"/><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 index="7" mode="1"/></sort><column_widths><column index="1" value="257"/><column index="2" value="108"/><column index="3" value="63"/><column index="4" value="156"/><column index="5" value="60"/><column index="6" value="81"/><column index="7" value="85"/><column index="8" value="156"/><column index="9" value="156"/><column index="10" value="151"/><column index="11" value="125"/><column index="12" value="129"/></column_widths><filter_values><column index="4" value="1"/><column index="7" value=">2026-05-27 17:22:36"/></filter_values><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="Notes" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="198"/><column index="2" value="144"/><column index="3" value="251"/><column index="4" value="84"/><column index="5" value="53"/><column index="6" value="300"/><column index="7" value="116"/><column index="8" value="152"/><column index="9" value="89"/><column index="10" value="63"/></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="14" mode="1"/></sort><column_widths><column index="1" value="236"/><column index="2" value="144"/><column index="3" value="126"/><column index="4" value="32"/><column index="5" value="32"/><column index="6" value="32"/><column index="7" value="32"/><column index="8" value="32"/><column index="9" value="0"/><column index="10" value="0"/><column index="11" value="0"/><column index="12" value="243"/><column index="13" value="300"/><column index="14" value="53"/><column index="15" value="37351"/><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="548"/><column index="25" value="60"/><column index="26" value="213"/><column index="27" value="532"/><column index="28" value="152"/><column index="29" value="152"/><column index="30" value="69"/><column index="31" value="63"/></column_widths><filter_values><column index="2" value="0"/><column index="1" value="734568371821084672"/></filter_values><conditional_formats/><row_id_formats/><display_formats/><hidden_columns><column index="9" value="1"/><column index="10" value="1"/><column index="11" value="1"/></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 < 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 P.slug, N.replyText, n.RootBlogName, n.PostID, NoteBlogName || '.tumblr.com' as NoteBlogName, DatetimeCrawled, TimeStamp, type, n.RootBlogName || '.tumblr.com/post/' || n.postid, datetime(timestamp, 'unixepoch')
|
||||
from Notes N inner join Posts P on p.PostID = n.PostID
|
||||
where
|
||||
DatetimeCrawled > '2026-08-07 11:47:22' and type like 'r%'
|
||||
and P.IsActive = 1
|
||||
order by n.DatetimeCrawled</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 <> '.'
|
||||
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 <> '.' and type <> '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><sql name="Collect">WITH PostsWithCount AS ( SELECT P.BlogName, P.PostID, 1925013599 AS LatestNoteTimestamp, P.NotesGatheredDateTime, COUNT(P.PostID) OVER(PARTITION BY P.BlogName) AS CNT, P.HasNotesGathered, P.NotFound, P.PostDate FROM Posts P WHERE COALESCE(P.IsActive, 1) = 1 ), Unioned AS ( SELECT BlogName, PostID, LatestNoteTimestamp, NotesGatheredDateTime, CNT, PostDate FROM PostsWithCount WHERE NotFound = 0 AND HasNotesGathered = 0 UNION SELECT BlogName, PostID, LatestNoteTimestamp, NotesGatheredDateTime, CNT, PostDate FROM PostsWithCount WHERE BlogName = 'zomb-eh' AND NotFound = 0 AND NotesGatheredDateTime < unixepoch('now', 'localtime', '-3 days') ) SELECT U.BlogName, U.PostID, U.LatestNoteTimestamp, U.NotesGatheredDateTime, U.CNT FROM Unioned U WHERE (U.NotesGatheredDateTime < 1786134037 OR U.NotesGatheredDateTime IS NULL) ORDER BY U.NotesGatheredDateTime, U.PostDate DESC, U.BlogName, U.PostID;</sql><sql name="Del Posts">delete from posts where postid in
|
||||
(
|
||||
'741662499571728384',
|
||||
178892849664,
|
||||
178264721139,
|
||||
177012868749,
|
||||
169950081964,
|
||||
755440787056099328
|
||||
)</sql><sql name="notes NO post*">select *
|
||||
-- delete
|
||||
from notes
|
||||
where postid not in (select distinct postid from posts where IsActive = 1)</sql><sql name="SQL 9">SELECT
|
||||
*
|
||||
FROM
|
||||
POSTS P
|
||||
WHERE
|
||||
P.ByLikes = 1
|
||||
AND
|
||||
P.DateCreated > '2026-05-26 17:47:32'
|
||||
ORDER BY
|
||||
P.DateCreated desc</sql><sql name="SQL 13">update posts set IsActive = 0 where blogname IN ( 'shoebiedoo', 'redheaded-girlygirl', 'xlittle-ghost' )</sql><sql name="SQL 14*">update Posts␍
|
||||
set IsActive = 0␍
|
||||
where postid in␍
|
||||
(␍
|
||||
␍
|
||||
␍
|
||||
'731937314675310592'␍
|
||||
␍
|
||||
␍
|
||||
␍
|
||||
)␍
|
||||
␍
|
||||
</sql><current_tab id="7"/></tab_sql></sqlb_project>
|
||||
@@ -1,19 +1,24 @@
|
||||
<?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 ␍
|
||||
BlogName || '.tumblr.com/post/' || postID,␍
|
||||
␍
|
||||
datetime(NotesGatheredDateTime, 'unixepoch'), *␍
|
||||
FROM␍
|
||||
Posts␍
|
||||
WHERE␍
|
||||
NotesGatheredDateTime <> 0␍
|
||||
ORDER BY␍
|
||||
postdate desc</sql><sql name="SQL 2*">SELECT ␍
|
||||
datetime(TimeStamp, 'unixepoch'),␍
|
||||
RootBlogName || '.tumblr.com/post/' || postid,␍
|
||||
*,␍
|
||||
NoteBlogName || '.tumblr.com'␍
|
||||
FROM␍
|
||||
Notes␍
|
||||
WHERE RootBlogName NOT IN ('xlittle-ghost', 'glimmerin-darlin')␍
|
||||
ORDER BY␍
|
||||
TimeStamp desc</sql><current_tab id="0"/></tab_sql></sqlb_project>
|
||||
<?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,
|
||||
|
||||
datetime(NotesGatheredDateTime, 'unixepoch'), *
|
||||
FROM
|
||||
Posts
|
||||
WHERE
|
||||
NotesGatheredDateTime <> 0
|
||||
ORDER BY
|
||||
postdate desc</sql><sql name="SQL 2*">SELECT
|
||||
datetime(TimeStamp, 'unixepoch'),
|
||||
RootBlogName || '.tumblr.com/post/' || N.postid,
|
||||
*,
|
||||
NoteBlogName || '.tumblr.com'
|
||||
FROM
|
||||
Notes N␍
|
||||
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
|
||||
TimeStamp desc</sql><current_tab id="1"/></tab_sql></sqlb_project>
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"build",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "publish",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"publish",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln",
|
||||
"/property:GenerateFullPaths=true",
|
||||
"/consoleloggerparameters:NoSummary;ForceNoAlign"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
},
|
||||
{
|
||||
"label": "watch",
|
||||
"command": "dotnet",
|
||||
"type": "process",
|
||||
"args": [
|
||||
"watch",
|
||||
"run",
|
||||
"--project",
|
||||
"${workspaceFolder}/URLNotesGrabberCORE.sln"
|
||||
],
|
||||
"problemMatcher": "$msCompile"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2981
-327
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1428
-363
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
||||
"profiles": {
|
||||
"URLNotesGrabberCORE": {
|
||||
"commandName": "Project",
|
||||
"commandLineArgs": "-collect 1"
|
||||
"commandLineArgs": "--collect 1 --api4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@ using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
|
||||
namespace URLNotesGrabberCORE
|
||||
{
|
||||
internal class ResponseNotes
|
||||
{
|
||||
}
|
||||
|
||||
// Root myDeserializedClass = JsonConvert.DeserializeObject<Root>(myJsonResponse);
|
||||
public class AvatarUrl
|
||||
{
|
||||
@@ -42,7 +42,7 @@ namespace URLNotesGrabberCORE
|
||||
public class Note
|
||||
{
|
||||
public string type { get; set; }
|
||||
public int timestamp { get; set; }
|
||||
public long timestamp { get; set; }
|
||||
public string blog_name { get; set; }
|
||||
public string blog_uuid { get; set; }
|
||||
public string blog_url { get; set; }
|
||||
@@ -51,6 +51,7 @@ namespace URLNotesGrabberCORE
|
||||
public AvatarUrl avatar_url { get; set; }
|
||||
public string post_id { get; set; }
|
||||
public string reblog_parent_blog_name { get; set; }
|
||||
public string reply_text { get; set; }
|
||||
}
|
||||
|
||||
public class QueryParams
|
||||
@@ -58,6 +59,8 @@ namespace URLNotesGrabberCORE
|
||||
public string mode { get; set; }
|
||||
public string id { get; set; }
|
||||
public string before_timestamp { get; set; }
|
||||
public string before { get; set; }
|
||||
public string after { get; set; }
|
||||
}
|
||||
|
||||
public class Response
|
||||
@@ -80,5 +83,62 @@ namespace URLNotesGrabberCORE
|
||||
public string statusCode { get; set; }
|
||||
|
||||
public int retryInSeconds { 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,679 @@
|
||||
# `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-08-07**; 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. File size: 148 MB.
|
||||
|
||||
> ### ⚠ Breaking change, 2026-08-07: `Notes` holds integer IDs, not names
|
||||
>
|
||||
> `Notes.RootBlogName`, `Notes.NoteBlogName` and `Notes.Type` **no longer exist**. They
|
||||
> are now `RootBlogId`, `NoteBlogId` and `TypeId`, resolved through the new `BlogNames`
|
||||
> and `NoteTypes` tables. Any query naming the old columns fails outright.
|
||||
>
|
||||
> There is no compatibility view. See [porting to the integer
|
||||
> schema](#porting-to-the-integer-schema) for the old-to-new translation of every query
|
||||
> shape the applications use.
|
||||
>
|
||||
> Applied by `../normalize-notes.sql`, which took the file from 207 MB to 148 MB. An
|
||||
> earlier change the same day (`../shrink-db.sql`) took it from 267 MB to 207 MB.
|
||||
|
||||
---
|
||||
|
||||
## The three content tables
|
||||
|
||||
| Table | Rows | What it is |
|
||||
|---|--:|---|
|
||||
| `Blogs` | 188,620 | The crawl registry — one row per known blog, plus crawl-state flags |
|
||||
| `Posts` | 22,468 | Stored post content. Only 3,867 blogs actually have any |
|
||||
| `Notes` | 1,182,333 | The engagement graph: `NoteBlogId` acted on `(RootBlogId, PostID)` |
|
||||
|
||||
…supported by two lookup tables that exist only to keep `Notes` small:
|
||||
|
||||
| Table | Rows | What it is |
|
||||
|---|--:|---|
|
||||
| `BlogNames` | 20,430 | `BlogId` ⇄ `BlogName`. The ID authority for everything in `Notes` |
|
||||
| `NoteTypes` | 5 | `TypeId` ⇄ `Type`. `like`, `reblog`, `reply`, `posted`, `post_attribution` |
|
||||
|
||||
The engagement graph is the interesting part. 20,311 distinct blogs appear as engagers —
|
||||
far more than the 3,867 that have stored posts — which is what makes this a social graph
|
||||
rather than a post archive. Only 2,771 blogs appear as the *root* of a note.
|
||||
|
||||
### `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,
|
||||
BlogId INTEGER,
|
||||
PRIMARY KEY("BlogName")
|
||||
);
|
||||
|
||||
CREATE INDEX ix_Blogs_BlogId ON Blogs (BlogId);
|
||||
```
|
||||
|
||||
`BlogName` is the primary key, so it is the only indexed way in by name. There is no index
|
||||
on any flag or date — filtering or sorting on those scans all 188k rows, which is
|
||||
affordable here and is not on `Notes`.
|
||||
|
||||
**`BlogId` is new as of 2026-08-07 and is the join key to `Notes`.** It exists so that
|
||||
`Notes` can reach `Blogs` in a single integer hop rather than going through `BlogNames`
|
||||
and ending in a text comparison:
|
||||
|
||||
```sql
|
||||
-- what you want
|
||||
FROM Blogs B JOIN Notes N ON N.NoteBlogId = B.BlogId
|
||||
|
||||
-- not this
|
||||
FROM Blogs B JOIN BlogNames BN ON BN.BlogName = B.BlogName
|
||||
JOIN Notes N ON N.NoteBlogId = BN.BlogId
|
||||
```
|
||||
|
||||
**`BlogId` is NULL on 168,202 of 188,620 rows** — every blog that has never appeared in a
|
||||
note. That is the large majority, and it is not an error: the registry is far bigger than
|
||||
the engagement graph. An inner join on `BlogId` therefore silently drops those blogs,
|
||||
which is usually what you want for engagement queries and is wrong for registry listings.
|
||||
|
||||
Flag distribution: `IsActive = 1` on 188,601 of 188,620 rows, `HasBeenOutput = 1` on
|
||||
5,059, `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, and `BlogId` is
|
||||
the newest example.
|
||||
|
||||
**`DateAdded` is not written consistently.** 170,677 rows hold ISO `yyyy-MM-dd HH:mm:ss`;
|
||||
17,943 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: 345
|
||||
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 now mostly populated: 20,679 of 22,468 rows, leaving 1,789 `NULL`.**
|
||||
This reverses what earlier revisions of this document said — the column really was empty
|
||||
on every row, and something has since started writing it. Anything that treated it as
|
||||
permanently unset, or derived the type from post content instead, should be re-examined
|
||||
against the live data. Rolodex still derives it.
|
||||
- `HasImage = 1` on 14,026 rows. 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,712 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 (
|
||||
RootBlogId INTEGER NOT NULL,
|
||||
PostID INTEGER NOT NULL,
|
||||
NoteBlogId INTEGER NOT NULL,
|
||||
TimeStamp INTEGER NOT NULL,
|
||||
TypeId INTEGER NOT NULL,
|
||||
replyText TEXT,
|
||||
DatetimeCrawled TEXT,
|
||||
DateModified TEXT,
|
||||
DateCreated TEXT,
|
||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (RootBlogId, PostID, TimeStamp, TypeId, NoteBlogId)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
CREATE INDEX ix_Notes_NoteBlogId ON Notes (NoteBlogId);
|
||||
```
|
||||
|
||||
**Integer IDs since 2026-08-07 — this is the breaking change.** `RootBlogName`,
|
||||
`NoteBlogName` and `Type` are gone, replaced by `RootBlogId`, `NoteBlogId` and `TypeId`.
|
||||
Resolve them through [`BlogNames`](#blognames) and [`NoteTypes`](#notetypes), or join
|
||||
straight to `Blogs` on `BlogId`. The old names were text repeated on 1.18 million rows,
|
||||
in the table *and* in every index over it; the swap took the file from 207 MB to 148 MB.
|
||||
|
||||
The **primary key column order is deliberately unchanged**, so the leading-prefix access
|
||||
patterns callers already depend on still hold: `(RootBlogId)` and `(RootBlogId, PostID)`
|
||||
remain cheap prefixes, exactly as `(RootBlogName)` and `(RootBlogName, PostID)` were.
|
||||
|
||||
Two nulls-and-defaults differences from the old DDL, both intentional:
|
||||
|
||||
- `replyText` and `DatetimeCrawled` **no longer carry column defaults**. The old table
|
||||
defaulted them to `'.'` and `'2/12/26 12am'`, which is how 1.1M rows acquired
|
||||
placeholder values nobody wrote. New rows now get `NULL` unless a writer supplies
|
||||
something. The crawler names both columns explicitly, so its behaviour is unchanged.
|
||||
- The five key columns are now `NOT NULL`. They always were in practice.
|
||||
|
||||
**`WITHOUT ROWID`, since earlier the same day.** The rows live in the primary key's
|
||||
b-tree rather than in a rowid table with a separate key index beside it. Two consequences
|
||||
matter before adding an index here:
|
||||
|
||||
- There is no `rowid` on this table. `SELECT rowid FROM Notes` is an error, and no
|
||||
code in any of the three apps relied on it.
|
||||
- A secondary index carries the whole five-column primary key as its row reference
|
||||
instead of a compact rowid, so indexes here are **expensive** — though far less so
|
||||
than before, now that the key is five integers rather than three integers and two
|
||||
strings. `ix_Notes_NoteBlogId` costs 27 MB; its text predecessor cost 58 MB.
|
||||
|
||||
**`Notes_idx_06e01ae3` on `TimeStamp DESC` was dropped at the same time.** It cost
|
||||
14 MB as a rowid index and would have cost 58 MB after the conversion. It was worth
|
||||
neither: the crawler's only `TimeStamp` filter (`>= 1535778000`) excludes 786 rows
|
||||
of 1.18M, Rolodex's default Notes sort carries a three-column tiebreaker that forces
|
||||
a full sort regardless, and the reply-matching `UPDATE` uses `ABS(TimeStamp - ?) <= 5`,
|
||||
which no index on `TimeStamp` can serve. The one path that got slower is Rolodex's
|
||||
Notes page with a date-range filter: 60 ms to 164 ms.
|
||||
|
||||
See `../shrink-db.sql` for the full rationale and the applied result.
|
||||
|
||||
**`DatetimeCrawled` is `NULL` on 1,148,077 rows, and that is the honest value.** Those
|
||||
rows previously stored the literal string `'2/12/26 12am'` — this column's own DDL
|
||||
default, written as a bulk backfill placeholder rather than as a crawl time. They were
|
||||
set to `NULL` on 2026-08-07, which is what consumers already displayed them as: the
|
||||
string parses as a date in neither format this schema writes.
|
||||
|
||||
Note the trap: **the `DEFAULT '2/12/26 12am'` clause is still in the DDL above.** Any
|
||||
`INSERT` that omits this column writes the placeholder straight back. The crawler names
|
||||
it explicitly on every insert, so nothing reintroduces it today, but a new writer that
|
||||
forgets to would — which is why consumers should keep treating an unparseable value here
|
||||
as "unknown" rather than assuming `NULL` is now the only such marker.
|
||||
|
||||
One row per engagement event. `TimeStamp` is **unix seconds** — unlike every date column
|
||||
elsewhere in the schema, which are text.
|
||||
|
||||
At 1.18M 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 (`RootBlogId`, then
|
||||
`PostID`) and `ix_Notes_NoteBlogId` on `NoteBlogId`. "Notes received by a blog" and
|
||||
"notes given by a blog" are both cheap; almost nothing else is.
|
||||
- **Every** ordering here is a full sort of whatever the filters leave, `TimeStamp`
|
||||
included. Filter first, then sort.
|
||||
- `replyText` is `'.'` on 1,167,464 rows — only `reply` notes carry real text. Those
|
||||
dots are inherited from the old column default; new rows get `NULL` instead.
|
||||
|
||||
**Resolve IDs by filtering the lookup, not by scanning `Notes`.** The lookup tables are
|
||||
tiny and uniquely indexed, so pushing a name predicate into them costs nothing and lets
|
||||
the `Notes` index do the work:
|
||||
|
||||
```sql
|
||||
-- good: BlogNames resolves the name, then the index is searched
|
||||
SELECT * FROM Notes
|
||||
WHERE NoteBlogId = (SELECT BlogId FROM BlogNames WHERE BlogName = ?);
|
||||
|
||||
-- also good, same plan
|
||||
SELECT n.* FROM Notes n
|
||||
JOIN BlogNames b ON b.BlogId = n.NoteBlogId
|
||||
WHERE b.BlogName = ?;
|
||||
```
|
||||
|
||||
### `BlogNames`
|
||||
|
||||
```sql
|
||||
CREATE TABLE BlogNames (
|
||||
BlogId INTEGER PRIMARY KEY,
|
||||
BlogName TEXT NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
20,430 rows — every name appearing in `Notes` as either participant, and nothing else.
|
||||
This is the **ID authority**: `Notes.RootBlogId` and `Notes.NoteBlogId` both point here,
|
||||
and `Blogs.BlogId` is a copy of the value for the blogs that have one.
|
||||
|
||||
**12 of these names have no `Blogs` row.** The registry has never been a superset of the
|
||||
engagement graph and still is not, so resolving an ID through `Blogs` rather than
|
||||
`BlogNames` will occasionally find nothing. Use `BlogNames` when you need the name itself
|
||||
and `Blogs` when you need registry columns.
|
||||
|
||||
IDs are assigned by SQLite and are **stable**: they are stored in over a million `Notes`
|
||||
rows. Never renumber them. A blog that is renamed upstream should get a new row, not an
|
||||
edit to an existing one, unless every `Notes` reference is migrated with it.
|
||||
|
||||
### `NoteTypes`
|
||||
|
||||
```sql
|
||||
CREATE TABLE NoteTypes (
|
||||
TypeId INTEGER PRIMARY KEY,
|
||||
Type TEXT NOT NULL UNIQUE
|
||||
);
|
||||
```
|
||||
|
||||
| `TypeId` | `Type` | Rows | Share |
|
||||
|--:|---|--:|--:|
|
||||
| 1 | `like` | 945,167 | 79.9% |
|
||||
| 2 | `reblog` | 219,203 | 18.5% |
|
||||
| 3 | `reply` | 15,345 | 1.3% |
|
||||
| 4 | `posted` | 2,617 | 0.2% |
|
||||
| 5 | `post_attribution` | 1 | — |
|
||||
|
||||
The set is fixed in practice, but it is a table rather than a `CHECK` constraint so that
|
||||
adding a type is an `INSERT` and not a schema migration. **The IDs above are stored in
|
||||
`Notes` and must not be reassigned.**
|
||||
|
||||
Five rows means the lookup is effectively free; write `t.Type = 'reblog'` and let SQLite
|
||||
resolve it, or hardcode the ID if you prefer — both are fine, but hardcoding ties your
|
||||
code to this table's contents, so prefer the join in anything long-lived.
|
||||
|
||||
---
|
||||
|
||||
## Porting to the integer schema
|
||||
|
||||
Everything here was checked against the live 148 MB file. There were 14 affected call
|
||||
sites in `DataAccess.cs` and 16 in `RolodexRepository.cs`. TumblThree needs no changes —
|
||||
its single statement touches `Blogs.IsActive` and `BlogName` only.
|
||||
|
||||
**`DataAccess.cs` is ported.** All 14 sites now read the integer schema, `AddNote`
|
||||
registers names and types before inserting, and `verify-db-schema.sql` reports a
|
||||
pre-migration file rather than letting the app fail on it. `RolodexRepository.cs` lives in
|
||||
the [Rolodex](https://git.basso.land/jim/Rolodex) repository and is not covered by that
|
||||
work. One site was dropped rather than translated: the `LEFT JOIN Notes` in `GetPosts`
|
||||
selected nothing and was collapsed by the query's own `GROUP BY`, so it could not affect
|
||||
the result.
|
||||
|
||||
### Column mapping
|
||||
|
||||
| Was | Is now | Resolve via |
|
||||
|---|---|---|
|
||||
| `Notes.RootBlogName` | `Notes.RootBlogId` | `BlogNames.BlogId` → `.BlogName` |
|
||||
| `Notes.NoteBlogName` | `Notes.NoteBlogId` | `BlogNames.BlogId` → `.BlogName` |
|
||||
| `Notes.Type` | `Notes.TypeId` | `NoteTypes.TypeId` → `.Type` |
|
||||
| `ix_NoteBlogName01` | `ix_Notes_NoteBlogId` | — |
|
||||
|
||||
`PostID`, `TimeStamp`, `replyText`, `DatetimeCrawled`, `DateModified`, `DateCreated` and
|
||||
`IsActive` are unchanged.
|
||||
|
||||
### Filtering by a blog name
|
||||
|
||||
```sql
|
||||
-- was
|
||||
WHERE NoteBlogName = @Name
|
||||
|
||||
-- now, either form; both search ix_Notes_NoteBlogId after a unique-index lookup
|
||||
WHERE NoteBlogId = (SELECT BlogId FROM BlogNames WHERE BlogName = @Name)
|
||||
-- or
|
||||
JOIN BlogNames b ON b.BlogId = n.NoteBlogId WHERE b.BlogName = @Name
|
||||
```
|
||||
|
||||
Measured 73 ms against 63 ms for the old text form on the busiest blog — the extra hop is
|
||||
a unique-index probe on a 20k-row table and does not show.
|
||||
|
||||
### Joining `Notes` to `Blogs`
|
||||
|
||||
This is the join to get right; it is the most common shape in both applications.
|
||||
|
||||
```sql
|
||||
-- was
|
||||
FROM Blogs B INNER JOIN Notes N ON N.NoteBlogName = B.BlogName
|
||||
|
||||
-- now: one integer hop, using the new Blogs.BlogId
|
||||
FROM Blogs B INNER JOIN Notes N ON N.NoteBlogId = B.BlogId
|
||||
```
|
||||
|
||||
Do **not** route this through `BlogNames` — that adds a hop and ends in the text
|
||||
comparison the change was meant to remove.
|
||||
|
||||
### Selecting a name back out
|
||||
|
||||
```sql
|
||||
-- was
|
||||
SELECT NoteBlogName AS blogName, COUNT(*) FROM Notes ... GROUP BY NoteBlogName
|
||||
|
||||
-- now
|
||||
SELECT bn.BlogName AS blogName, COUNT(*)
|
||||
FROM Notes n JOIN BlogNames bn ON bn.BlogId = n.NoteBlogId
|
||||
... GROUP BY bn.BlogName
|
||||
```
|
||||
|
||||
Group by `n.NoteBlogId` instead of `bn.BlogName` when you only need the name for display —
|
||||
grouping on the integer is cheaper and the name comes along for free.
|
||||
|
||||
### Filtering by type
|
||||
|
||||
```sql
|
||||
-- was
|
||||
WHERE type IN ('reblog', 'reply', 'posted')
|
||||
|
||||
-- now
|
||||
WHERE TypeId IN (SELECT TypeId FROM NoteTypes WHERE Type IN ('reblog','reply','posted'))
|
||||
-- or, equivalently
|
||||
JOIN NoteTypes t ON t.TypeId = n.TypeId WHERE t.Type IN ('reblog','reply','posted')
|
||||
```
|
||||
|
||||
`WHERE TypeId IN (2,3,4)` also works and is marginally faster, but hardcodes this table's
|
||||
contents into application code. Prefer the lookup outside of hot paths.
|
||||
|
||||
Note the negated form needs care: `type NOT IN ('reblog','reply','posted')` becomes
|
||||
`TypeId NOT IN (SELECT TypeId FROM NoteTypes WHERE Type IN (...))`, which is correct only
|
||||
because `TypeId` is `NOT NULL`.
|
||||
|
||||
### Inserting a note
|
||||
|
||||
The crawler must ensure both names have IDs first. `INSERT OR IGNORE` on `BlogNames` is
|
||||
the whole of it — no read-back, no round trip, safe to run every time:
|
||||
|
||||
```sql
|
||||
INSERT OR IGNORE INTO BlogNames (BlogName) VALUES (@rootBlogName);
|
||||
INSERT OR IGNORE INTO BlogNames (BlogName) VALUES (@noteBlogName);
|
||||
|
||||
INSERT OR IGNORE INTO Notes
|
||||
(RootBlogId, PostID, NoteBlogId, TimeStamp, TypeId,
|
||||
DatetimeCrawled, DateModified, DateCreated)
|
||||
SELECT (SELECT BlogId FROM BlogNames WHERE BlogName = @rootBlogName),
|
||||
@PostID,
|
||||
(SELECT BlogId FROM BlogNames WHERE BlogName = @noteBlogName),
|
||||
@TimeStamp,
|
||||
(SELECT TypeId FROM NoteTypes WHERE Type = @Type),
|
||||
@DatetimeCrawled, @DateModified, @DateCreated;
|
||||
```
|
||||
|
||||
Verified: a genuinely new note inserts, and re-running the identical statement inserts 0.
|
||||
Run all three statements in one transaction so a crash cannot leave a name registered
|
||||
with no note.
|
||||
|
||||
**The duplicate-key error message has changed.** `DataAccess.cs` compares against the
|
||||
literal string
|
||||
|
||||
```
|
||||
UNIQUE constraint failed: Notes.RootBlogName, Notes.PostID, Notes.TimeStamp, Notes.Type, Notes.NoteBlogName
|
||||
```
|
||||
|
||||
at two call sites to decide whether to swallow an exception. SQLite now emits the *new*
|
||||
column names, so those comparisons no longer match and real errors will surface where
|
||||
they used to be silently ignored — or vice versa.
|
||||
|
||||
Both sites now go through `IsNotesDuplicateKey` in `DataAccess.cs`, which matches on
|
||||
`UNIQUE constraint failed` plus `Notes.` rather than on the column list. A literal
|
||||
comparison is what broke here; the next rename should not break it again.
|
||||
|
||||
### Updating notes
|
||||
|
||||
Predicates translate the same way. The reply-matching update, which cannot use an index
|
||||
on `TimeStamp` either before or after:
|
||||
|
||||
```sql
|
||||
-- now
|
||||
UPDATE Notes SET replyText = @replyText, DateModified = @dateModified
|
||||
WHERE NoteBlogId = (SELECT BlogId FROM BlogNames WHERE BlogName = @noteBlogName)
|
||||
AND ABS(TimeStamp - @TimeStamp) <= 5
|
||||
AND TypeId = (SELECT TypeId FROM NoteTypes WHERE Type = 'reply')
|
||||
AND (replyText IS NULL OR replyText = '' OR replyText = '.')
|
||||
AND (replyText IS NULL OR replyText <> @replyText);
|
||||
```
|
||||
|
||||
Rolodex's soft-delete updates need no change beyond the `WHERE` clause — they set
|
||||
`IsActive`, which is untouched.
|
||||
|
||||
### Three traps
|
||||
|
||||
**`Blogs.BlogId` is NULL on 168,202 of 188,620 rows.** Any inner join on it silently drops
|
||||
every blog that has never appeared in a note. Correct for engagement queries; wrong for
|
||||
registry listings, which need a `LEFT JOIN` or no join at all.
|
||||
|
||||
**12 names in `BlogNames` have no `Blogs` row.** Resolving an ID to a name through `Blogs`
|
||||
will occasionally find nothing. Use `BlogNames` for names and `Blogs` for registry columns.
|
||||
|
||||
**IDs are stable and must stay so.** `BlogNames.BlogId` and `NoteTypes.TypeId` are stored
|
||||
in over a million `Notes` rows. Never renumber. A blog renamed upstream gets a new row,
|
||||
not an edited one, unless every `Notes` reference migrates with it.
|
||||
|
||||
---
|
||||
|
||||
### Referential integrity
|
||||
|
||||
There are no foreign keys, and the tables do not perfectly agree:
|
||||
|
||||
- 4 `Posts` rows name a blog with no `Blogs` row.
|
||||
- 12 of the 20,430 names in `BlogNames` 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 integer schema does not fix this and was not meant to. `BlogNames` is deliberately
|
||||
built from `Notes` rather than from `Blogs`, precisely so that the 12 unregistered
|
||||
engagers keep their IDs and their rows. Had it been built from the registry, those notes
|
||||
would have been dropped by the migration's inner joins.
|
||||
|
||||
---
|
||||
|
||||
## 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,167,464 |
|
||||
| `Posts.Title` | 12,562 |
|
||||
| `Posts.Body` | 172 |
|
||||
|
||||
`Notes.replyText` and `Notes.DatetimeCrawled` **no longer carry column defaults** as of
|
||||
the integer migration, so new note rows get `NULL` rather than a placeholder. The dots
|
||||
already in `replyText` were not rewritten — cleaning is still required on read.
|
||||
|
||||
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
|
||||
-- shape only; the ported GetBlogs joins Blogs directly on BlogId and needs no BlogNames hop
|
||||
SELECT bn.BlogName, count(*)
|
||||
FROM Notes n
|
||||
JOIN Blogs b ON b.BlogId = n.NoteBlogId
|
||||
JOIN BlogNames bn ON bn.BlogId = n.NoteBlogId
|
||||
WHERE b.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` — present, and written from outside
|
||||
|
||||
The same flag extends to the two content tables, with the same meaning: `0` is removed,
|
||||
anything else — including `NULL` — is live. **Both columns now exist in the live `TL.db`**
|
||||
and are included in the DDL quoted above. As of 2026-08-07, `Posts.IsActive = 0` on 5,900
|
||||
rows and `Notes.IsActive = 0` on none. Like `Blogs.IsActive`, they are written from
|
||||
outside this crawler.
|
||||
|
||||
On `Notes` the column is `INTEGER NOT NULL DEFAULT 1`, so a `NULL` cannot occur there;
|
||||
`Posts` and `Blogs` are laxer, which is why the predicate below still uses `COALESCE`.
|
||||
|
||||
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
|
||||
UNION ALL SELECT 'BlogNames', COUNT(*) FROM BlogNames;
|
||||
|
||||
-- note type mix (joins NoteTypes; Notes.Type no longer exists)
|
||||
SELECT t.Type, COUNT(*)
|
||||
FROM Notes n JOIN NoteTypes t ON t.TypeId = n.TypeId
|
||||
GROUP BY t.Type ORDER BY 2 DESC;
|
||||
|
||||
-- how much of the registry participates in the engagement graph
|
||||
SELECT COUNT(*) FILTER (WHERE BlogId IS NOT NULL) AS with_notes,
|
||||
COUNT(*) FILTER (WHERE BlogId IS NULL) AS without_notes
|
||||
FROM Blogs;
|
||||
|
||||
-- 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);
|
||||
|
||||
SELECT COUNT(*) FROM BlogNames bn
|
||||
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogName = bn.BlogName);
|
||||
|
||||
-- space by object, to see where the file actually goes
|
||||
SELECT name, SUM(pgsize)/1024/1024 AS mb
|
||||
FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC;
|
||||
```
|
||||
|
||||
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>
|
||||
</None>
|
||||
<None Update="TL.db">
|
||||
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="prefixes.txt">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
{
|
||||
"appSettings": {
|
||||
"PathInput": "u:\\jim\\Documents\\Web Copies\\blogs\\",
|
||||
"PathOutput": "u:\\jim\\Documents\\Web Copies\\blogs\\GetNotes.txt",
|
||||
@@ -6,13 +6,40 @@
|
||||
"PathOutputPosts": "u:\\jim\\Documents\\Web Copies\\blogs\\GetPosts.txt",
|
||||
"PathOutputBlogs": "u:\\jim\\Documents\\Web Copies\\blogs\\GetBlogs.txt",
|
||||
"PathOutputReplies": "u:\\jim\\Documents\\Web Copies\\blogs\\GetReplies.txt",
|
||||
"PathDB": "u:\\jim\\Documents\\Web Copies\\blogs\\TL.db",
|
||||
"ContainsList": "zombaee,zomb-eh,ahzombae,thebugandme,lovingbabybug,swarthyvillain,h4rdspot"
|
||||
"PathOutputUrls": "u:\\jim\\Documents\\Web Copies\\blogs\\GetUrls.txt",
|
||||
"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": {
|
||||
"ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3",
|
||||
"ConsumerSecret": "sA8BwNVTVKqBRRJmHbAD6NuyKPJ3bb9cei2bYhMgqT8cLX8tSG",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,143 @@
|
||||
-- normalize-notes.sql
|
||||
-- Replaces the repeated blog-name and type TEXT in Notes with integer IDs.
|
||||
-- Reduces TL.db from ~207 MB to ~148 MB (-29%).
|
||||
--
|
||||
-- THIS IS A BREAKING SCHEMA CHANGE. There is no compatibility layer. Every
|
||||
-- query in URLNotesGrabberCORE and Rolodex that names Notes.RootBlogName,
|
||||
-- Notes.NoteBlogName or Notes.Type stops working the moment this runs, and
|
||||
-- stays broken until those queries are rewritten. This was a deliberate choice
|
||||
-- over a view-plus-triggers shim, which was measured to work but cost 194 ms ->
|
||||
-- 321 ms on Rolodex's unfiltered Notes page.
|
||||
--
|
||||
-- TumblThree is unaffected. It touches only Blogs, and the column added to
|
||||
-- Blogs here is additive.
|
||||
--
|
||||
-- HOW TO RUN (DB Browser for SQLite):
|
||||
-- 1. Stop all three apps. Pause NextCloud sync.
|
||||
-- 2. Back up TL.db.
|
||||
-- 3. Execute SQL, paste this file, run. Then Write Changes.
|
||||
-- 4. Tools > Compact Database (VACUUM). Nothing shrinks until this finishes.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- The shape this produces
|
||||
--------------------------------------------------------------------------
|
||||
-- BlogNames(BlogId, BlogName) the ID authority: every name appearing in
|
||||
-- Notes as either participant. 20,430 rows.
|
||||
-- 12 of these have no Blogs row -- the
|
||||
-- registry has never been a superset of the
|
||||
-- engagement graph, and still is not.
|
||||
--
|
||||
-- NoteTypes(TypeId, Type) 5 rows. Fixed set, but written as a table
|
||||
-- rather than a CHECK so a new type is an
|
||||
-- INSERT and not a schema migration.
|
||||
--
|
||||
-- Notes(...Id columns...) integer FKs in place of text. WITHOUT ROWID,
|
||||
-- same 5-column key in the same column order.
|
||||
--
|
||||
-- Blogs.BlogId NEW additive column. Lets Notes join Blogs in
|
||||
-- one integer hop instead of going through
|
||||
-- BlogNames and comparing text at the end.
|
||||
-- NULL on the 168,202 blogs with no notes.
|
||||
|
||||
PRAGMA foreign_keys = off;
|
||||
|
||||
BEGIN;
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 1: the ID authority
|
||||
--------------------------------------------------------------------------
|
||||
CREATE TABLE BlogNames (
|
||||
BlogId INTEGER PRIMARY KEY,
|
||||
BlogName TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
INSERT INTO BlogNames (BlogName)
|
||||
SELECT RootBlogName FROM Notes
|
||||
UNION
|
||||
SELECT NoteBlogName FROM Notes;
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 2: the type lookup
|
||||
--------------------------------------------------------------------------
|
||||
CREATE TABLE NoteTypes (
|
||||
TypeId INTEGER PRIMARY KEY,
|
||||
Type TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
-- IDs are assigned explicitly and must stay stable: they are stored in Notes.
|
||||
INSERT INTO NoteTypes (TypeId, Type) VALUES
|
||||
(1, 'like'),
|
||||
(2, 'reblog'),
|
||||
(3, 'reply'),
|
||||
(4, 'posted'),
|
||||
(5, 'post_attribution');
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 3: rebuild Notes with integer keys
|
||||
--------------------------------------------------------------------------
|
||||
-- Column order of the primary key is unchanged from the text version, so the
|
||||
-- leading-column access patterns callers already rely on still hold:
|
||||
-- (RootBlogId) and (RootBlogId, PostID) remain cheap prefixes.
|
||||
CREATE TABLE NotesN (
|
||||
RootBlogId INTEGER NOT NULL,
|
||||
PostID INTEGER NOT NULL,
|
||||
NoteBlogId INTEGER NOT NULL,
|
||||
TimeStamp INTEGER NOT NULL,
|
||||
TypeId INTEGER NOT NULL,
|
||||
replyText TEXT,
|
||||
DatetimeCrawled TEXT,
|
||||
DateModified TEXT,
|
||||
DateCreated TEXT,
|
||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (RootBlogId, PostID, TimeStamp, TypeId, NoteBlogId)
|
||||
) WITHOUT ROWID;
|
||||
|
||||
-- Inner joins are safe here: BlogNames was just built from these very columns,
|
||||
-- and NoteTypes covers all 5 values present. A row that failed to match would
|
||||
-- be silently dropped, which is what the row-count check at the bottom is for.
|
||||
INSERT INTO NotesN
|
||||
SELECT r.BlogId, n.PostID, b.BlogId, n.TimeStamp, t.TypeId,
|
||||
n.replyText, n.DatetimeCrawled, n.DateModified, n.DateCreated, n.IsActive
|
||||
FROM Notes n
|
||||
JOIN BlogNames r ON r.BlogName = n.RootBlogName
|
||||
JOIN BlogNames b ON b.BlogName = n.NoteBlogName
|
||||
JOIN NoteTypes t ON t.Type = n.Type;
|
||||
|
||||
DROP TABLE Notes;
|
||||
ALTER TABLE NotesN RENAME TO Notes;
|
||||
|
||||
-- Replaces ix_NoteBlogName01. Renamed because it indexes a different column now.
|
||||
CREATE INDEX ix_Notes_NoteBlogId ON Notes (NoteBlogId);
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 4: give Blogs the matching id
|
||||
--------------------------------------------------------------------------
|
||||
-- Additive: no existing column changes, so TumblThree's
|
||||
-- "UPDATE Blogs SET IsActive = 0 ... WHERE BlogName = ?" is untouched.
|
||||
ALTER TABLE Blogs ADD COLUMN BlogId INTEGER;
|
||||
|
||||
UPDATE Blogs
|
||||
SET BlogId = (SELECT bn.BlogId FROM BlogNames bn WHERE bn.BlogName = Blogs.BlogName);
|
||||
|
||||
CREATE INDEX ix_Blogs_BlogId ON Blogs (BlogId);
|
||||
|
||||
COMMIT;
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 5: Write Changes, then Tools > Compact Database
|
||||
--------------------------------------------------------------------------
|
||||
-- From the CLI instead: sqlite3 TL.db "VACUUM;"
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- VERIFY
|
||||
--------------------------------------------------------------------------
|
||||
-- PRAGMA integrity_check; -- expect: ok
|
||||
-- SELECT COUNT(*) FROM Notes; -- expect: 1182333, unchanged
|
||||
-- SELECT COUNT(*) FROM BlogNames; -- expect: 20430
|
||||
-- SELECT COUNT(*) FROM Blogs WHERE BlogId IS NOT NULL; -- expect: 20418
|
||||
--
|
||||
-- Losslessness was proven before this ran, by reconstructing the old text shape
|
||||
-- from the new schema and diffing it against the original both ways:
|
||||
-- SELECT COUNT(*) FROM (SELECT * FROM old.Notes EXCEPT SELECT * FROM Rebuilt);
|
||||
-- SELECT COUNT(*) FROM (SELECT * FROM Rebuilt EXCEPT SELECT * FROM old.Notes);
|
||||
-- Both returned 0 across all 1,182,333 rows and all 10 columns.
|
||||
+4005
File diff suppressed because one or more lines are too long
+159
@@ -0,0 +1,159 @@
|
||||
-- shrink-db.sql
|
||||
-- Reduces TL.db from ~267 MB to ~207 MB (-22%) with no application changes,
|
||||
-- and no visible change in any of the three apps that touch this file.
|
||||
--
|
||||
-- The three consumers, and what each one uses:
|
||||
-- URLNotesGrabberCORE System.Data.SQLite 1.0.119 writes Notes, Posts, Blogs
|
||||
-- Rolodex (web) Microsoft.Data.Sqlite 10.0 reads all three; soft-deletes via IsActive
|
||||
-- TumblThree System.Data.SQLite.Core 1.0.119
|
||||
-- one statement only, ManagerController.cs:972 --
|
||||
-- "UPDATE Blogs SET IsActive = 0, DateModified = @DateModified
|
||||
-- WHERE BlogName = @BlogName"
|
||||
-- Nothing below touches the Blogs table, so TumblThree is
|
||||
-- unaffected. (Its GlobalDatabaseService talks to TumblThree's
|
||||
-- own separate FileEntries/BlogFiles database, not this file.)
|
||||
--
|
||||
-- WITHOUT ROWID needs SQLite >= 3.8.2 (Dec 2013). All three providers above are
|
||||
-- 2024-25 builds, an order of magnitude newer, so STEP 2 is readable by all of them.
|
||||
--
|
||||
-- Every figure below was measured on a copy of the live 267 MB file, and the
|
||||
-- result was checked against all three apps' access patterns:
|
||||
-- PRAGMA integrity_check ....... ok
|
||||
-- row counts ................... Notes 1182333, Posts 22468, Blogs 188620 (unchanged)
|
||||
-- Rolodex soft-delete UPDATE ... works
|
||||
-- Rolodex NoteBlogName filter .. still uses ix_NoteBlogName01
|
||||
-- crawler INSERT OR IGNORE ..... still dedupes (0 dupes admitted)
|
||||
-- TumblThree's UPDATE Blogs ..... untouched -- Blogs is not modified by this script
|
||||
--
|
||||
-- HOW TO RUN (DB Browser for SQLite):
|
||||
-- 1. Stop ALL THREE apps: the crawler, the Rolodex web app, and TumblThree.
|
||||
-- Rolodex holds the file open and checkpoints the WAL, so it must be down,
|
||||
-- not just idle. TumblThree only opens the file for an instant when you
|
||||
-- delete a blog, but it can also launch the crawler on its own
|
||||
-- (UrlNotesGrabberService) -- so close it rather than merely avoiding it.
|
||||
-- 2. Back up TL.db (copy the 267 MB file somewhere safe).
|
||||
-- 3. Open TL.db, go to Execute SQL, paste STEP 1-3, run.
|
||||
-- 4. Click "Write Changes".
|
||||
-- 5. Run Tools > Compact Database. This is VACUUM; it will not run from the
|
||||
-- Execute SQL tab because DB Browser keeps a transaction open there.
|
||||
-- NOTHING SHRINKS ON DISK UNTIL THIS FINISHES.
|
||||
--
|
||||
-- Expected: steps 1-3 a couple of minutes, Compact a couple more.
|
||||
-- Free disk needed during Compact: ~270 MB for the temp copy.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 1: drop the TimeStamp index (required by STEP 2, not optional)
|
||||
--------------------------------------------------------------------------
|
||||
-- Measured cost/benefit:
|
||||
--
|
||||
-- * Rolodex's DEFAULT Notes view does not use it. Its sort carries the
|
||||
-- tiebreaker "RootBlogName, PostID, NoteBlogName", which forces a full sort
|
||||
-- regardless -- the query plan is byte-identical with and without the index.
|
||||
-- Sorting.cs:138 already assumes as much, and is right in practice.
|
||||
-- * The crawler's collect query (DataAccess.cs:1183) filters
|
||||
-- TimeStamp >= 1535778000, which excludes 786 of 1,182,333 rows (0.07%).
|
||||
-- A full index scan wearing a disguise. Same measured time without it.
|
||||
-- * The reply-matching UPDATE uses ABS(TimeStamp - ?) <= 5, which can never
|
||||
-- use an index on TimeStamp.
|
||||
-- * It DOES help exactly one path: Rolodex's Notes page with a date-range
|
||||
-- filter applied. 60 ms -> 164 ms. That is the whole of what is lost.
|
||||
--
|
||||
-- And it must go, because after STEP 2 it stops being cheap. A secondary index
|
||||
-- on a WITHOUT ROWID table carries the full 5-column primary key instead of a
|
||||
-- compact rowid, so this index grows 14 MB -> 58 MB. Keeping it lands the file
|
||||
-- at 265 MB instead of 207 MB -- i.e. it cancels the entire exercise to save
|
||||
-- 100 ms on one filtered view.
|
||||
|
||||
DROP INDEX IF EXISTS Notes_idx_06e01ae3;
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 2: rebuild Notes as WITHOUT ROWID (-32 MB)
|
||||
--------------------------------------------------------------------------
|
||||
-- Notes has a 5-column composite primary key. In a rowid table SQLite stores
|
||||
-- that key twice: once in the table, once in sqlite_autoindex_Notes_1 (62 MB).
|
||||
-- WITHOUT ROWID stores the rows *in* the key's b-tree, so the copy disappears.
|
||||
--
|
||||
-- ix_NoteBlogName01 grows 25 -> 58 MB for the reason described above. Net -32 MB.
|
||||
-- It is kept because Rolodex filters on NoteBlogName and the crawler joins on it.
|
||||
--
|
||||
-- Safe: neither codebase references rowid on Notes (grep across both trees,
|
||||
-- zero matches). IsActive keeps its exact current declaration, which is what
|
||||
-- Rolodex's ActiveFlag predicate reads.
|
||||
|
||||
PRAGMA foreign_keys = off;
|
||||
|
||||
CREATE TABLE Notes_new (
|
||||
"RootBlogName" TEXT,
|
||||
"PostID" INTEGER,
|
||||
"NoteBlogName" TEXT,
|
||||
"TimeStamp" INTEGER,
|
||||
"Type" TEXT,
|
||||
"replyText" TEXT DEFAULT '.',
|
||||
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
|
||||
"DateModified" TEXT,
|
||||
"DateCreated" TEXT,
|
||||
IsActive INTEGER NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY("RootBlogName","PostID","TimeStamp","Type","NoteBlogName")
|
||||
) WITHOUT ROWID;
|
||||
|
||||
INSERT INTO Notes_new
|
||||
SELECT RootBlogName, PostID, NoteBlogName, TimeStamp, Type,
|
||||
replyText, DatetimeCrawled, DateModified, DateCreated, IsActive
|
||||
FROM Notes;
|
||||
|
||||
DROP TABLE Notes;
|
||||
ALTER TABLE Notes_new RENAME TO Notes;
|
||||
|
||||
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 3: clear the DatetimeCrawled placeholder (-13 MB)
|
||||
--------------------------------------------------------------------------
|
||||
-- 1,148,077 of 1,182,333 rows hold the literal DDL default '2/12/26 12am' --
|
||||
-- a backfill placeholder, not a crawl time. SQLite stores all 12 bytes of it
|
||||
-- on every one of those rows.
|
||||
--
|
||||
-- This is UI-NEUTRAL in Rolodex, which is why it is safe despite Rolodex
|
||||
-- displaying the column. Rolodex reads and sorts it through DateSql.Sortable
|
||||
-- (DateRange.cs:67), whose CASE matches '____-__-__%' or the 8-character
|
||||
-- '__/__/__'. The 12-character '2/12/26 12am' matches neither, so Sortable
|
||||
-- already returns NULL for these rows and the page already renders an em dash
|
||||
-- and sorts them to the bottom. RolodexRepository.cs:957-963 documents exactly
|
||||
-- this. Writing a real NULL changes the bytes on disk, not the screen.
|
||||
--
|
||||
-- The crawler never reads the column back -- it only writes it on INSERT
|
||||
-- (DataAccess.cs:713, 721).
|
||||
|
||||
UPDATE Notes SET DatetimeCrawled = NULL WHERE DatetimeCrawled = '2/12/26 12am';
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- DELIBERATELY NOT DONE: nulling Notes.DateCreated
|
||||
--------------------------------------------------------------------------
|
||||
-- An earlier draft of this script also cleared DateCreated = '2026-04-13'
|
||||
-- (a further -12 MB). Do not. Unlike DatetimeCrawled, that value DOES match
|
||||
-- Sortable's '____-__-__%' branch, so Rolodex renders it as a real date in the
|
||||
-- "Created" column on the Notes page and Post detail, and sorts by it. Nulling
|
||||
-- it would turn visible dates into em dashes and move rows in the sort order.
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- STEP 4: Write Changes, then Tools > Compact Database
|
||||
--------------------------------------------------------------------------
|
||||
-- Nothing above reclaims disk until VACUUM runs. From the sqlite3 CLI instead:
|
||||
-- sqlite3 TL.db "VACUUM;"
|
||||
|
||||
--------------------------------------------------------------------------
|
||||
-- VERIFY (run after compacting; file should be ~207 MB)
|
||||
--------------------------------------------------------------------------
|
||||
-- PRAGMA integrity_check;
|
||||
--
|
||||
-- SELECT 'Notes' t, COUNT(*) n FROM Notes
|
||||
-- UNION ALL SELECT 'Posts', COUNT(*) FROM Posts
|
||||
-- UNION ALL SELECT 'Blogs', COUNT(*) FROM Blogs;
|
||||
-- -- expect 1182333 / 22468 / 188620, unchanged
|
||||
--
|
||||
-- SELECT name, SUM(pgsize)/1024/1024 AS mb
|
||||
-- FROM dbstat GROUP BY name ORDER BY SUM(pgsize) DESC;
|
||||
-- -- expect Notes 78, ix_NoteBlogName01 58, Posts 50, Blogs 14
|
||||
--
|
||||
-- APPLIED 2026-08-07. Actual result: 267.32 MB -> 207.17 MB, integrity_check ok,
|
||||
-- row counts unchanged, journal_mode still wal. VACUUM took 6 seconds.
|
||||
@@ -0,0 +1,247 @@
|
||||
-- ============================================================================
|
||||
-- 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;'),
|
||||
-- Blogs.BlogId (2026-08-07) is the single-hop join key into Notes. Deliberately NOT
|
||||
-- auto-fixable: an added-but-empty BlogId makes every engagement join return zero
|
||||
-- rows silently, which is worse than the hard error a missing column gives.
|
||||
('Blogs','BlogId', 'MANUAL REVIEW - see query 1d: run normalize-notes.sql'),
|
||||
|
||||
-- Notes (base columns: manual review if missing)
|
||||
-- Integer IDs since 2026-08-07. RootBlogName/NoteBlogName/Type are GONE, not renamed
|
||||
-- in place -- a backup that still has them needs normalize-notes.sql, not an ALTER.
|
||||
-- Query 1d below reports exactly that case.
|
||||
('Notes','RootBlogId', 'MANUAL REVIEW - see query 1d: pre-2026-08-07 name schema, or damaged'),
|
||||
('Notes','PostID', 'MANUAL REVIEW - base/PK column missing'),
|
||||
('Notes','NoteBlogId', 'MANUAL REVIEW - see query 1d: pre-2026-08-07 name schema, or damaged'),
|
||||
('Notes','TimeStamp', 'MANUAL REVIEW - base/PK column missing'),
|
||||
('Notes','TypeId', 'MANUAL REVIEW - see query 1d: pre-2026-08-07 name schema, or damaged'),
|
||||
('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)
|
||||
-- No DEFAULT: the migrated schema dropped it, so new rows get NULL rather than a
|
||||
-- placeholder. EnsureReplyTextColumnExists in DataAccess.cs adds it the same way.
|
||||
('Notes','replyText', 'ALTER TABLE Notes ADD COLUMN replyText TEXT;'),
|
||||
|
||||
-- BlogNames / NoteTypes (the lookup tables Notes resolves its IDs through, 2026-08-07).
|
||||
-- Not auto-fixable: an empty BlogNames does not mean "add the table", it means the
|
||||
-- Notes rows have nothing to resolve against. Rebuild with normalize-notes.sql.
|
||||
('BlogNames','BlogId', 'MANUAL REVIEW - see query 1d: run normalize-notes.sql'),
|
||||
('BlogNames','BlogName', 'MANUAL REVIEW - see query 1d: run normalize-notes.sql'),
|
||||
('NoteTypes','TypeId', 'MANUAL REVIEW - see query 1d: run normalize-notes.sql'),
|
||||
('NoteTypes','Type', 'MANUAL REVIEW - see query 1d: run normalize-notes.sql'),
|
||||
|
||||
-- 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 'BlogNames', name FROM pragma_table_info('BlogNames')
|
||||
UNION ALL SELECT 'NoteTypes', name FROM pragma_table_info('NoteTypes')
|
||||
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'),('BlogNames'),('NoteTypes'),('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'),('Blogs','BlogId'),
|
||||
('Notes','RootBlogId'),('Notes','PostID'),('Notes','NoteBlogId'),('Notes','TimeStamp'),
|
||||
('Notes','TypeId'),('Notes','DatetimeCrawled'),('Notes','DateModified'),('Notes','DateCreated'),
|
||||
('Notes','replyText'),('Notes','IsActive'),
|
||||
('BlogNames','BlogId'),('BlogNames','BlogName'),
|
||||
('NoteTypes','TypeId'),('NoteTypes','Type'),
|
||||
('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 'BlogNames', name FROM pragma_table_info('BlogNames')
|
||||
UNION ALL SELECT 'NoteTypes', name FROM pragma_table_info('NoteTypes')
|
||||
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;
|
||||
|
||||
|
||||
-- 1d. PRE-MIGRATION DATABASE: a backup from before 2026-08-07, when Notes still
|
||||
-- stored names. Zero rows = good.
|
||||
--
|
||||
-- This is the one failure SECTION 2 cannot fix. Notes.RootBlogName /
|
||||
-- NoteBlogName / Type were replaced by RootBlogId / NoteBlogId / TypeId
|
||||
-- resolving through BlogNames and NoteTypes -- a data migration, not an
|
||||
-- ADD COLUMN. There is no compatibility view, so the current code fails
|
||||
-- outright ("no such column: RootBlogId") against such a file.
|
||||
--
|
||||
-- Fix: run normalize-notes.sql against a COPY of the backup, then re-run
|
||||
-- SECTION 1. Do not hand-add the ID columns: they would be empty, and an
|
||||
-- empty NoteBlogId is indistinguishable from a note by blog #0.
|
||||
SELECT 'Notes still stores names -- run normalize-notes.sql on a copy' AS pre_migration_schema,
|
||||
group_concat(name, ', ') AS legacy_columns_found
|
||||
FROM pragma_table_info('Notes')
|
||||
WHERE lower(name) IN ('rootblogname','noteblogname','type')
|
||||
HAVING COUNT(*) > 0;
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 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.
|
||||
--
|
||||
-- Nothing here addresses query 1d. The Notes integer schema is a data migration
|
||||
-- (normalize-notes.sql) and cannot be reached by adding columns.
|
||||
-- ============================================================================
|
||||
|
||||
-- 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;
|
||||
Reference in New Issue
Block a user