feedkeeper 0.27.1 → 0.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/dist/api/items.js +3 -2
- package/server/dist/api/v1/resources.js +13 -11
- package/server/dist/auth/tokens.js +10 -1
- package/server/dist/db/index.js +4 -0
- package/server/dist/db/migrations/0039_performance_indexes.sql +96 -0
- package/server/dist/feeds/archive.js +26 -14
- package/server/dist/feeds/cleanup.js +11 -8
- package/server/dist/feeds/contentNoise.js +24 -17
- package/server/dist/feeds/extractor.js +2 -4
- package/server/dist/feeds/fullTextQueue.js +7 -3
- package/server/dist/feeds/imageCache.js +10 -0
- package/server/dist/feeds/inbox.js +5 -4
- package/server/dist/feeds/pollQueue.js +11 -2
- package/server/dist/feeds/poller.js +1 -1
- package/server/dist/feeds/repository.js +101 -17
- package/server/dist/feeds/service.js +4 -4
- package/server/dist/native/editions.js +17 -13
- package/server/dist/oauth/store.js +9 -1
- package/server/dist/sync/changeLog.js +112 -33
- package/server/package.json +1 -1
package/package.json
CHANGED
package/server/dist/api/items.js
CHANGED
|
@@ -123,10 +123,11 @@ itemsRouter.post("/:itemId/extract-content", async (req, res) => {
|
|
|
123
123
|
res.status(FULL_TEXT_STATUS[result.error]).json({ error: result.error, ...(result.message ? { message: result.message } : {}) });
|
|
124
124
|
return;
|
|
125
125
|
}
|
|
126
|
-
const
|
|
126
|
+
const currentItem = findItemById(result.id);
|
|
127
|
+
const [served] = withArchivedImages([{ id: result.id, link: currentItem?.link ?? null, full_content_html: result.fullContentHtml }], requestOrigin(req));
|
|
127
128
|
res.json({
|
|
128
129
|
id: result.id,
|
|
129
|
-
full_content_html: readerHtml(served.full_content_html, result.title ??
|
|
130
|
+
full_content_html: readerHtml(served.full_content_html, result.title ?? currentItem?.title, currentItem?.author),
|
|
130
131
|
...(result.cached ? {} : { title: result.title, byline: result.byline }),
|
|
131
132
|
cached: result.cached,
|
|
132
133
|
});
|
|
@@ -11,7 +11,7 @@ import { loadFullText } from "../../feeds/fullText.js";
|
|
|
11
11
|
import { generateOpml, importOpmlFeeds } from "../../feeds/opml.js";
|
|
12
12
|
import { pollFeed, pollFeeds } from "../../feeds/poller.js";
|
|
13
13
|
import { canAccessFeed, loadFeedIcon } from "../../feeds/feedIcon.js";
|
|
14
|
-
import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
|
|
14
|
+
import { addMutedKeyword, canAccessItem, createFolder, deleteFolder, deleteItemNote, findFeedById, findFolderById, findItemForUser, findItemsForUser, findSubscriptionForUser, findItemNote, noteConflictState, isUserSubscribed, itemsWithNotes, listFoldersForUser, listItemsForUser, listMutedKeywords, listNotesForUser, listSubscriptionsForUser, markAllRead, removeMutedKeyword, reorderSubscriptions, searchItemsForUser, setItemNote, updateFolder, } from "../../feeds/repository.js";
|
|
15
15
|
import { saveToInbox, deleteInboxItem } from "../../feeds/inbox.js";
|
|
16
16
|
import { changeFeedUrl, FeedError, MultipleFeedsFoundError, subscribeToFeed, unsubscribeFromFeed, updateFeedSettings } from "../../feeds/service.js";
|
|
17
17
|
import { SsrfBlockedError, normalizeUrlCandidate } from "../../feeds/ssrfGuard.js";
|
|
@@ -29,7 +29,7 @@ const imageLimiter = rateLimit({ windowMs: 60 * 1000, limit: 600, standardHeader
|
|
|
29
29
|
const urlField = z.preprocess((val) => (typeof val === "string" ? normalizeUrlCandidate(val) : val), z.string().url());
|
|
30
30
|
const invalid = (res, details) => void res.status(400).json({ error: "invalid_input", ...(details ? { details } : {}) });
|
|
31
31
|
function subscriptionOf(userId, id) {
|
|
32
|
-
const subscription =
|
|
32
|
+
const subscription = findSubscriptionForUser(userId, id);
|
|
33
33
|
return subscription ? serializeSubscriptionDetail(subscription) : null;
|
|
34
34
|
}
|
|
35
35
|
// ---- Account -------------------------------------------------------------
|
|
@@ -302,9 +302,9 @@ const itemsQuery = z.object({
|
|
|
302
302
|
images: z.enum(["original", "proxy"]).default("original"),
|
|
303
303
|
});
|
|
304
304
|
/** Rows ready for a native client: images served by this server where it keeps copies. */
|
|
305
|
-
function prepare(rows, req, images) {
|
|
306
|
-
const archived = withArchivedImages(rows, origin(req), API_BASE);
|
|
307
|
-
return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE) : archived;
|
|
305
|
+
function prepare(rows, req, images, withContent = true) {
|
|
306
|
+
const archived = withArchivedImages(rows, origin(req), API_BASE, withContent);
|
|
307
|
+
return images === "proxy" ? withProxiedImages(archived, origin(req), API_BASE, withContent) : archived;
|
|
308
308
|
}
|
|
309
309
|
const searchQuery = z.object({
|
|
310
310
|
q: z.string().trim().min(1).max(200),
|
|
@@ -340,7 +340,7 @@ resourcesRouter.get("/search", requireCapability("search.fts"), (req, res) => {
|
|
|
340
340
|
res.json({
|
|
341
341
|
query: query.q,
|
|
342
342
|
total,
|
|
343
|
-
items: prepare(rows, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
343
|
+
items: prepare(rows, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
344
344
|
});
|
|
345
345
|
});
|
|
346
346
|
resourcesRouter.get("/items", (req, res) => {
|
|
@@ -374,7 +374,7 @@ resourcesRouter.get("/items", (req, res) => {
|
|
|
374
374
|
const progress = readingPositions(req.user.id, page.map((row) => row.id));
|
|
375
375
|
const hasNotes = itemsWithNotes(req.user.id, page.map((row) => row.id));
|
|
376
376
|
res.json({
|
|
377
|
-
items: prepare(page, req, query.images).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
377
|
+
items: prepare(page, req, query.images, withContent).map((row) => serializeItem(row, progress, withContent, hasNotes.has(row.id))),
|
|
378
378
|
newestCursor: page[0] ? encodeCursor(page[0]) : null,
|
|
379
379
|
nextCursor: hasMore && page.length ? encodeCursor(page[page.length - 1]) : null,
|
|
380
380
|
});
|
|
@@ -385,10 +385,11 @@ resourcesRouter.get("/items/bundle", (req, res) => {
|
|
|
385
385
|
if (!parsed.success)
|
|
386
386
|
return invalid(res, "ids: up to 50 comma separated article ids");
|
|
387
387
|
const ids = [...new Set(parsed.data.ids.split(",").map(Number))];
|
|
388
|
-
const
|
|
388
|
+
const byId = new Map(findItemsForUser(req.user.id, ids).map((row) => [row.id, row]));
|
|
389
|
+
const rows = ids.map((id) => byId.get(id)).filter((row) => Boolean(row));
|
|
389
390
|
const progress = readingPositions(req.user.id, rows.map((row) => row.id));
|
|
390
391
|
const hasNotes = itemsWithNotes(req.user.id, rows.map((row) => row.id));
|
|
391
|
-
res.json({ items: prepare(rows, req, parsed.data.images).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
|
|
392
|
+
res.json({ items: prepare(rows, req, parsed.data.images, true).map((row) => serializeItem(row, progress, true, hasNotes.has(row.id))) });
|
|
392
393
|
});
|
|
393
394
|
// Explicit extraction side effect; read-only devices continue to consume the shared cache.
|
|
394
395
|
resourcesRouter.post("/items/prepare", requireCapability("fulltext"), async (req, res) => {
|
|
@@ -515,12 +516,13 @@ resourcesRouter.get("/notes/export", (req, res) => {
|
|
|
515
516
|
});
|
|
516
517
|
// ---- Curated Edition ("Deine Zeitung") ------------------------------------
|
|
517
518
|
function editionResponse(req, edition) {
|
|
519
|
+
const byId = new Map(findItemsForUser(req.user.id, edition.itemIds).map((row) => [row.id, row]));
|
|
518
520
|
const rows = edition.itemIds
|
|
519
|
-
.map((id) =>
|
|
521
|
+
.map((id) => byId.get(id))
|
|
520
522
|
.filter((row) => Boolean(row));
|
|
521
523
|
const progress = readingPositions(req.user.id, rows.map((r) => r.id));
|
|
522
524
|
const hasNotes = itemsWithNotes(req.user.id, rows.map((r) => r.id));
|
|
523
|
-
const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original");
|
|
525
|
+
const prepared = prepare(rows, req, req.query.images === "proxy" ? "proxy" : "original", false);
|
|
524
526
|
return {
|
|
525
527
|
...edition,
|
|
526
528
|
items: prepared.map((row) => serializeItem(row, progress, false, hasNotes.has(row.id))),
|
|
@@ -38,9 +38,16 @@ export function resolveToken(token) {
|
|
|
38
38
|
if (stored.length !== provided.length || !timingSafeEqual(stored, provided)) {
|
|
39
39
|
return null;
|
|
40
40
|
}
|
|
41
|
-
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const lastTouch = tokenLastTouch.get(row.id) ?? 0;
|
|
43
|
+
if (!row.last_used_at || now - lastTouch >= TOKEN_TOUCH_INTERVAL_MS) {
|
|
44
|
+
tokenLastTouch.set(row.id, now);
|
|
45
|
+
db.prepare("UPDATE personal_access_tokens SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(row.id);
|
|
46
|
+
}
|
|
42
47
|
return { userId: row.user_id, scope: row.scope, kind: row.kind, tokenId: row.id };
|
|
43
48
|
}
|
|
49
|
+
const tokenLastTouch = new Map();
|
|
50
|
+
const TOKEN_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
|
44
51
|
/** Hand-made tokens only; paired devices are listed separately. */
|
|
45
52
|
export function listTokensForUser(userId) {
|
|
46
53
|
return db
|
|
@@ -49,6 +56,7 @@ export function listTokensForUser(userId) {
|
|
|
49
56
|
.map(({ token_hash, ...rest }) => rest);
|
|
50
57
|
}
|
|
51
58
|
export function deleteToken(userId, tokenId) {
|
|
59
|
+
tokenLastTouch.delete(tokenId);
|
|
52
60
|
db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'api'").run(tokenId, userId);
|
|
53
61
|
}
|
|
54
62
|
export function listDevicesForUser(userId) {
|
|
@@ -59,6 +67,7 @@ export function listDevicesForUser(userId) {
|
|
|
59
67
|
}
|
|
60
68
|
/** Revokes a device; its token stops working immediately. Returns whether one was removed. */
|
|
61
69
|
export function deleteDevice(userId, deviceId) {
|
|
70
|
+
tokenLastTouch.delete(deviceId);
|
|
62
71
|
return db.prepare("DELETE FROM personal_access_tokens WHERE id = ? AND user_id = ? AND kind = 'device'").run(deviceId, userId).changes > 0;
|
|
63
72
|
}
|
|
64
73
|
//# sourceMappingURL=tokens.js.map
|
package/server/dist/db/index.js
CHANGED
|
@@ -8,7 +8,11 @@ const MIGRATIONS_DIR = join(__dirname, "migrations");
|
|
|
8
8
|
mkdirSync(dirname(config.databasePath), { recursive: true });
|
|
9
9
|
export const db = new Database(config.databasePath);
|
|
10
10
|
db.pragma("journal_mode = WAL");
|
|
11
|
+
db.pragma("synchronous = NORMAL");
|
|
11
12
|
db.pragma("foreign_keys = ON");
|
|
13
|
+
db.pragma("busy_timeout = 5000");
|
|
14
|
+
db.pragma("temp_store = MEMORY");
|
|
15
|
+
db.pragma("cache_size = -65536");
|
|
12
16
|
function ensureMigrationsTable() {
|
|
13
17
|
db.exec(`CREATE TABLE IF NOT EXISTS _migrations (
|
|
14
18
|
name TEXT PRIMARY KEY,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
-- Foreign key and cascade delete indexes
|
|
2
|
+
CREATE INDEX IF NOT EXISTS idx_item_reads_item ON item_reads(item_id);
|
|
3
|
+
CREATE INDEX IF NOT EXISTS idx_item_progress_item ON item_progress(item_id);
|
|
4
|
+
CREATE INDEX IF NOT EXISTS idx_item_note_revisions_item ON item_note_revisions(item_id);
|
|
5
|
+
CREATE INDEX IF NOT EXISTS idx_full_text_jobs_user ON full_text_jobs(user_id);
|
|
6
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_codes_user ON oauth_codes(user_id);
|
|
7
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_codes_client ON oauth_codes(client_id);
|
|
8
|
+
CREATE INDEX IF NOT EXISTS idx_security_challenges_user ON security_challenges(user_id);
|
|
9
|
+
|
|
10
|
+
-- Housekeeping and retention cleanup indexes
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_native_requests_created ON native_requests(created_at);
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_applied_mutations_applied ON applied_mutations(applied_at);
|
|
13
|
+
CREATE INDEX IF NOT EXISTS idx_items_feed_eff_pub ON items(
|
|
14
|
+
feed_id,
|
|
15
|
+
COALESCE(julianday(published_at), julianday(created_at)) DESC,
|
|
16
|
+
id DESC
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
-- Item listing, cursor pagination, lead backfill, and full-text candidate indexes
|
|
20
|
+
CREATE INDEX IF NOT EXISTS idx_items_feed_created ON items(feed_id, created_at DESC, id DESC);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS idx_bookmarks_user_created ON item_bookmarks(user_id, created_at DESC, item_id DESC);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS idx_items_lead_version ON items(lead_version);
|
|
23
|
+
CREATE INDEX IF NOT EXISTS idx_items_fulltext_candidates ON items(feed_id, created_at DESC)
|
|
24
|
+
WHERE link IS NOT NULL AND full_content_html IS NULL AND extraction_status != 'feed_only';
|
|
25
|
+
|
|
26
|
+
-- Avoid rebuilding FTS5 entries when non-indexed item columns change during polling or extraction
|
|
27
|
+
DROP TRIGGER IF EXISTS trg_items_fts_update;
|
|
28
|
+
CREATE TRIGGER trg_items_fts_update AFTER UPDATE ON items
|
|
29
|
+
WHEN OLD.title IS NOT NEW.title
|
|
30
|
+
OR OLD.content_snippet IS NOT NEW.content_snippet
|
|
31
|
+
OR OLD.content_html IS NOT NEW.content_html
|
|
32
|
+
OR OLD.full_content_html IS NOT NEW.full_content_html
|
|
33
|
+
BEGIN
|
|
34
|
+
INSERT INTO items_fts(items_fts, rowid, title, content_snippet, content_html, full_content_html)
|
|
35
|
+
VALUES ('delete', old.id, old.title, old.content_snippet, old.content_html, old.full_content_html);
|
|
36
|
+
INSERT INTO items_fts(rowid, title, content_snippet, content_html, full_content_html)
|
|
37
|
+
VALUES (new.id, new.title, new.content_snippet, new.content_html, new.full_content_html);
|
|
38
|
+
END;
|
|
39
|
+
|
|
40
|
+
-- Monotonic per-user sync sequence high-water mark across change log pruning
|
|
41
|
+
ALTER TABLE sync_state ADD COLUMN last_seq INTEGER NOT NULL DEFAULT 0;
|
|
42
|
+
UPDATE sync_state
|
|
43
|
+
SET last_seq = MAX(
|
|
44
|
+
pruned_through,
|
|
45
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
DROP TRIGGER IF EXISTS trg_changes_seq_insert;
|
|
49
|
+
CREATE TRIGGER trg_changes_seq_insert
|
|
50
|
+
AFTER INSERT ON changes
|
|
51
|
+
WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
52
|
+
BEGIN
|
|
53
|
+
UPDATE changes
|
|
54
|
+
SET seq = MAX(
|
|
55
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
|
|
56
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
57
|
+
) + 1
|
|
58
|
+
WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
|
|
59
|
+
END;
|
|
60
|
+
|
|
61
|
+
DROP TRIGGER IF EXISTS trg_changes_seq_update;
|
|
62
|
+
CREATE TRIGGER trg_changes_seq_update
|
|
63
|
+
AFTER UPDATE OF seq ON changes
|
|
64
|
+
WHEN NEW.seq <= COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
65
|
+
BEGIN
|
|
66
|
+
UPDATE changes
|
|
67
|
+
SET seq = MAX(
|
|
68
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = NEW.user_id), 0),
|
|
69
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = NEW.user_id), 0)
|
|
70
|
+
) + 1
|
|
71
|
+
WHERE user_id = NEW.user_id AND entity = NEW.entity AND entity_id = NEW.entity_id;
|
|
72
|
+
END;
|
|
73
|
+
|
|
74
|
+
-- Record old feed deletion when a subscription moves to a different feed
|
|
75
|
+
DROP TRIGGER IF EXISTS trg_changes_subscription_move;
|
|
76
|
+
CREATE TRIGGER trg_changes_subscription_move
|
|
77
|
+
AFTER UPDATE OF feed_id ON subscriptions
|
|
78
|
+
WHEN OLD.feed_id IS NOT NEW.feed_id
|
|
79
|
+
BEGIN
|
|
80
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
81
|
+
VALUES (OLD.user_id, 'subscription', OLD.feed_id, 'delete', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = OLD.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
82
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
83
|
+
END;
|
|
84
|
+
|
|
85
|
+
-- Notify subscribers when a feed's URL or poll interval is updated in place
|
|
86
|
+
DROP TRIGGER IF EXISTS trg_changes_feed_update;
|
|
87
|
+
CREATE TRIGGER trg_changes_feed_update
|
|
88
|
+
AFTER UPDATE OF url, poll_interval_minutes ON feeds
|
|
89
|
+
WHEN OLD.url IS NOT NEW.url OR OLD.poll_interval_minutes IS NOT NEW.poll_interval_minutes
|
|
90
|
+
BEGIN
|
|
91
|
+
INSERT INTO changes (user_id, entity, entity_id, op, seq, changed_at)
|
|
92
|
+
SELECT s.user_id, 'subscription', NEW.id, 'upsert', (SELECT COALESCE(MAX(seq), 0) + 1 FROM changes WHERE user_id = s.user_id), strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
93
|
+
FROM subscriptions s
|
|
94
|
+
WHERE s.feed_id = NEW.id
|
|
95
|
+
ON CONFLICT (user_id, entity, entity_id) DO UPDATE SET op = excluded.op, seq = excluded.seq, changed_at = excluded.changed_at;
|
|
96
|
+
END;
|
|
@@ -22,6 +22,7 @@ const archiveWrites = new Set();
|
|
|
22
22
|
async function storeArchivedImage(destination, image) {
|
|
23
23
|
const temporary = `${destination}.tmp`;
|
|
24
24
|
archiveWrites.add(temporary);
|
|
25
|
+
archiveWrites.add(destination);
|
|
25
26
|
try {
|
|
26
27
|
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
|
|
27
28
|
throw error; });
|
|
@@ -164,13 +165,18 @@ export async function archiveItem(itemId) {
|
|
|
164
165
|
continue;
|
|
165
166
|
const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
|
|
166
167
|
const destination = join(archiveDir(), fileName);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
try {
|
|
169
|
+
await storeArchivedImage(destination, image);
|
|
170
|
+
// The last bookmark or article may have disappeared while the image was loading.
|
|
171
|
+
if (!hasBookmark(itemId) || !findItemById(itemId)) {
|
|
172
|
+
unlinkSync(destination);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
archiveWrites.delete(destination);
|
|
172
179
|
}
|
|
173
|
-
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
174
180
|
}
|
|
175
181
|
catch (error) {
|
|
176
182
|
// Leave the durable bookmark pending so startup can finish it later.
|
|
@@ -276,7 +282,7 @@ export function findArchivedImage(userId, imageId) {
|
|
|
276
282
|
return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
|
|
277
283
|
}
|
|
278
284
|
/** Swap image URLs of archived articles for their local copies before sending them to the browser. */
|
|
279
|
-
export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
285
|
+
export function withArchivedImages(items, origin, apiBase = "/api", withContent = true) {
|
|
280
286
|
const archived = archivedImagesFor(items.map((item) => item.id));
|
|
281
287
|
return items.map((item) => {
|
|
282
288
|
const images = archived.get(item.id);
|
|
@@ -286,8 +292,8 @@ export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
|
286
292
|
return {
|
|
287
293
|
...item,
|
|
288
294
|
image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
|
|
289
|
-
content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
290
|
-
full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
295
|
+
content_html: withContent && item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
296
|
+
full_content_html: withContent && item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
291
297
|
};
|
|
292
298
|
});
|
|
293
299
|
}
|
|
@@ -303,7 +309,7 @@ function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
|
|
|
303
309
|
* For offline copies: point the first images of articles that are not archived at an endpoint
|
|
304
310
|
* of this server, so the browser can store them without cross-origin requests.
|
|
305
311
|
*/
|
|
306
|
-
export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
312
|
+
export function withProxiedImages(items, origin, apiBase = "/api", withContent = true) {
|
|
307
313
|
const own = `${origin.replace(/\/$/, "")}/api/`;
|
|
308
314
|
return items.map((item) => {
|
|
309
315
|
const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
|
|
@@ -314,20 +320,26 @@ export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
|
314
320
|
return {
|
|
315
321
|
...item,
|
|
316
322
|
image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
|
|
317
|
-
content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
318
|
-
full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
323
|
+
content_html: withContent && item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
324
|
+
full_content_html: withContent && item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
319
325
|
};
|
|
320
326
|
});
|
|
321
327
|
}
|
|
322
328
|
/** Download one of an article's own images for the offline copy, or null when it is not one of them. */
|
|
323
329
|
export async function fetchItemImage(userId, itemId, imageUrl) {
|
|
324
330
|
const item = findItemForUser(userId, itemId);
|
|
325
|
-
if (!item
|
|
331
|
+
if (!item)
|
|
332
|
+
return "forbidden";
|
|
333
|
+
const isMatch = (it) => {
|
|
334
|
+
const hero = it.image_url ? resolveImageUrl(it.image_url, it.link) : null;
|
|
335
|
+
return hero === imageUrl || itemImageUrls(it).includes(imageUrl);
|
|
336
|
+
};
|
|
337
|
+
if (!isMatch(item))
|
|
326
338
|
return "forbidden";
|
|
327
339
|
const image = await sharedImage(item, imageUrl);
|
|
328
340
|
// Access or the article's image list can change while a shared download is running.
|
|
329
341
|
const current = findItemForUser(userId, itemId);
|
|
330
|
-
if (!current || !
|
|
342
|
+
if (!current || !isMatch(current))
|
|
331
343
|
return "forbidden";
|
|
332
344
|
return image;
|
|
333
345
|
}
|
|
@@ -91,8 +91,8 @@ export function runCleanup(custom) {
|
|
|
91
91
|
WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
92
92
|
AND i.id NOT IN (SELECT item_id FROM item_notes)
|
|
93
93
|
AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
94
|
-
AND NOT
|
|
95
|
-
WHERE
|
|
94
|
+
AND i.id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
95
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
96
96
|
AND i.id IN (SELECT item_id FROM item_reads)
|
|
97
97
|
AND NOT EXISTS (
|
|
98
98
|
SELECT 1 FROM subscriptions s
|
|
@@ -109,8 +109,8 @@ export function runCleanup(custom) {
|
|
|
109
109
|
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
110
110
|
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
111
111
|
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
112
|
-
AND NOT
|
|
113
|
-
WHERE
|
|
112
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
113
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
114
114
|
AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
|
|
115
115
|
deletedOldItems = res.changes;
|
|
116
116
|
}
|
|
@@ -120,8 +120,8 @@ export function runCleanup(custom) {
|
|
|
120
120
|
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
121
121
|
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
122
122
|
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
123
|
-
AND NOT
|
|
124
|
-
WHERE
|
|
123
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
124
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')
|
|
125
125
|
AND id IN (
|
|
126
126
|
SELECT id FROM (
|
|
127
127
|
SELECT id, ROW_NUMBER() OVER (
|
|
@@ -139,11 +139,14 @@ export function runCleanup(custom) {
|
|
|
139
139
|
pruneArchive();
|
|
140
140
|
pruneChangeLog();
|
|
141
141
|
const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
|
|
142
|
-
// Optimize query planning; rebuild storage only when this run freed
|
|
142
|
+
// Optimize query planning; rebuild storage only when this run freed substantial pages.
|
|
143
143
|
db.exec("PRAGMA optimize;");
|
|
144
144
|
if (totalDeleted > 0) {
|
|
145
145
|
try {
|
|
146
|
-
db.
|
|
146
|
+
const freelist = db.pragma("freelist_count", { simple: true }) ?? 0;
|
|
147
|
+
if (freelist > 500) {
|
|
148
|
+
db.exec("VACUUM;");
|
|
149
|
+
}
|
|
147
150
|
}
|
|
148
151
|
catch (err) {
|
|
149
152
|
console.warn("[cleanup] VACUUM skipped or could not run:", err);
|
|
@@ -139,6 +139,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
139
139
|
const authorKeys = (author ?? "").split(",").map(name => normalized(name)).filter(Boolean);
|
|
140
140
|
let leading = 0;
|
|
141
141
|
let reachedText = false;
|
|
142
|
+
let pastStart = false;
|
|
142
143
|
for (const block of leafBlocks(root)) {
|
|
143
144
|
const text = flat(block.textContent);
|
|
144
145
|
if (!text)
|
|
@@ -161,23 +162,29 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
161
162
|
}
|
|
162
163
|
}
|
|
163
164
|
// Near the start, a line that is only a date or a reading time repeats what the reader shows in its own head.
|
|
164
|
-
if (text.length <= 60
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
if (READING_TIME_ONLY.test(text)) {
|
|
170
|
-
drop(block, "reading-time");
|
|
171
|
-
continue;
|
|
165
|
+
if (!pastStart && text.length <= 60) {
|
|
166
|
+
const before = charsBefore(root, block);
|
|
167
|
+
if (before > 800) {
|
|
168
|
+
pastStart = true;
|
|
172
169
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
170
|
+
else {
|
|
171
|
+
if (DATE_ONLY.test(text)) {
|
|
172
|
+
drop(block, "dateline");
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (READING_TIME_ONLY.test(text)) {
|
|
176
|
+
drop(block, "reading-time");
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
// When the feed names the author, the reader shows the name in its head: a byline that only says that goes.
|
|
180
|
+
if (BYLINE.test(text) && authorKeys.length > 0 && authorKeys.every(name => normalized(text).includes(name))) {
|
|
181
|
+
drop(block, "byline");
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (BYLINE.test(text) && stripBylineDate(block)) {
|
|
185
|
+
stats["byline-date"] = (stats["byline-date"] ?? 0) + 1;
|
|
186
|
+
changed = true;
|
|
187
|
+
}
|
|
181
188
|
}
|
|
182
189
|
}
|
|
183
190
|
if (headline && normalized(text) === headline && (position < 4 || (heading && position < 14))) {
|
|
@@ -196,7 +203,7 @@ function removeReaderNoise(root, title, stats, author) {
|
|
|
196
203
|
drop(block, "player");
|
|
197
204
|
continue;
|
|
198
205
|
}
|
|
199
|
-
if (text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
206
|
+
if (!pastStart && text.length <= 70 && block.querySelector("time") && charsBefore(root, block) <= 600) {
|
|
200
207
|
const rest = flat(text.replace(flat(block.querySelector("time")?.textContent), ""));
|
|
201
208
|
if (/^(stand|zuletzt aktualisiert|aktualisiert|ver(ö|oe)ffentlicht|updated|published|posted)?\s*[:·|•-]*\s*$/i.test(rest)) {
|
|
202
209
|
drop(block, "dateline");
|
|
@@ -151,7 +151,7 @@ async function fetchArticleHtml(rawUrl, signal, dispatcher, fetchImpl, ownerSign
|
|
|
151
151
|
}
|
|
152
152
|
chunks.push(value);
|
|
153
153
|
}
|
|
154
|
-
const html = new TextDecoder().decode(Buffer.concat(chunks
|
|
154
|
+
const html = new TextDecoder().decode(Buffer.concat(chunks, total));
|
|
155
155
|
return { html, url: safeUrl.toString() };
|
|
156
156
|
}
|
|
157
157
|
return null;
|
|
@@ -169,14 +169,12 @@ export async function extractArticle(rawUrl, fetchImpl = defaultFetch, priority
|
|
|
169
169
|
}, priority);
|
|
170
170
|
}
|
|
171
171
|
async function extractArticleContent(rawUrl, fetchImpl, signal) {
|
|
172
|
-
signal.throwIfAborted();
|
|
173
|
-
const safeUrl = await assertPublicHttpUrl(rawUrl);
|
|
174
172
|
signal.throwIfAborted();
|
|
175
173
|
const dispatcher = createPublicDispatcher();
|
|
176
174
|
const controller = new AbortController();
|
|
177
175
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
178
176
|
try {
|
|
179
|
-
const fetched = await fetchArticleHtml(
|
|
177
|
+
const fetched = await fetchArticleHtml(rawUrl, AbortSignal.any([signal, controller.signal]), dispatcher, fetchImpl, signal);
|
|
180
178
|
if (!fetched)
|
|
181
179
|
return { status: "failed" };
|
|
182
180
|
const cleanedHtml = stripHeavyTags(fetched.html);
|
|
@@ -229,6 +229,8 @@ function pump() {
|
|
|
229
229
|
if (timer)
|
|
230
230
|
clearTimeout(timer);
|
|
231
231
|
const pool = configureFullTextPool();
|
|
232
|
+
if (active.size >= pool.limits.concurrency)
|
|
233
|
+
return;
|
|
232
234
|
if (seedTimer && db.prepare("SELECT COUNT(*) n FROM full_text_jobs").get().n < pool.limits.concurrency * 2)
|
|
233
235
|
scheduleFullTexts();
|
|
234
236
|
const jobs = db.prepare(`SELECT j.*, i.link FROM full_text_jobs j JOIN items i ON i.id=j.item_id
|
|
@@ -258,8 +260,10 @@ function pump() {
|
|
|
258
260
|
const work = execute(job, host).finally(() => { active.delete(job.item_id); wake(); });
|
|
259
261
|
active.set(job.item_id, work);
|
|
260
262
|
}
|
|
261
|
-
|
|
262
|
-
|
|
263
|
+
const nextJob = db.prepare("SELECT MIN(available_at) AS next_at FROM full_text_jobs").get();
|
|
264
|
+
if (nextJob?.next_at) {
|
|
265
|
+
const delay = Math.max(250, Math.min(30_000, Date.parse(nextJob.next_at) - Date.now()));
|
|
266
|
+
timer = setTimeout(wake, delay);
|
|
263
267
|
timer.unref();
|
|
264
268
|
}
|
|
265
269
|
}
|
|
@@ -272,7 +276,7 @@ async function execute(job, host) {
|
|
|
272
276
|
attemptErrors++;
|
|
273
277
|
errorCounts[result.error] = (errorCounts[result.error] ?? 0) + 1;
|
|
274
278
|
}
|
|
275
|
-
if (!result.ok && result.error === "source_busy") {
|
|
279
|
+
if (!result.ok && result.error === "source_busy" && !result.deferred) {
|
|
276
280
|
throttled++;
|
|
277
281
|
deferFullTextHost(result.host ?? host, result.retryAt);
|
|
278
282
|
}
|
|
@@ -36,6 +36,16 @@ export class ImageCache {
|
|
|
36
36
|
if (this.stopping)
|
|
37
37
|
throw new ImageCacheBusyError();
|
|
38
38
|
const key = createHash("sha256").update(`${scope}\0${url}`).digest("hex");
|
|
39
|
+
const missing = this.negative.get(key);
|
|
40
|
+
if (missing) {
|
|
41
|
+
if (missing.expires > this.now()) {
|
|
42
|
+
this.counters.negativeHits++;
|
|
43
|
+
if (missing.result === "unsupported")
|
|
44
|
+
return "unsupported";
|
|
45
|
+
throw new FeedHttpError(missing.result, undefined, "");
|
|
46
|
+
}
|
|
47
|
+
this.negative.delete(key);
|
|
48
|
+
}
|
|
39
49
|
const shared = this.running.get(key);
|
|
40
50
|
if (shared) {
|
|
41
51
|
this.counters.shared++;
|
|
@@ -5,8 +5,8 @@ import { hasUserCapability } from "../auth/capabilities.js";
|
|
|
5
5
|
import { configureFullTextPool, deferFullTextHost } from "./fullTextQueue.js";
|
|
6
6
|
import { extractArticle } from "./extractor.js";
|
|
7
7
|
import { assertPublicHttpUrl, normalizeUrlCandidate } from "./ssrfGuard.js";
|
|
8
|
-
import { articleImageUrls, scheduleArchive } from "./archive.js";
|
|
9
|
-
import { findFeedById, findFeedByUrl, isUserSubscribed,
|
|
8
|
+
import { articleImageUrls, pruneArchive, scheduleArchive } from "./archive.js";
|
|
9
|
+
import { findFeedById, findFeedByUrl, isUserSubscribed, findSubscriptionForUser, subscribe, bookmarkItem, findItemById, findItemForUser, setItemNote, } from "./repository.js";
|
|
10
10
|
function escapeHtml(str) {
|
|
11
11
|
return str
|
|
12
12
|
.replace(/&/g, "&")
|
|
@@ -31,7 +31,7 @@ export function ensureUserInbox(userId) {
|
|
|
31
31
|
if (!isUserSubscribed(userId, feed.id)) {
|
|
32
32
|
subscribe(userId, feed.id, "Inbox");
|
|
33
33
|
}
|
|
34
|
-
const sub =
|
|
34
|
+
const sub = findSubscriptionForUser(userId, feed.id);
|
|
35
35
|
if (!sub) {
|
|
36
36
|
throw new Error("Failed to ensure user inbox subscription");
|
|
37
37
|
}
|
|
@@ -53,7 +53,7 @@ export async function saveToInbox(userId, input) {
|
|
|
53
53
|
try {
|
|
54
54
|
configureFullTextPool();
|
|
55
55
|
const extracted = await extractArticle(targetUrl, undefined, 5, () => hasUserCapability(userId, "fulltext"));
|
|
56
|
-
if (extracted.status === "throttled")
|
|
56
|
+
if (extracted.status === "throttled" && !extracted.deferred)
|
|
57
57
|
deferFullTextHost(extracted.host, extracted.retryAt);
|
|
58
58
|
if (extracted.status === "ok" || extracted.status === "partial") {
|
|
59
59
|
extractionStatus = extracted.status === "ok" ? "ready" : "partial";
|
|
@@ -130,6 +130,7 @@ export function deleteInboxItem(userId, itemId) {
|
|
|
130
130
|
db.prepare("DELETE FROM item_notes WHERE item_id = ?").run(itemId);
|
|
131
131
|
db.prepare("DELETE FROM items WHERE id = ?").run(itemId);
|
|
132
132
|
})();
|
|
133
|
+
pruneArchive();
|
|
133
134
|
return true;
|
|
134
135
|
}
|
|
135
136
|
//# sourceMappingURL=inbox.js.map
|
|
@@ -190,8 +190,17 @@ export class PollQueue {
|
|
|
190
190
|
continue;
|
|
191
191
|
}
|
|
192
192
|
// A URL may have been changed since enqueue. Do not dispatch under its old host budget.
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
let currentHost;
|
|
194
|
+
try {
|
|
195
|
+
currentHost = sourceHost(feed.url);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
db.prepare("DELETE FROM feed_poll_jobs WHERE feed_id = ?").run(job.feed_id);
|
|
199
|
+
this.resolve(job.feed_id, { newItems: 0, error: "invalid_feed_url" });
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (currentHost !== job.host) {
|
|
203
|
+
db.prepare("UPDATE feed_poll_jobs SET host = ? WHERE feed_id = ?").run(currentHost, feed.id);
|
|
195
204
|
continue;
|
|
196
205
|
}
|
|
197
206
|
const waiter = this.waiters.get(feed.id);
|
|
@@ -132,10 +132,10 @@ async function pollAttempt(feed, signal) {
|
|
|
132
132
|
try {
|
|
133
133
|
signal.throwIfAborted();
|
|
134
134
|
if (iconCheckDue(feed.icon_checked_at)) {
|
|
135
|
+
updateFeedIcon(feed.id, feed.icon_url);
|
|
135
136
|
const siteUrl = parsed?.link || feed.site_url;
|
|
136
137
|
const urls = siteUrl ? await discoverIconUrls(siteUrl, signal) : [];
|
|
137
138
|
signal.throwIfAborted();
|
|
138
|
-
updateFeedIcon(feed.id, feed.icon_url);
|
|
139
139
|
await refreshFeedIcon(feed.id, siteUrl, urls, signal);
|
|
140
140
|
}
|
|
141
141
|
}
|
|
@@ -43,8 +43,8 @@ export function removeFeedIfUnused(feedId) {
|
|
|
43
43
|
if (countFeedSubscribers(feedId) > 0)
|
|
44
44
|
return;
|
|
45
45
|
db.prepare(`DELETE FROM items WHERE feed_id = ? AND id NOT IN (SELECT item_id FROM item_bookmarks) AND id NOT IN (SELECT item_id FROM item_notes)
|
|
46
|
-
AND NOT
|
|
47
|
-
WHERE
|
|
46
|
+
AND id NOT IN (SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
47
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed')`).run(feedId);
|
|
48
48
|
db.prepare("DELETE FROM feeds WHERE id = ? AND is_system_inbox = 0 AND NOT EXISTS (SELECT 1 FROM items WHERE feed_id = ?)").run(feedId, feedId);
|
|
49
49
|
}
|
|
50
50
|
/** Feeds kept only for saved articles or annotated articles whose last bookmark and note are gone. */
|
|
@@ -54,8 +54,10 @@ export function removeUnusedFeeds() {
|
|
|
54
54
|
AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id)
|
|
55
55
|
AND NOT EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = feeds.id)
|
|
56
56
|
AND NOT EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = feeds.id)
|
|
57
|
-
AND NOT EXISTS (SELECT 1 FROM items i
|
|
58
|
-
|
|
57
|
+
AND NOT EXISTS (SELECT 1 FROM items i WHERE i.feed_id = feeds.id AND i.id IN (
|
|
58
|
+
SELECT CAST(selected.value AS INTEGER) FROM editions e, json_each(e.item_ids) selected
|
|
59
|
+
WHERE e.source = 'curated' AND e.status <> 'dismissed'
|
|
60
|
+
))`).run().changes;
|
|
59
61
|
}
|
|
60
62
|
/** Subscribers can open every item of their feeds; anyone can open what they saved or added a note to. */
|
|
61
63
|
export function canAccessItem(userId, itemId) {
|
|
@@ -168,7 +170,7 @@ export function upsertItems(feedId, items) {
|
|
|
168
170
|
refreshLead(Number(result.lastInsertRowid));
|
|
169
171
|
}
|
|
170
172
|
else {
|
|
171
|
-
const previous = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
173
|
+
const previous = db.prepare("SELECT id, title, link, image_url, content_snippet, content_html, full_content_html, lead_version FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
|
|
172
174
|
const next = row.contentHtml;
|
|
173
175
|
// Some publishers replace complete feed bodies with short teasers on a later poll.
|
|
174
176
|
const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
|
|
@@ -176,14 +178,27 @@ export function upsertItems(feedId, items) {
|
|
|
176
178
|
// The summary belongs to the body it was sent with: keeping the longer body keeps its summary too, or
|
|
177
179
|
// a feed that alternates between two variants would change the content on every poll.
|
|
178
180
|
const keptPrevious = retained !== null && next !== retained;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
+
const nextSnippet = keptPrevious ? null : row.contentSnippet ?? null;
|
|
182
|
+
const nextImage = largerHeroImage(row.imageUrl, retained, row.link ?? previous?.link ?? null) ?? previous?.image_url ?? row.imageUrl ?? null;
|
|
181
183
|
if (previous) {
|
|
182
|
-
const changed = (
|
|
183
|
-
(row.
|
|
184
|
-
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet)
|
|
185
|
-
|
|
184
|
+
const changed = (row.title !== undefined && row.title !== previous.title) ||
|
|
185
|
+
(row.link !== undefined && row.link !== previous.link) ||
|
|
186
|
+
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet) ||
|
|
187
|
+
(retained !== null && retained !== previous.content_html) ||
|
|
188
|
+
(row.publishedAt !== undefined && row.publishedAt !== null) ||
|
|
189
|
+
(nextImage !== previous.image_url) ||
|
|
190
|
+
(row.author !== undefined);
|
|
191
|
+
if (changed) {
|
|
192
|
+
update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
|
|
193
|
+
const leadChanged = (retained !== null && retained !== previous.content_html) ||
|
|
194
|
+
(row.title !== undefined && row.title !== previous.title) ||
|
|
195
|
+
(!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
|
|
196
|
+
if (previous.lead_version !== LEAD_VERSION || (leadChanged && !previous.full_content_html))
|
|
197
|
+
refreshLead(previous.id);
|
|
198
|
+
}
|
|
199
|
+
else if (previous.lead_version !== LEAD_VERSION && !previous.full_content_html) {
|
|
186
200
|
refreshLead(previous.id);
|
|
201
|
+
}
|
|
187
202
|
}
|
|
188
203
|
}
|
|
189
204
|
}
|
|
@@ -193,7 +208,13 @@ export function upsertItems(feedId, items) {
|
|
|
193
208
|
}
|
|
194
209
|
export function listItemsForUser(userId, opts = {}) {
|
|
195
210
|
// Saved articles stay visible without a subscription (e.g. after unsubscribing).
|
|
196
|
-
const conditions = [
|
|
211
|
+
const conditions = [];
|
|
212
|
+
if (opts.bookmarkedOnly) {
|
|
213
|
+
conditions.push("b.item_id IS NOT NULL");
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
|
|
217
|
+
}
|
|
197
218
|
const params = [];
|
|
198
219
|
if (opts.feedId) {
|
|
199
220
|
conditions.push("i.feed_id = ?");
|
|
@@ -297,6 +318,51 @@ export function findItemForUser(userId, itemId) {
|
|
|
297
318
|
EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
|
|
298
319
|
AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).get(userId, userId, userId, userId, itemId, userId);
|
|
299
320
|
}
|
|
321
|
+
export function findItemsForUser(userId, itemIds) {
|
|
322
|
+
if (!itemIds.length)
|
|
323
|
+
return [];
|
|
324
|
+
const unique = [...new Set(itemIds)];
|
|
325
|
+
return db.prepare(`SELECT i.*, COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
|
|
326
|
+
f.site_url AS feed_site_url, f.url AS feed_url, s.full_text_mode AS feed_full_text_mode, f.icon_url AS feed_icon_url,
|
|
327
|
+
(r.item_id IS NOT NULL) AS read, (b.item_id IS NOT NULL) AS bookmarked, b.created_at AS bookmarked_at
|
|
328
|
+
FROM items i
|
|
329
|
+
JOIN feeds f ON f.id = i.feed_id
|
|
330
|
+
LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
331
|
+
LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
|
|
332
|
+
LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
|
|
333
|
+
LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
|
|
334
|
+
WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))
|
|
335
|
+
AND (s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.item_id IS NOT NULL OR
|
|
336
|
+
EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
|
|
337
|
+
AND selected.value = i.id AND e.source = 'curated' AND e.status <> 'dismissed'))`).all(userId, userId, userId, userId, JSON.stringify(unique), userId);
|
|
338
|
+
}
|
|
339
|
+
export function findSubscriptionForUser(userId, feedId) {
|
|
340
|
+
return db
|
|
341
|
+
.prepare(`SELECT
|
|
342
|
+
f.*,
|
|
343
|
+
fi.hash AS icon_hash,
|
|
344
|
+
s.id AS subscription_id,
|
|
345
|
+
NULLIF(TRIM(s.label), '') AS label,
|
|
346
|
+
s.folder_id AS folder_id,
|
|
347
|
+
fo.name AS folder_name,
|
|
348
|
+
s.position AS position,
|
|
349
|
+
s.full_text_mode AS full_text_mode,
|
|
350
|
+
s.notify AS notify,
|
|
351
|
+
s.badge AS badge,
|
|
352
|
+
(
|
|
353
|
+
SELECT COUNT(*) FROM items i
|
|
354
|
+
WHERE i.feed_id = f.id
|
|
355
|
+
AND NOT EXISTS (
|
|
356
|
+
SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?
|
|
357
|
+
)
|
|
358
|
+
) AS unread_count
|
|
359
|
+
FROM subscriptions s
|
|
360
|
+
JOIN feeds f ON f.id = s.feed_id
|
|
361
|
+
LEFT JOIN feed_icons fi ON fi.feed_id = f.id
|
|
362
|
+
LEFT JOIN folders fo ON fo.id = s.folder_id
|
|
363
|
+
WHERE s.user_id = ? AND s.feed_id = ?`)
|
|
364
|
+
.get(userId, userId, feedId) ?? null;
|
|
365
|
+
}
|
|
300
366
|
export function updateSubscriptionLabel(userId, feedId, label) {
|
|
301
367
|
db.prepare("UPDATE subscriptions SET label = ? WHERE user_id = ? AND feed_id = ?").run(label?.trim() || null, userId, feedId);
|
|
302
368
|
}
|
|
@@ -678,9 +744,24 @@ export function searchItemsForUser(userId, opts) {
|
|
|
678
744
|
}
|
|
679
745
|
const ranked = opts.sort !== "date" && fts;
|
|
680
746
|
// Materialize once: a virtual-table MATCH in the LEFT JOIN repeats the full ranking scan for each article.
|
|
747
|
+
// Page IDs before loading HTML to avoid copying large content blobs into temporary B-trees during sorting.
|
|
681
748
|
const querySql = `
|
|
682
|
-
${ranked ? "WITH ranked_matches AS MATERIALIZED (SELECT rowid, rank FROM items_fts WHERE items_fts MATCH ?)" : ""}
|
|
683
|
-
|
|
749
|
+
${ranked ? "WITH ranked_matches AS MATERIALIZED (SELECT rowid, rank FROM items_fts WHERE items_fts MATCH ?)," : "WITH"}
|
|
750
|
+
page AS (
|
|
751
|
+
SELECT i.id
|
|
752
|
+
FROM items i
|
|
753
|
+
JOIN feeds f ON f.id = i.feed_id
|
|
754
|
+
LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
755
|
+
LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
|
|
756
|
+
LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
|
|
757
|
+
LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
|
|
758
|
+
${ranked ? "LEFT JOIN ranked_matches fts ON fts.rowid = i.id" : ""}
|
|
759
|
+
WHERE ${conditions.join(" AND ")}
|
|
760
|
+
GROUP BY i.id
|
|
761
|
+
ORDER BY ${orderBy}
|
|
762
|
+
LIMIT ? OFFSET ?
|
|
763
|
+
)
|
|
764
|
+
SELECT i.*,
|
|
684
765
|
COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
|
|
685
766
|
f.site_url AS feed_site_url,
|
|
686
767
|
f.url AS feed_url,
|
|
@@ -689,16 +770,15 @@ export function searchItemsForUser(userId, opts) {
|
|
|
689
770
|
(r.item_id IS NOT NULL) AS read,
|
|
690
771
|
(b.item_id IS NOT NULL) AS bookmarked,
|
|
691
772
|
b.created_at AS bookmarked_at
|
|
692
|
-
FROM
|
|
773
|
+
FROM page
|
|
774
|
+
JOIN items i ON i.id = page.id
|
|
693
775
|
JOIN feeds f ON f.id = i.feed_id
|
|
694
776
|
LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
695
777
|
LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
|
|
696
778
|
LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
|
|
697
779
|
LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
|
|
698
780
|
${ranked ? "LEFT JOIN ranked_matches fts ON fts.rowid = i.id" : ""}
|
|
699
|
-
WHERE ${conditions.join(" AND ")}
|
|
700
781
|
ORDER BY ${orderBy}
|
|
701
|
-
LIMIT ? OFFSET ?
|
|
702
782
|
`;
|
|
703
783
|
const queryParams = [
|
|
704
784
|
...(ranked ? [fts] : []),
|
|
@@ -709,6 +789,10 @@ export function searchItemsForUser(userId, opts) {
|
|
|
709
789
|
...params,
|
|
710
790
|
limit,
|
|
711
791
|
offset,
|
|
792
|
+
userId,
|
|
793
|
+
userId,
|
|
794
|
+
userId,
|
|
795
|
+
userId,
|
|
712
796
|
];
|
|
713
797
|
const items = db.prepare(querySql).all(...queryParams);
|
|
714
798
|
return { items, total };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { config } from "../config.js";
|
|
2
|
-
import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo,
|
|
2
|
+
import { createFeed, findFeedByUrl, isUserSubscribed, subscribe, unsubscribe as unsubscribeRepo, findSubscriptionForUser, updateSubscriptionLabel, updateSubscriptionFolder, updateFeedPollInterval, updateSubscriptionFullTextMode, updateSubscriptionNotify, updateSubscriptionBadge, findFolderById, findFeedById, countFeedSubscribers, replaceFeedUrl, moveSubscription, } from "./repository.js";
|
|
3
3
|
import { pollFeed, probeFeed } from "./poller.js";
|
|
4
4
|
import { assertPublicHttpUrl } from "./ssrfGuard.js";
|
|
5
5
|
import { discoverFeeds } from "./discovery.js";
|
|
@@ -66,7 +66,7 @@ export async function subscribeToFeed(userId, inputUrl, label = null, folderId =
|
|
|
66
66
|
unsubscribeRepo(userId, feed.id);
|
|
67
67
|
throw new NoFeedsFoundError();
|
|
68
68
|
}
|
|
69
|
-
const subscription =
|
|
69
|
+
const subscription = findSubscriptionForUser(userId, feed.id);
|
|
70
70
|
if (!subscription)
|
|
71
71
|
throw new FeedError("subscription_lookup_failed");
|
|
72
72
|
return subscription;
|
|
@@ -109,7 +109,7 @@ export function updateFeedSettings(userId, feedId, settings) {
|
|
|
109
109
|
const minutes = Math.max(settings.pollIntervalMinutes, config.minPollIntervalMinutes);
|
|
110
110
|
updateFeedPollInterval(feedId, minutes);
|
|
111
111
|
}
|
|
112
|
-
const subscription =
|
|
112
|
+
const subscription = findSubscriptionForUser(userId, feedId);
|
|
113
113
|
if (!subscription)
|
|
114
114
|
throw new FeedError("subscription_lookup_failed");
|
|
115
115
|
return subscription;
|
|
@@ -127,7 +127,7 @@ export async function changeFeedUrl(userId, feedId, inputUrl) {
|
|
|
127
127
|
const { url } = await resolveFeedUrl(inputUrl);
|
|
128
128
|
let target = feedId;
|
|
129
129
|
const lookup = () => {
|
|
130
|
-
const subscription =
|
|
130
|
+
const subscription = findSubscriptionForUser(userId, target);
|
|
131
131
|
if (!subscription)
|
|
132
132
|
throw new FeedError("subscription_lookup_failed");
|
|
133
133
|
return subscription;
|
|
@@ -214,19 +214,23 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
|
|
|
214
214
|
const previousItemIds = db.prepare("SELECT item_ids FROM edition_history WHERE user_id = ? AND created_at >= ?").all(userId, new Date(now.getTime() - 72 * 3_600_000).toISOString()).flatMap((row) => JSON.parse(row.item_ids));
|
|
215
215
|
const previous = new Set(previousItemIds);
|
|
216
216
|
// Fetch bounded candidates per source, so a busy feed cannot hide the others.
|
|
217
|
-
const rows = db.prepare(`SELECT
|
|
218
|
-
|
|
219
|
-
coalesce(
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
217
|
+
const rows = db.prepare(`SELECT r.id, r.feed_id, r.folder_id, r.title, r.link, r.content_snippet, r.image_url,
|
|
218
|
+
r.source_name, r.folder_name, r.published, r.source_rank,
|
|
219
|
+
length(coalesce(full_item.full_content_html, full_item.content_html, r.content_snippet, '')) AS content_chars
|
|
220
|
+
FROM (
|
|
221
|
+
SELECT i.id, i.feed_id, s.folder_id, i.title, i.link, i.content_snippet, i.image_url,
|
|
222
|
+
coalesce(s.label, f.title) AS source_name, folders.name AS folder_name,
|
|
223
|
+
strftime('%Y-%m-%dT%H:%M:%fZ', coalesce(julianday(i.published_at), julianday(i.created_at))) AS published,
|
|
224
|
+
row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
|
|
225
|
+
FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
226
|
+
JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
|
|
227
|
+
WHERE NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
|
|
228
|
+
AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
|
|
229
|
+
(instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
|
|
230
|
+
AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
|
|
231
|
+
) r
|
|
232
|
+
JOIN items full_item ON full_item.id = r.id
|
|
233
|
+
WHERE r.source_rank <= 40 ORDER BY r.source_rank, r.published DESC, r.id DESC LIMIT 4000`)
|
|
230
234
|
.all(userId, userId, userId, new Date(now.getTime() - 7 * 86_400_000).toISOString(), now.toISOString());
|
|
231
235
|
const scored = rows.filter((row) => row.folder_id === null ? prefs.showUnfiled : !prefs.hiddenFolderIds.includes(row.folder_id))
|
|
232
236
|
.map((row) => ({
|
|
@@ -72,13 +72,20 @@ export function rotateGrant(refreshToken, clientId) {
|
|
|
72
72
|
return { ok: true, scope: grant.scope, tokens: { accessToken, refreshToken: newRefresh, expiresIn: ACCESS_TTL_MS / 1000 } };
|
|
73
73
|
})();
|
|
74
74
|
}
|
|
75
|
+
const grantLastTouch = new Map();
|
|
76
|
+
const GRANT_TOUCH_INTERVAL_MS = 5 * 60 * 1000;
|
|
75
77
|
export function resolveAccessToken(token) {
|
|
76
78
|
const row = db
|
|
77
79
|
.prepare("SELECT * FROM oauth_grants WHERE access_hash = ?")
|
|
78
80
|
.get(hash(token));
|
|
79
81
|
if (!row || row.access_expires_at <= now())
|
|
80
82
|
return null;
|
|
81
|
-
|
|
83
|
+
const nowMs = Date.now();
|
|
84
|
+
const lastTouch = grantLastTouch.get(row.id) ?? 0;
|
|
85
|
+
if (!row.last_used_at || nowMs - lastTouch >= GRANT_TOUCH_INTERVAL_MS) {
|
|
86
|
+
grantLastTouch.set(row.id, nowMs);
|
|
87
|
+
db.prepare("UPDATE oauth_grants SET last_used_at = ? WHERE id = ?").run(now(), row.id);
|
|
88
|
+
}
|
|
82
89
|
return { userId: row.user_id, scope: row.scope, grantId: row.id };
|
|
83
90
|
}
|
|
84
91
|
/** Revokes by either token. Returns whether a grant was removed. */
|
|
@@ -89,6 +96,7 @@ export function revokeByToken(token, clientId) {
|
|
|
89
96
|
.get(tokenHash, tokenHash);
|
|
90
97
|
if (!grant || (clientId && grant.client_id !== clientId))
|
|
91
98
|
return false;
|
|
99
|
+
grantLastTouch.delete(grant.id);
|
|
92
100
|
return db.prepare("DELETE FROM oauth_grants WHERE id = ?").run(grant.id).changes > 0;
|
|
93
101
|
}
|
|
94
102
|
export function listGrantsForUser(userId) {
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { canAccessItem, findItemById } from "../feeds/repository.js";
|
|
1
|
+
import { canAccessItem } from "../feeds/repository.js";
|
|
3
2
|
import { db } from "../db/index.js";
|
|
4
|
-
import {
|
|
5
|
-
import { getEditionState } from "../native/editions.js";
|
|
3
|
+
import { listFoldersForUser, listMutedKeywords, listSubscriptionsForUser, } from "../feeds/repository.js";
|
|
4
|
+
import { getEdition, getEditionState } from "../native/editions.js";
|
|
6
5
|
import { getNativePreferences } from "../native/preferences.js";
|
|
7
6
|
const TOMBSTONE_DAYS = 90;
|
|
8
7
|
const MAX_PAGE = 500;
|
|
@@ -26,21 +25,59 @@ export function serializeSubscription(subscription) {
|
|
|
26
25
|
: `/api/v1/subscriptions/${subscription.id}/icon`,
|
|
27
26
|
};
|
|
28
27
|
}
|
|
29
|
-
function
|
|
30
|
-
const
|
|
31
|
-
if (!
|
|
32
|
-
return
|
|
33
|
-
const
|
|
34
|
-
.prepare(`SELECT
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
28
|
+
function loadItemStates(userId, itemIds) {
|
|
29
|
+
const result = new Map();
|
|
30
|
+
if (!itemIds.length)
|
|
31
|
+
return result;
|
|
32
|
+
const rows = db
|
|
33
|
+
.prepare(`SELECT i.id,
|
|
34
|
+
r.item_id IS NOT NULL AS read,
|
|
35
|
+
b.created_at AS saved_at,
|
|
36
|
+
p.position AS progress
|
|
37
|
+
FROM items i
|
|
38
|
+
LEFT JOIN item_reads r ON r.user_id = ? AND r.item_id = i.id
|
|
39
|
+
LEFT JOIN item_bookmarks b ON b.user_id = ? AND b.item_id = i.id
|
|
40
|
+
LEFT JOIN item_progress p ON p.user_id = ? AND p.item_id = i.id
|
|
41
|
+
WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
42
|
+
.all(userId, userId, userId, JSON.stringify(itemIds));
|
|
43
|
+
for (const row of rows) {
|
|
44
|
+
result.set(row.id, {
|
|
45
|
+
read: Boolean(row.read),
|
|
46
|
+
saved: row.saved_at !== null,
|
|
47
|
+
savedAt: row.saved_at,
|
|
48
|
+
progress: row.progress,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
function loadItemContents(userId, itemIds) {
|
|
54
|
+
const result = new Map();
|
|
55
|
+
if (!itemIds.length)
|
|
56
|
+
return result;
|
|
57
|
+
const rows = db
|
|
58
|
+
.prepare(`SELECT i.id,
|
|
59
|
+
i.content_revision,
|
|
60
|
+
CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
|
|
61
|
+
WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
|
|
62
|
+
ELSE 'snippet' END AS source,
|
|
63
|
+
i.extraction_status,
|
|
64
|
+
i.extraction_attempted_at,
|
|
65
|
+
i.extraction_retry_at
|
|
66
|
+
FROM items i
|
|
67
|
+
WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
68
|
+
.all(JSON.stringify(itemIds));
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
if (!canAccessItem(userId, row.id))
|
|
71
|
+
continue;
|
|
72
|
+
result.set(row.id, {
|
|
73
|
+
revision: row.content_revision,
|
|
74
|
+
source: row.source,
|
|
75
|
+
status: row.extraction_status,
|
|
76
|
+
attemptedAt: row.extraction_attempted_at,
|
|
77
|
+
retryAt: row.extraction_retry_at,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
44
81
|
}
|
|
45
82
|
/**
|
|
46
83
|
* Add the data a user already had before the log existed. Everything that is in the log
|
|
@@ -83,7 +120,12 @@ export class ResyncRequired extends Error {
|
|
|
83
120
|
}
|
|
84
121
|
}
|
|
85
122
|
export function currentSeq(userId) {
|
|
86
|
-
return db
|
|
123
|
+
return db
|
|
124
|
+
.prepare(`SELECT MAX(
|
|
125
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = ?), 0),
|
|
126
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = ?), 0)
|
|
127
|
+
) AS seq`)
|
|
128
|
+
.get(userId, userId).seq;
|
|
87
129
|
}
|
|
88
130
|
/** Everything that changed after `since`, oldest first, with the current data of each object. */
|
|
89
131
|
export function listChanges(userId, since, limit = 200) {
|
|
@@ -101,29 +143,63 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
101
143
|
.all(userId, since, page + 1);
|
|
102
144
|
const hasMore = rows.length > page;
|
|
103
145
|
const slice = rows.slice(0, page);
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
146
|
+
const hasUpsert = (entity) => slice.some((row) => row.entity === entity && row.op !== "delete");
|
|
147
|
+
const subscriptions = hasUpsert("subscription")
|
|
148
|
+
? new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]))
|
|
149
|
+
: new Map();
|
|
150
|
+
const folders = hasUpsert("folder")
|
|
151
|
+
? new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]))
|
|
152
|
+
: new Map();
|
|
153
|
+
const keywords = hasUpsert("muted_keyword")
|
|
154
|
+
? new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]))
|
|
155
|
+
: new Map();
|
|
156
|
+
const stateIds = [...new Set(slice.filter((row) => row.entity === "item_state" && row.op !== "delete").map((row) => row.entity_id))];
|
|
157
|
+
const itemStates = loadItemStates(userId, stateIds);
|
|
158
|
+
const contentIds = [...new Set(slice.filter((row) => row.entity === "item_content" && row.op !== "delete").map((row) => row.entity_id))];
|
|
159
|
+
const itemContents = loadItemContents(userId, contentIds);
|
|
160
|
+
const noteUpsertIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op !== "delete").map((row) => row.entity_id))];
|
|
161
|
+
const notes = new Map();
|
|
162
|
+
if (noteUpsertIds.length) {
|
|
163
|
+
const noteRows = db
|
|
164
|
+
.prepare(`SELECT n.item_id, n.content, COALESCE(r.revision, 1) AS revision, n.created_at, n.updated_at
|
|
165
|
+
FROM item_notes n
|
|
166
|
+
LEFT JOIN item_note_revisions r ON r.user_id = n.user_id AND r.item_id = n.item_id
|
|
167
|
+
WHERE n.user_id = ? AND n.item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
168
|
+
.all(userId, JSON.stringify(noteUpsertIds));
|
|
169
|
+
for (const note of noteRows)
|
|
170
|
+
notes.set(note.item_id, note);
|
|
171
|
+
}
|
|
172
|
+
const noteDeleteIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op === "delete").map((row) => row.entity_id))];
|
|
173
|
+
const noteRevisions = new Map();
|
|
174
|
+
if (noteDeleteIds.length) {
|
|
175
|
+
const revRows = db
|
|
176
|
+
.prepare(`SELECT item_id, revision FROM item_note_revisions
|
|
177
|
+
WHERE user_id = ? AND item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
178
|
+
.all(userId, JSON.stringify(noteDeleteIds));
|
|
179
|
+
for (const rev of revRows)
|
|
180
|
+
noteRevisions.set(rev.item_id, rev.revision);
|
|
181
|
+
}
|
|
107
182
|
const changes = slice.map((row) => {
|
|
108
183
|
const change = { seq: row.seq, entity: row.entity, id: row.entity_id, op: row.op };
|
|
109
184
|
if (row.entity === "edition") {
|
|
110
185
|
const state = getEditionState(userId);
|
|
111
|
-
|
|
186
|
+
const edition = getEdition(userId);
|
|
187
|
+
return edition ? { ...change, op: "upsert", data: { ...edition } } :
|
|
112
188
|
{ ...change, op: "delete", data: { revision: state.revision, status: state.status } };
|
|
113
189
|
}
|
|
114
190
|
if (row.entity === "note" && row.op === "delete") {
|
|
115
|
-
const
|
|
116
|
-
return
|
|
191
|
+
const revision = noteRevisions.get(row.entity_id);
|
|
192
|
+
return revision !== undefined ? { ...change, data: { revision } } : change;
|
|
117
193
|
}
|
|
118
194
|
if (row.op === "delete")
|
|
119
195
|
return change;
|
|
120
196
|
let data;
|
|
121
197
|
if (row.entity === "item_content") {
|
|
122
|
-
|
|
123
|
-
|
|
198
|
+
data = itemContents.get(row.entity_id) ?? null;
|
|
199
|
+
}
|
|
200
|
+
else if (row.entity === "item_state") {
|
|
201
|
+
data = itemStates.get(row.entity_id) ?? null;
|
|
124
202
|
}
|
|
125
|
-
else if (row.entity === "item_state")
|
|
126
|
-
data = itemState(userId, row.entity_id);
|
|
127
203
|
else if (row.entity === "subscription") {
|
|
128
204
|
const subscription = subscriptions.get(row.entity_id);
|
|
129
205
|
data = subscription && serializeSubscription(subscription);
|
|
@@ -137,7 +213,7 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
137
213
|
data = keyword && { keyword: keyword.keyword };
|
|
138
214
|
}
|
|
139
215
|
else if (row.entity === "note") {
|
|
140
|
-
const note =
|
|
216
|
+
const note = notes.get(row.entity_id);
|
|
141
217
|
data = note && {
|
|
142
218
|
itemId: note.item_id,
|
|
143
219
|
content: note.content,
|
|
@@ -158,8 +234,11 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
158
234
|
export function pruneChangeLog() {
|
|
159
235
|
const cutoff = new Date(Date.now() - TOMBSTONE_DAYS * 86_400_000).toISOString();
|
|
160
236
|
return db.transaction(() => {
|
|
161
|
-
db.prepare(`UPDATE sync_state SET
|
|
162
|
-
|
|
237
|
+
db.prepare(`UPDATE sync_state SET
|
|
238
|
+
last_seq = MAX(last_seq, pruned_through, COALESCE(
|
|
239
|
+
(SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)),
|
|
240
|
+
pruned_through = MAX(pruned_through, COALESCE(
|
|
241
|
+
(SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
|
|
163
242
|
const old = db.prepare("DELETE FROM changes WHERE op = 'delete' AND changed_at < ?").run(cutoff).changes;
|
|
164
243
|
const gone = db.prepare("DELETE FROM changes WHERE entity IN ('item_state', 'item_content') AND entity_id NOT IN (SELECT id FROM items)").run().changes;
|
|
165
244
|
const goneNotes = db.prepare("DELETE FROM changes WHERE entity = 'note' AND entity_id NOT IN (SELECT id FROM items)").run().changes;
|