@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.
Files changed (131) hide show
  1. package/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. 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("removes the single .md storage object", async () => {
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
- expect(storage.files.size).toBe(0);
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("removes the .md file for every text attachment in the batch", async () => {
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(storage.files.size).toBe(0);
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("deletes poster from storage when posterKey exists", async () => {
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
- const deletedKeys: string[] = [];
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
- await mediaService.delete(media.id, mockStorage as never);
1242
- expect(deletedKeys).toContain("media/vid.mp4");
1243
- expect(deletedKeys).toContain("media/vid.poster.webp");
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("deletes poster keys from storage", async () => {
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
- const deletedKeys: string[] = [];
1289
- const mockStorage = {
1290
- delete: async (key: string) => {
1291
- deletedKeys.push(key);
1292
- },
1293
- put: async () => {},
1294
- get: async () => null,
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
- await mediaService.deleteByIds([m1.id, m2.id], mockStorage as never);
1298
- expect(deletedKeys).toContain("media/a.mp4");
1299
- expect(deletedKeys).toContain("media/a-poster.webp");
1300
- expect(deletedKeys).toContain("media/b.jpg");
1301
- expect(deletedKeys).toHaveLength(3);
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
  }