feedkeeper 0.0.0-stage → 0.23.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/LICENSE +21 -0
- package/README.md +22 -2
- package/package.json +57 -4
- package/server/dist/api/archive.js +19 -0
- package/server/dist/api/auth.js +162 -0
- package/server/dist/api/feeds.js +207 -0
- package/server/dist/api/filters.js +32 -0
- package/server/dist/api/folders.js +53 -0
- package/server/dist/api/index.js +37 -0
- package/server/dist/api/items.js +129 -0
- package/server/dist/api/oauth.js +14 -0
- package/server/dist/api/onboarding.js +30 -0
- package/server/dist/api/push.js +51 -0
- package/server/dist/api/system.js +42 -0
- package/server/dist/api/tokens.js +28 -0
- package/server/dist/api/v1/index.js +178 -0
- package/server/dist/api/v1/resources.js +653 -0
- package/server/dist/api/v1/serialize.js +48 -0
- package/server/dist/app.js +100 -0
- package/server/dist/auth/avatar.js +13 -0
- package/server/dist/auth/capabilities.js +53 -0
- package/server/dist/auth/middleware.js +77 -0
- package/server/dist/auth/pairing.js +36 -0
- package/server/dist/auth/password.js +9 -0
- package/server/dist/auth/plan.js +58 -0
- package/server/dist/auth/security.js +117 -0
- package/server/dist/auth/securityRoutes.js +249 -0
- package/server/dist/auth/tokens.js +64 -0
- package/server/dist/auth/users.js +54 -0
- package/server/dist/backup-cli.js +101 -0
- package/server/dist/cli.js +135 -0
- package/server/dist/clientAddress.js +26 -0
- package/server/dist/config.js +51 -0
- package/server/dist/db/index.js +40 -0
- package/server/dist/db/migrations/0001_init.sql +70 -0
- package/server/dist/db/migrations/0002_feed_health.sql +5 -0
- package/server/dist/db/migrations/0003_retention.sql +13 -0
- package/server/dist/db/migrations/0004_bookmarks_and_filters.sql +20 -0
- package/server/dist/db/migrations/0005_folders.sql +15 -0
- package/server/dist/db/migrations/0006_feed_position.sql +5 -0
- package/server/dist/db/migrations/0007_item_image_url.sql +2 -0
- package/server/dist/db/migrations/0008_item_content_html.sql +2 -0
- package/server/dist/db/migrations/0009_item_full_content_html.sql +1 -0
- package/server/dist/db/migrations/0010_token_scope.sql +3 -0
- package/server/dist/db/migrations/0011_user_avatars.sql +8 -0
- package/server/dist/db/migrations/0012_full_text_settings.sql +5 -0
- package/server/dist/db/migrations/0013_feed_icons.sql +4 -0
- package/server/dist/db/migrations/0014_bookmark_archive.sql +16 -0
- package/server/dist/db/migrations/0015_push.sql +13 -0
- package/server/dist/db/migrations/0016_feed_badge.sql +2 -0
- package/server/dist/db/migrations/0017_devices.sql +13 -0
- package/server/dist/db/migrations/0018_change_log.sql +166 -0
- package/server/dist/db/migrations/0019_native_extensions.sql +111 -0
- package/server/dist/db/migrations/0020_fts.sql +32 -0
- package/server/dist/db/migrations/0021_note_revisions.sql +33 -0
- package/server/dist/db/migrations/0022_high_resolution_icons.sql +2 -0
- package/server/dist/db/migrations/0023_server_editions.sql +81 -0
- package/server/dist/db/migrations/0024_inbox.sql +5 -0
- package/server/dist/db/migrations/0025_edition_overview.sql +2 -0
- package/server/dist/db/migrations/0026_article_content.sql +43 -0
- package/server/dist/db/migrations/0027_oauth.sql +38 -0
- package/server/dist/db/migrations/0028_user_intro.sql +2 -0
- package/server/dist/db/migrations/0029_account_security.sql +35 -0
- package/server/dist/db/migrations/0030_feed_poll_jobs.sql +15 -0
- package/server/dist/db/migrations/0031_push_outbox.sql +19 -0
- package/server/dist/feedkeeper.js +38 -0
- package/server/dist/feeds/archive.js +334 -0
- package/server/dist/feeds/articleContent.js +26 -0
- package/server/dist/feeds/cleanup.js +179 -0
- package/server/dist/feeds/discovery.js +153 -0
- package/server/dist/feeds/extractor.js +208 -0
- package/server/dist/feeds/feedIcon.js +171 -0
- package/server/dist/feeds/fetcher.js +217 -0
- package/server/dist/feeds/fullText.js +91 -0
- package/server/dist/feeds/fullTextQueue.js +159 -0
- package/server/dist/feeds/icon.js +97 -0
- package/server/dist/feeds/imageCache.js +266 -0
- package/server/dist/feeds/imageCacheSettings.js +19 -0
- package/server/dist/feeds/imageTypes.js +16 -0
- package/server/dist/feeds/inbox.js +128 -0
- package/server/dist/feeds/opml.js +190 -0
- package/server/dist/feeds/pollQueue.js +301 -0
- package/server/dist/feeds/pollSettings.js +23 -0
- package/server/dist/feeds/poller.js +222 -0
- package/server/dist/feeds/repository.js +674 -0
- package/server/dist/feeds/requestGate.js +99 -0
- package/server/dist/feeds/service.js +162 -0
- package/server/dist/feeds/ssrfGuard.js +98 -0
- package/server/dist/feeds/text.js +35 -0
- package/server/dist/index.js +15 -0
- package/server/dist/mcp/digest.js +73 -0
- package/server/dist/mcp/http.js +46 -0
- package/server/dist/mcp/items.js +73 -0
- package/server/dist/mcp/promptsAndResources.js +43 -0
- package/server/dist/mcp/server.js +547 -0
- package/server/dist/native/editionSelection.js +127 -0
- package/server/dist/native/editionText.js +15 -0
- package/server/dist/native/editions.js +218 -0
- package/server/dist/native/preferences.js +66 -0
- package/server/dist/native/requests.js +35 -0
- package/server/dist/oauth/constants.js +6 -0
- package/server/dist/oauth/host.js +8 -0
- package/server/dist/oauth/pages.js +148 -0
- package/server/dist/oauth/routes.js +361 -0
- package/server/dist/oauth/store.js +104 -0
- package/server/dist/push.js +147 -0
- package/server/dist/pushOutbox.js +164 -0
- package/server/dist/security-recover-cli.js +29 -0
- package/server/dist/server.js +108 -0
- package/server/dist/setup-cli.js +97 -0
- package/server/dist/sync/changeLog.js +170 -0
- package/server/dist/sync/mutations.js +144 -0
- package/server/dist/trustProxy.js +14 -0
- package/server/dist/version.js +6 -0
- package/server/package.json +6 -0
- package/web/dist/app-icon-180.png +0 -0
- package/web/dist/app-icon-192.png +0 -0
- package/web/dist/app-icon-512.png +0 -0
- package/web/dist/assets/index-B4e2oqbo.css +2 -0
- package/web/dist/assets/index-xx6TyOwB.js +32 -0
- package/web/dist/assets/inter-latin-400-normal-C38fXH4l.woff2 +0 -0
- package/web/dist/assets/inter-latin-400-normal-CyCys3Eg.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-BL9OpVg8.woff +0 -0
- package/web/dist/assets/inter-latin-500-normal-Cerq10X2.woff2 +0 -0
- package/web/dist/assets/inter-latin-600-normal-CiBQ2DWP.woff +0 -0
- package/web/dist/assets/inter-latin-600-normal-LgqL8muc.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-4f0koTD-.woff2 +0 -0
- package/web/dist/assets/manrope-latin-600-normal-BqgrALkZ.woff +0 -0
- package/web/dist/assets/manrope-latin-700-normal-BZp_XxE4.woff2 +0 -0
- package/web/dist/assets/manrope-latin-700-normal-DGRFkw-m.woff +0 -0
- package/web/dist/favicon-32.png +0 -0
- package/web/dist/index.html +40 -0
- package/web/dist/logo.svg +50 -0
- package/web/dist/manifest.webmanifest +64 -0
- package/web/dist/robots.txt +3 -0
- package/web/dist/sw.js +198 -0
- package/web/dist/theme-init.js +10 -0
|
@@ -0,0 +1,674 @@
|
|
|
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" : "i.published_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
|
+
export function searchItemsForUser(userId, opts) {
|
|
587
|
+
const fts = formatFtsQuery(opts.query);
|
|
588
|
+
const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
|
|
589
|
+
const params = [];
|
|
590
|
+
if (opts.feedId) {
|
|
591
|
+
conditions.push("i.feed_id = ?");
|
|
592
|
+
params.push(opts.feedId);
|
|
593
|
+
}
|
|
594
|
+
if (opts.folderId) {
|
|
595
|
+
conditions.push("s.folder_id = ?");
|
|
596
|
+
params.push(opts.folderId);
|
|
597
|
+
}
|
|
598
|
+
if (opts.unreadOnly) {
|
|
599
|
+
conditions.push("r.item_id IS NULL");
|
|
600
|
+
}
|
|
601
|
+
if (opts.bookmarkedOnly) {
|
|
602
|
+
conditions.push("b.item_id IS NOT NULL");
|
|
603
|
+
}
|
|
604
|
+
// Search filter (FTS on items + user notes)
|
|
605
|
+
if (fts) {
|
|
606
|
+
conditions.push(`(
|
|
607
|
+
i.id IN (SELECT rowid FROM items_fts WHERE items_fts MATCH ?)
|
|
608
|
+
OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
|
|
609
|
+
)`);
|
|
610
|
+
params.push(fts, userId, opts.query);
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
conditions.push(`(
|
|
614
|
+
INSTR(LOWER(COALESCE(i.title, '')), LOWER(?)) > 0
|
|
615
|
+
OR INSTR(LOWER(COALESCE(i.content_snippet, '')), LOWER(?)) > 0
|
|
616
|
+
OR INSTR(LOWER(COALESCE(i.content_html, '')), LOWER(?)) > 0
|
|
617
|
+
OR INSTR(LOWER(COALESCE(i.full_content_html, '')), LOWER(?)) > 0
|
|
618
|
+
OR i.id IN (SELECT item_id FROM item_notes WHERE user_id = ? AND INSTR(LOWER(content), LOWER(?)) > 0)
|
|
619
|
+
)`);
|
|
620
|
+
params.push(opts.query, opts.query, opts.query, opts.query, userId, opts.query);
|
|
621
|
+
}
|
|
622
|
+
// Count total matches
|
|
623
|
+
const totalRow = db.prepare(`
|
|
624
|
+
SELECT COUNT(DISTINCT i.id) AS count
|
|
625
|
+
FROM items i
|
|
626
|
+
JOIN feeds f ON f.id = i.feed_id
|
|
627
|
+
LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
628
|
+
LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
|
|
629
|
+
LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
|
|
630
|
+
LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
|
|
631
|
+
WHERE ${conditions.join(" AND ")}
|
|
632
|
+
`).get(userId, userId, userId, userId, ...params);
|
|
633
|
+
const total = totalRow?.count ?? 0;
|
|
634
|
+
const limit = Math.min(opts.limit ?? 30, 100);
|
|
635
|
+
const offset = Math.max(opts.offset ?? 0, 0);
|
|
636
|
+
let orderBy = "i.published_at DESC, i.created_at DESC, i.id DESC";
|
|
637
|
+
if (opts.sort !== "date" && fts) {
|
|
638
|
+
orderBy = "COALESCE(fts.rank, 0) ASC, i.published_at DESC";
|
|
639
|
+
}
|
|
640
|
+
const querySql = `
|
|
641
|
+
SELECT DISTINCT i.*,
|
|
642
|
+
COALESCE(NULLIF(TRIM(s.label), ''), NULLIF(TRIM(f.title), ''), f.url) AS feed_title,
|
|
643
|
+
f.site_url AS feed_site_url,
|
|
644
|
+
f.url AS feed_url,
|
|
645
|
+
s.full_text_mode AS feed_full_text_mode,
|
|
646
|
+
f.icon_url AS feed_icon_url,
|
|
647
|
+
(r.item_id IS NOT NULL) AS read,
|
|
648
|
+
(b.item_id IS NOT NULL) AS bookmarked,
|
|
649
|
+
b.created_at AS bookmarked_at
|
|
650
|
+
FROM items i
|
|
651
|
+
JOIN feeds f ON f.id = i.feed_id
|
|
652
|
+
LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
|
|
653
|
+
LEFT JOIN item_reads r ON r.item_id = i.id AND r.user_id = ?
|
|
654
|
+
LEFT JOIN item_bookmarks b ON b.item_id = i.id AND b.user_id = ?
|
|
655
|
+
LEFT JOIN item_notes n ON n.item_id = i.id AND n.user_id = ?
|
|
656
|
+
${opts.sort !== "date" && fts ? "LEFT JOIN items_fts fts ON fts.rowid = i.id AND items_fts MATCH ?" : ""}
|
|
657
|
+
WHERE ${conditions.join(" AND ")}
|
|
658
|
+
ORDER BY ${orderBy}
|
|
659
|
+
LIMIT ? OFFSET ?
|
|
660
|
+
`;
|
|
661
|
+
const queryParams = [
|
|
662
|
+
userId,
|
|
663
|
+
userId,
|
|
664
|
+
userId,
|
|
665
|
+
userId,
|
|
666
|
+
...(opts.sort !== "date" && fts ? [fts] : []),
|
|
667
|
+
...params,
|
|
668
|
+
limit,
|
|
669
|
+
offset,
|
|
670
|
+
];
|
|
671
|
+
const items = db.prepare(querySql).all(...queryParams);
|
|
672
|
+
return { items, total };
|
|
673
|
+
}
|
|
674
|
+
//# sourceMappingURL=repository.js.map
|