feedkeeper 0.27.1 → 0.28.0
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/index.js +1 -0
- 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/db/migrations/0040_empty_gallery.sql +10 -0
- package/server/dist/feeds/archive.js +26 -14
- package/server/dist/feeds/articleContent.js +1 -1
- 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 +9 -3
- package/server/dist/feeds/galleryContent.js +33 -0
- package/server/dist/feeds/imageCache.js +10 -0
- package/server/dist/feeds/inbox.js +5 -4
- package/server/dist/feeds/lead.js +13 -5
- package/server/dist/feeds/pollQueue.js +11 -2
- package/server/dist/feeds/poller.js +1 -1
- package/server/dist/feeds/repository.js +112 -19
- package/server/dist/feeds/service.js +4 -4
- package/server/dist/native/editions.js +20 -15
- package/server/dist/oauth/store.js +9 -1
- package/server/dist/push.js +2 -0
- package/server/dist/pushOutbox.js +1 -1
- package/server/dist/sync/changeLog.js +114 -33
- package/server/package.json +1 -1
- package/web/dist/assets/index-CTTNHCfi.js +32 -0
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +62 -13
- package/web/dist/assets/index-Ci8_fWis.js +0 -32
|
@@ -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,61 @@ 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
|
+
i.is_empty_gallery,
|
|
61
|
+
CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
|
|
62
|
+
WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
|
|
63
|
+
ELSE 'snippet' END AS source,
|
|
64
|
+
i.extraction_status,
|
|
65
|
+
i.extraction_attempted_at,
|
|
66
|
+
i.extraction_retry_at
|
|
67
|
+
FROM items i
|
|
68
|
+
WHERE i.id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
69
|
+
.all(JSON.stringify(itemIds));
|
|
70
|
+
for (const row of rows) {
|
|
71
|
+
if (!canAccessItem(userId, row.id))
|
|
72
|
+
continue;
|
|
73
|
+
result.set(row.id, {
|
|
74
|
+
revision: row.content_revision,
|
|
75
|
+
source: row.source,
|
|
76
|
+
status: row.extraction_status,
|
|
77
|
+
attemptedAt: row.extraction_attempted_at,
|
|
78
|
+
retryAt: row.extraction_retry_at,
|
|
79
|
+
isEmptyGallery: Boolean(row.is_empty_gallery),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return result;
|
|
44
83
|
}
|
|
45
84
|
/**
|
|
46
85
|
* Add the data a user already had before the log existed. Everything that is in the log
|
|
@@ -83,7 +122,12 @@ export class ResyncRequired extends Error {
|
|
|
83
122
|
}
|
|
84
123
|
}
|
|
85
124
|
export function currentSeq(userId) {
|
|
86
|
-
return db
|
|
125
|
+
return db
|
|
126
|
+
.prepare(`SELECT MAX(
|
|
127
|
+
COALESCE((SELECT MAX(seq) FROM changes WHERE user_id = ?), 0),
|
|
128
|
+
COALESCE((SELECT MAX(last_seq, pruned_through) FROM sync_state WHERE user_id = ?), 0)
|
|
129
|
+
) AS seq`)
|
|
130
|
+
.get(userId, userId).seq;
|
|
87
131
|
}
|
|
88
132
|
/** Everything that changed after `since`, oldest first, with the current data of each object. */
|
|
89
133
|
export function listChanges(userId, since, limit = 200) {
|
|
@@ -101,29 +145,63 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
101
145
|
.all(userId, since, page + 1);
|
|
102
146
|
const hasMore = rows.length > page;
|
|
103
147
|
const slice = rows.slice(0, page);
|
|
104
|
-
const
|
|
105
|
-
const
|
|
106
|
-
|
|
148
|
+
const hasUpsert = (entity) => slice.some((row) => row.entity === entity && row.op !== "delete");
|
|
149
|
+
const subscriptions = hasUpsert("subscription")
|
|
150
|
+
? new Map(listSubscriptionsForUser(userId).map((subscription) => [subscription.id, subscription]))
|
|
151
|
+
: new Map();
|
|
152
|
+
const folders = hasUpsert("folder")
|
|
153
|
+
? new Map(listFoldersForUser(userId).map((folder) => [folder.id, folder]))
|
|
154
|
+
: new Map();
|
|
155
|
+
const keywords = hasUpsert("muted_keyword")
|
|
156
|
+
? new Map(listMutedKeywords(userId).map((keyword) => [keyword.id, keyword]))
|
|
157
|
+
: new Map();
|
|
158
|
+
const stateIds = [...new Set(slice.filter((row) => row.entity === "item_state" && row.op !== "delete").map((row) => row.entity_id))];
|
|
159
|
+
const itemStates = loadItemStates(userId, stateIds);
|
|
160
|
+
const contentIds = [...new Set(slice.filter((row) => row.entity === "item_content" && row.op !== "delete").map((row) => row.entity_id))];
|
|
161
|
+
const itemContents = loadItemContents(userId, contentIds);
|
|
162
|
+
const noteUpsertIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op !== "delete").map((row) => row.entity_id))];
|
|
163
|
+
const notes = new Map();
|
|
164
|
+
if (noteUpsertIds.length) {
|
|
165
|
+
const noteRows = db
|
|
166
|
+
.prepare(`SELECT n.item_id, n.content, COALESCE(r.revision, 1) AS revision, n.created_at, n.updated_at
|
|
167
|
+
FROM item_notes n
|
|
168
|
+
LEFT JOIN item_note_revisions r ON r.user_id = n.user_id AND r.item_id = n.item_id
|
|
169
|
+
WHERE n.user_id = ? AND n.item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
170
|
+
.all(userId, JSON.stringify(noteUpsertIds));
|
|
171
|
+
for (const note of noteRows)
|
|
172
|
+
notes.set(note.item_id, note);
|
|
173
|
+
}
|
|
174
|
+
const noteDeleteIds = [...new Set(slice.filter((row) => row.entity === "note" && row.op === "delete").map((row) => row.entity_id))];
|
|
175
|
+
const noteRevisions = new Map();
|
|
176
|
+
if (noteDeleteIds.length) {
|
|
177
|
+
const revRows = db
|
|
178
|
+
.prepare(`SELECT item_id, revision FROM item_note_revisions
|
|
179
|
+
WHERE user_id = ? AND item_id IN (SELECT CAST(value AS INTEGER) FROM json_each(?))`)
|
|
180
|
+
.all(userId, JSON.stringify(noteDeleteIds));
|
|
181
|
+
for (const rev of revRows)
|
|
182
|
+
noteRevisions.set(rev.item_id, rev.revision);
|
|
183
|
+
}
|
|
107
184
|
const changes = slice.map((row) => {
|
|
108
185
|
const change = { seq: row.seq, entity: row.entity, id: row.entity_id, op: row.op };
|
|
109
186
|
if (row.entity === "edition") {
|
|
110
187
|
const state = getEditionState(userId);
|
|
111
|
-
|
|
188
|
+
const edition = getEdition(userId);
|
|
189
|
+
return edition ? { ...change, op: "upsert", data: { ...edition } } :
|
|
112
190
|
{ ...change, op: "delete", data: { revision: state.revision, status: state.status } };
|
|
113
191
|
}
|
|
114
192
|
if (row.entity === "note" && row.op === "delete") {
|
|
115
|
-
const
|
|
116
|
-
return
|
|
193
|
+
const revision = noteRevisions.get(row.entity_id);
|
|
194
|
+
return revision !== undefined ? { ...change, data: { revision } } : change;
|
|
117
195
|
}
|
|
118
196
|
if (row.op === "delete")
|
|
119
197
|
return change;
|
|
120
198
|
let data;
|
|
121
199
|
if (row.entity === "item_content") {
|
|
122
|
-
|
|
123
|
-
|
|
200
|
+
data = itemContents.get(row.entity_id) ?? null;
|
|
201
|
+
}
|
|
202
|
+
else if (row.entity === "item_state") {
|
|
203
|
+
data = itemStates.get(row.entity_id) ?? null;
|
|
124
204
|
}
|
|
125
|
-
else if (row.entity === "item_state")
|
|
126
|
-
data = itemState(userId, row.entity_id);
|
|
127
205
|
else if (row.entity === "subscription") {
|
|
128
206
|
const subscription = subscriptions.get(row.entity_id);
|
|
129
207
|
data = subscription && serializeSubscription(subscription);
|
|
@@ -137,7 +215,7 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
137
215
|
data = keyword && { keyword: keyword.keyword };
|
|
138
216
|
}
|
|
139
217
|
else if (row.entity === "note") {
|
|
140
|
-
const note =
|
|
218
|
+
const note = notes.get(row.entity_id);
|
|
141
219
|
data = note && {
|
|
142
220
|
itemId: note.item_id,
|
|
143
221
|
content: note.content,
|
|
@@ -158,8 +236,11 @@ export function listChanges(userId, since, limit = 200) {
|
|
|
158
236
|
export function pruneChangeLog() {
|
|
159
237
|
const cutoff = new Date(Date.now() - TOMBSTONE_DAYS * 86_400_000).toISOString();
|
|
160
238
|
return db.transaction(() => {
|
|
161
|
-
db.prepare(`UPDATE sync_state SET
|
|
162
|
-
|
|
239
|
+
db.prepare(`UPDATE sync_state SET
|
|
240
|
+
last_seq = MAX(last_seq, pruned_through, COALESCE(
|
|
241
|
+
(SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id), 0)),
|
|
242
|
+
pruned_through = MAX(pruned_through, COALESCE(
|
|
243
|
+
(SELECT MAX(seq) FROM changes WHERE changes.user_id = sync_state.user_id AND op = 'delete' AND changed_at < ?), 0))`).run(cutoff);
|
|
163
244
|
const old = db.prepare("DELETE FROM changes WHERE op = 'delete' AND changed_at < ?").run(cutoff).changes;
|
|
164
245
|
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
246
|
const goneNotes = db.prepare("DELETE FROM changes WHERE entity = 'note' AND entity_id NOT IN (SELECT id FROM items)").run().changes;
|