@jant/core 0.6.7 → 0.6.9
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/bin/commands/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/GeneralContent.tsx +38 -4
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-non-null-assertion -- Test assertions use ! for readability */
|
|
2
2
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
3
|
+
import { eq } from "drizzle-orm";
|
|
3
4
|
import {
|
|
4
5
|
createTestDatabase,
|
|
5
6
|
DEFAULT_TEST_SITE_ID,
|
|
@@ -7,7 +8,9 @@ import {
|
|
|
7
8
|
import { createMediaService } from "../media.js";
|
|
8
9
|
import { createPostService } from "../post.js";
|
|
9
10
|
import type { Database } from "../../db/index.js";
|
|
11
|
+
import { storagePurge } from "../../db/schema.js";
|
|
10
12
|
import { MediaQuotaExceededError } from "../../lib/errors.js";
|
|
13
|
+
import { now } from "../../lib/time.js";
|
|
11
14
|
|
|
12
15
|
interface MockStorageFile {
|
|
13
16
|
body: Uint8Array;
|
|
@@ -54,9 +57,21 @@ function createMockStorage() {
|
|
|
54
57
|
async delete(key: string) {
|
|
55
58
|
files.delete(key);
|
|
56
59
|
},
|
|
60
|
+
async copy(sourceKey: string, destKey: string) {
|
|
61
|
+
const file = files.get(sourceKey);
|
|
62
|
+
if (file) files.set(destKey, { ...file });
|
|
63
|
+
},
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
|
|
67
|
+
/** Storage mock without server-side copy (e.g. R2 Workers binding). */
|
|
68
|
+
function createNoCopyStorage() {
|
|
69
|
+
const full = createMockStorage();
|
|
70
|
+
const { copy: _copy, ...rest } = full;
|
|
71
|
+
void _copy;
|
|
72
|
+
return rest;
|
|
73
|
+
}
|
|
74
|
+
|
|
60
75
|
describe("MediaService", () => {
|
|
61
76
|
let db: Database;
|
|
62
77
|
let mediaService: ReturnType<typeof createMediaService>;
|
|
@@ -503,7 +518,7 @@ describe("MediaService", () => {
|
|
|
503
518
|
});
|
|
504
519
|
|
|
505
520
|
describe("delete for text attachments", () => {
|
|
506
|
-
it("
|
|
521
|
+
it("moves the .md object to trash and frees the original key", async () => {
|
|
507
522
|
const storage = createMockStorage();
|
|
508
523
|
const media = await mediaService.createTextAttachment(
|
|
509
524
|
{
|
|
@@ -521,13 +536,18 @@ describe("MediaService", () => {
|
|
|
521
536
|
|
|
522
537
|
await mediaService.delete(media.id, storage);
|
|
523
538
|
|
|
524
|
-
|
|
539
|
+
// Row gone and the original (public) key freed immediately; the bytes
|
|
540
|
+
// live on under a trash/ key, recoverable until purge.
|
|
541
|
+
expect(await mediaService.getById(media.id)).toBeNull();
|
|
525
542
|
expect(storage.files.has(media.storageKey)).toBe(false);
|
|
543
|
+
expect(
|
|
544
|
+
[...storage.files.keys()].some((k) => k.startsWith("trash/")),
|
|
545
|
+
).toBe(true);
|
|
526
546
|
});
|
|
527
547
|
});
|
|
528
548
|
|
|
529
549
|
describe("deleteByIds for text attachments", () => {
|
|
530
|
-
it("
|
|
550
|
+
it("moves every .md object in the batch to trash", async () => {
|
|
531
551
|
const storage = createMockStorage();
|
|
532
552
|
const a = await mediaService.createTextAttachment(
|
|
533
553
|
{ contentFormat: "markdown", content: "first" },
|
|
@@ -542,7 +562,14 @@ describe("MediaService", () => {
|
|
|
542
562
|
|
|
543
563
|
await mediaService.deleteByIds([a.id, b.id], storage);
|
|
544
564
|
|
|
545
|
-
expect(
|
|
565
|
+
expect(await mediaService.getById(a.id)).toBeNull();
|
|
566
|
+
expect(await mediaService.getById(b.id)).toBeNull();
|
|
567
|
+
// Originals freed; two trash copies remain until the purge sweep.
|
|
568
|
+
expect(storage.files.has(a.storageKey)).toBe(false);
|
|
569
|
+
expect(storage.files.has(b.storageKey)).toBe(false);
|
|
570
|
+
expect(
|
|
571
|
+
[...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
|
|
572
|
+
).toBe(2);
|
|
546
573
|
});
|
|
547
574
|
});
|
|
548
575
|
|
|
@@ -961,6 +988,112 @@ describe("MediaService", () => {
|
|
|
961
988
|
});
|
|
962
989
|
});
|
|
963
990
|
|
|
991
|
+
describe("listOrphanedMediaIds", () => {
|
|
992
|
+
it("returns unattached media created before the cutoff", async () => {
|
|
993
|
+
const m1 = await mediaService.create({
|
|
994
|
+
...sampleMedia,
|
|
995
|
+
storageKey: "media/a.jpg",
|
|
996
|
+
});
|
|
997
|
+
const m2 = await mediaService.create({
|
|
998
|
+
...sampleMedia,
|
|
999
|
+
storageKey: "media/b.jpg",
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1003
|
+
before: now() + 1,
|
|
1004
|
+
limit: 10,
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
expect(ids).toHaveLength(2);
|
|
1008
|
+
expect(ids).toContain(m1.id);
|
|
1009
|
+
expect(ids).toContain(m2.id);
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it("excludes media attached to a post", async () => {
|
|
1013
|
+
const post = await postService.create({
|
|
1014
|
+
format: "note",
|
|
1015
|
+
bodyMarkdown: "p",
|
|
1016
|
+
});
|
|
1017
|
+
const attached = await mediaService.create({
|
|
1018
|
+
...sampleMedia,
|
|
1019
|
+
storageKey: "media/a.jpg",
|
|
1020
|
+
});
|
|
1021
|
+
const orphan = await mediaService.create({
|
|
1022
|
+
...sampleMedia,
|
|
1023
|
+
storageKey: "media/b.jpg",
|
|
1024
|
+
});
|
|
1025
|
+
await mediaService.attachToPost(post.id, [attached.id]);
|
|
1026
|
+
|
|
1027
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1028
|
+
before: now() + 1,
|
|
1029
|
+
limit: 10,
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
expect(ids).toEqual([orphan.id]);
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("excludes site asset media (avatars, favicons) referenced by settings", async () => {
|
|
1036
|
+
const orphan = await mediaService.create({
|
|
1037
|
+
...sampleMedia,
|
|
1038
|
+
storageKey: "media/site/files/orphan.jpg",
|
|
1039
|
+
});
|
|
1040
|
+
const avatar = await mediaService.create({
|
|
1041
|
+
...sampleMedia,
|
|
1042
|
+
storageKey: "media/site/assets/avatar/avatar.png",
|
|
1043
|
+
});
|
|
1044
|
+
const favicon = await mediaService.create({
|
|
1045
|
+
...sampleMedia,
|
|
1046
|
+
storageKey: "media/site/assets/favicon/apple-touch-icon.png",
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1050
|
+
before: now() + 1,
|
|
1051
|
+
limit: 10,
|
|
1052
|
+
});
|
|
1053
|
+
|
|
1054
|
+
expect(ids).toEqual([orphan.id]);
|
|
1055
|
+
expect(ids).not.toContain(avatar.id);
|
|
1056
|
+
expect(ids).not.toContain(favicon.id);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it("excludes media created at or after the cutoff", async () => {
|
|
1060
|
+
await mediaService.create({
|
|
1061
|
+
...sampleMedia,
|
|
1062
|
+
storageKey: "media/a.jpg",
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1066
|
+
before: 1,
|
|
1067
|
+
limit: 10,
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
expect(ids).toEqual([]);
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
it("respects the batch limit", async () => {
|
|
1074
|
+
await mediaService.create({ ...sampleMedia, storageKey: "media/a.jpg" });
|
|
1075
|
+
await mediaService.create({ ...sampleMedia, storageKey: "media/b.jpg" });
|
|
1076
|
+
|
|
1077
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1078
|
+
before: now() + 1,
|
|
1079
|
+
limit: 1,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
expect(ids).toHaveLength(1);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it("returns no IDs when the limit is zero", async () => {
|
|
1086
|
+
await mediaService.create({ ...sampleMedia, storageKey: "media/a.jpg" });
|
|
1087
|
+
|
|
1088
|
+
const ids = await mediaService.listOrphanedMediaIds({
|
|
1089
|
+
before: now() + 1,
|
|
1090
|
+
limit: 0,
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
expect(ids).toEqual([]);
|
|
1094
|
+
});
|
|
1095
|
+
});
|
|
1096
|
+
|
|
964
1097
|
describe("validateIds", () => {
|
|
965
1098
|
it("passes for valid IDs", async () => {
|
|
966
1099
|
const m1 = await mediaService.create({
|
|
@@ -1222,25 +1355,24 @@ describe("MediaService", () => {
|
|
|
1222
1355
|
expect(result).toBe(false);
|
|
1223
1356
|
});
|
|
1224
1357
|
|
|
1225
|
-
it("
|
|
1358
|
+
it("moves storage objects to trash and frees the original keys", async () => {
|
|
1359
|
+
const storage = createMockStorage();
|
|
1360
|
+
await storage.put("media/vid.mp4", new Uint8Array([1]));
|
|
1361
|
+
await storage.put("media/vid.poster.webp", new Uint8Array([2]));
|
|
1226
1362
|
const media = await mediaService.create({
|
|
1227
1363
|
...sampleMedia,
|
|
1228
1364
|
storageKey: "media/vid.mp4",
|
|
1229
1365
|
posterKey: "media/vid.poster.webp",
|
|
1230
1366
|
});
|
|
1231
1367
|
|
|
1232
|
-
|
|
1233
|
-
const mockStorage = {
|
|
1234
|
-
delete: async (key: string) => {
|
|
1235
|
-
deletedKeys.push(key);
|
|
1236
|
-
},
|
|
1237
|
-
put: async () => {},
|
|
1238
|
-
get: async () => null,
|
|
1239
|
-
};
|
|
1368
|
+
await mediaService.delete(media.id, storage);
|
|
1240
1369
|
|
|
1241
|
-
|
|
1242
|
-
expect(
|
|
1243
|
-
expect(
|
|
1370
|
+
// Originals freed immediately; both objects moved under trash/.
|
|
1371
|
+
expect(storage.files.has("media/vid.mp4")).toBe(false);
|
|
1372
|
+
expect(storage.files.has("media/vid.poster.webp")).toBe(false);
|
|
1373
|
+
expect(
|
|
1374
|
+
[...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
|
|
1375
|
+
).toBe(2);
|
|
1244
1376
|
});
|
|
1245
1377
|
});
|
|
1246
1378
|
|
|
@@ -1274,7 +1406,11 @@ describe("MediaService", () => {
|
|
|
1274
1406
|
expect(await mediaService.getById(m1.id)).not.toBeNull();
|
|
1275
1407
|
});
|
|
1276
1408
|
|
|
1277
|
-
it("
|
|
1409
|
+
it("moves storage objects to trash and frees the original keys", async () => {
|
|
1410
|
+
const storage = createMockStorage();
|
|
1411
|
+
await storage.put("media/a.mp4", new Uint8Array([1]));
|
|
1412
|
+
await storage.put("media/a-poster.webp", new Uint8Array([2]));
|
|
1413
|
+
await storage.put("media/b.jpg", new Uint8Array([3]));
|
|
1278
1414
|
const m1 = await mediaService.create({
|
|
1279
1415
|
...sampleMedia,
|
|
1280
1416
|
storageKey: "media/a.mp4",
|
|
@@ -1285,20 +1421,128 @@ describe("MediaService", () => {
|
|
|
1285
1421
|
storageKey: "media/b.jpg",
|
|
1286
1422
|
});
|
|
1287
1423
|
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1424
|
+
await mediaService.deleteByIds([m1.id, m2.id], storage);
|
|
1425
|
+
|
|
1426
|
+
// Three originals freed, three trash copies remain.
|
|
1427
|
+
expect(storage.files.has("media/a.mp4")).toBe(false);
|
|
1428
|
+
expect(storage.files.has("media/a-poster.webp")).toBe(false);
|
|
1429
|
+
expect(storage.files.has("media/b.jpg")).toBe(false);
|
|
1430
|
+
expect(
|
|
1431
|
+
[...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
|
|
1432
|
+
).toBe(3);
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
describe("storage purge (recycle window)", () => {
|
|
1437
|
+
const FAR_FUTURE = () => now() + 365 * 24 * 60 * 60;
|
|
1438
|
+
|
|
1439
|
+
it("moves a deleted object to trash with a recorded original_key", async () => {
|
|
1440
|
+
const storage = createMockStorage();
|
|
1441
|
+
await storage.put("media/x.jpg", new Uint8Array([1]));
|
|
1442
|
+
const m = await mediaService.create({
|
|
1443
|
+
...sampleMedia,
|
|
1444
|
+
storageKey: "media/x.jpg",
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
await mediaService.delete(m.id, storage);
|
|
1448
|
+
|
|
1449
|
+
const rows = await db
|
|
1450
|
+
.select()
|
|
1451
|
+
.from(storagePurge)
|
|
1452
|
+
.where(eq(storagePurge.siteId, DEFAULT_TEST_SITE_ID));
|
|
1453
|
+
expect(rows).toHaveLength(1);
|
|
1454
|
+
expect(rows[0]!.originalKey).toBe("media/x.jpg");
|
|
1455
|
+
expect(rows[0]!.storageKey.startsWith("trash/")).toBe(true);
|
|
1456
|
+
expect(storage.files.has(rows[0]!.storageKey)).toBe(true);
|
|
1457
|
+
expect(storage.files.has("media/x.jpg")).toBe(false);
|
|
1458
|
+
});
|
|
1459
|
+
|
|
1460
|
+
it("purges trashed objects once due (storageKey + posterKey)", async () => {
|
|
1461
|
+
const storage = createMockStorage();
|
|
1462
|
+
await storage.put("media/v.mp4", new Uint8Array([1]));
|
|
1463
|
+
await storage.put("media/v-poster.webp", new Uint8Array([2]));
|
|
1464
|
+
const m = await mediaService.create({
|
|
1465
|
+
...sampleMedia,
|
|
1466
|
+
storageKey: "media/v.mp4",
|
|
1467
|
+
posterKey: "media/v-poster.webp",
|
|
1468
|
+
});
|
|
1469
|
+
await mediaService.delete(m.id, storage);
|
|
1470
|
+
|
|
1471
|
+
const purged = await mediaService.purgeDueStorageObjects(
|
|
1472
|
+
{ before: FAR_FUTURE(), limit: 50, provider: "r2" },
|
|
1473
|
+
storage,
|
|
1474
|
+
);
|
|
1475
|
+
|
|
1476
|
+
expect(purged).toBe(2);
|
|
1477
|
+
expect(storage.files.size).toBe(0);
|
|
1478
|
+
});
|
|
1479
|
+
|
|
1480
|
+
it("retains trash whose recycle window has not elapsed", async () => {
|
|
1481
|
+
const storage = createMockStorage();
|
|
1482
|
+
await storage.put("media/keep.jpg", new Uint8Array([1]));
|
|
1483
|
+
const m = await mediaService.create({
|
|
1484
|
+
...sampleMedia,
|
|
1485
|
+
storageKey: "media/keep.jpg",
|
|
1486
|
+
});
|
|
1487
|
+
await mediaService.delete(m.id, storage);
|
|
1488
|
+
|
|
1489
|
+
// before = now → the 30-day-out purge_after is not yet due.
|
|
1490
|
+
const purged = await mediaService.purgeDueStorageObjects(
|
|
1491
|
+
{ before: now(), limit: 50, provider: "r2" },
|
|
1492
|
+
storage,
|
|
1493
|
+
);
|
|
1494
|
+
|
|
1495
|
+
expect(purged).toBe(0);
|
|
1496
|
+
// Original freed, but the trash copy is retained.
|
|
1497
|
+
expect(storage.files.has("media/keep.jpg")).toBe(false);
|
|
1498
|
+
expect(
|
|
1499
|
+
[...storage.files.keys()].some((k) => k.startsWith("trash/")),
|
|
1500
|
+
).toBe(true);
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it("deletes immediately with no recycle when the driver lacks copy", async () => {
|
|
1504
|
+
const storage = createNoCopyStorage();
|
|
1505
|
+
await storage.put("media/nocopy.jpg", new Uint8Array([1]));
|
|
1506
|
+
const m = await mediaService.create({
|
|
1507
|
+
...sampleMedia,
|
|
1508
|
+
storageKey: "media/nocopy.jpg",
|
|
1509
|
+
});
|
|
1510
|
+
|
|
1511
|
+
await mediaService.delete(m.id, storage);
|
|
1512
|
+
|
|
1513
|
+
// Deleted outright; nothing in trash, nothing queued.
|
|
1514
|
+
expect(storage.files.size).toBe(0);
|
|
1515
|
+
const rows = await db
|
|
1516
|
+
.select()
|
|
1517
|
+
.from(storagePurge)
|
|
1518
|
+
.where(eq(storagePurge.siteId, DEFAULT_TEST_SITE_ID));
|
|
1519
|
+
expect(rows).toHaveLength(0);
|
|
1520
|
+
});
|
|
1521
|
+
|
|
1522
|
+
it("respects the batch limit", async () => {
|
|
1523
|
+
const storage = createMockStorage();
|
|
1524
|
+
await storage.put("media/p1.jpg", new Uint8Array([1]));
|
|
1525
|
+
await storage.put("media/p2.jpg", new Uint8Array([2]));
|
|
1526
|
+
const m1 = await mediaService.create({
|
|
1527
|
+
...sampleMedia,
|
|
1528
|
+
storageKey: "media/p1.jpg",
|
|
1529
|
+
});
|
|
1530
|
+
const m2 = await mediaService.create({
|
|
1531
|
+
...sampleMedia,
|
|
1532
|
+
storageKey: "media/p2.jpg",
|
|
1533
|
+
});
|
|
1534
|
+
await mediaService.deleteByIds([m1.id, m2.id], storage);
|
|
1535
|
+
|
|
1536
|
+
const purged = await mediaService.purgeDueStorageObjects(
|
|
1537
|
+
{ before: FAR_FUTURE(), limit: 1, provider: "r2" },
|
|
1538
|
+
storage,
|
|
1539
|
+
);
|
|
1296
1540
|
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
expect(
|
|
1300
|
-
|
|
1301
|
-
|
|
1541
|
+
expect(purged).toBe(1);
|
|
1542
|
+
// One trash object purged, one remains.
|
|
1543
|
+
expect(
|
|
1544
|
+
[...storage.files.keys()].filter((k) => k.startsWith("trash/")).length,
|
|
1545
|
+
).toBe(1);
|
|
1302
1546
|
});
|
|
1303
1547
|
});
|
|
1304
1548
|
});
|
|
@@ -842,6 +842,59 @@ describe("PostService", () => {
|
|
|
842
842
|
expect(posts[0]?.bodyText).toBe("root post");
|
|
843
843
|
});
|
|
844
844
|
|
|
845
|
+
it("filters thread roots by reply presence", async () => {
|
|
846
|
+
const threadRoot = await postService.create({
|
|
847
|
+
format: "note",
|
|
848
|
+
bodyMarkdown: "thread root",
|
|
849
|
+
});
|
|
850
|
+
await postService.create({
|
|
851
|
+
format: "note",
|
|
852
|
+
bodyMarkdown: "reply",
|
|
853
|
+
replyToId: threadRoot.id,
|
|
854
|
+
});
|
|
855
|
+
await postService.create({
|
|
856
|
+
format: "note",
|
|
857
|
+
bodyMarkdown: "standalone",
|
|
858
|
+
});
|
|
859
|
+
|
|
860
|
+
const threads = await postService.list({
|
|
861
|
+
excludeReplies: true,
|
|
862
|
+
hasReplies: true,
|
|
863
|
+
});
|
|
864
|
+
expect(threads.map((p) => p.bodyText)).toEqual(["thread root"]);
|
|
865
|
+
|
|
866
|
+
const singles = await postService.list({
|
|
867
|
+
excludeReplies: true,
|
|
868
|
+
hasReplies: false,
|
|
869
|
+
});
|
|
870
|
+
expect(singles.map((p) => p.bodyText)).toEqual(["standalone"]);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it("ignores draft replies when filtering by reply presence", async () => {
|
|
874
|
+
const root = await postService.create({
|
|
875
|
+
format: "note",
|
|
876
|
+
bodyMarkdown: "root with draft reply",
|
|
877
|
+
});
|
|
878
|
+
await postService.create({
|
|
879
|
+
format: "note",
|
|
880
|
+
bodyMarkdown: "draft reply",
|
|
881
|
+
replyToId: root.id,
|
|
882
|
+
status: "draft",
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const threads = await postService.list({
|
|
886
|
+
excludeReplies: true,
|
|
887
|
+
hasReplies: true,
|
|
888
|
+
});
|
|
889
|
+
expect(threads).toHaveLength(0);
|
|
890
|
+
|
|
891
|
+
const singles = await postService.list({
|
|
892
|
+
excludeReplies: true,
|
|
893
|
+
hasReplies: false,
|
|
894
|
+
});
|
|
895
|
+
expect(singles.map((p) => p.bodyText)).toEqual(["root with draft reply"]);
|
|
896
|
+
});
|
|
897
|
+
|
|
845
898
|
it("supports offset pagination", async () => {
|
|
846
899
|
for (let i = 0; i < 5; i++) {
|
|
847
900
|
await postService.create({
|
|
@@ -944,6 +997,34 @@ describe("PostService", () => {
|
|
|
944
997
|
expect(count).toBe(1);
|
|
945
998
|
});
|
|
946
999
|
|
|
1000
|
+
it("counts thread roots by reply presence", async () => {
|
|
1001
|
+
const threadRoot = await postService.create({
|
|
1002
|
+
format: "note",
|
|
1003
|
+
bodyMarkdown: "thread root",
|
|
1004
|
+
});
|
|
1005
|
+
await postService.create({
|
|
1006
|
+
format: "note",
|
|
1007
|
+
bodyMarkdown: "reply",
|
|
1008
|
+
replyToId: threadRoot.id,
|
|
1009
|
+
});
|
|
1010
|
+
await postService.create({
|
|
1011
|
+
format: "note",
|
|
1012
|
+
bodyMarkdown: "standalone",
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
const threadCount = await postService.count({
|
|
1016
|
+
excludeReplies: true,
|
|
1017
|
+
hasReplies: true,
|
|
1018
|
+
});
|
|
1019
|
+
expect(threadCount).toBe(1);
|
|
1020
|
+
|
|
1021
|
+
const singleCount = await postService.count({
|
|
1022
|
+
excludeReplies: true,
|
|
1023
|
+
hasReplies: false,
|
|
1024
|
+
});
|
|
1025
|
+
expect(singleCount).toBe(1);
|
|
1026
|
+
});
|
|
1027
|
+
|
|
947
1028
|
it("can stop counting after a small limit", async () => {
|
|
948
1029
|
const collection = await collectionService.create({
|
|
949
1030
|
slug: "rated",
|
|
@@ -446,6 +446,61 @@ describe("SettingsService", () => {
|
|
|
446
446
|
expect(await settingsService.get("SITE_LANGUAGE")).toBe("en");
|
|
447
447
|
});
|
|
448
448
|
|
|
449
|
+
it("stores a valid dashboard language and reports the change", async () => {
|
|
450
|
+
const result = await settingsService.updateLocaleSettings(
|
|
451
|
+
{
|
|
452
|
+
siteLanguage: "fr",
|
|
453
|
+
dashboardLanguage: "zh-Hant",
|
|
454
|
+
cjkSerifFont: "off",
|
|
455
|
+
timeZone: "UTC",
|
|
456
|
+
},
|
|
457
|
+
{ oldLanguage: "fr", oldDashboardLanguage: "" },
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
expect(result.languageChanged).toBe(true);
|
|
461
|
+
expect(await settingsService.get("SITE_LANGUAGE")).toBe("fr");
|
|
462
|
+
expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBe("zh-Hant");
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
it("clears DASHBOARD_LANGUAGE when dashboard language is blank", async () => {
|
|
466
|
+
await settingsService.set("DASHBOARD_LANGUAGE", "zh-Hans");
|
|
467
|
+
await settingsService.updateLocaleSettings(
|
|
468
|
+
{
|
|
469
|
+
siteLanguage: "en",
|
|
470
|
+
dashboardLanguage: "",
|
|
471
|
+
cjkSerifFont: "off",
|
|
472
|
+
timeZone: "UTC",
|
|
473
|
+
},
|
|
474
|
+
{ oldLanguage: "en", oldDashboardLanguage: "zh-Hans" },
|
|
475
|
+
);
|
|
476
|
+
|
|
477
|
+
expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("leaves DASHBOARD_LANGUAGE untouched when not provided", async () => {
|
|
481
|
+
await settingsService.set("DASHBOARD_LANGUAGE", "zh-Hans");
|
|
482
|
+
await settingsService.updateLocaleSettings(
|
|
483
|
+
{ siteLanguage: "en", cjkSerifFont: "off", timeZone: "UTC" },
|
|
484
|
+
{ oldLanguage: "en" },
|
|
485
|
+
);
|
|
486
|
+
|
|
487
|
+
expect(await settingsService.get("DASHBOARD_LANGUAGE")).toBe("zh-Hans");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("rejects a dashboard language Jant is not translated into", async () => {
|
|
491
|
+
await expect(
|
|
492
|
+
settingsService.updateLocaleSettings(
|
|
493
|
+
{
|
|
494
|
+
siteLanguage: "en",
|
|
495
|
+
dashboardLanguage: "fr",
|
|
496
|
+
cjkSerifFont: "off",
|
|
497
|
+
timeZone: "UTC",
|
|
498
|
+
},
|
|
499
|
+
{ oldLanguage: "en" },
|
|
500
|
+
),
|
|
501
|
+
).rejects.toThrow();
|
|
502
|
+
});
|
|
503
|
+
|
|
449
504
|
it("updates grouped feed, home, and search settings independently", async () => {
|
|
450
505
|
await settingsService.updateFeedSettings({ mainRssFeed: "latest" });
|
|
451
506
|
await settingsService.updateHomeBranding(true);
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
baseLocale,
|
|
14
14
|
isValidContentLanguage,
|
|
15
15
|
normalizeContentLanguage,
|
|
16
|
+
resolveCatalogLocale,
|
|
16
17
|
} from "../i18n/locales.js";
|
|
17
18
|
import { createNavItemService } from "./navigation.js";
|
|
18
19
|
import { createSettingsService } from "./settings.js";
|
|
@@ -65,6 +66,12 @@ export function createBootstrapService(
|
|
|
65
66
|
? normalizeContentLanguage(data.siteLanguage)
|
|
66
67
|
: baseLocale;
|
|
67
68
|
await settings.set("SITE_LANGUAGE", siteLanguage);
|
|
69
|
+
// Pin the dashboard UI locale to the detected catalog language so it stays
|
|
70
|
+
// stable if the operator later changes the public content language.
|
|
71
|
+
await settings.set(
|
|
72
|
+
"DASHBOARD_LANGUAGE",
|
|
73
|
+
resolveCatalogLocale(siteLanguage),
|
|
74
|
+
);
|
|
68
75
|
if (data.cjkSerifFont && data.cjkSerifFont !== "off") {
|
|
69
76
|
await settings.set("CJK_SERIF_FONT", data.cjkSerifFont);
|
|
70
77
|
}
|