feedkeeper 0.0.0-stage → 0.23.1

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.
Files changed (136) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -2
  3. package/package.json +57 -4
  4. package/server/dist/api/archive.js +19 -0
  5. package/server/dist/api/auth.js +167 -0
  6. package/server/dist/api/feeds.js +207 -0
  7. package/server/dist/api/filters.js +32 -0
  8. package/server/dist/api/folders.js +53 -0
  9. package/server/dist/api/index.js +37 -0
  10. package/server/dist/api/items.js +129 -0
  11. package/server/dist/api/oauth.js +14 -0
  12. package/server/dist/api/onboarding.js +30 -0
  13. package/server/dist/api/push.js +51 -0
  14. package/server/dist/api/system.js +42 -0
  15. package/server/dist/api/tokens.js +28 -0
  16. package/server/dist/api/v1/index.js +178 -0
  17. package/server/dist/api/v1/resources.js +653 -0
  18. package/server/dist/api/v1/serialize.js +48 -0
  19. package/server/dist/auth/avatar.js +13 -0
  20. package/server/dist/auth/capabilities.js +53 -0
  21. package/server/dist/auth/middleware.js +77 -0
  22. package/server/dist/auth/pairing.js +36 -0
  23. package/server/dist/auth/password.js +21 -0
  24. package/server/dist/auth/security.js +126 -0
  25. package/server/dist/auth/securityRoutes.js +249 -0
  26. package/server/dist/auth/tokens.js +64 -0
  27. package/server/dist/auth/users.js +54 -0
  28. package/server/dist/backup-cli.js +101 -0
  29. package/server/dist/cli.js +135 -0
  30. package/server/dist/clientAddress.js +26 -0
  31. package/server/dist/config.js +51 -0
  32. package/server/dist/db/index.js +40 -0
  33. package/server/dist/db/migrations/0001_init.sql +70 -0
  34. package/server/dist/db/migrations/0002_feed_health.sql +5 -0
  35. package/server/dist/db/migrations/0003_retention.sql +13 -0
  36. package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
  37. package/server/dist/db/migrations/0005_folders.sql +15 -0
  38. package/server/dist/db/migrations/0006_feed_position.sql +5 -0
  39. package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
  40. package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
  41. package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
  42. package/server/dist/db/migrations/0010_token_scope.sql +3 -0
  43. package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
  44. package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
  45. package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
  46. package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
  47. package/server/dist/db/migrations/0015_push.sql +13 -0
  48. package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
  49. package/server/dist/db/migrations/0017_devices.sql +13 -0
  50. package/server/dist/db/migrations/0018_change_log.sql +166 -0
  51. package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
  52. package/server/dist/db/migrations/0020_fts.sql +32 -0
  53. package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
  54. package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
  55. package/server/dist/db/migrations/0023_server_editions.sql +81 -0
  56. package/server/dist/db/migrations/0024_inbox.sql +5 -0
  57. package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
  58. package/server/dist/db/migrations/0026_article_content.sql +43 -0
  59. package/server/dist/db/migrations/0027_oauth.sql +38 -0
  60. package/server/dist/db/migrations/0028_user_intro.sql +2 -0
  61. package/server/dist/db/migrations/0029_account_security.sql +35 -0
  62. package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
  63. package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
  64. package/server/dist/db/migrations/0032_normalize_published_at.sql +12 -0
  65. package/server/dist/feedkeeper.js +38 -0
  66. package/server/dist/feeds/archive.js +334 -0
  67. package/server/dist/feeds/articleContent.js +26 -0
  68. package/server/dist/feeds/cleanup.js +179 -0
  69. package/server/dist/feeds/dates.js +21 -0
  70. package/server/dist/feeds/discovery.js +153 -0
  71. package/server/dist/feeds/extractor.js +208 -0
  72. package/server/dist/feeds/feedIcon.js +171 -0
  73. package/server/dist/feeds/fetcher.js +217 -0
  74. package/server/dist/feeds/fullText.js +91 -0
  75. package/server/dist/feeds/fullTextQueue.js +159 -0
  76. package/server/dist/feeds/icon.js +97 -0
  77. package/server/dist/feeds/imageCache.js +266 -0
  78. package/server/dist/feeds/imageCacheSettings.js +19 -0
  79. package/server/dist/feeds/imageTypes.js +16 -0
  80. package/server/dist/feeds/inbox.js +128 -0
  81. package/server/dist/feeds/opml.js +190 -0
  82. package/server/dist/feeds/pollQueue.js +301 -0
  83. package/server/dist/feeds/pollSettings.js +23 -0
  84. package/server/dist/feeds/poller.js +223 -0
  85. package/server/dist/feeds/repository.js +687 -0
  86. package/server/dist/feeds/requestGate.js +99 -0
  87. package/server/dist/feeds/service.js +162 -0
  88. package/server/dist/feeds/ssrfGuard.js +98 -0
  89. package/server/dist/feeds/text.js +35 -0
  90. package/server/dist/mcp/digest.js +73 -0
  91. package/server/dist/mcp/http.js +46 -0
  92. package/server/dist/mcp/items.js +73 -0
  93. package/server/dist/mcp/promptsAndResources.js +43 -0
  94. package/server/dist/mcp/server.js +547 -0
  95. package/server/dist/native/editionSelection.js +127 -0
  96. package/server/dist/native/editionText.js +15 -0
  97. package/server/dist/native/editions.js +218 -0
  98. package/server/dist/native/preferences.js +66 -0
  99. package/server/dist/native/requests.js +35 -0
  100. package/server/dist/oauth/constants.js +6 -0
  101. package/server/dist/oauth/host.js +8 -0
  102. package/server/dist/oauth/pages.js +148 -0
  103. package/server/dist/oauth/routes.js +370 -0
  104. package/server/dist/oauth/store.js +104 -0
  105. package/server/dist/push.js +147 -0
  106. package/server/dist/pushOutbox.js +164 -0
  107. package/server/dist/security-recover-cli.js +29 -0
  108. package/server/dist/server.js +108 -0
  109. package/server/dist/setup-cli.js +97 -0
  110. package/server/dist/sync/changeLog.js +170 -0
  111. package/server/dist/sync/mutations.js +144 -0
  112. package/server/dist/trustProxy.js +14 -0
  113. package/server/dist/version.js +6 -0
  114. package/server/package.json +6 -0
  115. package/web/dist/app-icon-180.png +0 -0
  116. package/web/dist/app-icon-192.png +0 -0
  117. package/web/dist/app-icon-512.png +0 -0
  118. package/web/dist/assets/index-B4e2oqbo.css +2 -0
  119. package/web/dist/assets/index-xx6TyOwB.js +32 -0
  120. package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
  121. package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
  122. package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
  123. package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
  124. package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
  125. package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
  126. package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
  127. package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
  128. package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
  129. package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
  130. package/web/dist/favicon-32.png +0 -0
  131. package/web/dist/index.html +40 -0
  132. package/web/dist/logo.svg +50 -0
  133. package/web/dist/manifest.webmanifest +64 -0
  134. package/web/dist/robots.txt +3 -0
  135. package/web/dist/sw.js +198 -0
  136. package/web/dist/theme-init.js +10 -0
