Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f4177a0c9 | ||
|
|
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 |
@@ -22,18 +22,18 @@ This document provides essential context for AI agents working with URLNotesGrab
|
|||||||
```powershell
|
```powershell
|
||||||
dotnet build
|
dotnet build
|
||||||
dotnet run # Process all files in input directory
|
dotnet run # Process all files in input directory
|
||||||
dotnet run -- -parse [blogname] # Process specific blog
|
dotnet run -- --parse [blogname] # Process specific blog
|
||||||
dotnet run -- -test [blogname] [postID] # Test API for specific post
|
dotnet run -- --test [blogname] [postID] # Test API for specific post
|
||||||
```
|
```
|
||||||
|
|
||||||
### Command-Line Interface
|
### Command-Line Interface
|
||||||
- `-parse [blogname]`: Parse text files for specific blog
|
- `--parse [blogname]`: Parse text files for specific blog
|
||||||
- `-test [blogname] [postID]`: Test API note collection
|
- `--test [blogname] [postID]`: Test API note collection
|
||||||
- `-posts`: Export post blogs to file
|
- `--posts`: Export post blogs to file
|
||||||
- `-blogs`: Export blog list to file
|
- `--blogs`: Export blog list to file
|
||||||
- `-collect`: Collect notes for all posts in DB
|
- `--collect`: Collect notes for all posts in DB
|
||||||
- `-blogsR`: Export reply blogs to file
|
- `--blogsR`: Export reply blogs to file
|
||||||
- `-blogsO [start] [stop]`: Export blogs within range
|
- `--blogsO [start] [stop]`: Export blogs within range
|
||||||
|
|
||||||
## Project Conventions
|
## Project Conventions
|
||||||
|
|
||||||
|
|||||||
BIN
Binary file not shown.
@@ -11,6 +11,7 @@
|
|||||||
- `ResponseNotes.cs`: Tumblr API response models
|
- `ResponseNotes.cs`: Tumblr API response models
|
||||||
- Round-robin API key rotation with rate-limit tracking
|
- Round-robin API key rotation with rate-limit tracking
|
||||||
- Automatic console color assignment per API key for output differentiation
|
- 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
|
## Developer Guidelines
|
||||||
|
|
||||||
@@ -27,6 +28,132 @@
|
|||||||
- Preserve console color state: use save/restore pattern for temporary color changes
|
- Preserve console color state: use save/restore pattern for temporary color changes
|
||||||
- API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()`
|
- API rate limits must use `ApiKeyPool.MarkRateLimited()`/`MarkAvailable()`
|
||||||
|
|
||||||
|
### API Failure Classification
|
||||||
|
Tumblr sits behind a CDN that returns HTML error pages (403, 5xx) which never reach the API. These
|
||||||
|
say nothing about the item being fetched, so they must not be recorded as per-item failures.
|
||||||
|
|
||||||
|
- A response body that will not parse as JSON did not come from the API. Flag it with
|
||||||
|
`Root.transientFailure`, never as `FAILURE`
|
||||||
|
- Transient failures retry in place (`TransientBackoffSeconds`) before the item is skipped; a skipped
|
||||||
|
item stays unmarked in the DB so a later launch retries it
|
||||||
|
- `MaxConsecutiveTransient` consecutive transient failures aborts the pass rather than skipping
|
||||||
|
item-by-item against an edge that is refusing all traffic
|
||||||
|
- Only call `ApiKeyPool.MarkAvailable()` on a response that actually reached the API. A transport or
|
||||||
|
CDN failure says nothing about the key's standing and must not clear its flag
|
||||||
|
- Only a real HTTP 429 (or `meta.status == 429`) counts as a rate limit. Do not infer one from the
|
||||||
|
presence of `X-RateLimit-*` headers, which Tumblr sends on every response
|
||||||
|
- Rate limiters must pace with `await AcquireAsync()`. `AttemptAcquire()` does not wait, so a
|
||||||
|
saturated window aborts the run instead of throttling it
|
||||||
|
- Long-running commands return exit 3 when a pass ends incomplete (rate-limit pause, breaker trip, or
|
||||||
|
skipped items), so a caller can distinguish that from a clean run
|
||||||
|
|
||||||
|
### `IsActive` Is Not Ours To Write
|
||||||
|
`Blogs.IsActive`, `Posts.IsActive` and `Notes.IsActive` are removal flags set by other tools
|
||||||
|
(Rolodex). `0` means removed; anything else, including `NULL`, means live. Full detail in
|
||||||
|
`URLNotesGrabberCORE/TL.db.md`.
|
||||||
|
|
||||||
|
- **Never write any `IsActive` column.** Not in an `INSERT` column list, not in an `UPDATE`,
|
||||||
|
and never via `INSERT OR REPLACE` on these tables — that resets the column default and
|
||||||
|
un-removes the row. Re-crawling a removed row must refresh its content and leave the flag
|
||||||
|
where it was
|
||||||
|
- **Filter at selection, not at write.** Every query that *selects* posts, notes or blogs
|
||||||
|
excludes removed rows. Update statements stay keyed on a row the caller already selected;
|
||||||
|
filtering them would spend API quota and then fail to persist the result
|
||||||
|
- `Posts.IsActive` and `Notes.IsActive` are **optional** — they are absent from databases
|
||||||
|
that predate them, and naming a missing column is a hard SQLite error. Compose the filter
|
||||||
|
with `AndIsActive`/`WhereIsActive` in `DataAccess.cs`, which return
|
||||||
|
`COALESCE(IsActive, 1) = 1` only when `HasIsActiveColumn` finds the column. `Blogs.IsActive`
|
||||||
|
is not optional and is filtered directly
|
||||||
|
- Do not add these columns from this app, and do not add them to the missing-column list in
|
||||||
|
`verify-db-schema.sql`
|
||||||
|
|
||||||
|
### `DateModified` Tracks Real Changes Only
|
||||||
|
`Blogs.DateModified`, `Posts.DateModified` and `Notes.DateModified` must move only when a
|
||||||
|
column beside `DateModified` itself actually changed. Re-crawling or re-ingesting identical
|
||||||
|
content has to leave the row — and its timestamp — untouched, or downstream consumers cannot
|
||||||
|
tell a refreshed row from a rewritten one.
|
||||||
|
|
||||||
|
- Enforce it in the `WHERE` clause, not in C#. Every `UPDATE` that sets `DateModified` ends
|
||||||
|
with an `AND (<col> <> @param OR ...)` term covering every column in its `SET` list, so
|
||||||
|
SQLite matches zero rows on a no-op and never writes
|
||||||
|
- Compare NULL-safely: `IFNULL(col, '') <> IFNULL(@param, '')` for text,
|
||||||
|
`IFNULL(col, 0) <> @param` for integer flags. A bare `col <> @param` is NULL on a NULL
|
||||||
|
column and silently skips the row that most needs writing
|
||||||
|
- Where NULL is not equivalent to the default, spell it out. The `HasBeenOutput = 0` stamps
|
||||||
|
use `(HasBeenOutput IS NULL OR HasBeenOutput <> 0)` because the selection queries test
|
||||||
|
`HasBeenOutput = 0`, which a NULL would never match
|
||||||
|
- Dynamic `SET` lists (`UpdatePostContentFields`) build the guard alongside the assignments
|
||||||
|
so the two lists cannot drift apart
|
||||||
|
- These statements now return 0 rows for "found but unchanged" as well as "not found".
|
||||||
|
Callers that read `ExecuteNonQuery()` must not treat 0 as "row missing"
|
||||||
|
|
||||||
|
**`Posts.NotesGatheredDateTime` is crawl bookkeeping, not content.** It moves on every
|
||||||
|
`-collect` pass and says nothing about the post, so it must never move `DateModified` on its
|
||||||
|
own. `UpdatePostMarkNotesCollected` still writes it every pass but wraps the timestamp in
|
||||||
|
`DateModified = CASE WHEN IFNULL(HasNotesGathered, 0) <> 1 THEN @dateModified ELSE
|
||||||
|
DateModified END` — SQLite evaluates `SET` expressions against the pre-`UPDATE` row, so only
|
||||||
|
the flag flipping counts as a modification. Use this shape for any column that has to be
|
||||||
|
refreshed unconditionally without being a change. `Blogs.LikesLastRefreshed` is the
|
||||||
|
deliberate exception: a refresh pass is treated as a real event on the blog row.
|
||||||
|
|
||||||
|
**`Blogs.DateAdded` is write-once.** `AddBlog`'s `INSERT` is the only place that sets it. A
|
||||||
|
new post arriving for a known blog reopens `HasBeenOutput` but must leave `DateAdded` alone —
|
||||||
|
a new post is not a new blog, and rewriting the column both destroys the registration date
|
||||||
|
and makes every insert look like a change.
|
||||||
|
|
||||||
|
**`"."` in a `Posts` content field means "not supplied", not "empty".** `ReblogRecord`
|
||||||
|
(`TraverseDirectory`'s parser for the local `.txt` export tree) and the `--likes` API path
|
||||||
|
both default every content field to the literal string `"."` when their source has no value
|
||||||
|
for it, then pass that straight to `UpdatePost`. A blog with two export folders in different
|
||||||
|
field formats (a duplicate `_2` folder, or a Tumblr export whose field set changed over time)
|
||||||
|
sends one record with a real `Title`/`Tags`/`Slug` and another with those fields `"."`
|
||||||
|
because that format never had a line for them — and without a guard, re-importing both on
|
||||||
|
every run flips the row back and forth forever, bumping `DateModified` on every pass even
|
||||||
|
though the true content never changes.
|
||||||
|
|
||||||
|
- Every content column in `UpdatePost`'s `SET` list is guarded the same way `RootBlogName`/
|
||||||
|
`RootURL` already were: `col = CASE WHEN @col = '.' THEN col ELSE @col END`. A `"."`
|
||||||
|
parameter leaves the existing value alone instead of overwriting it
|
||||||
|
- The change-detection `WHERE` clause carries the same exception —
|
||||||
|
`(@col <> '.' AND IFNULL(col, '') <> @col) OR ...` — so a `"."`-only difference does not
|
||||||
|
make the statement fire at all, and `DateModified` stays put
|
||||||
|
- Deliberately narrow: only the literal `"."` is the sentinel. An explicit empty string from
|
||||||
|
a real record still overwrites, same as before this fix. `postID`, `BlogName`, `hasImage`,
|
||||||
|
`ByLikes` are not part of this convention and are unaffected
|
||||||
|
- If a new content field is added to `Posts`/`UpdatePost`, decide explicitly whether its
|
||||||
|
source can legitimately supply `"."` as "field absent" before deciding whether it needs
|
||||||
|
the same `CASE` treatment — don't assume every column needs it
|
||||||
|
|
||||||
|
**`--ingest` (`UpsertPostFromTextFile`) uses `NULL`, not `"."`, for the same "field absent"
|
||||||
|
convention, and reconciling exactly this kind of duplicate IS the feature's job.**
|
||||||
|
`IngestMode` strips a trailing `_N` from the folder name before it ever reaches
|
||||||
|
`UpsertPostFromTextFile`, so a duplicate export folder collapses onto the same `BlogName` on
|
||||||
|
purpose — the whole point is to merge multiple differently-formatted files for the same post
|
||||||
|
into one row. `IngestMode.G(key)` returns `null` (not `"."`) when a field's line is absent
|
||||||
|
from a given file, `LegacyPostsDbImporter` passes `null` straight from a `NULL` source column,
|
||||||
|
and files are walked in raw filesystem enumeration order — never sorted — so which file's call
|
||||||
|
lands last for a given `(BlogName, PostID)` is arbitrary.
|
||||||
|
|
||||||
|
- Before the fix, the `UPDATE` branch set every column unconditionally, so whichever file
|
||||||
|
processed last for a `PostID` would null out every field its own record didn't carry —
|
||||||
|
silently erasing real `Title`/`Slug`/`Tags`/… another file had, the opposite of what
|
||||||
|
`--ingest` exists to do. This is worse than the `"."` case above: that one only caused
|
||||||
|
churn (the two writes canceled out); this one loses data, and which posts lose which
|
||||||
|
fields depends on filesystem enumeration order
|
||||||
|
- Same shape of fix, `NULL` instead of `"."` as the sentinel: `col = CASE WHEN @col IS NULL
|
||||||
|
THEN col ELSE @col END` in the `SET` list, `(@col IS NOT NULL AND IFNULL(col, '') <> @col)
|
||||||
|
OR ...` in the change-detection
|
||||||
|
- Same narrow rule: only `NULL` (the field's line was never present in this file) is the
|
||||||
|
sentinel. `G()` already distinguishes this from "present but blank" — a dictionary miss is
|
||||||
|
`null`, an empty value after the prefix is `""` — so an explicitly blank field still
|
||||||
|
overwrites
|
||||||
|
- `HasImage` is **not** guarded and remains a known gap: `IngestMode` always computes a
|
||||||
|
concrete `bool` (defaulting `false` when a file has no `Has Image:` line), so there is no
|
||||||
|
way for this function to tell "this format says no image" from "this format doesn't report
|
||||||
|
it at all" without changing the parameter to `bool?` and threading that through
|
||||||
|
`IngestMode`/`LegacyPostsDbImporter`. Fix this the same way if `--ingest` is observed
|
||||||
|
downgrading a post's `HasImage` from `1` to `0`
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
- No existing test suite; use xUnit if adding tests
|
- No existing test suite; use xUnit if adding tests
|
||||||
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
|
- Test critical logic: `ApiKeyPool` init, color parsing, config persistence
|
||||||
|
|||||||
+24
-5
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="C:/Users/jim/Nextcloud/C#/URLNotesGrabberCORE/URLNotesGrabberCORE/TL.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="4253"/><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="ApiKeyPoolMeta" custom_title="0" dock_id="4" table="4,14:mainApiKeyPoolMeta"/><dock_state state="000000ff00000000fd00000001000000020000077400000387fc0100000006fb000000160064006f0063006b00420072006f00770073006500310100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500320100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500330100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500350100000000000005f40000000000000000fc00000000000007740000000000fffffffaffffffff0100000001fb000000160064006f0063006b00420072006f00770073006500340000000000ffffffff0000000000000000fb000000160064006f0063006b00420072006f00770073006500340100000000ffffffff0000011e00ffffff000007740000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings/></tab_browse><tab_sql><sql name="SQL 1">UPDATE Posts
|
<?xml version="1.0" encoding="UTF-8"?><sqlb_project><db path="C:/Users/jim/Nextcloud/C#/URLNotesGrabberCORE/URLNotesGrabberCORE/TL.db" readonly="0" foreign_keys="1" case_sensitive_like="0" temp_store="0" wal_autocheckpoint="1000" synchronous="2"/><attached/><window><main_tabs open="structure browser pragmas query" current="3"/></window><tab_structure><column_width id="0" width="300"/><column_width id="1" width="0"/><column_width id="2" width="100"/><column_width id="3" width="4305"/><column_width id="4" width="0"/><expanded_item id="0" parent="1"/><expanded_item id="1" parent="1"/><expanded_item id="2" parent="1"/><expanded_item id="3" parent="1"/></tab_structure><tab_browse><table title="Posts" custom_title="0" dock_id="4" table="4,5:mainPosts"/><dock_state state="000000ff00000000fd00000001000000020000077200000379fc0100000006fb000000160064006f0063006b00420072006f00770073006500310100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500320100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500330100000000000004a10000000000000000fb000000160064006f0063006b00420072006f00770073006500350100000000000005f40000000000000000fb000000160064006f0063006b00420072006f00770073006500340100000000000007720000011700fffffffb000000160064006f0063006b00420072006f00770073006500340100000000000005f40000000000000000000007720000000000000004000000040000000800000008fc00000000"/><default_encoding codec=""/><browse_table_settings><table schema="main" name="ApiKeyPoolMeta" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="29"/><column index="2" value="64"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="Blogs" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort/><column_widths><column index="1" value="257"/><column index="2" value="95"/><column index="3" value="54"/><column index="4" value="156"/><column index="5" value="51"/><column index="6" value="71"/><column index="7" value="85"/><column index="8" value="156"/><column index="9" value="156"/></column_widths><filter_values/><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table><table schema="main" name="Posts" show_row_id="0" encoding="" plot_x_axis="" unlock_view_pk="_rowid_" freeze_columns="0"><sort><column index="28" mode="1"/></sort><column_widths><column index="1" value="241"/><column index="2" value="148"/><column index="3" value="126"/><column index="4" value="300"/><column index="5" value="75"/><column index="6" value="187"/><column index="7" value="159"/><column index="8" value="75"/><column index="9" value="300"/><column index="10" value="300"/><column index="11" value="78"/><column index="12" value="249"/><column index="13" value="300"/><column index="14" value="53"/><column index="15" value="300"/><column index="16" value="300"/><column index="17" value="41"/><column index="18" value="75"/><column index="19" value="96"/><column index="20" value="300"/><column index="21" value="96"/><column index="22" value="300"/><column index="23" value="300"/><column index="24" value="42"/><column index="25" value="60"/><column index="26" value="218"/><column index="27" value="920"/><column index="28" value="156"/><column index="29" value="156"/></column_widths><filter_values><column index="24" value="=1"/><column index="28" value=">2026-05-06 20:00:01"/></filter_values><conditional_formats/><row_id_formats/><display_formats/><hidden_columns/><plot_y_axes/><global_filter/></table></browse_table_settings></tab_browse><tab_sql><sql name="SQL 1">UPDATE Posts
|
||||||
SET HasNotesGathered = 0
|
SET HasNotesGathered = 0
|
||||||
WHERE (BlogName, PostID) IN (
|
WHERE (BlogName, PostID) IN (
|
||||||
SELECT p.BlogName, p.PostID
|
SELECT p.BlogName, p.PostID
|
||||||
@@ -31,9 +31,10 @@ blogname in
|
|||||||
|
|
||||||
)</sql><sql name="New Notes">select RootBlogName, PostID, NoteBlogName || '.tumblr.com' as NoteBlogName, DatetimeCrawled, TimeStamp, type, RootBlogName || '.tumblr.com/post/' || postid, datetime(timestamp, 'unixepoch')
|
)</sql><sql name="New Notes">select RootBlogName, PostID, NoteBlogName || '.tumblr.com' as NoteBlogName, DatetimeCrawled, TimeStamp, type, RootBlogName || '.tumblr.com/post/' || postid, datetime(timestamp, 'unixepoch')
|
||||||
from Notes
|
from Notes
|
||||||
where --type like 'r%' and
|
where
|
||||||
DatetimeCrawled <> '2026-04-30 09:25:43'
|
DatetimeCrawled > '2026-05-14 02:50:05' --and type like 'r%'
|
||||||
order by DatetimeCrawled desc, TimeStamp desc</sql><sql name="Pull Blogs">SELECT distinct
|
order by DatetimeCrawled desc</sql><sql name="Pull Blogs*">SELECT distinct␍
|
||||||
|
'''' || blogname || ''',',
|
||||||
blogs.*
|
blogs.*
|
||||||
, blogname || '.tumblr.com'
|
, blogname || '.tumblr.com'
|
||||||
FROM
|
FROM
|
||||||
@@ -45,4 +46,22 @@ WHERE
|
|||||||
order by
|
order by
|
||||||
Notes.Type desc,
|
Notes.Type desc,
|
||||||
DateAdded desc
|
DateAdded desc
|
||||||
LIMIT 100;</sql><current_tab id="1"/></tab_sql></sqlb_project>
|
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><current_tab id="3"/></tab_sql></sqlb_project>
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1170
-307
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]}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+476
-128
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using System.Configuration;
|
using System.Configuration;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Microsoft.Extensions.Diagnostics.Latency;
|
using Microsoft.Extensions.Diagnostics.Latency;
|
||||||
using static System.Runtime.InteropServices.JavaScript.JSType;
|
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace URLNotesGrabberCORE
|
namespace URLNotesGrabberCORE
|
||||||
@@ -13,12 +12,27 @@ namespace URLNotesGrabberCORE
|
|||||||
internal class Program
|
internal class Program
|
||||||
{
|
{
|
||||||
|
|
||||||
static void Main(string[] args)
|
static int Main(string[] args)
|
||||||
{
|
{
|
||||||
// Reset console color on exit (including Ctrl+C)
|
// Reset console color on exit (including Ctrl+C)
|
||||||
Console.CancelKeyPress += (s, e) => Console.ResetColor();
|
Console.CancelKeyPress += (s, e) => Console.ResetColor();
|
||||||
AppDomain.CurrentDomain.ProcessExit += (s, e) => Console.ResetColor();
|
AppDomain.CurrentDomain.ProcessExit += (s, e) => Console.ResetColor();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Run(args);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Turn configuration errors / unguarded indexers into a quiet, deterministic exit code.
|
||||||
|
Console.Error.WriteLine(ex.Message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static int Run(string[] args)
|
||||||
|
{
|
||||||
|
int exitCode = 0;
|
||||||
IConfiguration config = new ConfigurationBuilder()
|
IConfiguration config = new ConfigurationBuilder()
|
||||||
.SetBasePath(Directory.GetCurrentDirectory())
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
|
||||||
@@ -29,27 +43,38 @@ namespace URLNotesGrabberCORE
|
|||||||
string apiSectionName = "TumblrApi";
|
string apiSectionName = "TumblrApi";
|
||||||
bool apiExplicitlySet = false;
|
bool apiExplicitlySet = false;
|
||||||
string startFromBlogName = string.Empty;
|
string startFromBlogName = string.Empty;
|
||||||
|
bool forceIgnoreCooldown = false;
|
||||||
List<string> filteredArgs = new List<string>();
|
List<string> filteredArgs = new List<string>();
|
||||||
for (int i = 0; i < args.Length; i++)
|
for (int i = 0; i < args.Length; i++)
|
||||||
{
|
{
|
||||||
if (string.Equals(args[i], "-api3", StringComparison.OrdinalIgnoreCase) ||
|
if (args[i] == "--")
|
||||||
string.Equals(args[i], "--api3", StringComparison.OrdinalIgnoreCase))
|
{
|
||||||
|
// POSIX end-of-options: everything after is a literal operand.
|
||||||
|
for (int j = i + 1; j < args.Length; j++) filteredArgs.Add(args[j]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(args[i], "--force", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
forceIgnoreCooldown = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(args[i], "--api3", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
apiSectionName = "TumblrApi3";
|
apiSectionName = "TumblrApi3";
|
||||||
apiExplicitlySet = true;
|
apiExplicitlySet = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(args[i], "-api4", StringComparison.OrdinalIgnoreCase) ||
|
if (string.Equals(args[i], "--api4", StringComparison.OrdinalIgnoreCase))
|
||||||
string.Equals(args[i], "--api4", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
apiSectionName = "TumblrApi4";
|
apiSectionName = "TumblrApi4";
|
||||||
apiExplicitlySet = true;
|
apiExplicitlySet = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(args[i], "-api", StringComparison.OrdinalIgnoreCase) ||
|
if (string.Equals(args[i], "--api", StringComparison.OrdinalIgnoreCase))
|
||||||
string.Equals(args[i], "--api", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1]))
|
if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1]))
|
||||||
{
|
{
|
||||||
@@ -59,13 +84,12 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("--Missing API section after -api/--api. Using default TumblrApi.--");
|
Console.WriteLine("--Missing API section after --api. Using default TumblrApi.--");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(args[i], "-start", StringComparison.OrdinalIgnoreCase) ||
|
if (string.Equals(args[i], "--start", StringComparison.OrdinalIgnoreCase))
|
||||||
string.Equals(args[i], "--start", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1]))
|
if (i + 1 < args.Length && !string.IsNullOrWhiteSpace(args[i + 1]))
|
||||||
{
|
{
|
||||||
@@ -74,7 +98,7 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("--Missing blog name after -start/--start. Ignoring.--");
|
Console.WriteLine("--Missing blog name after --start. Ignoring.--");
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -115,7 +139,10 @@ namespace URLNotesGrabberCORE
|
|||||||
Console.SetOut(dualLogger);
|
Console.SetOut(dualLogger);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<string> contains = settings.GetValue<string>("ContainsList").Split(',').ToList();
|
string? containsListSetting = settings.GetValue<string>("ContainsList");
|
||||||
|
if (string.IsNullOrEmpty(containsListSetting))
|
||||||
|
throw new InvalidOperationException("ContainsList is not configured in appsettings.json");
|
||||||
|
List<string> contains = containsListSetting.Split(',').ToList();
|
||||||
bool logTraversalRecordImports = settings.GetValue("LogTraversalRecordImports", false);
|
bool logTraversalRecordImports = settings.GetValue("LogTraversalRecordImports", false);
|
||||||
|
|
||||||
if (args.Length == 0) //Traverse folder structure to add posts and thus blogs to DB
|
if (args.Length == 0) //Traverse folder structure to add posts and thus blogs to DB
|
||||||
@@ -124,10 +151,12 @@ namespace URLNotesGrabberCORE
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
DataAccess.EnableImportModePragmas();
|
DataAccess.EnableImportModePragmas();
|
||||||
|
DataAccess.BeginImportSession();
|
||||||
TraverseDirectory(settings.GetValue<string>("PathInput"), settings.GetValue<string>("PathOutputBlogs"), contains, ref postsAdded, startFromBlogName: startFromBlogName, logRecordImports: logTraversalRecordImports);
|
TraverseDirectory(settings.GetValue<string>("PathInput"), settings.GetValue<string>("PathOutputBlogs"), contains, ref postsAdded, startFromBlogName: startFromBlogName, logRecordImports: logTraversalRecordImports);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
DataAccess.EndImportSession();
|
||||||
DataAccess.RestoreImportModePragmas();
|
DataAccess.RestoreImportModePragmas();
|
||||||
}
|
}
|
||||||
Console.WriteLine($"Total posts added: {postsAdded}");
|
Console.WriteLine($"Total posts added: {postsAdded}");
|
||||||
@@ -137,41 +166,23 @@ namespace URLNotesGrabberCORE
|
|||||||
switch (args[0])
|
switch (args[0])
|
||||||
{
|
{
|
||||||
case "-?":
|
case "-?":
|
||||||
Console.WriteLine("\t Parse .txt files to find blogs");
|
case "-h":
|
||||||
|
case "--help":
|
||||||
Console.WriteLine("-?\t Usage help");
|
PrintHelp();
|
||||||
|
|
||||||
Console.WriteLine("-parse\t Parse .txt files with specified blogname");
|
|
||||||
|
|
||||||
Console.WriteLine("-test\t Calls API for given blogname and postID");
|
|
||||||
|
|
||||||
Console.WriteLine("-posts\t For each Post in DB, write blogname to file");
|
|
||||||
|
|
||||||
Console.WriteLine("-blogs\t For each Blog in DB, write blogname to file");
|
|
||||||
|
|
||||||
Console.WriteLine("-collect\t For each Post in DB, hit API to collect Notes. Optional datetime parameter to filter by NotesGatheredDateTime");
|
|
||||||
|
|
||||||
Console.WriteLine("-blogsR\t For each Note that is a REPLY, write blogname to file ");
|
|
||||||
|
|
||||||
Console.WriteLine("-blogsO\t For each Blog in DB, write blogname to file, but limit via a passed start and stop range ");
|
|
||||||
|
|
||||||
Console.WriteLine("-replies\t Fetch and update missing reply text for all replies in database");
|
|
||||||
|
|
||||||
Console.WriteLine("-likes\t Fetch likes for all blogs needing it (LikesPulled=0), or a specific blog via param");
|
|
||||||
|
|
||||||
Console.WriteLine("-urldump\t Scan all posts' text columns and extract suspected URLs to configured file");
|
|
||||||
|
|
||||||
Console.WriteLine("-api3\t Use TumblrApi3 settings from appsettings.json");
|
|
||||||
|
|
||||||
Console.WriteLine("-api4\t Use TumblrApi4 settings from appsettings.json");
|
|
||||||
|
|
||||||
Console.WriteLine("-start [blogname]\t Start traversal alphabetically at this blog name");
|
|
||||||
|
|
||||||
Console.WriteLine("-api [section]\t Use a specific API settings section from appsettings.json (e.g. TumblrApi3)");
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-parse":
|
case "-V":
|
||||||
|
case "--version":
|
||||||
|
Console.WriteLine(System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown");
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--parse":
|
||||||
|
if (args.Length < 2)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Usage: --parse <blogname>");
|
||||||
|
exitCode = 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
string blogNameToParse = args[1];
|
string blogNameToParse = args[1];
|
||||||
int postsAdded = 0;
|
int postsAdded = 0;
|
||||||
try
|
try
|
||||||
@@ -186,29 +197,31 @@ namespace URLNotesGrabberCORE
|
|||||||
Console.WriteLine($"Total posts added: {postsAdded}");
|
Console.WriteLine($"Total posts added: {postsAdded}");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-test":
|
case "--test":
|
||||||
Console.WriteLine("Test command not implemented");
|
Console.WriteLine("Test command not implemented");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-post":
|
case "--post":
|
||||||
TraverseDirectoryForCorruption(settings.GetValue<string>("PathInput"), settings.GetValue<string>("PathOutputBlogs"), contains);
|
TraverseDirectoryForCorruption(settings.GetValue<string>("PathInput"), settings.GetValue<string>("PathOutputBlogs"), contains);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-posts": //write post's blogs to file
|
case "--posts": //write post's blogs to file
|
||||||
WritePostBlogsToFile(settings.GetValue<string>("PathOutputPosts"));
|
WritePostBlogsToFile(settings.GetValue<string>("PathOutputPosts"));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-blogs": //write blogs to file
|
case "--blogs": //write blogs to file
|
||||||
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"));
|
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-collect": //collect notes from all posts
|
case "--collect": //collect notes from all posts
|
||||||
bool withoutNotesOnly = true;
|
bool withoutNotesOnly = true;
|
||||||
DateTime? beforeDate = DateTime.Now;
|
DateTime? beforeDate = DateTime.Now;
|
||||||
|
bool explicitDateSupplied = false;
|
||||||
|
|
||||||
if (args.Length < 2)
|
if (args.Length < 2)
|
||||||
{
|
{
|
||||||
Console.WriteLine("--Expected WITHOUTNOTESONLY (0, 1) [OPTIONAL: BEFOREDATE]--");
|
Console.WriteLine("--Expected WITHOUTNOTESONLY (0, 1) [OPTIONAL: BEFOREDATE]--");
|
||||||
|
exitCode = 2;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,26 +248,50 @@ namespace URLNotesGrabberCORE
|
|||||||
if (DateTime.TryParse(args[2], out DateTime parsedDate))
|
if (DateTime.TryParse(args[2], out DateTime parsedDate))
|
||||||
{
|
{
|
||||||
beforeDate = parsedDate;
|
beforeDate = parsedDate;
|
||||||
|
explicitDateSupplied = true;
|
||||||
Console.WriteLine($"Filter: Collecting notes for posts with NotesGatheredDateTime < {beforeDate}");
|
Console.WriteLine($"Filter: Collecting notes for posts with NotesGatheredDateTime < {beforeDate}");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine($"ERROR: Invalid date format '{args[2]}'");
|
Console.WriteLine($"ERROR: Invalid date format '{args[2]}'");
|
||||||
|
exitCode = 2;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate).GetAwaiter().GetResult();
|
// Mode 0 (full re-check) with no explicit date is a *managed* run: freeze the cutoff and
|
||||||
|
// persist it so an interrupted run resumes against the same cutoff and a completed run stops
|
||||||
|
// instead of restarting. Mode 1 and explicit-date runs keep their existing behavior.
|
||||||
|
bool managedCollectRun = false;
|
||||||
|
if (!withoutNotesOnly && !explicitDateSupplied)
|
||||||
|
{
|
||||||
|
DataAccess.EnsureCollectRunStateTableExists();
|
||||||
|
var runState = DataAccess.GetCollectRunState();
|
||||||
|
if (runState != null && !runState.Value.complete)
|
||||||
|
{
|
||||||
|
beforeDate = DateTimeOffset.FromUnixTimeSeconds(runState.Value.cutoff).LocalDateTime;
|
||||||
|
Console.WriteLine($"Resuming interrupted full re-check (cutoff = {beforeDate})");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
beforeDate = DateTime.Now;
|
||||||
|
DataAccess.BeginCollectRun(new DateTimeOffset(beforeDate.Value).ToUnixTimeSeconds());
|
||||||
|
Console.WriteLine($"Starting new full re-check run (cutoff = {beforeDate})");
|
||||||
|
}
|
||||||
|
managedCollectRun = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode = CollectNotes(settings.GetValue<string>("PathOutput"), withoutNotesOnly, beforeDate, managedCollectRun).GetAwaiter().GetResult();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-blogsR": //collect notes from all posts
|
case "--blogsR": //collect notes from all posts
|
||||||
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"), true);
|
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"), true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-blogsO": //collect notes from all posts
|
case "--blogsO": //collect notes from all posts
|
||||||
int from = 1, to = 999999, top = 100;
|
int from = 1, to = 999999, top = 100;
|
||||||
|
|
||||||
if (args[1] is not null && args[2] is not null && args[3] is not null)
|
if (args.Length >= 4 && args[1] is not null && args[2] is not null && args[3] is not null)
|
||||||
{
|
{
|
||||||
from = int.Parse(args[1]);
|
from = int.Parse(args[1]);
|
||||||
to = int.Parse(args[2]);
|
to = int.Parse(args[2]);
|
||||||
@@ -263,14 +300,16 @@ namespace URLNotesGrabberCORE
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("--Expected FROM TO--");
|
Console.WriteLine("--Expected FROM TO--");
|
||||||
|
exitCode = 2;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"), false, from, to, top);
|
WriteBlogsToFile(settings.GetValue<string>("PathOutputBlogs"), false, from, to, top);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-bop": //collect notes from all posts
|
case "--bop": //collect notes from all posts
|
||||||
from = 1; to = 999999; top = 100;
|
from = 1; to = 999999; top = 100;
|
||||||
|
|
||||||
if (args[1] is not null && args[2] is not null && args[3] is not null)
|
if (args.Length >= 4 && args[1] is not null && args[2] is not null && args[3] is not null)
|
||||||
{
|
{
|
||||||
from = int.Parse(args[1]);
|
from = int.Parse(args[1]);
|
||||||
to = int.Parse(args[2]);
|
to = int.Parse(args[2]);
|
||||||
@@ -279,25 +318,70 @@ namespace URLNotesGrabberCORE
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("--Expected FROM TO--");
|
Console.WriteLine("--Expected FROM TO--");
|
||||||
|
exitCode = 2;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
WriteBlogsToFileAll(settings.GetValue<string>("PathOutputBlogs"), false, from, to, top);
|
WriteBlogsToFileAll(settings.GetValue<string>("PathOutputBlogs"), false, from, to, top);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-replies": //update reply text
|
case "--replies": //update reply text
|
||||||
CollectMissingReplyText().GetAwaiter().GetResult();
|
CollectMissingReplyText().GetAwaiter().GetResult();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-likes":
|
case "--likes":
|
||||||
string likeBlog = args.Length > 1 ? args[1] : null;
|
string likeBlog = args.Length > 1 ? args[1] : null;
|
||||||
CollectLikes(likeBlog, contains).GetAwaiter().GetResult();
|
int cooldownDays = settings.GetValue("LikesRefreshCooldownDays", 7);
|
||||||
|
CollectLikes(likeBlog, contains, cooldownDays, forceIgnoreCooldown).GetAwaiter().GetResult();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "-urldump":
|
case "--urldump":
|
||||||
DumpUrls(settings.GetValue<string>("PathOutputUrls"));
|
DumpUrls(settings.GetValue<string>("PathOutputUrls"));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "--ingest":
|
||||||
|
exitCode = IngestMode.Run(config, args.Skip(1).ToArray());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--output":
|
||||||
|
exitCode = OutputMode.Run(config, args.Skip(1).ToArray());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--revert":
|
||||||
|
exitCode = RevertMode.Run(config, args.Length > 1 ? args[1] : null);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "--correct":
|
||||||
|
{
|
||||||
|
bool applyChanges = args.Skip(1).Any(a => string.Equals(a, "--apply", StringComparison.OrdinalIgnoreCase));
|
||||||
|
var correctArgs = args.Skip(1)
|
||||||
|
.Where(a => !string.Equals(a, "--apply", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.ToArray();
|
||||||
|
exitCode = CorrectMode.Run(config, correctArgs, applyChanges);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "--updatepaths":
|
||||||
|
{
|
||||||
|
string rootPath = args.Length > 1 ? args[1] : (settings.GetValue<string>("PathTTRoot") ?? settings.GetValue<string>("PathInput") ?? string.Empty);
|
||||||
|
exitCode = UpdateBlogPathsRunner.Run(rootPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "--importposts":
|
||||||
|
{
|
||||||
|
if (args.Length < 2)
|
||||||
|
{
|
||||||
|
Console.WriteLine("Usage: --importposts <path-to-legacy-posts.db>");
|
||||||
|
exitCode = 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
exitCode = LegacyPostsDbImporter.Run(args[1]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
Console.WriteLine("** Unknown Command ** " + args[0]);
|
Console.WriteLine("** Unknown Command ** " + args[0]);
|
||||||
|
exitCode = 2;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,6 +390,71 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
System.Console.WriteLine("<fin>:/");
|
System.Console.WriteLine("<fin>:/");
|
||||||
//System.Console.ReadKey();
|
//System.Console.ReadKey();
|
||||||
|
return exitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void PrintHelp()
|
||||||
|
{
|
||||||
|
Console.WriteLine("\t Parse .txt files to find blogs");
|
||||||
|
|
||||||
|
Console.WriteLine("-?, -h, --help\t Usage help");
|
||||||
|
|
||||||
|
Console.WriteLine("-V, --version\t Print the application version");
|
||||||
|
|
||||||
|
Console.WriteLine("--\t End of options: treat every following token as a literal operand");
|
||||||
|
|
||||||
|
Console.WriteLine("--parse\t Parse .txt files with specified blogname");
|
||||||
|
|
||||||
|
Console.WriteLine("--test\t Calls API for given blogname and postID");
|
||||||
|
|
||||||
|
Console.WriteLine("--post\t Traverse the input directory tree checking .txt files for corruption");
|
||||||
|
|
||||||
|
Console.WriteLine("--posts\t For each Post in DB, write blogname to file");
|
||||||
|
|
||||||
|
Console.WriteLine("--blogs\t For each Blog in DB, write blogname to file");
|
||||||
|
|
||||||
|
Console.WriteLine("--collect [0|1] [datetime]\t Collect Notes from API. 1=only posts without notes. 0=full re-check of all posts: a single resumable pass (interrupt & relaunch to resume; stops when complete, retrigger for a new pass). Optional datetime overrides the cutoff and runs as a one-off (bypasses resume tracking).");
|
||||||
|
|
||||||
|
Console.WriteLine("--blogsR\t For each Note that is a REPLY, write blogname to file ");
|
||||||
|
|
||||||
|
Console.WriteLine("--blogsO\t For each Blog in DB, write blogname to file, but limit via a passed start and stop range ");
|
||||||
|
|
||||||
|
Console.WriteLine("--bop [from] [to] [top]\t Write ALL blog names to file, limited by FROM TO TOP range arguments");
|
||||||
|
|
||||||
|
Console.WriteLine("--replies\t Fetch and update missing reply text for all replies in database");
|
||||||
|
|
||||||
|
Console.WriteLine("--likes\t Fetch likes: initial backfill for new blogs, incremental refresh for blogs past cooldown. Optional blog name forces single-blog run.");
|
||||||
|
|
||||||
|
Console.WriteLine("--force\t (with --likes) Ignore cooldown and refresh every fully-backfilled blog");
|
||||||
|
|
||||||
|
Console.WriteLine("--urldump\t Scan all posts' text columns and extract suspected URLs to configured file");
|
||||||
|
|
||||||
|
Console.WriteLine("--api3\t Use TumblrApi3 settings from appsettings.json");
|
||||||
|
|
||||||
|
Console.WriteLine("--api4\t Use TumblrApi4 settings from appsettings.json");
|
||||||
|
|
||||||
|
Console.WriteLine("--start [blogname]\t Start traversal alphabetically at this blog name");
|
||||||
|
|
||||||
|
Console.WriteLine("--api [section]\t Use a specific API settings section from appsettings.json (e.g. TumblrApi3)");
|
||||||
|
|
||||||
|
Console.WriteLine("--ingest [blogname]\t Ingest Tumblr .txt exports from appSettings:PathTTRoot into TL.db (all blogs, or single blog if name given)");
|
||||||
|
|
||||||
|
Console.WriteLine("--output [rootPath]\t Refresh Blogs.TTFolderPath from <root>\\Index (or appSettings:PathTTRoot), then export posts from TL.db back to .txt files in each blog's folder");
|
||||||
|
|
||||||
|
Console.WriteLine("--output --norefresh\t Export without refreshing TTFolderPath first");
|
||||||
|
|
||||||
|
Console.WriteLine("--revert [blogname]\t Recursively scan the PathInput tree and restore *.bak back to *.txt (current .txt saved as next-free .bkN); optional blogname filters by path substring");
|
||||||
|
|
||||||
|
Console.WriteLine("--correct [bakPath]\t Dry-run: report multi-line field updates available from a BAK directory");
|
||||||
|
|
||||||
|
Console.WriteLine("--correct --apply [bakPath]\t Apply BAK-file corrections to matching posts (prompts yes/no)");
|
||||||
|
|
||||||
|
Console.WriteLine("--updatepaths [rootPath]\t Read .tumblr/.tmblrpriv metadata from <root>\\Index and set Blogs.TTFolderPath");
|
||||||
|
|
||||||
|
Console.WriteLine("--importposts [path-to-posts.db]\t One-time migration: copy legacy ThreeTxtFileHelper posts.db rows into TL.db");
|
||||||
|
|
||||||
|
Console.WriteLine();
|
||||||
|
Console.WriteLine("Exit status: 0 = success; 1 = unexpected error; 2 = usage error (unknown command or bad/missing arguments); 3 = incomplete (--collect paused on a rate limit, or skipped posts after transient API failures) - relaunch to resume");
|
||||||
}
|
}
|
||||||
|
|
||||||
static void WritePostBlogsToFile(string outPath)
|
static void WritePostBlogsToFile(string outPath)
|
||||||
@@ -435,16 +584,11 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
protected static string NormalizeBlogFolderName(string folderName)
|
protected static string NormalizeBlogFolderName(string folderName)
|
||||||
{
|
{
|
||||||
return folderName
|
// Archive tools suffix duplicate blog folders with _1, _2, ... _10 and beyond. Strip only a
|
||||||
.Replace("_1", "")
|
// trailing numeric suffix: unanchored substring removal ate the "_1" inside "_10" and left the
|
||||||
.Replace("_2", "")
|
// "0" welded to the name (zomb-eh_10 -> zomb-eh0), and mangled any blog whose real name
|
||||||
.Replace("_3", "")
|
// contains "_1". A blog name is never a prefix of itself plus "_<digits>", so this is safe.
|
||||||
.Replace("_4", "")
|
return System.Text.RegularExpressions.Regex.Replace(folderName, @"_\d+$", "");
|
||||||
.Replace("_5", "")
|
|
||||||
.Replace("_6", "")
|
|
||||||
.Replace("_7", "")
|
|
||||||
.Replace("_8", "")
|
|
||||||
.Replace("_9", "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task FetchAndStoreReplyText(string blogName, long postID, long timestamp)
|
static async Task FetchAndStoreReplyText(string blogName, long postID, long timestamp)
|
||||||
@@ -679,14 +823,14 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task CollectLikes(string specificBlog, List<string> contains)
|
static async Task CollectLikes(string specificBlog, List<string> contains, int cooldownDays = 7, bool ignoreCooldown = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
DataAccess.EnsureBlogsLikesColumnsExist();
|
DataAccess.EnsureBlogsLikesColumnsExist();
|
||||||
|
|
||||||
Console.WriteLine("Starting collection of likes...");
|
Console.WriteLine($"Starting collection of likes... (cooldown {cooldownDays}d, ignoreCooldown={ignoreCooldown})");
|
||||||
var blogsToProcess = DataAccess.GetBlogsForLikes(specificBlog);
|
var blogsToProcess = DataAccess.GetBlogsForLikes(specificBlog, cooldownDays, ignoreCooldown);
|
||||||
|
|
||||||
if (blogsToProcess.Count == 0)
|
if (blogsToProcess.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -694,11 +838,15 @@ namespace URLNotesGrabberCORE
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine($"Found {blogsToProcess.Count} blogs to process likes.");
|
int backfillCount = blogsToProcess.Count(b => b.Item2 == 0);
|
||||||
|
int refreshCount = blogsToProcess.Count(b => b.Item2 == 1);
|
||||||
|
Console.WriteLine($"Found {blogsToProcess.Count} blogs to process likes ({backfillCount} backfill, {refreshCount} refresh).");
|
||||||
|
|
||||||
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
|
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = 300,
|
// 1/sec average, matching --collect: the CDN reacts to aggregate traffic from the IP,
|
||||||
|
// not to per-command rates.
|
||||||
|
PermitLimit = 60,
|
||||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||||
QueueLimit = 1,
|
QueueLimit = 1,
|
||||||
Window = TimeSpan.FromMinutes(1),
|
Window = TimeSpan.FromMinutes(1),
|
||||||
@@ -706,22 +854,37 @@ namespace URLNotesGrabberCORE
|
|||||||
AutoReplenishment = true
|
AutoReplenishment = true
|
||||||
});
|
});
|
||||||
|
|
||||||
foreach (var blogInfo in blogsToProcess)
|
for (int i = 0; i < blogsToProcess.Count; i++)
|
||||||
{
|
{
|
||||||
|
var blogInfo = blogsToProcess[i];
|
||||||
|
int remaining = blogsToProcess.Count - i - 1;
|
||||||
string blogName = blogInfo.Item1;
|
string blogName = blogInfo.Item1;
|
||||||
int likesPulled = blogInfo.Item2;
|
int likesPulled = blogInfo.Item2;
|
||||||
long cursor = blogInfo.Item3;
|
long cursor = blogInfo.Item3;
|
||||||
|
long storedNewestTs = blogInfo.Item4;
|
||||||
|
|
||||||
|
bool isRefresh = likesPulled == 1;
|
||||||
long parsedForBlog = 0;
|
long parsedForBlog = 0;
|
||||||
long matchedForBlog = 0;
|
long matchedForBlog = 0;
|
||||||
int likedCountForBlog = 0;
|
int likedCountForBlog = 0;
|
||||||
|
long observedMaxLikedTs = storedNewestTs;
|
||||||
|
int newInsertedInRefresh = 0;
|
||||||
|
|
||||||
Console.WriteLine($"Processing likes for blog: {blogName} | Cursor: {cursor}");
|
// Refresh always starts from the top (newest) and walks backward until it crosses
|
||||||
|
// the stored high-water mark. Backfill resumes from its last persisted cursor.
|
||||||
|
if (isRefresh) cursor = 0;
|
||||||
|
|
||||||
|
string mode = isRefresh ? "REFRESH" : "BACKFILL";
|
||||||
|
Console.WriteLine($"[{remaining} remaining] Processing likes for blog: {blogName} | Mode: {mode} | Cursor: {cursor} | HighWaterMark: {storedNewestTs}");
|
||||||
|
|
||||||
bool hasMoreLikes = true;
|
bool hasMoreLikes = true;
|
||||||
|
bool isFirstPage = true;
|
||||||
|
|
||||||
while (hasMoreLikes)
|
while (hasMoreLikes)
|
||||||
{
|
{
|
||||||
using RateLimitLease lease = limiter.AttemptAcquire(1);
|
// Wait for a permit rather than giving up on one: the limiter paces the loop, it is
|
||||||
|
// not a failure condition. Only one acquire is ever pending, so QueueLimit = 1 suffices.
|
||||||
|
using RateLimitLease lease = await limiter.AcquireAsync(1);
|
||||||
if (!lease.IsAcquired)
|
if (!lease.IsAcquired)
|
||||||
{
|
{
|
||||||
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
||||||
@@ -746,6 +909,9 @@ namespace URLNotesGrabberCORE
|
|||||||
if (response?.statusCode == "NotFound" || (response?.meta != null && response.meta.status == 404))
|
if (response?.statusCode == "NotFound" || (response?.meta != null && response.meta.status == 404))
|
||||||
{
|
{
|
||||||
Console.WriteLine($"API returned 404 Not Found for {blogName} Likes");
|
Console.WriteLine($"API returned 404 Not Found for {blogName} Likes");
|
||||||
|
if (isRefresh)
|
||||||
|
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||||
|
else
|
||||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -753,7 +919,13 @@ namespace URLNotesGrabberCORE
|
|||||||
if (response?.response?.liked_posts == null || response.response.liked_posts.Count == 0)
|
if (response?.response?.liked_posts == null || response.response.liked_posts.Count == 0)
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Likes] No more likes found for {blogName}. Marking complete.");
|
Console.WriteLine($"[Likes] No more likes found for {blogName}. Marking complete.");
|
||||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor); // Done parsing
|
if (isRefresh)
|
||||||
|
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursor);
|
||||||
|
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||||
|
}
|
||||||
hasMoreLikes = false;
|
hasMoreLikes = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -761,7 +933,9 @@ namespace URLNotesGrabberCORE
|
|||||||
if (response.response.liked_count > 0)
|
if (response.response.liked_count > 0)
|
||||||
likedCountForBlog = response.response.liked_count;
|
likedCountForBlog = response.response.liked_count;
|
||||||
|
|
||||||
Console.WriteLine($"[Likes] Fetched {response.response.liked_posts.Count} likes for {blogName}");
|
Console.WriteLine($"[Likes] [Fetched {response.response.liked_posts.Count}] {blogName}");
|
||||||
|
|
||||||
|
bool crossedHighWaterMark = false;
|
||||||
|
|
||||||
foreach (var post in response.response.liked_posts)
|
foreach (var post in response.response.liked_posts)
|
||||||
{
|
{
|
||||||
@@ -770,6 +944,20 @@ namespace URLNotesGrabberCORE
|
|||||||
long postID = 0;
|
long postID = 0;
|
||||||
try { postID = Convert.ToInt64(post.id); } catch { continue; }
|
try { postID = Convert.ToInt64(post.id); } catch { continue; }
|
||||||
|
|
||||||
|
// liked_timestamp is when the user liked the post (matches the `before` cursor semantics).
|
||||||
|
// It's the only reliable field for the refresh stop condition.
|
||||||
|
long likedTs = 0;
|
||||||
|
try { likedTs = Convert.ToInt64(post.liked_timestamp); } catch { }
|
||||||
|
|
||||||
|
if (isRefresh && likedTs > 0 && storedNewestTs > 0 && likedTs <= storedNewestTs)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Likes] Reached high-water mark for {blogName} at liked_timestamp={likedTs} (<= stored {storedNewestTs}). Stopping refresh.");
|
||||||
|
crossedHighWaterMark = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (likedTs > observedMaxLikedTs) observedMaxLikedTs = likedTs;
|
||||||
|
|
||||||
string authorBlog = post.blog_name?.ToString() ?? ".";
|
string authorBlog = post.blog_name?.ToString() ?? ".";
|
||||||
string postURL = post.post_url?.ToString() ?? ".";
|
string postURL = post.post_url?.ToString() ?? ".";
|
||||||
string date = post.date?.ToString() ?? ".";
|
string date = post.date?.ToString() ?? ".";
|
||||||
@@ -861,7 +1049,8 @@ namespace URLNotesGrabberCORE
|
|||||||
if (shouldInsert)
|
if (shouldInsert)
|
||||||
{
|
{
|
||||||
matchedForBlog++;
|
matchedForBlog++;
|
||||||
Console.WriteLine($"[Likes] Match | Author: {authorBlog} | PostID: {postID} | Field: {matchedFieldName}");
|
if (isRefresh) newInsertedInRefresh++;
|
||||||
|
Console.WriteLine($"[Likes] [Matched] {blogName} | Author: {authorBlog} | PostID: {postID} | Field: {matchedFieldName}");
|
||||||
await Task.Delay(3000);
|
await Task.Delay(3000);
|
||||||
DataAccess.AddPost(authorBlog, postID, reblogURL, date, postURL, slug, reblogKey,
|
DataAccess.AddPost(authorBlog, postID, reblogURL, date, postURL, slug, reblogKey,
|
||||||
reblogName, summary, quote, body, tags, link, photoURL,
|
reblogName, summary, quote, body, tags, link, photoURL,
|
||||||
@@ -872,6 +1061,22 @@ namespace URLNotesGrabberCORE
|
|||||||
|
|
||||||
WriteLikesTotalsLine(blogName, "Running Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
WriteLikesTotalsLine(blogName, "Running Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
||||||
|
|
||||||
|
// Branch B: refresh terminates as soon as we crossed the high-water mark.
|
||||||
|
if (isRefresh && crossedHighWaterMark)
|
||||||
|
{
|
||||||
|
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||||
|
hasMoreLikes = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch A: on the very first page, capture & persist the newest liked_timestamp
|
||||||
|
// so subsequent refresh runs (after backfill completes) have a stopping point.
|
||||||
|
if (!isRefresh && isFirstPage && observedMaxLikedTs > 0)
|
||||||
|
{
|
||||||
|
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||||
|
}
|
||||||
|
isFirstPage = false;
|
||||||
|
|
||||||
// Determine the next BeforeCursor.
|
// Determine the next BeforeCursor.
|
||||||
long nextCursor = 0;
|
long nextCursor = 0;
|
||||||
if (response.response._links?.next?.query_params != null)
|
if (response.response._links?.next?.query_params != null)
|
||||||
@@ -879,9 +1084,12 @@ namespace URLNotesGrabberCORE
|
|||||||
long.TryParse(response.response._links.next.query_params.before ?? "0", out nextCursor);
|
long.TryParse(response.response._links.next.query_params.before ?? "0", out nextCursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isRefresh)
|
||||||
|
{
|
||||||
// Persist cursor progress after every page so resume is always up-to-date
|
// Persist cursor progress after every page so resume is always up-to-date
|
||||||
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
||||||
DataAccess.UpdateBlogLikesStatus(blogName, 0, cursorToPersist);
|
DataAccess.UpdateBlogLikesStatus(blogName, 0, cursorToPersist);
|
||||||
|
}
|
||||||
|
|
||||||
if (nextCursor > 0)
|
if (nextCursor > 0)
|
||||||
{
|
{
|
||||||
@@ -891,17 +1099,29 @@ namespace URLNotesGrabberCORE
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine($"[Likes] No further pagination items. Done with {blogName}.");
|
Console.WriteLine($"[Likes] No further pagination items. Done with {blogName}.");
|
||||||
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursorToPersist); // Mark as completely pulled
|
if (isRefresh)
|
||||||
|
{
|
||||||
|
DataAccess.UpdateBlogLikesRefreshStatus(blogName, observedMaxLikedTs, newInsertedInRefresh);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
long cursorToPersist = nextCursor > 0 ? nextCursor : cursor;
|
||||||
|
DataAccess.UpdateBlogLikesStatus(blogName, 1, cursorToPersist);
|
||||||
|
DataAccess.UpdateBlogLikesNewestTimestamp(blogName, observedMaxLikedTs);
|
||||||
|
}
|
||||||
hasMoreLikes = false;
|
hasMoreLikes = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"[{remaining} remaining] Done with {blogName}");
|
||||||
await Task.Delay(1000); // 1-second delay between pages
|
await Task.Delay(1000); // 1-second delay between pages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isRefresh)
|
||||||
|
Console.WriteLine($"[Likes] {blogName} refresh complete | New inserted: {newInsertedInRefresh} | New HighWaterMark: {observedMaxLikedTs}");
|
||||||
WriteLikesTotalsLine(blogName, "Final Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
WriteLikesTotalsLine(blogName, "Final Totals", parsedForBlog, matchedForBlog, likedCountForBlog);
|
||||||
}
|
}
|
||||||
|
|
||||||
Console.WriteLine("Likes collection complete.");
|
Console.WriteLine("[Likes] [Likes collection complete]");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -910,6 +1130,43 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backoff between in-place retries of a transient infrastructure failure. Most CDN 403s and edge
|
||||||
|
// 5xxs clear within a few seconds, so retrying here saves the post its single attempt for the pass.
|
||||||
|
static readonly int[] TransientBackoffSeconds = { 1, 4, 10 };
|
||||||
|
|
||||||
|
// Fetches one page, retrying transient failures in place. Rate limits are returned to the caller
|
||||||
|
// untouched — those are handled by pausing the whole run, not by retrying this post.
|
||||||
|
static async Task<Root> FetchNotesPage(Tuple<string, long, long, long> post, string beforeTimestamp)
|
||||||
|
{
|
||||||
|
Root response = null!;
|
||||||
|
|
||||||
|
for (int attempt = 0; ; attempt++)
|
||||||
|
{
|
||||||
|
var key = ApiKeyPool.GetCurrentKey();
|
||||||
|
response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
|
||||||
|
|
||||||
|
if (response.statusCode == "TooManyRequests")
|
||||||
|
{
|
||||||
|
ApiKeyPool.MarkRateLimited(key, response.retryInSeconds > 0 ? response.retryInSeconds : 60);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.transientFailure)
|
||||||
|
{
|
||||||
|
// Only a response that actually reached the API says anything about the key's standing.
|
||||||
|
ApiKeyPool.MarkAvailable(key);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt >= TransientBackoffSeconds.Length)
|
||||||
|
return response;
|
||||||
|
|
||||||
|
int delay = TransientBackoffSeconds[attempt];
|
||||||
|
Console.WriteLine($"[Transient] retry {attempt + 1}/{TransientBackoffSeconds.Length} in {delay}s");
|
||||||
|
await Task.Delay(delay * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static async Task<string> GrabNotes(Tuple<string, long, long, long> post)
|
static async Task<string> GrabNotes(Tuple<string, long, long, long> post)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -927,18 +1184,16 @@ namespace URLNotesGrabberCORE
|
|||||||
string beforeTimestamp = post.Item3.ToString();
|
string beforeTimestamp = post.Item3.ToString();
|
||||||
bool hasReplies = false;
|
bool hasReplies = false;
|
||||||
const int maxPages = 500;
|
const int maxPages = 500;
|
||||||
var key = ApiKeyPool.GetCurrentKey();
|
var response = await FetchNotesPage(post, beforeTimestamp);
|
||||||
var response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
|
|
||||||
|
|
||||||
if (response.statusCode == "TooManyRequests")
|
if (response.statusCode == "TooManyRequests")
|
||||||
{
|
|
||||||
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
|
|
||||||
ApiKeyPool.MarkRateLimited(key, retry);
|
|
||||||
return "TooManyRequests";
|
return "TooManyRequests";
|
||||||
}
|
|
||||||
|
|
||||||
if (response.meta?.status != 429)
|
if (response.transientFailure)
|
||||||
ApiKeyPool.MarkAvailable(key);
|
{
|
||||||
|
Console.WriteLine($"[Skip] {post.Item1}/{post.Item2} — {response.statusCode} after {TransientBackoffSeconds.Length} retries");
|
||||||
|
return "Transient";
|
||||||
|
}
|
||||||
|
|
||||||
if (IsNotFound(response))
|
if (IsNotFound(response))
|
||||||
{
|
{
|
||||||
@@ -947,19 +1202,6 @@ namespace URLNotesGrabberCORE
|
|||||||
Thread.Sleep(1000);
|
Thread.Sleep(1000);
|
||||||
return "NotFound";
|
return "NotFound";
|
||||||
}
|
}
|
||||||
if (response == null)
|
|
||||||
{
|
|
||||||
Console.WriteLine("##### Response is null - API Failure? ###");
|
|
||||||
return "FAILURE";
|
|
||||||
}
|
|
||||||
if (response.statusCode == "TooManyRequests")
|
|
||||||
{
|
|
||||||
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
|
|
||||||
ApiKeyPool.MarkRateLimited(key, retry);
|
|
||||||
|
|
||||||
ApiKeyPool.SleepUntilAnyAvailable(30);
|
|
||||||
return response.statusCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pagination loop
|
// Pagination loop
|
||||||
while (true)
|
while (true)
|
||||||
@@ -1009,16 +1251,15 @@ namespace URLNotesGrabberCORE
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
key = ApiKeyPool.GetCurrentKey();
|
response = await FetchNotesPage(post, beforeTimestamp);
|
||||||
response = await APIAccess.GrabNotes(key, post.Item1, post.Item2, beforeTimestamp);
|
|
||||||
if (response.statusCode == "TooManyRequests")
|
if (response.statusCode == "TooManyRequests")
|
||||||
{
|
|
||||||
int retry = response.retryInSeconds > 0 ? response.retryInSeconds : 60;
|
|
||||||
ApiKeyPool.MarkRateLimited(key, retry);
|
|
||||||
return "TooManyRequests";
|
return "TooManyRequests";
|
||||||
|
|
||||||
|
if (response.transientFailure)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Skip] {post.Item1}/{post.Item2} — {response.statusCode} on page {page} after {TransientBackoffSeconds.Length} retries");
|
||||||
|
return "Transient";
|
||||||
}
|
}
|
||||||
if (response.meta?.status != 429)
|
|
||||||
ApiKeyPool.MarkAvailable(key);
|
|
||||||
|
|
||||||
if (IsNotFound(response))
|
if (IsNotFound(response))
|
||||||
{
|
{
|
||||||
@@ -1049,13 +1290,27 @@ namespace URLNotesGrabberCORE
|
|||||||
return "UNKNOWN";
|
return "UNKNOWN";
|
||||||
}
|
}
|
||||||
|
|
||||||
static async Task CollectNotes(string outPath, bool withoutNotesOnly = true, DateTime? beforeDate = null)
|
// Consecutive transient failures that mean the API edge is rejecting traffic wholesale rather than
|
||||||
|
// blipping on one post. Past this, skipping post-by-post would just hammer a closed door.
|
||||||
|
const int MaxConsecutiveTransient = 10;
|
||||||
|
|
||||||
|
static async Task<int> CollectNotes(string outPath, bool withoutNotesOnly = true, DateTime? beforeDate = null, bool managedRun = false)
|
||||||
{
|
{
|
||||||
List<Tuple<string, long, long, long>> posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
|
List<Tuple<string, long, long, long>> posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
|
||||||
|
|
||||||
|
// Posts attempted (with a definitive, non-throttle result) during *this* process. Guarantees a single
|
||||||
|
// attempt pass: once every remaining post has been attempted, the loop stops instead of spinning on a
|
||||||
|
// post that keeps returning FAILURE/UNKNOWN. Successful/NotFound posts drop out via the DB filter anyway.
|
||||||
|
HashSet<(string, long)> attempted = new HashSet<(string, long)>();
|
||||||
|
|
||||||
|
int skipped = 0;
|
||||||
|
int consecutiveTransient = 0;
|
||||||
|
|
||||||
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
|
RateLimiter limiter = new SlidingWindowRateLimiter(new SlidingWindowRateLimiterOptions
|
||||||
{
|
{
|
||||||
PermitLimit = 300,
|
// 1/sec average. Sustained higher rates draw CDN-level 403s that the API's own rate-limit
|
||||||
|
// headers never warn about, so this sits well under the per-key quota on purpose.
|
||||||
|
PermitLimit = 60,
|
||||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||||
QueueLimit = 1,
|
QueueLimit = 1,
|
||||||
Window = TimeSpan.FromMinutes(1),
|
Window = TimeSpan.FromMinutes(1),
|
||||||
@@ -1069,49 +1324,120 @@ namespace URLNotesGrabberCORE
|
|||||||
{
|
{
|
||||||
while (posts.Count > 0)
|
while (posts.Count > 0)
|
||||||
{
|
{
|
||||||
|
// First post not yet attempted this process. If all remaining have been attempted, the pass
|
||||||
|
// is done (the stragglers returned FAILURE/UNKNOWN) — stop rather than loop forever.
|
||||||
|
var post = posts.FirstOrDefault(p => !attempted.Contains((p.Item1, p.Item2)));
|
||||||
|
if (post == null)
|
||||||
|
{
|
||||||
|
Console.WriteLine("All remaining posts have been attempted this run; ending pass.");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
ApiKeyPool.SleepUntilAnyAvailable(30);
|
ApiKeyPool.SleepUntilAnyAvailable(30);
|
||||||
|
|
||||||
var post = posts[0]; // Process the first post in the list
|
// Wait for a permit rather than giving up on one: the limiter paces the loop, it is
|
||||||
string status;
|
// not a failure condition. Only one acquire is ever pending, so QueueLimit = 1 suffices.
|
||||||
|
using RateLimitLease lease = await limiter.AcquireAsync(1);
|
||||||
using RateLimitLease lease = limiter.AttemptAcquire(1);
|
if (!lease.IsAcquired)
|
||||||
if (lease.IsAcquired)
|
|
||||||
{
|
{
|
||||||
|
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
||||||
|
return 3; // abort without completing the run so a later launch resumes
|
||||||
|
}
|
||||||
|
|
||||||
Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString());
|
Console.WriteLine("{0,32} - {1,15} - {2}", post.Item1, post.Item2, DateTimeOffset.FromUnixTimeSeconds(post.Item4).ToString());
|
||||||
status = await GrabNotes(post);
|
string status = await GrabNotes(post);
|
||||||
|
|
||||||
|
if (status == "Transient")
|
||||||
|
{
|
||||||
|
// Retries in GrabNotes are already exhausted. Skip the post so the pass can make
|
||||||
|
// progress; it stays unmarked in the DB, so the next launch picks it up again.
|
||||||
|
attempted.Add((post.Item1, post.Item2));
|
||||||
|
skipped++;
|
||||||
|
consecutiveTransient++;
|
||||||
|
|
||||||
|
if (consecutiveTransient >= MaxConsecutiveTransient)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Abort] {consecutiveTransient} consecutive transient failures - the API edge is rejecting traffic. Pausing run; relaunch to resume. ({skipped} post(s) skipped)");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
Console.WriteLine("!@@@@@@@ - Rate Limited Exceeded: No Lease Available");
|
consecutiveTransient = 0;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (status == "Success")
|
if (status == "Success")
|
||||||
{
|
{
|
||||||
|
attempted.Add((post.Item1, post.Item2));
|
||||||
DataAccess.UpdatePostMarkNotesCollected(post.Item1, post.Item2);
|
DataAccess.UpdatePostMarkNotesCollected(post.Item1, post.Item2);
|
||||||
}
|
}
|
||||||
else if (status == "NotFound")
|
else if (status == "NotFound")
|
||||||
{
|
{
|
||||||
|
attempted.Add((post.Item1, post.Item2));
|
||||||
Console.WriteLine("GrabNotes Result: NotFound");
|
Console.WriteLine("GrabNotes Result: NotFound");
|
||||||
DataAccess.UpdatePostMarkNotFound(post.Item1, post.Item2);
|
DataAccess.UpdatePostMarkNotFound(post.Item1, post.Item2);
|
||||||
}
|
}
|
||||||
|
else if (status == "TooManyRequests")
|
||||||
|
{
|
||||||
|
// Throttle, not a real per-post failure: don't consume this post's single attempt.
|
||||||
|
// Abort the pass without completing so a later launch resumes against the same cutoff.
|
||||||
|
Console.WriteLine($"GrabNotes Result: TooManyRequests - pausing run; relaunch to resume. ({skipped} post(s) skipped)");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// FAILURE / UNKNOWN: count as attempted so the pass can finish instead of retrying forever.
|
||||||
|
attempted.Add((post.Item1, post.Item2));
|
||||||
Console.WriteLine("GrabNotes Result: " + status);
|
Console.WriteLine("GrabNotes Result: " + status);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-fetch the updated list after processing the current post
|
// Re-fetch the updated list after processing the current post
|
||||||
posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
|
posts = DataAccess.GetPosts(withoutNotesOnly, beforeDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reached only when the pass finished naturally (worklist drained or all stragglers attempted).
|
||||||
|
if (managedRun)
|
||||||
|
{
|
||||||
|
DataAccess.CompleteCollectRun();
|
||||||
|
Console.WriteLine("Full re-check run complete.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped > 0)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Pass finished with {skipped} post(s) skipped after transient failures; relaunch to retry them.");
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
Console.WriteLine(ex.ToString());
|
Console.WriteLine(ex.ToString());
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Field prefixes TraverseDirectory recognizes as the start of a new record field.
|
||||||
|
// Used to know where a multi-line Body/Downloaded files value ends.
|
||||||
|
private static readonly string[] TraverseDirectoryFieldPrefixes = new[]
|
||||||
|
{
|
||||||
|
"Post id:", "Reblog url:", "Reblog name:", "Reblog root url:", "Downloaded files:",
|
||||||
|
"Reblog key:", "Date:", "Body:", "Post url:", "Answer:", "Audio Caption:", "Blog Name:",
|
||||||
|
"Link:", "Photo Caption:", "Photo url:", "Question:", "Quote:", "Slug:", "Summary:",
|
||||||
|
"Tags:", "Title:"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static bool IsTraverseDirectoryFieldLine(string line)
|
||||||
|
{
|
||||||
|
foreach (var prefix in TraverseDirectoryFieldPrefixes)
|
||||||
|
{
|
||||||
|
if (line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
static void TraverseDirectory(string path, string outPath, List<string> contains, ref int postsAdded, string blogName = "", string startFromBlogName = "", bool logRecordImports = false)
|
static void TraverseDirectory(string path, string outPath, List<string> contains, ref int postsAdded, string blogName = "", string startFromBlogName = "", bool logRecordImports = false)
|
||||||
{
|
{
|
||||||
DateTime directoryStart = DateTime.Now;
|
DateTime directoryStart = DateTime.Now;
|
||||||
@@ -1148,8 +1474,10 @@ namespace URLNotesGrabberCORE
|
|||||||
var urls = new List<string>();
|
var urls = new List<string>();
|
||||||
var reblog = new ReblogRecord();
|
var reblog = new ReblogRecord();
|
||||||
|
|
||||||
foreach (string line in File.ReadLines(file))
|
string[] fileLines = File.ReadAllLines(file);
|
||||||
|
for (int lineIndex = 0; lineIndex < fileLines.Length; lineIndex++)
|
||||||
{
|
{
|
||||||
|
string line = fileLines[lineIndex];
|
||||||
if (line.StartsWith("Post id:", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith("Post id:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (reblog.reblogName != "." && reblog.postID != "." && reblog.date != "." && reblog.reblogURL != "." && reblog.downloadedFiles == ".")
|
if (reblog.reblogName != "." && reblog.postID != "." && reblog.date != "." && reblog.reblogURL != "." && reblog.downloadedFiles == ".")
|
||||||
@@ -1170,7 +1498,7 @@ namespace URLNotesGrabberCORE
|
|||||||
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
|
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
|
||||||
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
|
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
|
||||||
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
||||||
reblog.title, false);
|
reblog.title, false, rootURL: reblog.rootURL);
|
||||||
recordImportStopwatch.Stop();
|
recordImportStopwatch.Stop();
|
||||||
|
|
||||||
postsAdded++;
|
postsAdded++;
|
||||||
@@ -1195,9 +1523,21 @@ namespace URLNotesGrabberCORE
|
|||||||
{
|
{
|
||||||
reblog.reblogName = line.Substring(13).Trim();
|
reblog.reblogName = line.Substring(13).Trim();
|
||||||
}
|
}
|
||||||
|
if (line.StartsWith(@"Reblog root url:", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
reblog.rootURL = line.Substring(16).Trim();
|
||||||
|
}
|
||||||
if (line.StartsWith(@"Downloaded files:", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith(@"Downloaded files:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
reblog.downloadedFiles = line.Substring(17).Trim();
|
var valueLines = new List<string> { line.Substring(17).Trim() };
|
||||||
|
int nextLineIndex = lineIndex + 1;
|
||||||
|
while (nextLineIndex < fileLines.Length && !IsTraverseDirectoryFieldLine(fileLines[nextLineIndex]))
|
||||||
|
{
|
||||||
|
valueLines.Add(fileLines[nextLineIndex]);
|
||||||
|
nextLineIndex++;
|
||||||
|
}
|
||||||
|
reblog.downloadedFiles = string.Join("\n", valueLines).Trim();
|
||||||
|
lineIndex = nextLineIndex - 1;
|
||||||
}
|
}
|
||||||
if (line.StartsWith(@"Reblog key:", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith(@"Reblog key:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1209,7 +1549,15 @@ namespace URLNotesGrabberCORE
|
|||||||
}
|
}
|
||||||
if (line.StartsWith(@"Body:", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith(@"Body:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
reblog.body = line.Substring(6).Trim();
|
var valueLines = new List<string> { line.Substring(6).Trim() };
|
||||||
|
int nextLineIndex = lineIndex + 1;
|
||||||
|
while (nextLineIndex < fileLines.Length && !IsTraverseDirectoryFieldLine(fileLines[nextLineIndex]))
|
||||||
|
{
|
||||||
|
valueLines.Add(fileLines[nextLineIndex]);
|
||||||
|
nextLineIndex++;
|
||||||
|
}
|
||||||
|
reblog.body = string.Join("\n", valueLines).Trim();
|
||||||
|
lineIndex = nextLineIndex - 1;
|
||||||
}
|
}
|
||||||
if (line.StartsWith(@"Post url:", StringComparison.OrdinalIgnoreCase))
|
if (line.StartsWith(@"Post url:", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -1298,7 +1646,7 @@ namespace URLNotesGrabberCORE
|
|||||||
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
|
DataAccess.AddPost(curDir, long.Parse(reblog.postID), reblog.reblogURL, reblog.date, reblog.postURL, reblog.slug, reblog.reblogKey,
|
||||||
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
|
reblog.reblogName, reblog.summary, reblog.quote, reblog.body, reblog.tags, reblog.link, reblog.photoURL,
|
||||||
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
reblog.photoCaption, reblog.downloadedFiles, reblog.audioCaption, reblog.question, reblog.answer,
|
||||||
reblog.title, true);
|
reblog.title, true, rootURL: reblog.rootURL);
|
||||||
recordImportStopwatch.Stop();
|
recordImportStopwatch.Stop();
|
||||||
|
|
||||||
postsAdded++;
|
postsAdded++;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"profiles": {
|
"profiles": {
|
||||||
"URLNotesGrabberCORE": {
|
"URLNotesGrabberCORE": {
|
||||||
"commandName": "Project",
|
"commandName": "Project",
|
||||||
"commandLineArgs": "-collect 1 -api4"
|
"commandLineArgs": "--collect 1 --api4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,6 +85,10 @@ namespace URLNotesGrabberCORE
|
|||||||
public int retryInSeconds { get; set; }
|
public int retryInSeconds { get; set; }
|
||||||
|
|
||||||
public string rawJson { get; set; }
|
public string rawJson { get; set; }
|
||||||
|
|
||||||
|
// The request never reached the Tumblr API (transport error, or an edge/CDN response with a
|
||||||
|
// non-JSON body). Says nothing about the post, so the caller should retry rather than fail it.
|
||||||
|
public bool transientFailure { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Classes for Posts API endpoint (for reply_text)
|
// Classes for Posts API endpoint (for reply_text)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
# `TL.db` — schema notes
|
||||||
|
|
||||||
|
The SQLite database behind **URLNotesGrabberCORE** and its sibling crawlers, and the one
|
||||||
|
[Rolodex](https://git.basso.land/jim/Rolodex) reads.
|
||||||
|
|
||||||
|
Everything below was read out of the live file, not inferred from code. Counts are as of
|
||||||
|
**2026-07-29**; re-run the queries at the bottom to refresh them.
|
||||||
|
|
||||||
|
- Journal mode: **WAL** — `TL.db-wal` and `TL.db-shm` live beside the file and are part of
|
||||||
|
the database. Copying `TL.db` alone gives you whatever was last checkpointed, not the
|
||||||
|
current state.
|
||||||
|
- Page size: 4096.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three content tables
|
||||||
|
|
||||||
|
| Table | Rows | What it is |
|
||||||
|
|---|--:|---|
|
||||||
|
| `Blogs` | 144,367 | The crawl registry — one row per known blog, plus crawl-state flags |
|
||||||
|
| `Posts` | 14,589 | Stored post content. Only 3,602 blogs actually have any |
|
||||||
|
| `Notes` | 1,189,604 | The engagement graph: `NoteBlogName` acted on `(RootBlogName, PostID)` |
|
||||||
|
|
||||||
|
The engagement graph is the interesting part. 31,888 distinct blogs appear as engagers —
|
||||||
|
far more than the 3,602 that have stored posts — which is what makes this a social graph
|
||||||
|
rather than a post archive.
|
||||||
|
|
||||||
|
### `Blogs`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "Blogs" (
|
||||||
|
"BlogName" TEXT,
|
||||||
|
"HasBeenOutput" INTEGER DEFAULT 0,
|
||||||
|
"IsActive" INTEGER DEFAULT 1,
|
||||||
|
"DateAdded" TEXT NOT NULL DEFAULT '12/24/25',
|
||||||
|
"ByLikes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"LikesPulled" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"LikesCursor" INTEGER DEFAULT 0,
|
||||||
|
"DateModified" TEXT,
|
||||||
|
"DateCreated" TEXT,
|
||||||
|
LikesNewestTimestamp INTEGER DEFAULT 0,
|
||||||
|
LikesLastRefreshed INTEGER DEFAULT 0,
|
||||||
|
LikesLastNewCount INTEGER DEFAULT 0,
|
||||||
|
TTFolderPath TEXT,
|
||||||
|
PRIMARY KEY("BlogName")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`BlogName` is the primary key, so it is the only indexed way in. There is no index on any
|
||||||
|
flag or date — filtering or sorting on those scans all 144k rows, which is affordable
|
||||||
|
here and is not on `Notes`.
|
||||||
|
|
||||||
|
Flag distribution: `IsActive = 1` on 144,366 of 144,367 rows, `HasBeenOutput = 1` on
|
||||||
|
5,369, `ByLikes = 1` on 2. `IsActive` carries a second meaning as of Rolodex — see
|
||||||
|
[`Blogs.IsActive`](#blogsisactive--now-written-by-two-applications) below.
|
||||||
|
|
||||||
|
The columns after `DateCreated` were added later by `ALTER TABLE`, which is why they carry
|
||||||
|
no quoting in the stored DDL. That is the normal way this schema grows.
|
||||||
|
|
||||||
|
**`DateAdded` is not written consistently.** 126,423 rows hold ISO `yyyy-MM-dd HH:mm:ss`;
|
||||||
|
17,944 hold US-format `M/d/yy` from a bulk import. As text those two sort into different
|
||||||
|
parts of the table, so anything ordering or range-filtering on this column has to
|
||||||
|
normalise first — see `DateSql` in Rolodex.
|
||||||
|
|
||||||
|
### `Posts`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "Posts" (
|
||||||
|
"BlogName" TEXT,
|
||||||
|
"PostID" INTEGER,
|
||||||
|
"HasNotesGathered" INTEGER DEFAULT 0,
|
||||||
|
"reblogURL" TEXT,
|
||||||
|
"NotFound" INTEGER DEFAULT 0,
|
||||||
|
"PostDate" TEXT,
|
||||||
|
"NotesGatheredDateTime" INTEGER NOT NULL DEFAULT 1729746000,
|
||||||
|
"HasImage" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"PostURL" TEXT,
|
||||||
|
"Slug" TEXT,
|
||||||
|
"ReblogKey" TEXT,
|
||||||
|
"ReblogName" TEXT,
|
||||||
|
"Summary" TEXT,
|
||||||
|
"Quote" TEXT,
|
||||||
|
"Body" TEXT,
|
||||||
|
"Tags" TEXT,
|
||||||
|
"Link" TEXT,
|
||||||
|
"PhotoURL" TEXT,
|
||||||
|
"PhotoCaption" TEXT,
|
||||||
|
"DownloadedFiles" TEXT,
|
||||||
|
"AudioCaption" TEXT,
|
||||||
|
"Question" TEXT,
|
||||||
|
"Answer" TEXT,
|
||||||
|
"Title" TEXT,
|
||||||
|
"ByLikes" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"RootBlogName" TEXT,
|
||||||
|
"RootURL" TEXT,
|
||||||
|
"DateModified" TEXT,
|
||||||
|
"DateCreated" TEXT,
|
||||||
|
PostType TEXT,
|
||||||
|
PRIMARY KEY("BlogName","PostID")
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**The key is `(BlogName, PostID)`, not `PostID`.** This matters more than it looks: 325
|
||||||
|
post IDs exist under more than one blog, so an ID on its own is both ambiguous *and*
|
||||||
|
unindexed. Any lookup should carry the blog name, and a batch lookup should group by blog
|
||||||
|
so it stays on the leading column of the key.
|
||||||
|
|
||||||
|
Notable:
|
||||||
|
|
||||||
|
- **`PostType` is `NULL` on all 14,589 rows.** The column exists but nothing has ever
|
||||||
|
populated it. Treat it as unpopulated rather than as a type discriminator.
|
||||||
|
- `HasImage = 1` on 14,268 rows — nearly all of them. It records that the post *had* a
|
||||||
|
picture, not that a usable URL was kept, so it is not a reliable predictor that anything
|
||||||
|
will render.
|
||||||
|
- `PhotoURL` is largely unused; in practice the image markup lives inside `Body`.
|
||||||
|
- `NotFound = 1` on 4,663 rows — posts that have since been deleted upstream.
|
||||||
|
- The content columns (`Body`, `Quote`, `Question`, `Answer`, …) are the heavy ones. List
|
||||||
|
views should not select them.
|
||||||
|
|
||||||
|
### `Notes`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE "Notes" (
|
||||||
|
"RootBlogName" TEXT,
|
||||||
|
"PostID" INTEGER,
|
||||||
|
"NoteBlogName" TEXT,
|
||||||
|
"TimeStamp" INTEGER,
|
||||||
|
"Type" TEXT,
|
||||||
|
"replyText" TEXT DEFAULT '.',
|
||||||
|
"DatetimeCrawled" TEXT DEFAULT '2/12/26 12am',
|
||||||
|
"DateModified" TEXT,
|
||||||
|
"DateCreated" TEXT,
|
||||||
|
PRIMARY KEY("RootBlogName","PostID","TimeStamp","Type","NoteBlogName")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "Notes_idx_06e01ae3" ON "Notes" ("TimeStamp" DESC);
|
||||||
|
CREATE INDEX "ix_NoteBlogName01" ON "Notes" ("NoteBlogName");
|
||||||
|
```
|
||||||
|
|
||||||
|
One row per engagement event. `TimeStamp` is **unix seconds** — unlike every date column
|
||||||
|
elsewhere in the schema, which are text.
|
||||||
|
|
||||||
|
| `Type` | Rows | Share |
|
||||||
|
|---|--:|--:|
|
||||||
|
| `like` | 947,955 | 79.7% |
|
||||||
|
| `reblog` | 224,323 | 18.9% |
|
||||||
|
| `reply` | 15,201 | 1.3% |
|
||||||
|
| `posted` | 2,106 | 0.2% |
|
||||||
|
| `post_attribution` | 19 | — |
|
||||||
|
|
||||||
|
At 1.19M rows this is the table that dictates how the whole database has to be queried:
|
||||||
|
|
||||||
|
- **Nothing should run an unbounded `SELECT` or a bare `COUNT(*)` here.** A count scans
|
||||||
|
the lot on every call.
|
||||||
|
- The only fast access paths are the primary key's leading columns (`RootBlogName`, then
|
||||||
|
`PostID`) and `ix_NoteBlogName01` on `NoteBlogName`. "Notes received by a blog" and
|
||||||
|
"notes given by a blog" are both cheap; almost nothing else is.
|
||||||
|
- Ordering by anything but `TimeStamp` is a full sort of whatever the filters leave.
|
||||||
|
- `replyText` is `'.'` on 1,174,706 rows — only `reply` notes carry real text.
|
||||||
|
|
||||||
|
### Referential integrity
|
||||||
|
|
||||||
|
There are no foreign keys, and the tables do not perfectly agree:
|
||||||
|
|
||||||
|
- 4 `Posts` rows name a blog with no `Blogs` row.
|
||||||
|
- 15 of the 31,888 distinct engagers have no `Blogs` row.
|
||||||
|
|
||||||
|
So a name appearing in `Notes` or `Posts` is not a guarantee that the registry knows about
|
||||||
|
it. Joins from those tables back to `Blogs` should tolerate a miss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The `'.'` placeholder convention
|
||||||
|
|
||||||
|
**The crawler writes a single dot into text columns it has no value for, rather than
|
||||||
|
`NULL`.** This is the single most surprising thing about the schema and it affects every
|
||||||
|
consumer.
|
||||||
|
|
||||||
|
| Column | `'.'` rows |
|
||||||
|
|---|--:|
|
||||||
|
| `Notes.replyText` | 1,174,706 |
|
||||||
|
| `Posts.Title` | 13,144 |
|
||||||
|
| `Posts.Body` | 172 |
|
||||||
|
|
||||||
|
Any query whose output reaches a human should collapse it:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
NULLIF(NULLIF(SomeColumn, '.'), '') AS SomeColumn
|
||||||
|
```
|
||||||
|
|
||||||
|
Empty string turns up too, hence the double `NULLIF`. Not every column is affected —
|
||||||
|
`Blogs.TTFolderPath` and `Blogs.DateModified` currently have zero dot rows — but new
|
||||||
|
columns tend to acquire them, so treat cleaning as the default for any text column
|
||||||
|
rendered to a user.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supporting tables
|
||||||
|
|
||||||
|
Crawler bookkeeping. Rolodex ignores all of these.
|
||||||
|
|
||||||
|
| Table | Rows | What it is |
|
||||||
|
|---|--:|---|
|
||||||
|
| `DailyAPICount` | 133 | `(Date TEXT PK, APICount INTEGER)` — per-day API call tally against the rate limit |
|
||||||
|
| `ApiKeyPoolState` | 2 | `(KeyName TEXT PK, RetryUntil INTEGER)` — per-key backoff; `RetryUntil` is unix seconds |
|
||||||
|
| `ApiKeyPoolMeta` | 1 | `(Id PK CHECK (Id = 1), LastIndex)` — round-robin cursor. Singleton by check constraint |
|
||||||
|
| `CollectRunState` | 1 | `(Id PK CHECK (Id = 1), RunCutoff, RunComplete, RunStarted, RunCompletedAt)` — resume state for an interrupted collection run. Also a singleton |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Blogs.IsActive` — now written by two applications
|
||||||
|
|
||||||
|
`IsActive` has always been the crawler's work-selection flag. `GetBlogs` in
|
||||||
|
`DataAccess.cs` joins on it to decide what to collect:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT NoteBlogName, count(*) FROM notes
|
||||||
|
INNER JOIN blogs ON blogs.BlogName = notes.NoteBlogName
|
||||||
|
WHERE blogs.IsActive = @isActive AND ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Nothing inside the crawler *writes* it — it is an input, set from outside.
|
||||||
|
|
||||||
|
**Rolodex is now one of the things that sets it.** Removing a blog through the Rolodex UI
|
||||||
|
runs exactly this:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE Blogs SET IsActive = 0 WHERE BlogName = ?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rolodex adds no column and changes no schema. It reuses this flag because the two meanings
|
||||||
|
were judged to be one decision: a blog you do not want in the browsing UI is a blog you do
|
||||||
|
not want to keep crawling. Removal therefore stops collection, and the Rolodex
|
||||||
|
confirmation screen says so before anyone commits.
|
||||||
|
|
||||||
|
- `1` (or absent/NULL) — live. Crawled, and visible in Rolodex.
|
||||||
|
- `0` — removed. Not crawled, hidden from the Rolodex registry, dashboard counts and
|
||||||
|
engagement rollups.
|
||||||
|
|
||||||
|
Restoring is the same `UPDATE` with a `1`. Nothing is destroyed either way: the blog's
|
||||||
|
`Posts` and `Notes` rows are never touched, and Rolodex deliberately keeps showing them
|
||||||
|
under its Posts and Notes pages. Removing a blog hides the blog, not what it collected.
|
||||||
|
|
||||||
|
### What other tools need to know
|
||||||
|
|
||||||
|
1. **Setting `IsActive = 0` now also hides the blog from Rolodex**, and setting it back to
|
||||||
|
`1` makes it reappear. If another tool deactivates blogs in bulk, it is also removing
|
||||||
|
them from the browsing UI — which may be exactly right, but it is no longer a
|
||||||
|
crawler-only decision.
|
||||||
|
2. **Re-crawling a removed blog will not bring it back**, since nothing in the crawler
|
||||||
|
writes the flag. An `INSERT OR REPLACE` on the `Blogs` row *would*, by resetting it to
|
||||||
|
the column default of `1`. Prefer an `UPDATE` of the specific columns, or
|
||||||
|
`INSERT … ON CONFLICT DO UPDATE SET` naming only the columns being refreshed.
|
||||||
|
3. **NULL is treated as live.** The column is `INTEGER DEFAULT 1` with no `NOT NULL`, so
|
||||||
|
Rolodex reads it through `COALESCE(IsActive, 1)`. A NULL therefore leaves the blog
|
||||||
|
visible rather than stranding it outside both the registry and the removed list, where
|
||||||
|
no screen could reach it. Write `0` or `1`, not NULL.
|
||||||
|
4. **Backing the feature out is a configuration change, not a migration.** Because there is
|
||||||
|
no Rolodex-owned column, setting `Rolodex__EnableBlogDeletion=false` is the whole of it;
|
||||||
|
there is nothing to drop. Any blogs already at `IsActive = 0` simply go back to being
|
||||||
|
ordinary inactive blogs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `Posts.IsActive` and `Notes.IsActive` — optional, and not in this database yet
|
||||||
|
|
||||||
|
The same flag is being extended to the two content tables, with the same meaning: `0` is
|
||||||
|
removed, anything else — including `NULL` — is live. **Neither column exists in the live
|
||||||
|
`TL.db` as of 2026-07-29**; the DDL quoted above for `Posts` and `Notes` is complete. Like
|
||||||
|
`Blogs.IsActive`, they are written from outside this crawler.
|
||||||
|
|
||||||
|
The crawler therefore treats both as optional, and as nothing it owns:
|
||||||
|
|
||||||
|
- **It never writes them.** No `INSERT` column list names `IsActive`, no `UPDATE` sets it,
|
||||||
|
and `MapPrefixToColumn` — the only place a column name is chosen at runtime — cannot map
|
||||||
|
to it. Re-crawling a removed post or note refreshes its content and leaves the flag at
|
||||||
|
`0`. There is no `INSERT OR REPLACE` on `Posts` or `Notes` for a default to be reset by.
|
||||||
|
- **It filters on them only when they exist.** `HasIsActiveColumn` in `DataAccess.cs` asks
|
||||||
|
`PRAGMA table_info` once per table per database path and caches the answer; the filter
|
||||||
|
is `COALESCE(IsActive, 1) = 1`, and it is dropped entirely when the column is absent.
|
||||||
|
Naming a missing column is a hard SQLite error, so this is what lets one build run
|
||||||
|
against databases on both sides of the change. The cache lives for the process — adding
|
||||||
|
the columns to a live database takes effect on the next run.
|
||||||
|
|
||||||
|
Every read that selects posts or notes carries the filter: `GetPosts`, `GetReplies`,
|
||||||
|
`GetRepliesWithMissingText`, `GetRepliesWithFilledText`, `GetAllPostTextColumns`,
|
||||||
|
`GetAllPostsForBlog`, `GetPost`, `GetPostByIdAnyBlog`, and the engagement queries that
|
||||||
|
count or join `Notes` (`GetBlogs`, `GetBlogsAll`, `GetBlogsForLikes`). The one deliberate
|
||||||
|
omission is the `LEFT JOIN Notes` in `GetPosts`: nothing is selected from it and it can
|
||||||
|
neither add nor remove a row, so filtering it would buy nothing.
|
||||||
|
|
||||||
|
`LegacyPostsDbImporter` is unfiltered too — it reads a foreign legacy database whose
|
||||||
|
`Posts` table is not this schema.
|
||||||
|
|
||||||
|
Two consequences worth stating plainly, both inherited from how `Blogs.IsActive` is
|
||||||
|
handled:
|
||||||
|
|
||||||
|
1. **Removal hides a row; it does not freeze it.** The write paths are keyed on a post the
|
||||||
|
caller already selected, so an ingest or a correction run still overwrites the content
|
||||||
|
of a removed post. Only selection is filtered.
|
||||||
|
2. **`NULL` is live.** Write `0` or `1`, not `NULL`, but a `NULL` leaves the row visible
|
||||||
|
rather than stranding it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Reproducing the numbers
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT 'Blogs', COUNT(*) FROM Blogs
|
||||||
|
UNION ALL SELECT 'Posts', COUNT(*) FROM Posts
|
||||||
|
UNION ALL SELECT 'Notes', COUNT(*) FROM Notes;
|
||||||
|
|
||||||
|
-- note type mix
|
||||||
|
SELECT Type, COUNT(*) FROM Notes GROUP BY Type ORDER BY 2 DESC;
|
||||||
|
|
||||||
|
-- the two date shapes in Blogs.DateAdded
|
||||||
|
SELECT CASE WHEN DateAdded LIKE '____-__-__%' THEN 'ISO' ELSE 'US' END, COUNT(*)
|
||||||
|
FROM Blogs GROUP BY 1;
|
||||||
|
|
||||||
|
-- post IDs that are ambiguous without a blog name
|
||||||
|
SELECT COUNT(*) FROM (
|
||||||
|
SELECT PostID FROM Posts GROUP BY PostID HAVING COUNT(DISTINCT BlogName) > 1);
|
||||||
|
|
||||||
|
-- rows that reference a blog the registry does not have
|
||||||
|
SELECT COUNT(*) FROM Posts p
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM Blogs b WHERE b.BlogName = p.BlogName);
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the file read-only so an inspection can never disturb a running crawl:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 "file:TL.db?mode=ro" ".schema"
|
||||||
|
```
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
<None Update="TL.db">
|
<None Update="TL.db">
|
||||||
|
<CopyToOutputDirectory>Never</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="prefixes.txt">
|
||||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace URLNotesGrabberCORE
|
||||||
|
{
|
||||||
|
// Port of ThreeTxtFileHelper/UpdateBlogPaths.cs. Reads .tumblr / .tmblrpriv metadata
|
||||||
|
// files from a root\Index folder and populates Blogs.TTFolderPath in TL.db.
|
||||||
|
//
|
||||||
|
// Scan() is the reusable engine: --updatepaths wraps it as a standalone command and
|
||||||
|
// --output calls it as a refresh step, because a TL.db synced between machines cannot
|
||||||
|
// hold one absolute path that is correct on both.
|
||||||
|
public static class UpdateBlogPathsRunner
|
||||||
|
{
|
||||||
|
public enum ScanOutcome
|
||||||
|
{
|
||||||
|
Completed,
|
||||||
|
NoRootConfigured,
|
||||||
|
IndexFolderMissing
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ScanResult
|
||||||
|
{
|
||||||
|
public ScanOutcome Outcome { get; init; }
|
||||||
|
public string RootPath { get; init; } = string.Empty;
|
||||||
|
public string IndexPath { get; init; } = string.Empty;
|
||||||
|
public int MetadataFiles { get; init; }
|
||||||
|
public int Written { get; init; }
|
||||||
|
public int Unchanged { get; init; }
|
||||||
|
public int NoLocation { get; init; }
|
||||||
|
public int NoMatchingRow { get; init; }
|
||||||
|
public int Errors { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// verbose: log a line per metadata file. --updatepaths wants that detail; --output
|
||||||
|
// only wants the counts, since a few hundred lines before the export would bury it.
|
||||||
|
public static ScanResult Scan(string? rootPath, bool verbose)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
return new ScanResult { Outcome = ScanOutcome.NoRootConfigured };
|
||||||
|
|
||||||
|
DataAccess.EnsureTTFileHelperColumnsExist();
|
||||||
|
|
||||||
|
string indexPath = Path.Combine(rootPath, "Index");
|
||||||
|
if (!Directory.Exists(indexPath))
|
||||||
|
{
|
||||||
|
return new ScanResult
|
||||||
|
{
|
||||||
|
Outcome = ScanOutcome.IndexFolderMissing,
|
||||||
|
RootPath = rootPath,
|
||||||
|
IndexPath = indexPath
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
var blogFiles = Directory.GetFiles(indexPath, "*.tumblr")
|
||||||
|
.Concat(Directory.GetFiles(indexPath, "*.tmblrpriv"))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (verbose)
|
||||||
|
Console.WriteLine($"Found {blogFiles.Count} blog metadata files");
|
||||||
|
|
||||||
|
int updatedCount = 0;
|
||||||
|
int unchangedCount = 0;
|
||||||
|
int noLocationCount = 0;
|
||||||
|
int noRowCount = 0;
|
||||||
|
int errorCount = 0;
|
||||||
|
|
||||||
|
foreach (var blogFile in blogFiles)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string blogName = Path.GetFileNameWithoutExtension(blogFile);
|
||||||
|
string jsonContent = File.ReadAllText(blogFile);
|
||||||
|
using JsonDocument doc = JsonDocument.Parse(jsonContent);
|
||||||
|
JsonElement root = doc.RootElement;
|
||||||
|
|
||||||
|
if (root.TryGetProperty("FileDownloadLocation", out JsonElement locationElement))
|
||||||
|
{
|
||||||
|
string? fileDownloadLocation = locationElement.GetString()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(fileDownloadLocation))
|
||||||
|
{
|
||||||
|
// Report the database's answer, not the fact that the file parsed.
|
||||||
|
if (DataAccess.SetBlogTTFolderPath(blogName, fileDownloadLocation))
|
||||||
|
{
|
||||||
|
updatedCount++;
|
||||||
|
if (verbose)
|
||||||
|
Console.WriteLine($"Updated {blogName}: {fileDownloadLocation}");
|
||||||
|
}
|
||||||
|
else if (DataAccess.BlogExists(blogName))
|
||||||
|
{
|
||||||
|
unchangedCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noRowCount++;
|
||||||
|
Console.WriteLine($"No Blogs row named '{blogName}' -- path not stored (name may differ in case)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noLocationCount++;
|
||||||
|
if (verbose)
|
||||||
|
Console.WriteLine($"Empty FileDownloadLocation in {blogFile}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
noLocationCount++;
|
||||||
|
if (verbose)
|
||||||
|
Console.WriteLine($"No FileDownloadLocation found in {blogFile}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorCount++;
|
||||||
|
Console.WriteLine($"Error processing {blogFile}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ScanResult
|
||||||
|
{
|
||||||
|
Outcome = ScanOutcome.Completed,
|
||||||
|
RootPath = rootPath,
|
||||||
|
IndexPath = indexPath,
|
||||||
|
MetadataFiles = blogFiles.Count,
|
||||||
|
Written = updatedCount,
|
||||||
|
Unchanged = unchangedCount,
|
||||||
|
NoLocation = noLocationCount,
|
||||||
|
NoMatchingRow = noRowCount,
|
||||||
|
Errors = errorCount
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int Run(string rootPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rootPath))
|
||||||
|
{
|
||||||
|
Console.WriteLine("UpdateBlogPaths: rootPath is required.");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
string indexPath = Path.Combine(rootPath, "Index");
|
||||||
|
Console.WriteLine($"Scanning Index folder: {indexPath}");
|
||||||
|
|
||||||
|
var result = Scan(rootPath, verbose: true);
|
||||||
|
|
||||||
|
if (result.Outcome == ScanOutcome.IndexFolderMissing)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Index folder not found at: {result.IndexPath}");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
Console.WriteLine($"\n========== UpdateBlogPaths summary ==========");
|
||||||
|
Console.WriteLine($"Metadata files: {result.MetadataFiles}");
|
||||||
|
Console.WriteLine($"TTFolderPath written: {result.Written}");
|
||||||
|
Console.WriteLine($"Already correct: {result.Unchanged}");
|
||||||
|
Console.WriteLine($"No FileDownloadLocation: {result.NoLocation}");
|
||||||
|
Console.WriteLine($"No matching blog row: {result.NoMatchingRow}");
|
||||||
|
Console.WriteLine($"Errors: {result.Errors}");
|
||||||
|
|
||||||
|
Console.WriteLine($"\nBlogs now holding a TTFolderPath: {DataAccess.CountBlogsWithTTFolderPath()}");
|
||||||
|
|
||||||
|
return result.Errors == 0 ? 0 : 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,13 @@
|
|||||||
"ContainsList": "zombaee,zomb-eh,ahzombae,thebugandme,lovingbabybug,h4rdspot",
|
"ContainsList": "zombaee,zomb-eh,ahzombae,thebugandme,lovingbabybug,h4rdspot",
|
||||||
"PostIDToExclude": "730084470076686336,726309940037287936,639974102120235008,184045126218",
|
"PostIDToExclude": "730084470076686336,726309940037287936,639974102120235008,184045126218",
|
||||||
"EnableFileLogging": false,
|
"EnableFileLogging": false,
|
||||||
"LogTraversalRecordImports": false
|
"LogTraversalRecordImports": false,
|
||||||
|
"LikesRefreshCooldownDays": 7,
|
||||||
|
"PathTTRoot": "",
|
||||||
|
"PathTTBackup": "",
|
||||||
|
"PathPrefixes": "prefixes.txt",
|
||||||
|
"PathCorrectionReport": "correction_report.txt",
|
||||||
|
"PathCorrectionApplied": "correction_applied.txt"
|
||||||
},
|
},
|
||||||
"TumblrApi": {
|
"TumblrApi": {
|
||||||
"ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3",
|
"ConsumerKey": "PtsBCGumcsgihyynxUh8b47Jfmi7uXhEIOU46bmhdsUSJ5mEP3",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
@echo off
|
|
||||||
REM Batch file to run URLNotesGrabberCORE 500 times in a loop
|
|
||||||
|
|
||||||
setlocal enabledelayedexpansion
|
|
||||||
|
|
||||||
REM Set the path to the executable
|
|
||||||
REM Update this path if your executable is in a different location
|
|
||||||
set APP_PATH=URLNotesGrabberCORE.exe
|
|
||||||
|
|
||||||
REM Check if the executable exists
|
|
||||||
if not exist "%APP_PATH%" (
|
|
||||||
echo Error: %APP_PATH% not found in the current directory.
|
|
||||||
echo Please ensure the executable is in the same directory as this batch file,
|
|
||||||
echo or update the APP_PATH variable with the correct path.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
REM Loop counter
|
|
||||||
set ITERATIONS=500
|
|
||||||
set COUNTER=0
|
|
||||||
|
|
||||||
echo Starting to run %APP_PATH% %ITERATIONS% times...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:LOOP
|
|
||||||
set /a COUNTER+=1
|
|
||||||
echo [%COUNTER%/%ITERATIONS%] Running iteration %COUNTER%...
|
|
||||||
echo Started at: %date% %time%
|
|
||||||
|
|
||||||
REM Run the application with -replies option
|
|
||||||
call "%APP_PATH%" -replies
|
|
||||||
|
|
||||||
REM Check if the application ran successfully
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Warning: Application exited with error code !ERRORLEVEL! on iteration %COUNTER%
|
|
||||||
) else (
|
|
||||||
echo Iteration %COUNTER% completed successfully.
|
|
||||||
)
|
|
||||||
|
|
||||||
echo Completed at: %date% %time%
|
|
||||||
echo.
|
|
||||||
|
|
||||||
REM Check if we've reached 500 iterations
|
|
||||||
if %COUNTER% lss %ITERATIONS% (
|
|
||||||
goto LOOP
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo Completed all %ITERATIONS% iterations!
|
|
||||||
echo.
|
|
||||||
pause
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- verify-db-schema.sql
|
||||||
|
--
|
||||||
|
-- Purpose: Verify that a TL.db (e.g. a restored backup) has every column the
|
||||||
|
-- current URLNotesGrabberCORE code expects. The app has NO startup
|
||||||
|
-- migration: missing columns only get added when specific modes run,
|
||||||
|
-- and a referenced-but-missing column causes a "no such column" crash.
|
||||||
|
--
|
||||||
|
-- How to use (DB Browser for SQLite):
|
||||||
|
-- 1. File > Open Database -> pick the restored backup.
|
||||||
|
-- 2. Execute SQL tab. Run SECTION 1 (it is read-only).
|
||||||
|
-- * Zero rows from every query = schema is fully aligned, you're done.
|
||||||
|
-- * Rows in "MISSING COLUMNS" = copy the run_this_to_fix text.
|
||||||
|
-- 3. If columns are missing: KEEP A COPY OF THE BACKUP FIRST, then go to
|
||||||
|
-- SECTION 2, uncomment ONLY the ALTER lines that match the report, and run.
|
||||||
|
-- 4. Re-run SECTION 1 to confirm zero rows.
|
||||||
|
--
|
||||||
|
-- This script never UPDATEs/DELETEs/DROPs. In particular it deliberately does
|
||||||
|
-- NOT replicate the likes-reset that the app's -likes migration performs
|
||||||
|
-- (DataAccess.cs:375), so existing likes high-water marks are preserved.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SECTION 1 -- VERIFICATION (read-only)
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Expected schema for the current code version.
|
||||||
|
-- alter_stmt is a runnable ALTER for additively-fixable columns; for base
|
||||||
|
-- columns it is a 'MANUAL REVIEW' note (a missing base column means the backup
|
||||||
|
-- predates the table's creation or is damaged -- do not blindly auto-add).
|
||||||
|
WITH expected(tbl, col, alter_stmt) AS (
|
||||||
|
VALUES
|
||||||
|
-- Posts (base columns: manual review if missing)
|
||||||
|
('Posts','BlogName', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Posts','PostID', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Posts','HasNotesGathered', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','reblogURL', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','NotFound', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','PostDate', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','NotesGatheredDateTime', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','HasImage', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','PostURL', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Slug', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','ReblogKey', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','ReblogName', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Summary', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Quote', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Body', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Tags', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Link', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','PhotoURL', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','PhotoCaption', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','DownloadedFiles', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','AudioCaption', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Question', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Answer', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','Title', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','ByLikes', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','RootBlogName', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','RootURL', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','DateModified', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Posts','DateCreated', 'MANUAL REVIEW - base column missing'),
|
||||||
|
-- Posts (additive migration column, auto-fixable)
|
||||||
|
('Posts','PostType', 'ALTER TABLE Posts ADD COLUMN PostType TEXT;'),
|
||||||
|
|
||||||
|
-- Blogs (base columns: manual review if missing)
|
||||||
|
('Blogs','BlogName', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Blogs','HasBeenOutput', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Blogs','IsActive', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Blogs','DateAdded', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Blogs','ByLikes', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Blogs','DateModified', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Blogs','DateCreated', 'MANUAL REVIEW - base column missing'),
|
||||||
|
-- Blogs (additive migration columns, auto-fixable)
|
||||||
|
('Blogs','LikesPulled', 'ALTER TABLE Blogs ADD COLUMN LikesPulled INTEGER DEFAULT 0;'),
|
||||||
|
('Blogs','LikesCursor', 'ALTER TABLE Blogs ADD COLUMN LikesCursor INTEGER DEFAULT 0;'),
|
||||||
|
('Blogs','LikesNewestTimestamp', 'ALTER TABLE Blogs ADD COLUMN LikesNewestTimestamp INTEGER DEFAULT 0;'),
|
||||||
|
('Blogs','LikesLastRefreshed', 'ALTER TABLE Blogs ADD COLUMN LikesLastRefreshed INTEGER DEFAULT 0;'),
|
||||||
|
('Blogs','LikesLastNewCount', 'ALTER TABLE Blogs ADD COLUMN LikesLastNewCount INTEGER DEFAULT 0;'),
|
||||||
|
('Blogs','TTFolderPath', 'ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;'),
|
||||||
|
|
||||||
|
-- Notes (base columns: manual review if missing)
|
||||||
|
('Notes','RootBlogName', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Notes','PostID', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Notes','NoteBlogName', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Notes','TimeStamp', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Notes','Type', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('Notes','DatetimeCrawled', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Notes','DateModified', 'MANUAL REVIEW - base column missing'),
|
||||||
|
('Notes','DateCreated', 'MANUAL REVIEW - base column missing'),
|
||||||
|
-- Notes (additive migration column, auto-fixable)
|
||||||
|
('Notes','replyText', 'ALTER TABLE Notes ADD COLUMN replyText TEXT DEFAULT ''.'';'),
|
||||||
|
|
||||||
|
-- DailyAPICount (base columns)
|
||||||
|
('DailyAPICount','Date', 'MANUAL REVIEW - base/PK column missing'),
|
||||||
|
('DailyAPICount','APICount', 'MANUAL REVIEW - base column missing'),
|
||||||
|
|
||||||
|
-- ApiKeyPoolState (created at runtime by EnsureApiKeyPoolTables; auto-fixable by re-running app, but safe to add)
|
||||||
|
('ApiKeyPoolState','KeyName', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
|
||||||
|
('ApiKeyPoolState','RetryUntil', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
|
||||||
|
('ApiKeyPoolMeta','Id', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables'),
|
||||||
|
('ApiKeyPoolMeta','LastIndex', 'MANUAL REVIEW - run app once to auto-create ApiKeyPool tables')
|
||||||
|
),
|
||||||
|
actual(tbl, col) AS (
|
||||||
|
SELECT 'Posts', name FROM pragma_table_info('Posts')
|
||||||
|
UNION ALL SELECT 'Blogs', name FROM pragma_table_info('Blogs')
|
||||||
|
UNION ALL SELECT 'Notes', name FROM pragma_table_info('Notes')
|
||||||
|
UNION ALL SELECT 'DailyAPICount', name FROM pragma_table_info('DailyAPICount')
|
||||||
|
UNION ALL SELECT 'ApiKeyPoolState', name FROM pragma_table_info('ApiKeyPoolState')
|
||||||
|
UNION ALL SELECT 'ApiKeyPoolMeta', name FROM pragma_table_info('ApiKeyPoolMeta')
|
||||||
|
)
|
||||||
|
|
||||||
|
-- 1a. MISSING COLUMNS: columns the code needs that the DB does not have.
|
||||||
|
-- Zero rows = good. Otherwise copy run_this_to_fix into SECTION 2.
|
||||||
|
SELECT
|
||||||
|
e.tbl AS table_name,
|
||||||
|
e.col AS missing_column,
|
||||||
|
e.alter_stmt AS run_this_to_fix
|
||||||
|
FROM expected e
|
||||||
|
LEFT JOIN actual a
|
||||||
|
ON a.tbl = e.tbl AND lower(a.col) = lower(e.col)
|
||||||
|
WHERE a.col IS NULL
|
||||||
|
ORDER BY (e.alter_stmt LIKE 'ALTER%') DESC, e.tbl, e.col;
|
||||||
|
|
||||||
|
|
||||||
|
-- 1b. MISSING TABLES: expected tables that don't exist at all in this DB.
|
||||||
|
-- Zero rows = good.
|
||||||
|
WITH expected_tables(tbl) AS (
|
||||||
|
VALUES ('Posts'),('Blogs'),('Notes'),('DailyAPICount'),
|
||||||
|
('ApiKeyPoolState'),('ApiKeyPoolMeta')
|
||||||
|
)
|
||||||
|
SELECT et.tbl AS missing_table
|
||||||
|
FROM expected_tables et
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM sqlite_master
|
||||||
|
WHERE type = 'table' AND lower(name) = lower(et.tbl)
|
||||||
|
)
|
||||||
|
ORDER BY et.tbl;
|
||||||
|
|
||||||
|
|
||||||
|
-- 1c. EXTRA / UNEXPECTED COLUMNS: present in the DB but not in the expected
|
||||||
|
-- list above. Informational only -- e.g. a NEWER backup, or a column this
|
||||||
|
-- script's expected-list hasn't been updated for. Not an error by itself.
|
||||||
|
-- Posts.IsActive and Notes.IsActive are listed here and NOT in 1a on
|
||||||
|
-- purpose: they are written by other tools, the app only reads them when
|
||||||
|
-- present, and it must not be told to add them. See TL.db.md.
|
||||||
|
WITH expected(tbl, col) AS (
|
||||||
|
VALUES
|
||||||
|
('Posts','BlogName'),('Posts','PostID'),('Posts','HasNotesGathered'),('Posts','reblogURL'),
|
||||||
|
('Posts','NotFound'),('Posts','PostDate'),('Posts','NotesGatheredDateTime'),('Posts','HasImage'),
|
||||||
|
('Posts','PostURL'),('Posts','Slug'),('Posts','ReblogKey'),('Posts','ReblogName'),('Posts','Summary'),
|
||||||
|
('Posts','Quote'),('Posts','Body'),('Posts','Tags'),('Posts','Link'),('Posts','PhotoURL'),
|
||||||
|
('Posts','PhotoCaption'),('Posts','DownloadedFiles'),('Posts','AudioCaption'),('Posts','Question'),
|
||||||
|
('Posts','Answer'),('Posts','Title'),('Posts','ByLikes'),('Posts','RootBlogName'),('Posts','RootURL'),
|
||||||
|
('Posts','DateModified'),('Posts','DateCreated'),('Posts','PostType'),('Posts','IsActive'),
|
||||||
|
('Blogs','BlogName'),('Blogs','HasBeenOutput'),('Blogs','IsActive'),('Blogs','DateAdded'),
|
||||||
|
('Blogs','ByLikes'),('Blogs','DateModified'),('Blogs','DateCreated'),('Blogs','LikesPulled'),
|
||||||
|
('Blogs','LikesCursor'),('Blogs','LikesNewestTimestamp'),('Blogs','LikesLastRefreshed'),
|
||||||
|
('Blogs','LikesLastNewCount'),('Blogs','TTFolderPath'),
|
||||||
|
('Notes','RootBlogName'),('Notes','PostID'),('Notes','NoteBlogName'),('Notes','TimeStamp'),
|
||||||
|
('Notes','Type'),('Notes','DatetimeCrawled'),('Notes','DateModified'),('Notes','DateCreated'),
|
||||||
|
('Notes','replyText'),('Notes','IsActive'),
|
||||||
|
('DailyAPICount','Date'),('DailyAPICount','APICount'),
|
||||||
|
('ApiKeyPoolState','KeyName'),('ApiKeyPoolState','RetryUntil'),
|
||||||
|
('ApiKeyPoolMeta','Id'),('ApiKeyPoolMeta','LastIndex')
|
||||||
|
),
|
||||||
|
actual(tbl, col) AS (
|
||||||
|
SELECT 'Posts', name FROM pragma_table_info('Posts')
|
||||||
|
UNION ALL SELECT 'Blogs', name FROM pragma_table_info('Blogs')
|
||||||
|
UNION ALL SELECT 'Notes', name FROM pragma_table_info('Notes')
|
||||||
|
UNION ALL SELECT 'DailyAPICount', name FROM pragma_table_info('DailyAPICount')
|
||||||
|
UNION ALL SELECT 'ApiKeyPoolState', name FROM pragma_table_info('ApiKeyPoolState')
|
||||||
|
UNION ALL SELECT 'ApiKeyPoolMeta', name FROM pragma_table_info('ApiKeyPoolMeta')
|
||||||
|
)
|
||||||
|
SELECT a.tbl AS table_name, a.col AS unexpected_column
|
||||||
|
FROM actual a
|
||||||
|
LEFT JOIN expected e
|
||||||
|
ON e.tbl = a.tbl AND lower(e.col) = lower(a.col)
|
||||||
|
WHERE e.col IS NULL
|
||||||
|
ORDER BY a.tbl, a.col;
|
||||||
|
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- SECTION 2 -- FIX (opt-in, additive only)
|
||||||
|
--
|
||||||
|
-- Run ONLY the lines that query 1a flagged with an ALTER statement.
|
||||||
|
-- KEEP A COPY OF THE BACKUP FIRST. SQLite has no "ADD COLUMN IF NOT EXISTS",
|
||||||
|
-- so running an ALTER for a column that already exists throws a harmless
|
||||||
|
-- "duplicate column name" error and changes nothing -- just run the flagged
|
||||||
|
-- subset. These are the 8 additive migration columns and nothing else; the
|
||||||
|
-- likes high-water-mark reset is intentionally NOT included.
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- ALTER TABLE Posts ADD COLUMN PostType TEXT;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN LikesPulled INTEGER DEFAULT 0;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN LikesCursor INTEGER DEFAULT 0;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN LikesNewestTimestamp INTEGER DEFAULT 0;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN LikesLastRefreshed INTEGER DEFAULT 0;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN LikesLastNewCount INTEGER DEFAULT 0;
|
||||||
|
-- ALTER TABLE Blogs ADD COLUMN TTFolderPath TEXT;
|
||||||
|
-- ALTER TABLE Notes ADD COLUMN replyText TEXT DEFAULT '.';
|
||||||
Reference in New Issue
Block a user