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.
- 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 +167 -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/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 +21 -0
- package/server/dist/auth/security.js +126 -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/db/migrations/0032_normalize_published_at.sql +12 -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/dates.js +21 -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 +223 -0
- package/server/dist/feeds/repository.js +687 -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/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 +370 -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,12 @@
|
|
|
1
|
+
-- Migration 0032: publish dates that are not dates.
|
|
2
|
+
--
|
|
3
|
+
-- Entries whose feed gave a date that could not be read ("gestern", localised weekday names, an empty string) had
|
|
4
|
+
-- the raw text stored. Compared as text it sorted above every real date and never aged. Dates before 1991 and
|
|
5
|
+
-- dates more than a day ahead are placeholders (1970-01-01, year 9999). All of them become NULL, which makes the
|
|
6
|
+
-- app use the time it stored the article.
|
|
7
|
+
UPDATE items SET published_at = NULL
|
|
8
|
+
WHERE published_at IS NOT NULL AND julianday(published_at) IS NULL;
|
|
9
|
+
|
|
10
|
+
UPDATE items SET published_at = NULL
|
|
11
|
+
WHERE published_at IS NOT NULL
|
|
12
|
+
AND (julianday(published_at) < julianday('1991-01-01') OR julianday(published_at) > julianday('now', '+1 day'));
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { HELP, UsageError, parseArgs, prepareEnvironment } from "./cli.js";
|
|
5
|
+
import { APP_VERSION } from "./version.js";
|
|
6
|
+
// Everything that reads the settings is imported only after the environment is complete: the configuration
|
|
7
|
+
// module validates SESSION_SECRET the moment it loads.
|
|
8
|
+
async function main() {
|
|
9
|
+
const options = parseArgs(process.argv.slice(2));
|
|
10
|
+
if (options.action === "help")
|
|
11
|
+
return void console.log(HELP);
|
|
12
|
+
if (options.action === "version")
|
|
13
|
+
return void console.log(APP_VERSION);
|
|
14
|
+
const envFile = resolve(process.cwd(), ".env");
|
|
15
|
+
if (existsSync(envFile))
|
|
16
|
+
process.loadEnvFile(envFile);
|
|
17
|
+
const prepared = prepareEnvironment(options, process.env);
|
|
18
|
+
if (options.action === "backup" || options.action === "restore") {
|
|
19
|
+
process.argv.splice(2, process.argv.length, ...options.forwarded);
|
|
20
|
+
await import("./backup-cli.js");
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
console.log(`[feedkeeper] version ${APP_VERSION}, data in ${prepared.dataDir}`);
|
|
24
|
+
if (prepared.secretCreated)
|
|
25
|
+
console.log(`[feedkeeper] created a session secret at ${prepared.secretPath}; keep it with your backups`);
|
|
26
|
+
await import("./server.js");
|
|
27
|
+
}
|
|
28
|
+
main().catch((error) => {
|
|
29
|
+
if (error instanceof UsageError) {
|
|
30
|
+
console.error(`feedkeeper: ${error.message}\nRun "feedkeeper --help" for usage.`);
|
|
31
|
+
process.exitCode = 2;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
console.error(error instanceof Error ? error.message : error);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
//# sourceMappingURL=feedkeeper.js.map
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { link, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
import { db } from "../db/index.js";
|
|
7
|
+
import { fetchImage } from "./fetcher.js";
|
|
8
|
+
import { loadFullText } from "./fullText.js";
|
|
9
|
+
import { canAccessItem, findItemById, findItemForUser } from "./repository.js";
|
|
10
|
+
import { decodeEntities } from "./text.js";
|
|
11
|
+
import { ImageCache, ImageCacheBusyError } from "./imageCache.js";
|
|
12
|
+
import { detectImageType } from "./imageTypes.js";
|
|
13
|
+
export { detectImageType } from "./imageTypes.js";
|
|
14
|
+
const MAX_IMAGES_PER_ARTICLE = 30;
|
|
15
|
+
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
16
|
+
const FILE_NAME = /^\d+-[0-9a-f]{16}\.(jpg|png|webp|gif|avif)$/;
|
|
17
|
+
let imageCache;
|
|
18
|
+
let imageCacheTimer;
|
|
19
|
+
let imageCacheStopping = false;
|
|
20
|
+
let imageCachePruning = false;
|
|
21
|
+
const archiveWrites = new Set();
|
|
22
|
+
async function storeArchivedImage(destination, image) {
|
|
23
|
+
const temporary = `${destination}.tmp`;
|
|
24
|
+
archiveWrites.add(temporary);
|
|
25
|
+
try {
|
|
26
|
+
await unlink(temporary).catch((error) => { if (error.code !== "ENOENT")
|
|
27
|
+
throw error; });
|
|
28
|
+
if (image.path)
|
|
29
|
+
await link(image.path, temporary).catch(() => writeFile(temporary, image.buffer, { mode: 0o600 }));
|
|
30
|
+
else
|
|
31
|
+
await writeFile(temporary, image.buffer, { mode: 0o600 });
|
|
32
|
+
await rename(temporary, destination);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
archiveWrites.delete(temporary);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function sharedImages() {
|
|
39
|
+
if (!imageCache) {
|
|
40
|
+
imageCache = new ImageCache(config.imageCache);
|
|
41
|
+
imageCacheTimer = setInterval(() => {
|
|
42
|
+
if (imageCachePruning)
|
|
43
|
+
return;
|
|
44
|
+
imageCachePruning = true;
|
|
45
|
+
void imageCache.prune().catch(() => undefined).then(() => {
|
|
46
|
+
console.log(JSON.stringify({ event: "image_cache", ...imageCache.metrics() }));
|
|
47
|
+
}).finally(() => { imageCachePruning = false; });
|
|
48
|
+
}, 60_000);
|
|
49
|
+
imageCacheTimer.unref();
|
|
50
|
+
}
|
|
51
|
+
return imageCache;
|
|
52
|
+
}
|
|
53
|
+
export async function stopImageCache() {
|
|
54
|
+
imageCacheStopping = true;
|
|
55
|
+
if (imageCacheTimer)
|
|
56
|
+
clearInterval(imageCacheTimer);
|
|
57
|
+
await imageCache?.close();
|
|
58
|
+
}
|
|
59
|
+
function sharedImage(item, url) {
|
|
60
|
+
if (imageCacheStopping)
|
|
61
|
+
return Promise.reject(new ImageCacheBusyError());
|
|
62
|
+
const archived = db.prepare("SELECT file_name FROM archived_images WHERE item_id = ? AND source_url = ?").get(item.id, url);
|
|
63
|
+
// An old saved snapshot must not replace a newer article's image at the same URL.
|
|
64
|
+
const scope = archived ? `${item.feed_id}:archive:${item.id}` : item.feed_id;
|
|
65
|
+
return sharedImages().get(scope, url, async (signal) => {
|
|
66
|
+
if (archived) {
|
|
67
|
+
const path = join(archiveDir(), archived.file_name);
|
|
68
|
+
try {
|
|
69
|
+
const buffer = await readFile(path);
|
|
70
|
+
if (buffer.length <= MAX_IMAGE_BYTES && detectImageType(buffer))
|
|
71
|
+
return { buffer, path };
|
|
72
|
+
}
|
|
73
|
+
catch { /* Recover a missing archived file from its source. */ }
|
|
74
|
+
}
|
|
75
|
+
return fetchImage(url, MAX_IMAGE_BYTES, signal);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
function archiveDir() {
|
|
79
|
+
return resolve(config.archivePath);
|
|
80
|
+
}
|
|
81
|
+
function srcAttribute(tag) {
|
|
82
|
+
const match = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(tag);
|
|
83
|
+
if (!match)
|
|
84
|
+
return null;
|
|
85
|
+
const value = match[1] ?? match[2] ?? match[3] ?? "";
|
|
86
|
+
const start = match.index + match[0].indexOf(value, match[0].indexOf("="));
|
|
87
|
+
return { value, start, end: start + value.length };
|
|
88
|
+
}
|
|
89
|
+
function resolveImageUrl(raw, baseUrl) {
|
|
90
|
+
try {
|
|
91
|
+
const url = new URL(decodeEntities(raw.trim()), baseUrl ?? undefined);
|
|
92
|
+
return url.protocol === "https:" || url.protocol === "http:" ? url.toString() : null;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** Image URLs an article shows, in order, without tracking pixels. */
|
|
99
|
+
export function articleImageUrls(html, baseUrl) {
|
|
100
|
+
const urls = new Set();
|
|
101
|
+
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
102
|
+
if (/\swidth\s*=\s*["']?1["'\s>]/i.test(tag) && /\sheight\s*=\s*["']?1["'\s>]/i.test(tag))
|
|
103
|
+
continue;
|
|
104
|
+
const src = srcAttribute(tag);
|
|
105
|
+
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
106
|
+
if (url)
|
|
107
|
+
urls.add(url);
|
|
108
|
+
}
|
|
109
|
+
return [...urls];
|
|
110
|
+
}
|
|
111
|
+
/** Replace the `src` of every image for which `replacement` knows a new address. */
|
|
112
|
+
function rewriteImageSources(html, baseUrl, replacement) {
|
|
113
|
+
return html.replace(/<img\b[^>]*>/gi, (tag) => {
|
|
114
|
+
const src = srcAttribute(tag);
|
|
115
|
+
const url = src && resolveImageUrl(src.value, baseUrl);
|
|
116
|
+
const next = url ? replacement(url) : undefined;
|
|
117
|
+
return src && next ? `${tag.slice(0, src.start)}${next}${tag.slice(src.end)}` : tag;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Point archived images in article HTML at their local copies under `origin`. */
|
|
121
|
+
export function rewriteArchivedImages(html, baseUrl, images, origin, apiBase = "/api") {
|
|
122
|
+
if (images.size === 0)
|
|
123
|
+
return html;
|
|
124
|
+
return rewriteImageSources(html, baseUrl, (url) => {
|
|
125
|
+
const id = images.get(url);
|
|
126
|
+
return id ? archivedImageUrl(origin, id, apiBase) : undefined;
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Absolute, so the web app treats archived images like any other image URL. */
|
|
130
|
+
export function archivedImageUrl(origin, imageId, apiBase = "/api") {
|
|
131
|
+
return `${origin.replace(/\/$/, "")}${apiBase}/archive/images/${imageId}`;
|
|
132
|
+
}
|
|
133
|
+
function hasBookmark(itemId) {
|
|
134
|
+
return db.prepare("SELECT user_id FROM item_bookmarks WHERE item_id = ? LIMIT 1").get(itemId)?.user_id;
|
|
135
|
+
}
|
|
136
|
+
/** Keep a saved article readable for good: fetch its full text and store its images locally. */
|
|
137
|
+
export async function archiveItem(itemId) {
|
|
138
|
+
const userId = hasBookmark(itemId);
|
|
139
|
+
if (userId === undefined)
|
|
140
|
+
return;
|
|
141
|
+
// Respects per-feed full-text settings and consent walls; falls back to the feed content.
|
|
142
|
+
await loadFullText(userId, itemId).catch(() => undefined);
|
|
143
|
+
const item = findItemById(itemId);
|
|
144
|
+
if (!item)
|
|
145
|
+
return;
|
|
146
|
+
const html = item.full_content_html ?? item.content_html ?? "";
|
|
147
|
+
const heroUrl = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
148
|
+
const urls = [...new Set([...(heroUrl ? [heroUrl] : []), ...articleImageUrls(html, item.link)])].slice(0, MAX_IMAGES_PER_ARTICLE);
|
|
149
|
+
const known = new Set(db.prepare("SELECT source_url FROM archived_images WHERE item_id = ?").all(itemId).map((row) => row.source_url));
|
|
150
|
+
const insert = db.prepare("INSERT INTO archived_images (item_id, source_url, file_name, mime_type, byte_size) VALUES (?, ?, ?, ?, ?) ON CONFLICT (item_id, source_url) DO NOTHING");
|
|
151
|
+
mkdirSync(archiveDir(), { recursive: true });
|
|
152
|
+
for (const url of urls) {
|
|
153
|
+
if (imageCacheStopping)
|
|
154
|
+
return;
|
|
155
|
+
if (known.has(url))
|
|
156
|
+
continue;
|
|
157
|
+
try {
|
|
158
|
+
const image = await sharedImage(item, url);
|
|
159
|
+
if (image === "unsupported")
|
|
160
|
+
continue;
|
|
161
|
+
const { buffer } = image;
|
|
162
|
+
const type = detectImageType(buffer);
|
|
163
|
+
if (!type)
|
|
164
|
+
continue;
|
|
165
|
+
const fileName = `${itemId}-${createHash("sha256").update(url).digest("hex").slice(0, 16)}.${type.ext}`;
|
|
166
|
+
const destination = join(archiveDir(), fileName);
|
|
167
|
+
await storeArchivedImage(destination, image);
|
|
168
|
+
// The last bookmark or article may have disappeared while the image was loading.
|
|
169
|
+
if (!hasBookmark(itemId) || !findItemById(itemId)) {
|
|
170
|
+
unlinkSync(destination);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
insert.run(itemId, url, fileName, type.mime, buffer.length);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Leave the durable bookmark pending so startup can finish it later.
|
|
177
|
+
if (error instanceof ImageCacheBusyError || imageCacheStopping)
|
|
178
|
+
return;
|
|
179
|
+
// A missing image should not stop the rest of the article from being archived.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (!imageCacheStopping)
|
|
183
|
+
db.prepare("UPDATE items SET archived_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') WHERE id = ?").run(itemId);
|
|
184
|
+
}
|
|
185
|
+
const queue = [];
|
|
186
|
+
let draining = false;
|
|
187
|
+
async function drain() {
|
|
188
|
+
if (draining)
|
|
189
|
+
return;
|
|
190
|
+
draining = true;
|
|
191
|
+
try {
|
|
192
|
+
while (queue.length > 0) {
|
|
193
|
+
const itemId = queue.shift();
|
|
194
|
+
await archiveItem(itemId).catch((error) => console.warn(`[archive] item ${itemId}:`, error));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
draining = false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/** Archive in the background, one article at a time, so saving stays instant. */
|
|
202
|
+
export function scheduleArchive(itemId) {
|
|
203
|
+
if (!queue.includes(itemId))
|
|
204
|
+
queue.push(itemId);
|
|
205
|
+
void drain();
|
|
206
|
+
}
|
|
207
|
+
/** Catch up on saved articles that were never archived, e.g. saved before this feature. */
|
|
208
|
+
export function scheduleMissingArchives() {
|
|
209
|
+
const rows = db.prepare("SELECT DISTINCT i.id FROM items i JOIN item_bookmarks b ON b.item_id = i.id WHERE i.archived_at IS NULL ORDER BY i.id").all();
|
|
210
|
+
rows.forEach((row) => scheduleArchive(row.id));
|
|
211
|
+
return rows.length;
|
|
212
|
+
}
|
|
213
|
+
/** Wait until queued archiving is done (used by tests and shutdown). */
|
|
214
|
+
export async function archiveIdle() {
|
|
215
|
+
while (draining || queue.length > 0)
|
|
216
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
217
|
+
}
|
|
218
|
+
/** Delete archived images of articles nobody has saved anymore, plus stray files. */
|
|
219
|
+
export function pruneArchive() {
|
|
220
|
+
const stale = db.prepare("SELECT a.id, a.item_id, a.file_name FROM archived_images a WHERE NOT EXISTS (SELECT 1 FROM item_bookmarks b WHERE b.item_id = a.item_id)").all();
|
|
221
|
+
db.transaction(() => {
|
|
222
|
+
for (const row of stale) {
|
|
223
|
+
db.prepare("DELETE FROM archived_images WHERE id = ?").run(row.id);
|
|
224
|
+
db.prepare("UPDATE items SET archived_at = NULL WHERE id = ?").run(row.item_id);
|
|
225
|
+
}
|
|
226
|
+
db.prepare("UPDATE items SET archived_at = NULL WHERE archived_at IS NOT NULL AND id NOT IN (SELECT item_id FROM item_bookmarks)").run();
|
|
227
|
+
})();
|
|
228
|
+
let removed = 0;
|
|
229
|
+
let files;
|
|
230
|
+
try {
|
|
231
|
+
files = readdirSync(archiveDir());
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
const kept = new Set(db.prepare("SELECT file_name FROM archived_images").all().map((row) => row.file_name));
|
|
237
|
+
for (const file of files) {
|
|
238
|
+
if (file.endsWith(".tmp") && FILE_NAME.test(file.slice(0, -4)) && !archiveWrites.has(join(archiveDir(), file))) {
|
|
239
|
+
try {
|
|
240
|
+
unlinkSync(join(archiveDir(), file));
|
|
241
|
+
removed++;
|
|
242
|
+
}
|
|
243
|
+
catch { /* Already gone. */ }
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (!FILE_NAME.test(file) || kept.has(file))
|
|
247
|
+
continue;
|
|
248
|
+
try {
|
|
249
|
+
unlinkSync(join(archiveDir(), file));
|
|
250
|
+
removed++;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Already gone.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return removed;
|
|
257
|
+
}
|
|
258
|
+
/** Local copies for the given items, keyed by original URL. */
|
|
259
|
+
export function archivedImagesFor(itemIds) {
|
|
260
|
+
const result = new Map();
|
|
261
|
+
if (itemIds.length === 0)
|
|
262
|
+
return result;
|
|
263
|
+
const rows = db.prepare(`SELECT id, item_id, source_url FROM archived_images WHERE item_id IN (${itemIds.map(() => "?").join(",")})`).all(...itemIds);
|
|
264
|
+
for (const row of rows) {
|
|
265
|
+
if (!result.has(row.item_id))
|
|
266
|
+
result.set(row.item_id, new Map());
|
|
267
|
+
result.get(row.item_id).set(row.source_url, row.id);
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
/** An archived image the user may see (they subscribe to or saved its article). */
|
|
272
|
+
export function findArchivedImage(userId, imageId) {
|
|
273
|
+
const row = db.prepare("SELECT item_id, file_name, mime_type FROM archived_images WHERE id = ?").get(imageId);
|
|
274
|
+
if (!row || !canAccessItem(userId, row.item_id))
|
|
275
|
+
return null;
|
|
276
|
+
return { path: join(archiveDir(), row.file_name), mime: row.mime_type };
|
|
277
|
+
}
|
|
278
|
+
/** Swap image URLs of archived articles for their local copies before sending them to the browser. */
|
|
279
|
+
export function withArchivedImages(items, origin, apiBase = "/api") {
|
|
280
|
+
const archived = archivedImagesFor(items.map((item) => item.id));
|
|
281
|
+
return items.map((item) => {
|
|
282
|
+
const images = archived.get(item.id);
|
|
283
|
+
if (!images)
|
|
284
|
+
return item;
|
|
285
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
286
|
+
return {
|
|
287
|
+
...item,
|
|
288
|
+
image_url: hero && images.has(hero) ? archivedImageUrl(origin, images.get(hero), apiBase) : item.image_url,
|
|
289
|
+
content_html: item.content_html ? rewriteArchivedImages(item.content_html, item.link, images, origin, apiBase) : item.content_html,
|
|
290
|
+
full_content_html: item.full_content_html ? rewriteArchivedImages(item.full_content_html, item.link, images, origin, apiBase) : item.full_content_html,
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/** Every image address an article shows: the lead image first, then those in the text. */
|
|
295
|
+
export function itemImageUrls(item) {
|
|
296
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
297
|
+
return [...new Set([...(hero ? [hero] : []), ...articleImageUrls(item.full_content_html ?? "", item.link), ...articleImageUrls(item.content_html ?? "", item.link)])];
|
|
298
|
+
}
|
|
299
|
+
function proxiedImageUrl(origin, itemId, imageUrl, apiBase = "/api") {
|
|
300
|
+
return `${origin.replace(/\/$/, "")}${apiBase}/items/${itemId}/image?src=${encodeURIComponent(imageUrl).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)}`;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* For offline copies: point the first images of articles that are not archived at an endpoint
|
|
304
|
+
* of this server, so the browser can store them without cross-origin requests.
|
|
305
|
+
*/
|
|
306
|
+
export function withProxiedImages(items, origin, apiBase = "/api") {
|
|
307
|
+
const own = `${origin.replace(/\/$/, "")}/api/`;
|
|
308
|
+
return items.map((item) => {
|
|
309
|
+
const urls = new Set(itemImageUrls(item).filter((url) => !url.startsWith(own)));
|
|
310
|
+
if (urls.size === 0)
|
|
311
|
+
return item;
|
|
312
|
+
const replace = (url) => (urls.has(url) ? proxiedImageUrl(origin, item.id, url, apiBase) : undefined);
|
|
313
|
+
const hero = item.image_url ? resolveImageUrl(item.image_url, item.link) : null;
|
|
314
|
+
return {
|
|
315
|
+
...item,
|
|
316
|
+
image_url: hero && urls.has(hero) ? proxiedImageUrl(origin, item.id, hero, apiBase) : item.image_url,
|
|
317
|
+
content_html: item.content_html ? rewriteImageSources(item.content_html, item.link, replace) : item.content_html,
|
|
318
|
+
full_content_html: item.full_content_html ? rewriteImageSources(item.full_content_html, item.link, replace) : item.full_content_html,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
/** Download one of an article's own images for the offline copy, or null when it is not one of them. */
|
|
323
|
+
export async function fetchItemImage(userId, itemId, imageUrl) {
|
|
324
|
+
const item = findItemForUser(userId, itemId);
|
|
325
|
+
if (!item || !itemImageUrls(item).includes(imageUrl))
|
|
326
|
+
return "forbidden";
|
|
327
|
+
const image = await sharedImage(item, imageUrl);
|
|
328
|
+
// Access or the article's image list can change while a shared download is running.
|
|
329
|
+
const current = findItemForUser(userId, itemId);
|
|
330
|
+
if (!current || !itemImageUrls(current).includes(imageUrl))
|
|
331
|
+
return "forbidden";
|
|
332
|
+
return image;
|
|
333
|
+
}
|
|
334
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { JSDOM } from "jsdom";
|
|
2
|
+
import { decodeEntities } from "./text.js";
|
|
3
|
+
/** Plain publisher content, shared by readers, search consumers and MCP. */
|
|
4
|
+
export function articleText(html) {
|
|
5
|
+
if (!html)
|
|
6
|
+
return "";
|
|
7
|
+
const fragment = JSDOM.fragment(html);
|
|
8
|
+
fragment.querySelectorAll("script, style, noscript, nav, footer").forEach(node => node.remove());
|
|
9
|
+
fragment.querySelectorAll("p, div, br, li, h1, h2, h3, h4, blockquote").forEach(node => node.append(" "));
|
|
10
|
+
return (fragment.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
11
|
+
}
|
|
12
|
+
export function contentMetadata(item) {
|
|
13
|
+
const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
|
|
14
|
+
return { revision: item.content_revision, source, status: item.extraction_status,
|
|
15
|
+
attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
|
|
16
|
+
}
|
|
17
|
+
export function sourceText(item) {
|
|
18
|
+
return `${decodeEntities(item.title) ?? ""}\n${articleText(item.full_content_html || item.content_html) || decodeEntities(item.content_snippet) || ""}`.trim();
|
|
19
|
+
}
|
|
20
|
+
/** Prefer existing substantive publisher text over a shorter extracted teaser. */
|
|
21
|
+
export function improvesContent(item, extracted) {
|
|
22
|
+
const candidate = articleText(extracted).length;
|
|
23
|
+
const existing = articleText(item.full_content_html || item.content_html).length;
|
|
24
|
+
return candidate >= 200 && candidate >= existing * 0.9;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=articleContent.js.map
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { existsSync, statSync } from "node:fs";
|
|
2
|
+
import cron from "node-cron";
|
|
3
|
+
import { db } from "../db/index.js";
|
|
4
|
+
import { config } from "../config.js";
|
|
5
|
+
import { removeUnusedFeeds } from "./repository.js";
|
|
6
|
+
import { pruneArchive } from "./archive.js";
|
|
7
|
+
import { pruneChangeLog } from "../sync/changeLog.js";
|
|
8
|
+
export function getDatabaseFileSizeBytes() {
|
|
9
|
+
let size = 0;
|
|
10
|
+
for (const path of [config.databasePath, `${config.databasePath}-wal`, `${config.databasePath}-shm`]) {
|
|
11
|
+
if (existsSync(path)) {
|
|
12
|
+
try {
|
|
13
|
+
size += statSync(path).size;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// ignore if concurrently removed
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return size;
|
|
21
|
+
}
|
|
22
|
+
export function getDatabaseStats() {
|
|
23
|
+
const totalItemsRow = db.prepare("SELECT COUNT(*) AS count FROM items").get();
|
|
24
|
+
const readItemsRow = db.prepare("SELECT COUNT(DISTINCT item_id) AS count FROM item_reads").get();
|
|
25
|
+
const feedsRow = db.prepare("SELECT COUNT(*) AS count FROM feeds").get();
|
|
26
|
+
const oldestRow = db.prepare("SELECT MIN(COALESCE(published_at, created_at)) AS oldest FROM items").get();
|
|
27
|
+
return {
|
|
28
|
+
totalItems: totalItemsRow.count,
|
|
29
|
+
readItems: readItemsRow.count,
|
|
30
|
+
feedsCount: feedsRow.count,
|
|
31
|
+
oldestItemDate: oldestRow.oldest,
|
|
32
|
+
databaseSizeBytes: getDatabaseFileSizeBytes(),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export function getRetentionSettings() {
|
|
36
|
+
try {
|
|
37
|
+
const rows = db.prepare("SELECT key, value FROM system_settings").all();
|
|
38
|
+
const map = new Map(rows.map((r) => [r.key, r.value]));
|
|
39
|
+
return {
|
|
40
|
+
retentionReadDays: Number(map.get("retention_read_days") ?? config.retentionReadDays),
|
|
41
|
+
retentionMaxDays: Number(map.get("retention_max_days") ?? config.retentionMaxDays),
|
|
42
|
+
retentionMaxItemsPerFeed: Number(map.get("retention_max_items_per_feed") ?? config.retentionMaxItemsPerFeed),
|
|
43
|
+
autoCleanupEnabled: (map.get("auto_cleanup_enabled") ?? String(config.autoCleanupEnabled)) === "true",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {
|
|
48
|
+
retentionReadDays: config.retentionReadDays,
|
|
49
|
+
retentionMaxDays: config.retentionMaxDays,
|
|
50
|
+
retentionMaxItemsPerFeed: config.retentionMaxItemsPerFeed,
|
|
51
|
+
autoCleanupEnabled: config.autoCleanupEnabled,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function updateRetentionSettings(settings) {
|
|
56
|
+
const updateStmt = db.prepare(`INSERT INTO system_settings (key, value, updated_at)
|
|
57
|
+
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
58
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`);
|
|
59
|
+
const apply = db.transaction(() => {
|
|
60
|
+
if (settings.retentionReadDays !== undefined) {
|
|
61
|
+
updateStmt.run("retention_read_days", String(Math.max(0, settings.retentionReadDays)));
|
|
62
|
+
}
|
|
63
|
+
if (settings.retentionMaxDays !== undefined) {
|
|
64
|
+
updateStmt.run("retention_max_days", String(Math.max(0, settings.retentionMaxDays)));
|
|
65
|
+
}
|
|
66
|
+
if (settings.retentionMaxItemsPerFeed !== undefined) {
|
|
67
|
+
updateStmt.run("retention_max_items_per_feed", String(Math.max(0, settings.retentionMaxItemsPerFeed)));
|
|
68
|
+
}
|
|
69
|
+
if (settings.autoCleanupEnabled !== undefined) {
|
|
70
|
+
updateStmt.run("auto_cleanup_enabled", String(settings.autoCleanupEnabled));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
apply();
|
|
74
|
+
return getRetentionSettings();
|
|
75
|
+
}
|
|
76
|
+
export function runCleanup(custom) {
|
|
77
|
+
const settings = {
|
|
78
|
+
...getRetentionSettings(),
|
|
79
|
+
...custom,
|
|
80
|
+
};
|
|
81
|
+
const sizeBefore = getDatabaseFileSizeBytes();
|
|
82
|
+
let deletedReadItems = 0;
|
|
83
|
+
let deletedOldItems = 0;
|
|
84
|
+
let deletedPerFeedExcess = 0;
|
|
85
|
+
// 1. Delete read items older than retentionReadDays (excluding bookmarked items and items with notes)
|
|
86
|
+
if (settings.retentionReadDays > 0) {
|
|
87
|
+
const res = db.prepare(`DELETE FROM items
|
|
88
|
+
WHERE id IN (
|
|
89
|
+
SELECT i.id
|
|
90
|
+
FROM items i
|
|
91
|
+
WHERE i.id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
92
|
+
AND i.id NOT IN (SELECT item_id FROM item_notes)
|
|
93
|
+
AND i.feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
94
|
+
AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
|
|
95
|
+
WHERE selected.value = i.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
|
|
96
|
+
AND i.id IN (SELECT item_id FROM item_reads)
|
|
97
|
+
AND NOT EXISTS (
|
|
98
|
+
SELECT 1 FROM subscriptions s
|
|
99
|
+
WHERE s.feed_id = i.feed_id
|
|
100
|
+
AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = s.user_id)
|
|
101
|
+
)
|
|
102
|
+
AND COALESCE(julianday(i.published_at), julianday(i.created_at)) <= julianday('now', '-' || ? || ' days')
|
|
103
|
+
)`).run(settings.retentionReadDays);
|
|
104
|
+
deletedReadItems = res.changes;
|
|
105
|
+
}
|
|
106
|
+
// 2. Delete all items older than retentionMaxDays (excluding bookmarked items and items with notes)
|
|
107
|
+
if (settings.retentionMaxDays > 0) {
|
|
108
|
+
const res = db.prepare(`DELETE FROM items
|
|
109
|
+
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
110
|
+
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
111
|
+
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
112
|
+
AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
|
|
113
|
+
WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
|
|
114
|
+
AND COALESCE(julianday(published_at), julianday(created_at)) <= julianday('now', '-' || ? || ' days')`).run(settings.retentionMaxDays);
|
|
115
|
+
deletedOldItems = res.changes;
|
|
116
|
+
}
|
|
117
|
+
// 3. Delete items exceeding retentionMaxItemsPerFeed (excluding bookmarked items and items with notes)
|
|
118
|
+
if (settings.retentionMaxItemsPerFeed > 0) {
|
|
119
|
+
const res = db.prepare(`DELETE FROM items
|
|
120
|
+
WHERE id NOT IN (SELECT item_id FROM item_bookmarks)
|
|
121
|
+
AND id NOT IN (SELECT item_id FROM item_notes)
|
|
122
|
+
AND feed_id NOT IN (SELECT id FROM feeds WHERE is_system_inbox = 1)
|
|
123
|
+
AND NOT EXISTS (SELECT 1 FROM editions e, json_each(e.item_ids) selected
|
|
124
|
+
WHERE selected.value = items.id AND e.status = 'active' AND julianday(e.expires_at) > julianday('now'))
|
|
125
|
+
AND id IN (
|
|
126
|
+
SELECT id FROM (
|
|
127
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
128
|
+
PARTITION BY feed_id
|
|
129
|
+
ORDER BY COALESCE(julianday(published_at), julianday(created_at)) DESC, id DESC
|
|
130
|
+
) as rn
|
|
131
|
+
FROM items
|
|
132
|
+
)
|
|
133
|
+
WHERE rn > ?
|
|
134
|
+
)`).run(settings.retentionMaxItemsPerFeed);
|
|
135
|
+
deletedPerFeedExcess = res.changes;
|
|
136
|
+
}
|
|
137
|
+
// Feeds kept only for saved articles go once nothing of them is saved anymore.
|
|
138
|
+
removeUnusedFeeds();
|
|
139
|
+
pruneArchive();
|
|
140
|
+
pruneChangeLog();
|
|
141
|
+
const totalDeleted = deletedReadItems + deletedOldItems + deletedPerFeedExcess;
|
|
142
|
+
// Optimize query planning; rebuild storage only when this run freed rows.
|
|
143
|
+
db.exec("PRAGMA optimize;");
|
|
144
|
+
if (totalDeleted > 0) {
|
|
145
|
+
try {
|
|
146
|
+
db.exec("VACUUM;");
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
console.warn("[cleanup] VACUUM skipped or could not run:", err);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const sizeAfter = getDatabaseFileSizeBytes();
|
|
153
|
+
return {
|
|
154
|
+
deletedReadItems,
|
|
155
|
+
deletedOldItems,
|
|
156
|
+
deletedPerFeedExcess,
|
|
157
|
+
totalDeleted,
|
|
158
|
+
sizeBefore,
|
|
159
|
+
sizeAfter,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function startCleanupScheduler() {
|
|
163
|
+
// Run daily at 03:00 AM server time
|
|
164
|
+
cron.schedule("0 3 * * *", () => {
|
|
165
|
+
const settings = getRetentionSettings();
|
|
166
|
+
if (!settings.autoCleanupEnabled) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
try {
|
|
170
|
+
console.log("[cleanup] running daily automated housekeeping...");
|
|
171
|
+
const result = runCleanup(settings);
|
|
172
|
+
console.log(`[cleanup] automated housekeeping complete: ${result.totalDeleted} items deleted (read: ${result.deletedReadItems}, max age: ${result.deletedOldItems}, feed cap: ${result.deletedPerFeedExcess}). Database size: ${(result.sizeBefore / 1024 / 1024).toFixed(2)} MB -> ${(result.sizeAfter / 1024 / 1024).toFixed(2)} MB`);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error("[cleanup] error during automated housekeeping:", err);
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
//# sourceMappingURL=cleanup.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Nothing on the web is older than this, so an earlier date is a placeholder such as 1970-01-01.
|
|
2
|
+
const EARLIEST_PLAUSIBLE = Date.UTC(1991, 0, 1);
|
|
3
|
+
// Feeds may announce an article a little ahead of time or in another zone, but not by years.
|
|
4
|
+
const MAX_AHEAD_MS = 24 * 60 * 60 * 1000;
|
|
5
|
+
/**
|
|
6
|
+
* The publish date of a feed entry as an ISO timestamp, or null when the feed gives none we can trust: missing,
|
|
7
|
+
* not a date ("gestern", localised weekday names), a placeholder in the past or a date far in the future. Null makes
|
|
8
|
+
* the app use the time it stored the article, so the article lists, ages and is cleaned up like any other.
|
|
9
|
+
*/
|
|
10
|
+
export function normalizePublishedAt(value, now = Date.now()) {
|
|
11
|
+
if (typeof value !== "string")
|
|
12
|
+
return null;
|
|
13
|
+
const text = value.trim();
|
|
14
|
+
if (!text)
|
|
15
|
+
return null;
|
|
16
|
+
const time = Date.parse(text);
|
|
17
|
+
if (!Number.isFinite(time) || time < EARLIEST_PLAUSIBLE || time > now + MAX_AHEAD_MS)
|
|
18
|
+
return null;
|
|
19
|
+
return new Date(time).toISOString();
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=dates.js.map
|