@@ -0,0 +1,687 @@
1
+ import { db } from "../db/index.js";
2
+ import { articleText } from "./articleContent.js";
3
+ import { decodeEntities } from "./text.js";
4
+ export function findFeedByUrl(url) {
5
+ return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
6
+ }
7
+ export function findFeedById(id) {
8
+ return db.prepare("SELECT * FROM feeds WHERE id = ?").get(id);
9
+ }
10
+ export function createFeed(url, pollIntervalMinutes) {
11
+ const result = db
12
+ .prepare("INSERT INTO feeds (url, poll_interval_minutes) VALUES (?, ?)")
13
+ .run(url, pollIntervalMinutes);
14
+ return findFeedById(Number(result.lastInsertRowid));
15
+ }
16
+ export function subscribe(userId, feedId, label, folderId) {
17
+ const maxPosRow = db
18
+ .prepare("SELECT MAX(position) AS max_pos FROM subscriptions WHERE user_id = ?")
19
+ .get(userId);
20
+ const nextPosition = (maxPosRow?.max_pos ?? -1) + 1;
21
+ const result = db
22
+ .prepare(`INSERT INTO subscriptions (user_id, feed_id, label, folder_id, position) VALUES (?, ?, ?, ?, ?)
23
+ ON CONFLICT (user_id, feed_id) DO UPDATE SET
24
+ label = COALESCE(excluded.label, subscriptions.label),
25
+ folder_id = COALESCE(excluded.folder_id, subscriptions.folder_id)`)
26
+ .run(userId, feedId, label?.trim() || null, folderId ?? null, nextPosition);
27
+ return Number(result.lastInsertRowid);
28
+ }
29
+ export function unsubscribe(userId, feedId) {
30
+ db.transaction(() => {
31
+ db.prepare("DELETE FROM subscriptions WHERE user_id = ? AND feed_id = ?").run(userId, feedId);
32
+ removeFeedIfUnused(feedId);
33
+ })();
34
+ }
35
+ /**
36
+ * Once nobody subscribes to a feed, drop it with its items — except saved articles:
37
+ * their feed stays (unpolled) so the archive keeps its source and title.
38
+ */
39
+ export function removeFeedIfUnused(feedId) {
40
+ if (countFeedSubscribers(feedId) > 0)
41
+ return;
42
+ 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)
43
+ AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
44
+ WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run(feedId);
45
+ 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);
46
+ }
47
+ /** Feeds kept only for saved articles or annotated articles whose last bookmark and note are gone. */
48
+ export function removeUnusedFeeds() {
49
+ return db.prepare(`DELETE FROM feeds
50
+ WHERE is_system_inbox = 0
51
+ AND NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id)
52
+ AND NOT EXISTS (SELECT 1 FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.feed_id = feeds.id)
53
+ AND NOT EXISTS (SELECT 1 FROM items i JOIN item_notes n ON n.item_id = i.id WHERE i.feed_id = feeds.id)
54
+ AND NOT EXISTS (SELECT 1 FROM items i, editions e, json_each(e.item_ids) selected
55
+ WHERE i.feed_id = feeds.id AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))`).run().changes;
56
+ }
57
+ /** Subscribers can open every item of their feeds; anyone can open what they saved or added a note to. */
58
+ export function canAccessItem(userId, itemId) {
59
+ return Boolean(db.prepare(`SELECT 1 FROM items i
60
+ WHERE i.id = ?
61
+ AND (EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = i.feed_id AND s.user_id = ?)
62
+ OR EXISTS (SELECT 1 FROM item_bookmarks b WHERE b.item_id = i.id AND b.user_id = ?)
63
+ OR EXISTS (SELECT 1 FROM item_notes n WHERE n.item_id = i.id AND n.user_id = ?)
64
+ OR EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
65
+ AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(itemId, userId, userId, userId, userId));
66
+ }
67
+ export function listSubscriptionsForUser(userId) {
68
+ return db
69
+ .prepare(`SELECT
70
+ f.*,
71
+ fi.hash AS icon_hash,
72
+ s.id AS subscription_id,
73
+ NULLIF(TRIM(s.label), '') AS label,
74
+ s.folder_id AS folder_id,
75
+ fo.name AS folder_name,
76
+ s.position AS position,
77
+ s.full_text_mode AS full_text_mode,
78
+ s.notify AS notify,
79
+ s.badge AS badge,
80
+ (
81
+ SELECT COUNT(*) FROM items i
82
+ WHERE i.feed_id = f.id
83
+ AND NOT EXISTS (
84
+ SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?
85
+ )
86
+ ) AS unread_count
87
+ FROM subscriptions s
88
+ JOIN feeds f ON f.id = s.feed_id
89
+ LEFT JOIN feed_icons fi ON fi.feed_id = f.id
90
+ LEFT JOIN folders fo ON fo.id = s.folder_id
91
+ WHERE s.user_id = ?
92
+ ORDER BY s.position ASC, s.created_at ASC`)
93
+ .all(userId, userId);
94
+ }
95
+ export function isUserSubscribed(userId, feedId) {
96
+ const row = db
97
+ .prepare("SELECT COUNT(*) AS count FROM subscriptions WHERE user_id = ? AND feed_id = ?")
98
+ .get(userId, feedId);
99
+ return row.count > 0;
100
+ }
101
+ /** The accounts that subscribe to a feed. */
102
+ export function listSubscriberIds(feedId) {
103
+ return db.prepare("SELECT user_id FROM subscriptions WHERE feed_id = ?").all(feedId).map((row) => row.user_id);
104
+ }
105
+ export function listFeedsDueForPoll() {
106
+ return db
107
+ .prepare(`SELECT * FROM feeds
108
+ WHERE is_system_inbox = 0
109
+ AND EXISTS (SELECT 1 FROM subscriptions s WHERE s.feed_id = feeds.id)
110
+ AND (last_polled_at IS NULL
111
+ OR last_polled_at <= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-' || poll_interval_minutes || ' minutes'))`)
112
+ .all();
113
+ }
114
+ export function updateFeedAfterPoll(feedId, data) {
115
+ if (data.error) {
116
+ db.prepare(`UPDATE feeds SET
117
+ last_error = ?,
118
+ consecutive_errors = consecutive_errors + 1,
119
+ last_polled_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
120
+ WHERE id = ?`).run(data.error, feedId);
121
+ }
122
+ else {
123
+ db.prepare(`UPDATE feeds SET
124
+ title = COALESCE(?, title),
125
+ site_url = COALESCE(?, site_url),
126
+ etag = COALESCE(?, etag),
127
+ last_modified = COALESCE(?, last_modified),
128
+ last_error = NULL,
129
+ consecutive_errors = 0,
130
+ last_success_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now'),
131
+ last_polled_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
132
+ WHERE id = ?`).run(data.title ?? null, data.siteUrl ?? null, data.etag ?? null, data.lastModified ?? null, feedId);
133
+ }
134
+ }
135
+ export function upsertItems(feedId, items) {
136
+ const insert = db.prepare(`INSERT INTO items (feed_id, guid, title, link, content_snippet, content_html, published_at, image_url)
137
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
138
+ ON CONFLICT (feed_id, guid) DO NOTHING`);
139
+ const update = db.prepare(`UPDATE items SET
140
+ title = COALESCE(?, title),
141
+ link = COALESCE(?, link),
142
+ content_snippet = COALESCE(?, content_snippet),
143
+ content_html = COALESCE(?, content_html),
144
+ published_at = COALESCE(?, published_at),
145
+ image_url = COALESCE(?, image_url)
146
+ WHERE feed_id = ? AND guid = ?`);
147
+ const insertMany = db.transaction((rows) => {
148
+ let inserted = 0;
149
+ for (const row of rows) {
150
+ const values = [
151
+ feedId,
152
+ row.guid,
153
+ row.title ?? null,
154
+ row.link ?? null,
155
+ row.contentSnippet ?? null,
156
+ row.contentHtml ?? null,
157
+ row.publishedAt ?? null,
158
+ row.imageUrl ?? null,
159
+ ];
160
+ const result = insert.run(...values);
161
+ if (result.changes > 0) {
162
+ inserted++;
163
+ }
164
+ else {
165
+ const previous = db.prepare("SELECT content_html FROM items WHERE feed_id = ? AND guid = ?").get(feedId, row.guid);
166
+ const next = row.contentHtml;
167
+ // Some publishers replace complete feed bodies with short teasers on a later poll.
168
+ const retained = previous?.content_html && next && next.length < previous.content_html.length * 0.9 &&
169
+ articleText(next).length < articleText(previous.content_html).length * 0.9 ? previous.content_html : next ?? null;
170
+ update.run(row.title ?? null, row.link ?? null, row.contentSnippet ?? null, retained, row.publishedAt ?? null, row.imageUrl ?? null, feedId, row.guid);
171
+ }
172
+ }
173
+ return inserted;
174
+ });
175
+ return insertMany(items);
176
+ }
177
+ export function listItemsForUser(userId, opts = {}) {
178
+ // Saved articles stay visible without a subscription (e.g. after unsubscribing).
179
+ const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL)"];
180
+ const params = [];
181
+ if (opts.feedId) {
182
+ conditions.push("i.feed_id = ?");
183
+ params.push(opts.feedId);
184
+ }
185
+ if (opts.folderId) {
186
+ conditions.push("s.folder_id = ?");
187
+ params.push(opts.folderId);
188
+ }
189
+ if (opts.unreadOnly) {
190
+ conditions.push("r.item_id IS NULL");
191
+ }
192
+ if (opts.bookmarkedOnly) {
193
+ conditions.push("b.item_id IS NOT NULL");
194
+ }
195
+ // The word filter tidies the stream; it never hides what the user deliberately saved.
196
+ if (!opts.includeMuted && !opts.bookmarkedOnly) {
197
+ conditions.push(`NOT EXISTS (
198
+ SELECT 1 FROM user_muted_keywords m
199
+ WHERE m.user_id = ?
200
+ AND (
201
+ INSTR(LOWER(COALESCE(i.title, '')), LOWER(m.keyword)) > 0
202
+ OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(m.keyword)) > 0
203
+ )
204
+ )`);
205
+ params.push(userId);
206
+ }
207
+ if (opts.search) {
208
+ // Saved articles are searched in their archived full text too.
209
+ const fields = ["i.title", "i.content_snippet", ...(opts.bookmarkedOnly ? ["i.full_content_html", "i.content_html"] : [])];
210
+ conditions.push(`(${fields.map((field) => `INSTR(LOWER(COALESCE(${field}, '')), LOWER(?)) > 0`).join(" OR ")} OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0))`);
211
+ params.push(...fields.map(() => opts.search), userId, opts.search);
212
+ }
213
+ if (opts.since) {
214
+ conditions.push("i.created_at > ?");
215
+ params.push(opts.since);
216
+ }
217
+ // Feeds without a parseable publish date fall back to when FeedKeeper stored the item.
218
+ if (opts.publishedSince) {
219
+ conditions.push("COALESCE(julianday(i.published_at), julianday(i.created_at)) >= julianday(?)");
220
+ params.push(opts.publishedSince);
221
+ }
222
+ if (opts.publishedUntil) {
223
+ conditions.push("COALESCE(julianday(i.published_at), julianday(i.created_at)) <= julianday(?)");
224
+ params.push(opts.publishedUntil);
225
+ }
226
+ if (opts.before) {
227
+ conditions.push("(i.created_at < ? OR (i.created_at = ? AND i.id < ?))");
228
+ params.push(opts.before.createdAt, opts.before.createdAt, opts.before.id);
229
+ }
230
+ if (opts.after) {
231
+ conditions.push("(i.created_at > ? OR (i.created_at = ? AND i.id > ?))");
232
+ params.push(opts.after.createdAt, opts.after.createdAt, opts.after.id);
233
+ }
234
+ const limit = Math.min(opts.limit ?? 50, 200);
235
+ const offset = Math.max(opts.offset ?? 0, 0);
236
+ params.push(limit, offset);
237
+ const orderBy = opts.sortByAdded ? "i.created_at DESC, i.id DESC" : opts.bookmarkedOnly ? "b.created_at DESC, i.id DESC" : "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
238
+ // Page IDs before loading HTML. User/feed and user/item keys make these joins unique.
239
+ // Keep selection and loading in one statement so they share the same read snapshot.
240
+ return db
241
+ .prepare(`WITH page AS (
242
+ SELECT i.id
243
+ FROM items i
244
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
245
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
246
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
247
+ WHERE ${conditions.join(" AND ")}
248
+ ORDER BY ${orderBy}
249
+ LIMIT ? OFFSET ?
250
+ )
251
+ SELECT i.*,
252
+ COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
253
+ f.site_url AS feed_site_url,
254
+ f.url AS feed_url,
255
+ s.full_text_mode AS feed_full_text_mode,
256
+ f.icon_url AS feed_icon_url,
257
+ (r.item_id IS NOT NULL) AS read,
258
+ (b.item_id IS NOT NULL) AS bookmarked,
259
+ b.created_at AS bookmarked_at
260
+ FROM page
261
+ JOIN items i ON i.id = page.id
262
+ JOIN feeds f ON f.id = i.feed_id
263
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
264
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
265
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
266
+ ORDER BY ${orderBy}`)
267
+ .all(userId, userId, userId, ...params, userId, userId, userId);
268
+ }
269
+ export function findItemForUser(userId, itemId) {
270
+ return db.prepare(`SELECT i.*, COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
271
+ 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,
272
+ (r.item_id IS NOT NULL) AS read, (b.item_id IS NOT NULL) AS bookmarked, b.created_at AS bookmarked_at
273
+ FROM items i
274
+ JOIN feeds f ON f.id = i.feed_id
275
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
276
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
277
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
278
+ LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
279
+ WHERE i.id = ? AND (s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.item_id IS NOT NULL OR
280
+ EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected WHERE e.user_id = ?
281
+ AND selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now')))`).get(userId, userId, userId, userId, itemId, userId);
282
+ }
283
+ export function updateSubscriptionLabel(userId, feedId, label) {
284
+ db.prepare("UPDATE subscriptions SET label = ? WHERE user_id = ? AND feed_id = ?").run(label?.trim() || null, userId, feedId);
285
+ }
286
+ export function updateSubscriptionFolder(userId, feedId, folderId) {
287
+ db.prepare("UPDATE subscriptions SET folder_id = ? WHERE user_id = ? AND feed_id = ?").run(folderId, userId, feedId);
288
+ }
289
+ export function listFoldersForUser(userId) {
290
+ return db
291
+ .prepare(`SELECT
292
+ fo.*,
293
+ COUNT(DISTINCT s.feed_id) AS feed_count,
294
+ COALESCE(
295
+ (
296
+ SELECT COUNT(*)
297
+ FROM items i
298
+ JOIN subscriptions s2 ON s2.feed_id = i.feed_id AND s2.folder_id = fo.id AND s2.user_id = ?
299
+ WHERE NOT EXISTS (
300
+ SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?
301
+ )
302
+ ), 0
303
+ ) AS unread_count
304
+ FROM folders fo
305
+ LEFT JOIN subscriptions s ON s.folder_id = fo.id AND s.user_id = fo.user_id
306
+ WHERE fo.user_id = ?
307
+ GROUP BY fo.id
308
+ ORDER BY fo.name COLLATE NOCASE ASC`)
309
+ .all(userId, userId, userId);
310
+ }
311
+ export function findFolderByName(userId, name) {
312
+ return db
313
+ .prepare("SELECT * FROM folders WHERE user_id = ? AND name = ?")
314
+ .get(userId, name.trim());
315
+ }
316
+ export function findFolderById(userId, id) {
317
+ return db
318
+ .prepare("SELECT * FROM folders WHERE user_id = ? AND id = ?")
319
+ .get(userId, id);
320
+ }
321
+ export function createFolder(userId, name, iconSymbol) {
322
+ const trimmed = name.trim();
323
+ db.prepare("INSERT INTO folders (user_id, name, icon_symbol) VALUES (?, ?, ?) ON CONFLICT (user_id, name) DO NOTHING").run(userId, trimmed, iconSymbol ?? null);
324
+ return findFolderByName(userId, trimmed);
325
+ }
326
+ export function updateFolder(userId, id, updates) {
327
+ const data = typeof updates === "string" ? { name: updates } : updates;
328
+ const current = findFolderById(userId, id);
329
+ if (!current)
330
+ throw new Error("Folder not found");
331
+ const name = data.name !== undefined ? data.name.trim() : current.name;
332
+ const iconSymbol = data.iconSymbol !== undefined ? data.iconSymbol : current.icon_symbol;
333
+ db.prepare("UPDATE folders SET name = ?, icon_symbol = ? WHERE id = ? AND user_id = ?").run(name, iconSymbol, id, userId);
334
+ return findFolderById(userId, id);
335
+ }
336
+ export function deleteFolder(userId, id) {
337
+ db.prepare("DELETE FROM folders WHERE id = ? AND user_id = ?").run(id, userId);
338
+ }
339
+ export function updateFeedPollInterval(feedId, minutes) {
340
+ db.prepare("UPDATE feeds SET poll_interval_minutes = ? WHERE id = ?").run(minutes, feedId);
341
+ }
342
+ export function markAllRead(userId, filter) {
343
+ const feedId = typeof filter === "number" ? filter : filter?.feedId;
344
+ const folderId = typeof filter === "object" ? filter?.folderId : undefined;
345
+ const conditions = ["s.user_id = ?"];
346
+ const params = [userId, userId];
347
+ if (feedId) {
348
+ conditions.push("i.feed_id = ?");
349
+ params.push(feedId);
350
+ }
351
+ if (folderId) {
352
+ conditions.push("s.folder_id = ?");
353
+ params.push(folderId);
354
+ }
355
+ const result = db
356
+ .prepare(`INSERT INTO item_reads (user_id, item_id)
357
+ SELECT ?, i.id
358
+ FROM items i
359
+ JOIN subscriptions s ON s.feed_id = i.feed_id
360
+ WHERE ${conditions.join(" AND ")}
361
+ AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.user_id = s.user_id AND r.item_id = i.id)`)
362
+ .run(...params);
363
+ return result.changes;
364
+ }
365
+ export function markItemRead(userId, itemId) {
366
+ if (!canAccessItem(userId, itemId))
367
+ return 0;
368
+ return db.prepare("INSERT INTO item_reads (user_id, item_id) VALUES (?, ?) ON CONFLICT (user_id, item_id) DO NOTHING").run(userId, itemId).changes;
369
+ }
370
+ export function markItemUnread(userId, itemId) {
371
+ return db.prepare("DELETE FROM item_reads WHERE user_id = ? AND item_id = ?").run(userId, itemId).changes;
372
+ }
373
+ export function bookmarkItem(userId, itemId) {
374
+ return db.prepare(`INSERT INTO item_bookmarks (user_id, item_id)
375
+ SELECT ?, i.id FROM items i
376
+ JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
377
+ WHERE i.id = ?
378
+ ON CONFLICT (user_id, item_id) DO NOTHING`).run(userId, userId, itemId).changes;
379
+ }
380
+ export function unbookmarkItem(userId, itemId) {
381
+ return db.prepare("DELETE FROM item_bookmarks WHERE user_id = ? AND item_id = ?").run(userId, itemId).changes;
382
+ }
383
+ export function listMutedKeywords(userId) {
384
+ return db
385
+ .prepare("SELECT * FROM user_muted_keywords WHERE user_id = ? ORDER BY keyword ASC")
386
+ .all(userId);
387
+ }
388
+ export function addMutedKeyword(userId, keyword) {
389
+ const trimmed = keyword.trim().toLowerCase();
390
+ const insert = db.prepare("INSERT INTO user_muted_keywords (user_id, keyword) VALUES (?, ?) ON CONFLICT(user_id, keyword) DO NOTHING");
391
+ insert.run(userId, trimmed);
392
+ return db
393
+ .prepare("SELECT * FROM user_muted_keywords WHERE user_id = ? AND keyword = ?")
394
+ .get(userId, trimmed);
395
+ }
396
+ export function removeMutedKeyword(userId, keywordId) {
397
+ db.prepare("DELETE FROM user_muted_keywords WHERE id = ? AND user_id = ?").run(keywordId, userId);
398
+ }
399
+ export function reorderSubscriptions(userId, feedIds) {
400
+ const update = db.prepare("UPDATE subscriptions SET position = ? WHERE user_id = ? AND feed_id = ?");
401
+ const runTransaction = db.transaction(() => {
402
+ feedIds.forEach((feedId, index) => {
403
+ update.run(index, userId, feedId);
404
+ });
405
+ });
406
+ runTransaction();
407
+ }
408
+ export function findItemById(id) {
409
+ return db.prepare("SELECT * FROM items WHERE id = ?").get(id);
410
+ }
411
+ export function updateItemFullContent(id, fullContentHtml) {
412
+ db.prepare("UPDATE items SET full_content_html = ? WHERE id = ?").run(fullContentHtml, id);
413
+ }
414
+ /** Apply a per-item change to many items atomically; returns how many rows changed. */
415
+ export function applyToItems(itemIds, change) {
416
+ return db.transaction(() => [...new Set(itemIds)].reduce((total, itemId) => total + change(itemId), 0))();
417
+ }
418
+ export function updateSubscriptionFullTextMode(userId, feedId, mode) {
419
+ db.prepare("UPDATE subscriptions SET full_text_mode = ? WHERE user_id = ? AND feed_id = ?").run(mode, userId, feedId);
420
+ }
421
+ export function updateSubscriptionNotify(userId, feedId, notify) {
422
+ db.prepare("UPDATE subscriptions SET notify = ? WHERE user_id = ? AND feed_id = ?").run(notify ? 1 : 0, userId, feedId);
423
+ }
424
+ export function updateSubscriptionBadge(userId, feedId, badge) {
425
+ db.prepare("UPDATE subscriptions SET badge = ? WHERE user_id = ? AND feed_id = ?").run(badge ? 1 : 0, userId, feedId);
426
+ }
427
+ /** The user's full-text mode for a feed, or undefined when they are not subscribed. */
428
+ export function findSubscriptionFullTextMode(userId, feedId) {
429
+ return db
430
+ .prepare("SELECT full_text_mode FROM subscriptions WHERE user_id = ? AND feed_id = ?")
431
+ .get(userId, feedId)?.full_text_mode;
432
+ }
433
+ /** Count a consent wall for a feed; after `threshold` in a row the feed is marked as blocked. */
434
+ export function recordFullTextBlock(feedId, threshold) {
435
+ db.prepare(`UPDATE feeds SET
436
+ full_text_blocks = full_text_blocks + 1,
437
+ full_text_blocked_at = CASE WHEN full_text_blocks + 1 >= ? THEN strftime('%Y-%m-%dT%H:%M:%fZ', 'now') ELSE full_text_blocked_at END
438
+ WHERE id = ?`).run(threshold, feedId);
439
+ }
440
+ export function clearFullTextBlock(feedId) {
441
+ db.prepare("UPDATE feeds SET full_text_blocks = 0, full_text_blocked_at = NULL WHERE id = ? AND full_text_blocks > 0").run(feedId);
442
+ }
443
+ export function countFeedSubscribers(feedId) {
444
+ return db.prepare("SELECT COUNT(*) AS count FROM subscriptions WHERE feed_id = ?").get(feedId).count;
445
+ }
446
+ /** Point a feed at a new URL in place, keeping its items; resets fetch state so the next poll starts fresh. */
447
+ export function replaceFeedUrl(feedId, url) {
448
+ db.prepare(`UPDATE feeds SET url = ?, etag = NULL, last_modified = NULL, last_error = NULL, consecutive_errors = 0,
449
+ full_text_blocks = 0, full_text_blocked_at = NULL
450
+ WHERE id = ?`).run(url, feedId);
451
+ }
452
+ /** Move a user's subscription to another feed, keeping label, folder, order and full-text mode. */
453
+ export function moveSubscription(userId, fromFeedId, toFeedId) {
454
+ db.transaction(() => {
455
+ db.prepare("UPDATE subscriptions SET feed_id = ? WHERE user_id = ? AND feed_id = ?").run(toFeedId, userId, fromFeedId);
456
+ removeFeedIfUnused(fromFeedId);
457
+ })();
458
+ }
459
+ export function updateFeedIcon(feedId, iconUrl) {
460
+ db.prepare("UPDATE feeds SET icon_url = ?, icon_checked_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(iconUrl, feedId);
461
+ }
462
+ /** Fix item titles, snippets, feed titles and labels stored before entity decoding existed; cheap once nothing matches. */
463
+ export function repairEncodedText() {
464
+ const rows = db
465
+ .prepare("SELECT id, title, content_snippet FROM items WHERE title LIKE '%&%;%' OR content_snippet LIKE '%&%;%'")
466
+ .all();
467
+ const update = db.prepare("UPDATE items SET title = ?, content_snippet = ? WHERE id = ?");
468
+ let fixed = 0;
469
+ db.transaction(() => {
470
+ for (const row of rows) {
471
+ const title = decodeEntities(row.title);
472
+ const snippet = decodeEntities(row.content_snippet);
473
+ if (title !== row.title || snippet !== row.content_snippet) {
474
+ update.run(title ?? null, snippet ?? null, row.id);
475
+ fixed++;
476
+ }
477
+ }
478
+ for (const feed of db.prepare("SELECT id, title FROM feeds WHERE title LIKE '%&%;%'").all()) {
479
+ const title = decodeEntities(feed.title);
480
+ if (title !== feed.title) {
481
+ db.prepare("UPDATE feeds SET title = ? WHERE id = ?").run(title, feed.id);
482
+ fixed++;
483
+ }
484
+ }
485
+ for (const sub of db.prepare("SELECT id, label FROM subscriptions WHERE label LIKE '%&%;%'").all()) {
486
+ const label = decodeEntities(sub.label);
487
+ if (label !== sub.label) {
488
+ db.prepare("UPDATE subscriptions SET label = ? WHERE id = ?").run(label, sub.id);
489
+ fixed++;
490
+ }
491
+ }
492
+ })();
493
+ return fixed;
494
+ }
495
+ // ---------------------------------------------------------------------------
496
+ // Article notes
497
+ // ---------------------------------------------------------------------------
498
+ export function findItemNote(userId, itemId) {
499
+ return db
500
+ .prepare("SELECT * FROM item_notes WHERE user_id = ? AND item_id = ?")
501
+ .get(userId, itemId);
502
+ }
503
+ export function noteConflictState(userId, itemId) {
504
+ const note = findItemNote(userId, itemId);
505
+ if (note)
506
+ return {
507
+ itemId, content: note.content, revision: note.revision,
508
+ createdAt: note.created_at, updatedAt: note.updated_at, deleted: false,
509
+ };
510
+ const previous = db.prepare("SELECT revision, updated_at FROM item_note_revisions WHERE user_id = ? AND item_id = ?").get(userId, itemId);
511
+ return {
512
+ itemId, content: null, revision: previous?.revision ?? 0,
513
+ createdAt: null, updatedAt: previous?.updated_at ?? null, deleted: true,
514
+ };
515
+ }
516
+ export function setItemNote(userId, itemId, content, expectedRevision) {
517
+ return db.transaction(() => {
518
+ const existing = findItemNote(userId, itemId);
519
+ const current = noteConflictState(userId, itemId);
520
+ if (expectedRevision !== undefined && current.revision !== expectedRevision) {
521
+ return { conflict: current };
522
+ }
523
+ if (existing) {
524
+ const nextRev = existing.revision + 1;
525
+ const now = new Date().toISOString();
526
+ db.prepare("UPDATE item_notes SET content = ?, revision = ?, updated_at = ? WHERE user_id = ? AND item_id = ?").run(content, nextRev, now, userId, itemId);
527
+ return { note: findItemNote(userId, itemId) };
528
+ }
529
+ else {
530
+ const now = new Date().toISOString();
531
+ db.prepare("INSERT INTO item_notes (user_id, item_id, content, revision, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)").run(userId, itemId, content, current.revision + 1, now, now);
532
+ return { note: findItemNote(userId, itemId) };
533
+ }
534
+ })();
535
+ }
536
+ export function deleteItemNote(userId, itemId, expectedRevision) {
537
+ return db.transaction(() => {
538
+ const current = noteConflictState(userId, itemId);
539
+ if (expectedRevision !== undefined && expectedRevision !== current.revision)
540
+ return { conflict: current };
541
+ const deleted = db.prepare("DELETE FROM item_notes WHERE user_id = ? AND item_id = ?").run(userId, itemId).changes > 0;
542
+ return { deleted };
543
+ })();
544
+ }
545
+ export function listNotesForUser(userId) {
546
+ return db
547
+ .prepare(`SELECT n.*, i.title AS item_title, i.link AS item_link, f.title AS feed_title
548
+ FROM item_notes n
549
+ JOIN items i ON i.id = n.item_id
550
+ LEFT JOIN feeds f ON f.id = i.feed_id
551
+ WHERE n.user_id = ?
552
+ ORDER BY n.updated_at DESC`)
553
+ .all(userId);
554
+ }
555
+ export function itemsWithNotes(userId, itemIds) {
556
+ if (itemIds.length === 0)
557
+ return new Set();
558
+ const placeholders = itemIds.map(() => "?").join(",");
559
+ const rows = db
560
+ .prepare(`SELECT item_id FROM item_notes WHERE user_id = ? AND item_id IN (${placeholders})`)
561
+ .all(userId, ...itemIds);
562
+ return new Set(rows.map((row) => row.item_id));
563
+ }
564
+ // ---------------------------------------------------------------------------
565
+ // Server editions use the native service; keep existing imports compatible.
566
+ // ---------------------------------------------------------------------------
567
+ export { getEdition, publishEdition, deleteEdition } from "../native/editions.js";
568
+ export function formatFtsQuery(raw) {
569
+ const tokens = raw.match(/"[^"]*"|[^\s"]+/g) || [];
570
+ const sanitized = tokens
571
+ .map((token) => {
572
+ if (token.startsWith('"') && token.endsWith('"') && token.length > 2) {
573
+ const inner = token.slice(1, -1).replace(/"/g, '""').trim();
574
+ return inner ? `"${inner}"` : null;
575
+ }
576
+ const clean = token.replace(/[^a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF]/g, "");
577
+ if (!clean)
578
+ return null;
579
+ return `"${clean}"*`;
580
+ })
581
+ .filter(Boolean);
582
+ if (sanitized.length === 0)
583
+ return "";
584
+ return sanitized.join(" ");
585
+ }
586
+ /**
587
+ * True when the query has letters or digits that the full-text index cannot be asked for as typed (Japanese,
588
+ * Cyrillic, Greek, ...). `formatFtsQuery` drops them, so a query such as "RSS 新聞" would silently search for "RSS"
589
+ * alone; such queries are matched word by word as substrings instead. Quoted phrases are passed through as typed.
590
+ */
591
+ export function needsSubstringSearch(raw) {
592
+ const outsideQuotes = raw.replace(/"[^"]*"/g, " ");
593
+ return /(?![a-zA-Z0-9_\u00C0-\u024F\u1E00-\u1EFF])[\p{L}\p{N}]/u.test(outsideQuotes);
594
+ }
595
+ export function searchItemsForUser(userId, opts) {
596
+ const fts = needsSubstringSearch(opts.query) ? "" : formatFtsQuery(opts.query);
597
+ const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
598
+ const params = [];
599
+ if (opts.feedId) {
600
+ conditions.push("i.feed_id = ?");
601
+ params.push(opts.feedId);
602
+ }
603
+ if (opts.folderId) {
604
+ conditions.push("s.folder_id = ?");
605
+ params.push(opts.folderId);
606
+ }
607
+ if (opts.unreadOnly) {
608
+ conditions.push("r.item_id IS NULL");
609
+ }
610
+ if (opts.bookmarkedOnly) {
611
+ conditions.push("b.item_id IS NOT NULL");
612
+ }
613
+ // Search filter (FTS on items + user notes)
614
+ if (fts) {
615
+ conditions.push(`(
616
+ i.id IN (SELECT rowid FROM items_fts WHERE items_fts MATCH ?)
617
+ OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
618
+ )`);
619
+ params.push(fts, userId, opts.query);
620
+ }
621
+ else {
622
+ // Every word has to occur somewhere in the article or in the person's note.
623
+ const words = opts.query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
624
+ for (const word of words) {
625
+ conditions.push(`(
626
+ INSTR(LOWER(COALESCE(i.title, '')), LOWER(?)) > 0
627
+ OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(?)) > 0
628
+ OR INSTR(LOWER(COALESCE(i.content_html, '')), LOWER(?)) > 0
629
+ OR INSTR(LOWER(COALESCE(i.full_content_html, '')), LOWER(?)) > 0
630
+ OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
631
+ )`);
632
+ params.push(word, word, word, word, userId, word);
633
+ }
634
+ }
635
+ // Count total matches
636
+ const totalRow = db.prepare(`
637
+ SELECT COUNT(DISTINCT i.id) AS count
638
+ FROM items i
639
+ JOIN feeds f ON f.id = i.feed_id
640
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
641
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
642
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
643
+ LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
644
+ WHERE ${conditions.join(" AND ")}
645
+ `).get(userId, userId, userId, userId, ...params);
646
+ const total = totalRow?.count ?? 0;
647
+ const limit = Math.min(opts.limit ?? 30, 100);
648
+ const offset = Math.max(opts.offset ?? 0, 0);
649
+ let orderBy = "COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC, i.created_at DESC, i.id DESC";
650
+ if (opts.sort !== "date" && fts) {
651
+ orderBy = "COALESCE(fts.rank, 0) ASC, COALESCE(julianday(i.published_at), julianday(i.created_at)) DESC";
652
+ }
653
+ const querySql = `
654
+ SELECT DISTINCT i.*,
655
+ COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
656
+ f.site_url AS feed_site_url,
657
+ f.url AS feed_url,
658
+ s.full_text_mode AS feed_full_text_mode,
659
+ f.icon_url AS feed_icon_url,
660
+ (r.item_id IS NOT NULL) AS read,
661
+ (b.item_id IS NOT NULL) AS bookmarked,
662
+ b.created_at AS bookmarked_at
663
+ FROM items i
664
+ JOIN feeds f ON f.id = i.feed_id
665
+ LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
666
+ LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
667
+ LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
668
+ LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
669
+ ${opts.sort !== "date" && fts ? "LEFT JOIN items_fts fts ON fts.rowid = i.id AND items_fts MATCH ?" : ""}
670
+ WHERE ${conditions.join(" AND ")}
671
+ ORDER BY ${orderBy}
672
+ LIMIT ? OFFSET ?
673
+ `;
674
+ const queryParams = [
675
+ userId,
676
+ userId,
677
+ userId,
678
+ userId,
679
+ ...(opts.sort !== "date" && fts ? [fts] : []),
680
+ ...params,
681
+ limit,
682
+ offset,
683
+ ];
684
+ const items = db.prepare(querySql).all(...queryParams);
685
+ return { items, total };
686
+ }
687
+ //# sourceMappingURL=repository.js.map