@jant/core 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (537) hide show
  1. package/README.md +11 -11
  2. package/bin/commands/assets/upload.js +192 -15
  3. package/bin/commands/import-site.js +67 -4
  4. package/bin/commands/reset-password.js +21 -8
  5. package/bin/commands/site/snapshot/export.js +1 -1
  6. package/bin/commands/site/snapshot/import.js +1 -1
  7. package/bin/lib/d1-query.js +5 -16
  8. package/bin/lib/node-env.js +40 -4
  9. package/bin/lib/site-selection.js +85 -29
  10. package/bin/lib/wrangler-cli.js +28 -2
  11. package/bin/lib/wrangler-json.js +35 -0
  12. package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
  13. package/dist/client/.vite/manifest.json +255 -90
  14. package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
  15. package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
  16. package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
  17. package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
  18. package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
  19. package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
  20. package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
  21. package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
  22. package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
  23. package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
  24. package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
  25. package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
  26. package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
  27. package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
  28. package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
  29. package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
  30. package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
  31. package/dist/client/_assets/client-GiYENVw8.css +2 -0
  32. package/dist/client/_assets/client-VnFxJN7G.js +295 -0
  33. package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
  34. package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
  35. package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
  36. package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
  37. package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
  38. package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
  39. package/dist/index.js +2 -5
  40. package/dist/node.js +5 -7
  41. package/package.json +3 -3
  42. package/src/__tests__/auth-session-freshness.test.ts +57 -0
  43. package/src/__tests__/basecoat-variants.test.ts +121 -0
  44. package/src/__tests__/bin/assets-upload.test.ts +68 -0
  45. package/src/__tests__/export-hugo-build.test.ts +260 -0
  46. package/src/__tests__/export-service.test.ts +231 -0
  47. package/src/__tests__/helpers/component-css.ts +91 -0
  48. package/src/__tests__/helpers/db.ts +33 -0
  49. package/src/__tests__/helpers/export-fixtures.ts +3 -0
  50. package/src/__tests__/mise-config.test.ts +22 -0
  51. package/src/__tests__/stylesheet-audience.test.ts +159 -0
  52. package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
  53. package/src/app.tsx +71 -16
  54. package/src/auth.ts +18 -1
  55. package/src/client/__tests__/collection-page-actions.test.ts +51 -2
  56. package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
  57. package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
  58. package/src/client/__tests__/compose-bridge.test.ts +524 -2
  59. package/src/client/__tests__/compose-launch.test.ts +62 -1
  60. package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
  61. package/src/client/__tests__/compose-triggers.test.ts +90 -0
  62. package/src/client/__tests__/copy-field.test.ts +162 -0
  63. package/src/client/__tests__/custom-url-menu.test.ts +127 -6
  64. package/src/client/__tests__/image-processor.test.ts +324 -2
  65. package/src/client/__tests__/lazy-entries.test.ts +92 -0
  66. package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
  67. package/src/client/__tests__/runtime-paths.test.ts +84 -0
  68. package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
  69. package/src/client/__tests__/toast.test.ts +88 -1
  70. package/src/client/__tests__/video-processor-probe.test.ts +175 -0
  71. package/src/client/__tests__/video-processor.test.ts +181 -0
  72. package/src/client/audio-processor.ts +24 -15
  73. package/src/client/audio-waveform.ts +49 -0
  74. package/src/client/collection-dialog-host.ts +72 -0
  75. package/src/client/collection-navigation.ts +51 -16
  76. package/src/client/collection-page-actions.ts +26 -0
  77. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  78. package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
  79. package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
  80. package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
  81. package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
  82. package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
  83. package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
  84. package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
  85. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
  86. package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
  87. package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
  88. package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
  89. package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
  90. package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
  91. package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
  92. package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
  93. package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
  94. package/src/client/components/collection-dialog-types.ts +49 -0
  95. package/src/client/components/collection-manager-types.ts +32 -1
  96. package/src/client/components/collection-types.ts +1 -15
  97. package/src/client/components/compose-format-convert.ts +5 -1
  98. package/src/client/components/compose-types.ts +121 -3
  99. package/src/client/components/jant-collection-dialog.ts +722 -0
  100. package/src/client/components/jant-collection-directory.ts +567 -182
  101. package/src/client/components/jant-collection-form.ts +53 -232
  102. package/src/client/components/jant-command-palette.ts +106 -49
  103. package/src/client/components/jant-compose-dialog.ts +2757 -1194
  104. package/src/client/components/jant-compose-editor.ts +866 -397
  105. package/src/client/components/jant-compose-fullscreen.ts +53 -47
  106. package/src/client/components/jant-config-editor.ts +65 -53
  107. package/src/client/components/jant-confirm-dialog.ts +13 -11
  108. package/src/client/components/jant-locale-picker.ts +418 -0
  109. package/src/client/components/jant-media-lightbox.ts +146 -136
  110. package/src/client/components/jant-nav-manager.ts +690 -366
  111. package/src/client/components/jant-post-menu.ts +1142 -268
  112. package/src/client/components/jant-post-picker.ts +324 -0
  113. package/src/client/components/jant-repo-picker.ts +138 -106
  114. package/src/client/components/jant-settings-avatar.ts +31 -27
  115. package/src/client/components/jant-settings-general.ts +617 -543
  116. package/src/client/components/jant-settings-language.ts +1087 -0
  117. package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
  118. package/src/client/components/jant-text-preview.ts +94 -86
  119. package/src/client/components/nav-manager-types.ts +62 -1
  120. package/src/client/components/settings-types.ts +14 -29
  121. package/src/client/components/smart-collection-conditions.ts +285 -0
  122. package/src/client/components/smart-collection-dialog-types.ts +68 -0
  123. package/src/client/compose-bridge.ts +336 -242
  124. package/src/client/compose-launch.ts +89 -2
  125. package/src/client/compose-shortcuts.ts +9 -7
  126. package/src/client/compose-triggers.ts +50 -0
  127. package/src/client/copy-field.ts +108 -0
  128. package/src/client/custom-url-menu.ts +60 -4
  129. package/src/client/icons.ts +14 -0
  130. package/src/client/image-plan.ts +165 -0
  131. package/src/client/image-processor.ts +195 -117
  132. package/src/client/image-worker-client.ts +45 -0
  133. package/src/client/image-worker.ts +135 -0
  134. package/src/client/lazy-entries.ts +95 -0
  135. package/src/client/lazy-slugify.ts +2 -2
  136. package/src/client/media-scroll-hint.ts +43 -2
  137. package/src/client/mediabunny.ts +29 -0
  138. package/src/client/post-picker.ts +41 -0
  139. package/src/client/post-refresh.ts +135 -0
  140. package/src/client/runtime-paths.ts +74 -1
  141. package/src/client/settings-bridge.ts +4 -10
  142. package/src/client/site-header-nav.js +18 -6
  143. package/src/client/smart-collection-dialog-host.ts +116 -0
  144. package/src/client/smart-collection-page-actions.ts +208 -0
  145. package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
  146. package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
  147. package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
  148. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  149. package/src/client/tiptap/bubble-menu.ts +47 -16
  150. package/src/client/tiptap/floating-position.ts +216 -2
  151. package/src/client/tiptap/inline-image-upload.ts +7 -3
  152. package/src/client/tiptap/link-input-rules.ts +4 -4
  153. package/src/client/tiptap/link-toolbar.ts +22 -9
  154. package/src/client/toast.ts +143 -38
  155. package/src/client/types/vite-worker.d.ts +12 -0
  156. package/src/client/upload-with-metadata.ts +3 -13
  157. package/src/client/video-processor.ts +351 -107
  158. package/src/client-auth.ts +14 -31
  159. package/src/client-compose.ts +19 -0
  160. package/src/client-manage.ts +13 -0
  161. package/src/client-settings.ts +14 -0
  162. package/src/client-site.ts +10 -3
  163. package/src/client.ts +4 -0
  164. package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
  165. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  166. package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
  167. package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
  168. package/src/db/__tests__/thread-activity.test.ts +113 -0
  169. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  170. package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
  171. package/src/db/migrations/0031_many_ego.sql +3 -0
  172. package/src/db/migrations/0032_married_tomas.sql +3 -0
  173. package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
  174. package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
  175. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  176. package/src/db/migrations/meta/0032_snapshot.json +2582 -0
  177. package/src/db/migrations/meta/0033_snapshot.json +2816 -0
  178. package/src/db/migrations/meta/0034_snapshot.json +2816 -0
  179. package/src/db/migrations/meta/_journal.json +28 -0
  180. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  181. package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
  182. package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
  183. package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
  184. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  185. package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
  186. package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
  187. package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
  188. package/src/db/migrations/pg/meta/_journal.json +28 -0
  189. package/src/db/pg/schema.ts +213 -27
  190. package/src/db/post-visibility.ts +117 -0
  191. package/src/db/schema.ts +242 -27
  192. package/src/db/thread-activity.ts +101 -0
  193. package/src/i18n/__tests__/detect.test.ts +26 -81
  194. package/src/i18n/__tests__/supported-locales.test.ts +83 -0
  195. package/src/i18n/context.tsx +12 -2
  196. package/src/i18n/detect.ts +7 -66
  197. package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
  198. package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
  199. package/src/i18n/locales/public/en.po +568 -168
  200. package/src/i18n/locales/public/en.ts +1 -1
  201. package/src/i18n/locales/public/zh-Hans.po +562 -162
  202. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  203. package/src/i18n/locales/public/zh-Hant.po +562 -162
  204. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  205. package/src/i18n/locales/settings/en.po +551 -69
  206. package/src/i18n/locales/settings/en.ts +1 -1
  207. package/src/i18n/locales/settings/zh-Hans.po +597 -115
  208. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  209. package/src/i18n/locales/settings/zh-Hant.po +610 -128
  210. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  211. package/src/i18n/locales.ts +94 -0
  212. package/src/i18n/middleware.ts +12 -2
  213. package/src/i18n/supported-locales.ts +80 -0
  214. package/src/lib/__tests__/api-settings.test.ts +11 -2
  215. package/src/lib/__tests__/asset-path.test.ts +92 -0
  216. package/src/lib/__tests__/deferred.test.ts +90 -0
  217. package/src/lib/__tests__/discover-ping.test.ts +89 -0
  218. package/src/lib/__tests__/discover.test.ts +410 -0
  219. package/src/lib/__tests__/feed-policy.test.ts +26 -0
  220. package/src/lib/__tests__/feed.test.ts +1691 -12
  221. package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
  222. package/src/lib/__tests__/hosted-signin.test.ts +1 -1
  223. package/src/lib/__tests__/http-cache.test.ts +162 -0
  224. package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
  225. package/src/lib/__tests__/jant-docs.test.ts +23 -0
  226. package/src/lib/__tests__/lang-detect.test.ts +231 -0
  227. package/src/lib/__tests__/markdown.test.ts +16 -0
  228. package/src/lib/__tests__/media-helpers.test.ts +81 -0
  229. package/src/lib/__tests__/navigation.test.ts +135 -1
  230. package/src/lib/__tests__/resolve-config.test.ts +64 -3
  231. package/src/lib/__tests__/schemas.test.ts +42 -4
  232. package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
  233. package/src/lib/__tests__/slug.test.ts +88 -0
  234. package/src/lib/__tests__/slugify.test.ts +85 -0
  235. package/src/lib/__tests__/storage.test.ts +32 -0
  236. package/src/lib/__tests__/thread-fold.test.ts +113 -0
  237. package/src/lib/__tests__/translit.test.ts +87 -0
  238. package/src/lib/__tests__/upload-sideload.test.ts +14 -0
  239. package/src/lib/__tests__/url-fetch.test.ts +1 -2
  240. package/src/lib/__tests__/url.test.ts +93 -34
  241. package/src/lib/__tests__/view-language.test.ts +413 -0
  242. package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
  243. package/src/lib/address.ts +57 -0
  244. package/src/lib/api-posts.ts +12 -0
  245. package/src/lib/asset-path.ts +98 -1
  246. package/src/lib/build-env.ts +16 -0
  247. package/src/lib/collection-groups.ts +5 -1
  248. package/src/lib/collection-paths.ts +3 -10
  249. package/src/lib/constants.ts +28 -3
  250. package/src/lib/copy-field.ts +36 -0
  251. package/src/lib/crypto.ts +40 -0
  252. package/src/lib/csp-builder.ts +2 -1
  253. package/src/lib/deferred.ts +79 -0
  254. package/src/lib/discover-ping.ts +141 -0
  255. package/src/lib/discover.ts +437 -0
  256. package/src/lib/env.ts +134 -0
  257. package/src/lib/errors.ts +33 -0
  258. package/src/lib/feed-policy.ts +236 -2
  259. package/src/lib/feed.ts +1021 -66
  260. package/src/lib/filter-dimensions.ts +1349 -0
  261. package/src/lib/github-sync-site-config.ts +3 -0
  262. package/src/lib/hosted-signin.ts +1 -1
  263. package/src/lib/http-cache.ts +148 -0
  264. package/src/lib/hugo-markdown.ts +21 -0
  265. package/src/lib/icons.ts +9 -2
  266. package/src/lib/ids.ts +3 -0
  267. package/src/lib/jant-docs.ts +42 -0
  268. package/src/lib/lang-detect.ts +308 -0
  269. package/src/lib/link-preview.ts +25 -0
  270. package/src/lib/markdown-manager.ts +6 -1
  271. package/src/lib/media-helpers.ts +13 -1
  272. package/src/lib/navigation.ts +78 -11
  273. package/src/lib/pagination.ts +28 -24
  274. package/src/lib/per-language-surfaces.ts +67 -0
  275. package/src/lib/post-display.ts +42 -9
  276. package/src/lib/post-meta.ts +19 -0
  277. package/src/lib/render.tsx +55 -7
  278. package/src/lib/resolve-config.ts +53 -5
  279. package/src/lib/schemas.ts +201 -10
  280. package/src/lib/site-header-fragment.tsx +2 -1
  281. package/src/lib/site-unavailable-page.ts +47 -0
  282. package/src/lib/slug.ts +51 -7
  283. package/src/lib/slugify.ts +113 -0
  284. package/src/lib/startup-config.ts +4 -6
  285. package/src/lib/storage.ts +7 -0
  286. package/src/lib/summary.ts +50 -0
  287. package/src/lib/thread-fold.ts +161 -0
  288. package/src/lib/time.ts +32 -0
  289. package/src/lib/timeline.ts +81 -10
  290. package/src/lib/tiptap-render.ts +5 -4
  291. package/src/lib/translit.ts +288 -0
  292. package/src/lib/upload.ts +16 -13
  293. package/src/lib/url.ts +188 -24
  294. package/src/lib/version.ts +10 -8
  295. package/src/lib/view-language.ts +436 -0
  296. package/src/lib/view.ts +79 -23
  297. package/src/lib/viewer-context.ts +62 -0
  298. package/src/middleware/__tests__/auth.test.ts +70 -2
  299. package/src/middleware/__tests__/cache-control.test.ts +71 -3
  300. package/src/middleware/__tests__/onboarding.test.ts +76 -5
  301. package/src/middleware/__tests__/secure-headers.test.ts +1 -1
  302. package/src/middleware/__tests__/session.test.ts +181 -33
  303. package/src/middleware/auth.ts +50 -31
  304. package/src/middleware/cache-control.ts +75 -10
  305. package/src/middleware/config.ts +6 -6
  306. package/src/middleware/error-handler.ts +12 -0
  307. package/src/middleware/onboarding.ts +32 -10
  308. package/src/middleware/secure-headers.ts +1 -1
  309. package/src/middleware/session.ts +96 -8
  310. package/src/node/__tests__/cli-migrate.test.ts +21 -19
  311. package/src/node/__tests__/cli-reset-password.test.ts +81 -8
  312. package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
  313. package/src/node/__tests__/cli-site-selection.test.ts +128 -38
  314. package/src/node/__tests__/runtime.test.ts +37 -0
  315. package/src/node/index.ts +1 -0
  316. package/src/preset.css +76 -22
  317. package/src/routes/__tests__/feed-validator.test.ts +156 -0
  318. package/src/routes/api/__tests__/collections.test.ts +114 -0
  319. package/src/routes/api/__tests__/discover.test.ts +222 -0
  320. package/src/routes/api/__tests__/nav-items.test.ts +212 -2
  321. package/src/routes/api/__tests__/palette.test.ts +44 -0
  322. package/src/routes/api/__tests__/post-translations.test.ts +829 -0
  323. package/src/routes/api/__tests__/settings.test.ts +24 -3
  324. package/src/routes/api/__tests__/telegram.test.ts +6 -5
  325. package/src/routes/api/collections.ts +49 -6
  326. package/src/routes/api/custom-urls.ts +0 -1
  327. package/src/routes/api/discover.ts +95 -0
  328. package/src/routes/api/export.ts +3 -0
  329. package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
  330. package/src/routes/api/nav-items.ts +55 -8
  331. package/src/routes/api/posts.ts +172 -9
  332. package/src/routes/api/public/__tests__/archive.test.ts +111 -0
  333. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  334. package/src/routes/api/public/archive.ts +77 -81
  335. package/src/routes/api/public/posts.ts +30 -6
  336. package/src/routes/api/smart-collections.ts +132 -0
  337. package/src/routes/api/telegram.ts +2 -40
  338. package/src/routes/api/uploads.ts +1 -2
  339. package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
  340. package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
  341. package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
  342. package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
  343. package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
  344. package/src/routes/auth/__tests__/setup.test.ts +278 -26
  345. package/src/routes/auth/__tests__/signin.test.ts +135 -0
  346. package/src/routes/auth/setup.tsx +934 -186
  347. package/src/routes/auth/signin.tsx +10 -8
  348. package/src/routes/compose.tsx +11 -2
  349. package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
  350. package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
  351. package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
  352. package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
  353. package/src/routes/dash/custom-urls.tsx +1 -37
  354. package/src/routes/dash/settings.tsx +502 -41
  355. package/src/routes/discover-announce.ts +96 -0
  356. package/src/routes/feed/__tests__/feed.test.ts +165 -2
  357. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  358. package/src/routes/feed/feed.ts +69 -40
  359. package/src/routes/feed/sitemap.ts +95 -11
  360. package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
  361. package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
  362. package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
  363. package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
  364. package/src/routes/pages/__tests__/collections.test.ts +9 -3
  365. package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
  366. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  367. package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
  368. package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
  369. package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
  370. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  371. package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
  372. package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
  373. package/src/routes/pages/archive.tsx +507 -357
  374. package/src/routes/pages/collection.tsx +94 -96
  375. package/src/routes/pages/collections.tsx +38 -50
  376. package/src/routes/pages/featured.tsx +32 -13
  377. package/src/routes/pages/home.tsx +25 -6
  378. package/src/routes/pages/language.tsx +113 -0
  379. package/src/routes/pages/latest.tsx +26 -16
  380. package/src/routes/pages/new.tsx +3 -0
  381. package/src/routes/pages/page.tsx +183 -19
  382. package/src/routes/pages/partials.tsx +41 -0
  383. package/src/routes/pages/search.tsx +18 -3
  384. package/src/routes/pages/smart-collection.tsx +340 -0
  385. package/src/routes/pages/subscribe.tsx +182 -0
  386. package/src/runtime/__tests__/index.test.ts +19 -0
  387. package/src/runtime/__tests__/node.test.ts +129 -4
  388. package/src/runtime/cloudflare.ts +21 -2
  389. package/src/runtime/index.ts +27 -1
  390. package/src/runtime/node.ts +18 -1
  391. package/src/runtime/site.ts +95 -24
  392. package/src/services/__tests__/auth.test.ts +116 -7
  393. package/src/services/__tests__/collection.test.ts +139 -4
  394. package/src/services/__tests__/language.test.ts +495 -0
  395. package/src/services/__tests__/navigation.test.ts +213 -10
  396. package/src/services/__tests__/path.test.ts +180 -0
  397. package/src/services/__tests__/post-language.test.ts +830 -0
  398. package/src/services/__tests__/post-timeline.test.ts +142 -11
  399. package/src/services/__tests__/post.test.ts +323 -9
  400. package/src/services/__tests__/settings.test.ts +228 -17
  401. package/src/services/__tests__/site-admin.test.ts +5 -5
  402. package/src/services/__tests__/smart-collection.test.ts +651 -0
  403. package/src/services/about-page.ts +22 -1
  404. package/src/services/auth.ts +51 -49
  405. package/src/services/bootstrap.ts +188 -38
  406. package/src/services/collection-directory-position.ts +76 -0
  407. package/src/services/collection.ts +330 -47
  408. package/src/services/custom-url.ts +27 -22
  409. package/src/services/export-theme/assets/client-site.css +1 -1
  410. package/src/services/export-theme/assets/client-site.js +123 -123
  411. package/src/services/export-theme/layouts/_default/rss.xml +77 -1
  412. package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
  413. package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
  414. package/src/services/export-theme/layouts/partials/pagination.html +14 -13
  415. package/src/services/export-theme/layouts/partials/post-card.html +8 -4
  416. package/src/services/export-theme/layouts/partials/reply.html +2 -1
  417. package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
  418. package/src/services/export-theme/styles/main.css +13 -1
  419. package/src/services/export.ts +281 -24
  420. package/src/services/index.ts +34 -3
  421. package/src/services/language.ts +412 -0
  422. package/src/services/mcp.ts +3 -1
  423. package/src/services/media.ts +14 -6
  424. package/src/services/navigation.ts +429 -67
  425. package/src/services/path.ts +225 -7
  426. package/src/services/post.ts +1318 -252
  427. package/src/services/search.ts +48 -2
  428. package/src/services/settings.ts +238 -43
  429. package/src/services/site-admin.ts +24 -7
  430. package/src/services/site.ts +77 -1
  431. package/src/services/smart-collection.ts +636 -0
  432. package/src/services/telegram.ts +20 -22
  433. package/src/services/upload-session.ts +1 -3
  434. package/src/style-author.css +27 -0
  435. package/src/styles/components-author.css +1622 -0
  436. package/src/styles/components.css +24 -1585
  437. package/src/styles/site-media.css +20 -6
  438. package/src/styles/tokens.css +144 -13
  439. package/src/styles/ui-author.css +7602 -0
  440. package/src/styles/ui.css +1224 -7276
  441. package/src/types/app-context.ts +7 -0
  442. package/src/types/bindings.ts +3 -0
  443. package/src/types/config.ts +94 -15
  444. package/src/types/constants.ts +188 -16
  445. package/src/types/entities.ts +92 -1
  446. package/src/types/operations.ts +64 -2
  447. package/src/types/props.ts +168 -30
  448. package/src/types/views.ts +61 -1
  449. package/src/ui/__tests__/font-themes.test.ts +52 -12
  450. package/src/ui/compose/ComposeDialog.tsx +215 -21
  451. package/src/ui/compose/ComposePrompt.tsx +1 -5
  452. package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
  453. package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
  454. package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
  455. package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
  456. package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
  457. package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
  458. package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
  459. package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
  460. package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
  461. package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
  462. package/src/ui/dash/settings/GeneralContent.tsx +275 -113
  463. package/src/ui/dash/settings/LanguageContent.tsx +409 -0
  464. package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
  465. package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
  466. package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
  467. package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
  468. package/src/ui/dash/settings/discover-copy.ts +116 -0
  469. package/src/ui/feed/LinkCard.tsx +55 -45
  470. package/src/ui/feed/LinkPreview.tsx +2 -7
  471. package/src/ui/feed/NoteCard.tsx +9 -4
  472. package/src/ui/feed/PostStatusBadges.tsx +103 -17
  473. package/src/ui/feed/QuoteCard.tsx +55 -30
  474. package/src/ui/feed/ThreadPreview.tsx +6 -2
  475. package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
  476. package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
  477. package/src/ui/feed/thread-preview-state.ts +20 -6
  478. package/src/ui/font-themes.ts +39 -29
  479. package/src/ui/layouts/BaseLayout.tsx +155 -10
  480. package/src/ui/layouts/SiteLayout.tsx +192 -34
  481. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
  482. package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
  483. package/src/ui/pages/ArchivePage.tsx +401 -301
  484. package/src/ui/pages/CollectionPage.tsx +86 -76
  485. package/src/ui/pages/CollectionsPage.tsx +5 -0
  486. package/src/ui/pages/ComposePage.tsx +8 -0
  487. package/src/ui/pages/PostPage.tsx +47 -0
  488. package/src/ui/pages/SearchPage.tsx +3 -3
  489. package/src/ui/pages/SmartCollectionPage.tsx +434 -0
  490. package/src/ui/pages/SubscribePage.tsx +236 -0
  491. package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
  492. package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
  493. package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
  494. package/src/ui/shared/CollectionDirectory.tsx +178 -3
  495. package/src/ui/shared/CollectionsManager.tsx +70 -17
  496. package/src/ui/shared/CopyField.tsx +127 -0
  497. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  498. package/src/ui/shared/EmptyPostContent.tsx +23 -0
  499. package/src/ui/shared/MediaGallery.tsx +125 -46
  500. package/src/ui/shared/Pagination.tsx +20 -5
  501. package/src/ui/shared/PostFooter.tsx +55 -23
  502. package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
  503. package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
  504. package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
  505. package/src/ui/shared/__tests__/pagination.test.ts +38 -24
  506. package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
  507. package/src/ui/shared/archive-labels.ts +98 -0
  508. package/src/ui/shared/collection-management-labels.ts +94 -16
  509. package/src/ui/shared/custom-icons.ts +5 -0
  510. package/src/ui/shared/navigation-labels.ts +45 -71
  511. package/src/ui/shared/post-article-attributes.ts +8 -0
  512. package/src/ui/shared/smart-collection-labels.ts +343 -0
  513. package/dist/app-BxCOR3Uc.js +0 -6
  514. package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
  515. package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
  516. package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
  517. package/dist/client/_assets/client-B_eGKN5p.css +0 -2
  518. package/dist/client/_assets/client-DxY5BFe8.js +0 -296
  519. package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
  520. package/dist/env-OHRKGcMj.js +0 -281
  521. package/dist/github-api-BgSiE71w.js +0 -176
  522. package/dist/github-app-BbklkFmU.js +0 -275
  523. package/dist/github-sync-Cq1pzzOI.js +0 -431
  524. package/dist/github-sync-ppWXN3jb.js +0 -4
  525. package/dist/url-BMYO-Zlt.js +0 -395
  526. package/src/client/__tests__/collection-created-notice.test.ts +0 -36
  527. package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
  528. package/src/client/__tests__/post-form-bridge.test.ts +0 -82
  529. package/src/client/collection-created-notice.ts +0 -66
  530. package/src/client/collection-form-bridge.ts +0 -191
  531. package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
  532. package/src/client/components/jant-post-form.ts +0 -422
  533. package/src/client/components/post-form-template.ts +0 -352
  534. package/src/client/components/post-form-types.ts +0 -96
  535. package/src/client/media-metadata.ts +0 -247
  536. package/src/client/post-form-bridge.ts +0 -193
  537. package/src/ui/pages/CollectionEditorPage.tsx +0 -175
@@ -15,6 +15,7 @@ import {
15
15
  isNull,
16
16
  desc,
17
17
  inArray,
18
+ notInArray,
18
19
  sql,
19
20
  isNotNull,
20
21
  asc,
@@ -32,6 +33,11 @@ import {
32
33
  sqliteSchemaBundle,
33
34
  type DatabaseSchema,
34
35
  } from "../db/schema-bundle.js";
36
+ import {
37
+ buildRootActivityExpr,
38
+ rootActivityColumns,
39
+ } from "../db/thread-activity.js";
40
+ import { buildReaderVisibilityConditions } from "../db/post-visibility.js";
35
41
  import { createEntityId } from "../lib/ids.js";
36
42
  import { now } from "../lib/time.js";
37
43
  import { trimTiptapBody } from "../lib/tiptap-render.js";
@@ -42,12 +48,17 @@ import {
42
48
  tryPreparePostBodyHtml,
43
49
  } from "../lib/post-body-html.js";
44
50
  import { extractSummary, extractBodyText } from "../lib/summary.js";
51
+ import {
52
+ THREAD_LEADING_REPLIES,
53
+ THREAD_TRAILING_REPLIES,
54
+ } from "../lib/thread-fold.js";
45
55
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
46
56
  import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
47
57
  import { generatePostSlug } from "../lib/slug.js";
48
58
  import { getSlugValidationIssue } from "../lib/slug-format.js";
49
59
  import { isReservedPath } from "../lib/constants.js";
50
- import { normalizePath, slugify } from "../lib/url.js";
60
+ import { normalizePath } from "../lib/url.js";
61
+ import { slugify } from "../lib/slugify.js";
51
62
  import type { StorageDriver } from "../lib/storage.js";
52
63
  import type { MediaService } from "./media.js";
53
64
  import {
@@ -74,6 +85,14 @@ import {
74
85
  ValidationError,
75
86
  NotFoundError,
76
87
  } from "../lib/errors.js";
88
+ import {
89
+ isValidContentLanguage,
90
+ normalizeContentLanguage,
91
+ toLanguagePrefix,
92
+ } from "../i18n/locales.js";
93
+ import { readLanguageSettings } from "./language.js";
94
+ import { getOrBuildEntry } from "../i18n/supported-locales.js";
95
+ import { suggestPostLanguage } from "../lib/lang-detect.js";
77
96
  import { createPathService, type PathService } from "./path.js";
78
97
  import {
79
98
  extractYouTubeVideoId,
@@ -137,12 +156,36 @@ export interface PostFilters {
137
156
  /** Exclude private posts from results */
138
157
  excludePrivate?: boolean;
139
158
  threadId?: string;
159
+ /**
160
+ * Restrict to these Posts. The list becomes one `IN (…)`, so a caller keeps
161
+ * it within a single statement's parameter budget — 50 is what D1 leaves
162
+ * room for beside the other conditions.
163
+ */
164
+ ids?: string[];
140
165
  /** Unix timestamp (inclusive) — only posts published at or after this time */
141
166
  publishedAfter?: number;
142
167
  /** Unix timestamp (exclusive) — only posts published before this time */
143
168
  publishedBefore?: number;
169
+ /**
170
+ * Unix timestamp (inclusive) — range filter on whichever column `sortBy`
171
+ * selects. Kept separate from `publishedAfter` so a caller can pin a
172
+ * publication window (the RSS delay) and still filter on the sort axis.
173
+ */
174
+ axisAfter?: number;
175
+ /** Unix timestamp (exclusive) — upper bound on the `sortBy` column */
176
+ axisBefore?: number;
144
177
  /** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
145
178
  mediaKinds?: MediaKind[];
179
+ /**
180
+ * Restrict to one BCP 47 content language (canonical form, matched exactly).
181
+ *
182
+ * A plain column predicate is correct at every grain because `post.language`
183
+ * is uniform across a Thread — replies inherit the Root's value and a
184
+ * language change rewrites the whole Thread. It rides along as a residual
185
+ * predicate on the existing hot-path partial indexes rather than getting its
186
+ * own index; see the multilingual design notes for the upgrade path.
187
+ */
188
+ lang?: string;
146
189
  /** Filter by media presence */
147
190
  hasMedia?: boolean;
148
191
  /** Filter by title presence */
@@ -156,8 +199,20 @@ export interface PostFilters {
156
199
  hasReplies?: boolean;
157
200
  /** Explicit result sort order */
158
201
  sortOrder?: SortOrder;
159
- /** Timestamp used for chronological sorting (defaults to thread activity). */
160
- sortBy?: "activity" | "published";
202
+ /**
203
+ * Time axis for chronological queries (defaults to announced activity).
204
+ *
205
+ * - `activity` — `lastActivityAt`: last announced post. Quiet replies do not
206
+ * move it. Latest and the feeds use this.
207
+ * - `thread_updated` — `threadUpdatedAt`: last post of any kind, quiet
208
+ * included. The archive's updated sort uses this.
209
+ * - `published` — when the Thread root itself was published.
210
+ *
211
+ * Selects the column used for sorting **and** for the year/month bucketing
212
+ * done by `countByYearMonth` and `getDistinctYears`, so a caller that groups
213
+ * its results cannot end up with buckets that disagree with the order.
214
+ */
215
+ sortBy?: "activity" | "thread_updated" | "published";
161
216
  /** Ignore global pinned ordering when results must remain chronological. */
162
217
  ignorePinnedSort?: boolean;
163
218
  limit?: number;
@@ -165,18 +220,29 @@ export interface PostFilters {
165
220
  offset?: number; // offset for page-based pagination
166
221
  }
167
222
 
223
+ /** What one filter measured over a set of Posts. See `aggregateMany`. */
224
+ export interface PostFilterAggregate {
225
+ count: number;
226
+ /** Newest activity among the matched rows; `null` when nothing matched. */
227
+ recentActivityAt: number | null;
228
+ }
229
+
168
230
  /** Config for automatic summary extraction */
169
231
  export interface SummaryConfig {
170
232
  maxParagraphs: number;
171
233
  maxChars: number;
172
234
  }
173
235
 
174
- interface ThreadRootPageOptions {
236
+ export interface ThreadRootPageOptions {
175
237
  status?: Status;
176
238
  excludePrivate?: boolean;
177
239
  excludeLatestHidden?: boolean;
240
+ /** See `PostFilters.lang`. */
241
+ lang?: string;
178
242
  /** Exclude Posts published at or after this Unix timestamp. */
179
243
  publishedBefore?: number;
244
+ /** Restrict to these Threads, by root ID. Same budget as `PostFilters.ids`. */
245
+ threadIds?: string[];
180
246
  /** Restrict by the Thread root's format without excluding Child Posts. */
181
247
  rootFormat?: Format;
182
248
  /** Restrict to Threads with at least one rated published post. */
@@ -243,8 +309,41 @@ export interface SitemapPostEntry {
243
309
  alias: string | null;
244
310
  updatedAt: number;
245
311
  featuredAt: number | null;
312
+ /** Content language, when the post has one. */
313
+ language: string | null;
314
+ /** Translation group, used to emit sitemap `hreflang` alternates. */
315
+ translationGroupId: string | null;
246
316
  }
247
317
 
318
+ /**
319
+ * What an address the author pasted turned out to be, for the one question the
320
+ * translation picker asks: can this Thread be linked to that one?
321
+ *
322
+ * Every "no" carries which one it is, because the author is looking at a page
323
+ * they know exists and needs to be told what is wrong with it, not that the
324
+ * search found nothing.
325
+ */
326
+ export type TranslationCandidateResolution =
327
+ /** Linkable: the Thread root the address names. */
328
+ | { status: "ok"; post: Post }
329
+ /** No page at that address — a typo, or something since deleted. */
330
+ | { status: "not_found" }
331
+ /** A real page, but a collection or an archive rather than a Post. */
332
+ | { status: "not_a_post" }
333
+ /** The Thread the author is already linking from. */
334
+ | { status: "same_thread" }
335
+ | { status: "unpublished" }
336
+ /** Nothing to translate between until it has a language. */
337
+ | { status: "no_language" }
338
+ /** Written in the same language as this Thread. */
339
+ | { status: "same_language" }
340
+ /** A Thread already linked here speaks for that language. */
341
+ | { status: "language_taken"; language: string }
342
+ /** Both sides carry a group; linking would silently merge them. */
343
+ | { status: "group_conflict" }
344
+ /** Its group already holds this Thread's language. */
345
+ | { status: "group_language_taken"; language: string };
346
+
248
347
  export interface PostService {
249
348
  getById(id: string): Promise<Post | null>;
250
349
  getBodyContent(id: string): Promise<PostBodyContent | null>;
@@ -289,7 +388,39 @@ export interface PostService {
289
388
  count(filters?: PostFilters): Promise<number>;
290
389
  /** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
291
390
  countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
292
- /** Count posts grouped by published year-month (YYYY-MM) */
391
+ /**
392
+ * Measure several filters at once, in a single round trip.
393
+ *
394
+ * One table scan with one `SUM(CASE …)` and one `MAX(CASE …)` per filter,
395
+ * rather than one query per filter. On Workers the round trip is the cost
396
+ * that matters, and a page that lists twenty smart collections would
397
+ * otherwise pay twenty of them — `Promise.all` only turns "slow in sequence"
398
+ * into "expensive in parallel".
399
+ *
400
+ * Every predicate is built by the same `buildFilterConditions` a single
401
+ * `count` uses, so the numbers here and the numbers on the pages they link to
402
+ * cannot drift.
403
+ *
404
+ * `recentActivityAt` is the newest activity among the matched rows, on the
405
+ * one definition {@link buildRootActivityExpr} holds for everything that
406
+ * orders or dates a Thread. It reads the matched row's own columns, so it
407
+ * describes a Thread only when `base` excludes replies — which is what every
408
+ * caller does, because a reply is not a member of anything.
409
+ *
410
+ * @param filters - One filter per measurement, in order
411
+ * @param base - Applied to every measurement: the site, the reader's
412
+ * visibility floor, and anything else common to all of them
413
+ * @returns Count and newest activity, positionally matching `filters`;
414
+ * `recentActivityAt` is `null` where nothing matched
415
+ * @example
416
+ * await posts.aggregateMany([{ format: "note" }, { format: "quote" }], base);
417
+ * // [{ count: 12, recentActivityAt: 1706100000 }, { count: 5, … }]
418
+ */
419
+ aggregateMany(
420
+ filters: readonly PostFilters[],
421
+ base: PostFilters,
422
+ ): Promise<PostFilterAggregate[]>;
423
+ /** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
293
424
  countByYearMonth(
294
425
  filters?: PostFilters,
295
426
  ): Promise<{ yearMonth: string; count: number }[]>;
@@ -346,11 +477,132 @@ export interface PostService {
346
477
  */
347
478
  deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
348
479
  getThread(rootId: string): Promise<Post[]>;
480
+ /**
481
+ * 1-based position of a Post in the reply chain running from its Thread root
482
+ * down to it: a root is 1, a reply to it is 2, a reply to that is 3.
483
+ *
484
+ * Threads are stored as a tree (`replyToId`) flattened under one `threadId`,
485
+ * so this is the Post's depth, not how many Posts the Thread holds — two
486
+ * replies to the same parent are both at the same position.
487
+ *
488
+ * @param postId - TypeID of the Post to locate
489
+ * @returns The 1-based position, or 0 when no such Post exists
490
+ * @example
491
+ * // Root -> reply -> reply, asked about the last one
492
+ * await posts.getThreadPosition(lastId); // => 3
493
+ */
494
+ getThreadPosition(postId: string): Promise<number>;
349
495
  updateThreadStatusAndVisibility(
350
496
  rootId: string,
351
497
  status: Status,
352
498
  visibility: Visibility,
353
499
  ): Promise<void>;
500
+ /**
501
+ * Set the content language of a whole Thread (root and every reply).
502
+ *
503
+ * Thread-wide by design: `post.language` is uniform inside a Thread, which is
504
+ * what lets every language filter stay a plain column predicate. Rejects a
505
+ * language another Post in the same translation group already holds.
506
+ *
507
+ * @param postId - Any Post in the Thread; the Thread is resolved from it
508
+ * @param language - Canonical BCP 47 tag
509
+ * @throws {NotFoundError} When no such Post exists on this site
510
+ * @throws {ConflictError} When the translation group already has that language
511
+ * @example
512
+ * await posts.setThreadLanguage(post.id, "zh-Hans");
513
+ */
514
+ setThreadLanguage(postId: string, language: string): Promise<void>;
515
+ /**
516
+ * Stamp every Post that has no language yet with `language`.
517
+ *
518
+ * Runs once when an author turns multilingual content on, and is idempotent
519
+ * so re-enabling later only touches Posts written while it was off. Scoped to
520
+ * this site — hosted deployments share one database.
521
+ *
522
+ * @param language - Canonical BCP 47 tag of the site's primary language
523
+ * @returns Number of Posts stamped
524
+ * @example
525
+ * await posts.materializeMissingLanguage("zh-Hans"); // => 347
526
+ */
527
+ materializeMissingLanguage(language: string): Promise<number>;
528
+ /** Count Posts on this site that still have no language. */
529
+ countMissingLanguage(): Promise<number>;
530
+ /** Count Posts on this site written in `language`, drafts included. */
531
+ countByLanguage(language: string): Promise<number>;
532
+ /**
533
+ * Every language stamped on this site's Posts, with how many carry it.
534
+ * Drafts included; the pre-multilingual NULL rows are not.
535
+ */
536
+ listLanguagesInUse(): Promise<Array<{ language: string; count: number }>>;
537
+ /**
538
+ * List the Thread roots that are translations of the given Post, excluding
539
+ * the Post itself. Empty when it belongs to no translation group.
540
+ */
541
+ listTranslations(postId: string): Promise<Post[]>;
542
+ /**
543
+ * List translations for many Thread roots in one round trip, keyed by the
544
+ * root ID that was asked about. Roots with no group are omitted.
545
+ */
546
+ getTranslationsMap(postIds: string[]): Promise<Map<string, Post[]>>;
547
+ /**
548
+ * Find published Thread roots this Post could be linked to as a translation.
549
+ *
550
+ * Filtered server-side rather than in the menu, because "can these two be
551
+ * linked" is a rule the service owns: a candidate has to be a Thread root in
552
+ * a language this Post's translation group does not already hold, and the two
553
+ * groups must not both exist — `linkTranslation` refuses to merge them.
554
+ * Offering a post that will be rejected teaches the author nothing.
555
+ *
556
+ * The match is a plain substring scan over title and body text. It is a
557
+ * deliberate, authenticated action over one author's own posts, so the cost
558
+ * is bounded and the shared search index (which cannot express these filters)
559
+ * is not worth involving.
560
+ *
561
+ * @param postId - Any Post in the Thread looking for a translation
562
+ * @param options - Search text and how many candidates to return
563
+ * @returns Matching Thread roots, newest first
564
+ * @example
565
+ * await posts.listTranslationCandidates(id, { query: "recipe" });
566
+ */
567
+ listTranslationCandidates(
568
+ postId: string,
569
+ options: { query: string; limit?: number },
570
+ ): Promise<Post[]>;
571
+ /**
572
+ * Resolve an address the author pasted into the Thread it names, and say
573
+ * whether this Post could be linked to it as a translation.
574
+ *
575
+ * The same rules as {@link PostService.listTranslationCandidates}, asked
576
+ * about one known Thread instead of searched for — so the answer can be a
577
+ * reason rather than an empty list. An address that lands on a reply names
578
+ * its Thread, because translations link whole Threads.
579
+ *
580
+ * @param postId - Any Post in the Thread looking for a translation
581
+ * @param path - Internal path, as produced by `toInternalPath()`
582
+ * @returns The Thread root to link, or why it cannot be
583
+ * @example
584
+ * await posts.resolveTranslationCandidate(id, "/hello-world");
585
+ * // { status: "unpublished" }
586
+ */
587
+ resolveTranslationCandidate(
588
+ postId: string,
589
+ path: string,
590
+ ): Promise<TranslationCandidateResolution>;
591
+ /**
592
+ * Link two already-published Thread roots as translations of each other.
593
+ *
594
+ * Joins the side without a group into the other's group, minting one when
595
+ * neither has any. Merging two existing groups is refused — that would
596
+ * silently restructure both sides.
597
+ *
598
+ * @throws {ConflictError} When the languages clash or both sides have groups
599
+ */
600
+ linkTranslation(postId: string, otherPostId: string): Promise<void>;
601
+ /**
602
+ * Remove a Thread root from its translation group. When that leaves a single
603
+ * Post behind, its group is cleared too so no one-member groups survive.
604
+ */
605
+ unlinkTranslation(postId: string): Promise<void>;
354
606
  /** Get reply counts for multiple posts */
355
607
  getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
356
608
  /** Get preview replies for multiple thread roots */
@@ -416,10 +668,31 @@ export interface PostService {
416
668
  rootIds: string[],
417
669
  options?: Pick<PostFilters, "publishedBefore">,
418
670
  ): Promise<Map<string, Post[]>>;
419
- /** Get distinct years that have published posts */
671
+ /** Get distinct years with posts, bucketed on the `sortBy` time axis */
420
672
  getDistinctYears(filters?: PostFilters): Promise<number[]>;
421
- /** For each thread ID, return the ID of the last published, non-deleted post */
422
- getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
673
+ /**
674
+ * For each Thread ID, resolve the Post that currently ends the chain.
675
+ *
676
+ * Two callers need two different answers. Readers ask what the audience can
677
+ * see, so drafts must not count — the Reply affordance belongs on the last
678
+ * published Post. The reply guard asks what the chain physically ends with,
679
+ * and there an unpublished draft still owns the slot: attaching a second
680
+ * reply to the same parent would fork a structure that is meant to be linear.
681
+ *
682
+ * @param threadIds - Thread root IDs to resolve (duplicates are fine)
683
+ * @param options - `includeDrafts` counts unpublished members as the tail
684
+ * @returns Map of Thread root ID to its tail Post ID; threads with no
685
+ * matching member are absent from the map
686
+ * @example
687
+ * ```ts
688
+ * const tails = await posts.getThreadTailIds([root.id], { includeDrafts: true });
689
+ * const tailId = tails.get(root.id);
690
+ * ```
691
+ */
692
+ getThreadTailIds(
693
+ threadIds: string[],
694
+ options?: { includeDrafts?: boolean },
695
+ ): Promise<Map<string, string>>;
423
696
  /**
424
697
  * Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
425
698
  * by post id. For each row, recomputes the plain-text extraction via
@@ -559,6 +832,34 @@ function ensurePostRating(
559
832
  throw new ErrorCtor("Rating must be an integer between 1 and 5.");
560
833
  }
561
834
 
835
+ /**
836
+ * Normalize a caller-supplied content language to what the column stores.
837
+ *
838
+ * Empty, missing, and unparseable values all become `null` — the single-language
839
+ * state — so the column never holds a half-set value. Whether the tag is one
840
+ * the site actually offers is a settings-layer question, checked where the site
841
+ * language configuration is in scope.
842
+ *
843
+ * @param value - Raw BCP 47 tag from a request or import
844
+ * @returns Canonical tag (`zh-Hans`), or `null` when there is nothing to store
845
+ * @example
846
+ * normalizePostLanguage("ZH-hans"); // "zh-Hans"
847
+ * normalizePostLanguage(" "); // null
848
+ */
849
+ export function normalizePostLanguage(
850
+ value: string | null | undefined,
851
+ ): string | null {
852
+ if (typeof value !== "string") return null;
853
+ const trimmed = value.trim();
854
+ if (!trimmed) return null;
855
+ if (!isValidContentLanguage(trimmed)) {
856
+ throw new ValidationError(
857
+ "Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
858
+ );
859
+ }
860
+ return normalizeContentLanguage(trimmed);
861
+ }
862
+
562
863
  function assertPostFormatShape(data: {
563
864
  format: Format;
564
865
  title?: string | null;
@@ -640,22 +941,28 @@ export function createPostService(
640
941
  const databaseDialect = config.databaseDialect ?? "sqlite";
641
942
  const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
642
943
 
643
- function buildPublishedYearMonthExpr(): SQL<string> {
644
- return databaseDialect === "pg"
645
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY-MM')`
646
- : sql<string>`strftime('%Y-%m', ${posts.publishedAt}, 'unixepoch')`;
944
+ /**
945
+ * Column that carries the time axis selected by `PostFilters.sortBy`.
946
+ * Sorting, year/month bucketing, and date-range filters all read it through
947
+ * here so they can never drift onto different columns.
948
+ */
949
+ function timeAxisColumn(filters: Pick<PostFilters, "sortBy">) {
950
+ if (filters.sortBy === "activity") return posts.lastActivityAt;
951
+ if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
952
+ return posts.publishedAt;
647
953
  }
648
954
 
649
- function buildPublishedYearExpr(): SQL<string> {
955
+ function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
650
956
  return databaseDialect === "pg"
651
- ? sql<string>`to_char(timezone('UTC', to_timestamp(${posts.publishedAt})), 'YYYY')`
652
- : sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`;
957
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
958
+ : sql<string>`strftime('%Y-%m', ${column}, 'unixepoch')`;
653
959
  }
654
960
 
655
- const effectiveVisibilityExpr = sql<string>`coalesce(
656
- ${posts.visibility},
657
- (SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId} AND root.site_id = ${siteId})
658
- )`;
961
+ function buildYearExpr(column: SQLWrapper): SQL<string> {
962
+ return databaseDialect === "pg"
963
+ ? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')`
964
+ : sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
965
+ }
659
966
 
660
967
  /** Check if a slug is available (not used by posts or path_registry) */
661
968
  async function isSlugAvailable(slug: string): Promise<boolean> {
@@ -690,28 +997,58 @@ export function createPostService(
690
997
  return rows.length > 0;
691
998
  }
692
999
 
693
- async function recalculateThreadLastActivity(rootId: string): Promise<void> {
1000
+ /**
1001
+ * Recompute both Thread activity timestamps on the root from the Thread's
1002
+ * rows.
1003
+ *
1004
+ * - `lastActivityAt` — newest published post excluding quiet replies. This
1005
+ * is "last announced" and drives Latest and the feeds.
1006
+ * - `threadUpdatedAt` — newest published post including quiet replies. This
1007
+ * is "last changed" and drives the archive's updated sort.
1008
+ *
1009
+ * Both are derived, never accumulated, so a quiet reply stays quiet no
1010
+ * matter what later edits or deletions trigger a recalculation.
1011
+ *
1012
+ * @param rootId - Thread root post ID
1013
+ */
1014
+ async function recalculateThreadActivity(rootId: string): Promise<void> {
694
1015
  const rootRows = await db
695
1016
  .select({
696
- latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
697
- "latest_published_at",
1017
+ announcedAt: sql<number | null>`MAX(
1018
+ CASE
1019
+ WHEN ${posts.quietReply} THEN NULL
1020
+ ELSE ${posts.publishedAt}
1021
+ END
1022
+ )`.as("announced_at"),
1023
+ updatedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
1024
+ "thread_updated_at",
698
1025
  ),
699
1026
  })
700
1027
  .from(posts)
701
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)));
1028
+ .where(
1029
+ and(
1030
+ eq(posts.siteId, siteId),
1031
+ eq(posts.threadId, rootId),
1032
+ eq(posts.status, "published"),
1033
+ ),
1034
+ );
702
1035
 
703
- const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
1036
+ const announcedAt = rootRows[0]?.announcedAt ?? null;
1037
+ const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
704
1038
  const root = await db
705
1039
  .select({ updatedAt: posts.updatedAt })
706
1040
  .from(posts)
707
1041
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)))
708
1042
  .limit(1);
709
1043
 
710
- const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
1044
+ const fallback = root[0]?.updatedAt ?? now();
711
1045
 
712
1046
  await db
713
1047
  .update(posts)
714
- .set({ lastActivityAt })
1048
+ .set({
1049
+ lastActivityAt: announcedAt ?? fallback,
1050
+ threadUpdatedAt: threadUpdatedAt ?? fallback,
1051
+ })
715
1052
  .where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
716
1053
  }
717
1054
 
@@ -833,22 +1170,71 @@ export function createPostService(
833
1170
  )`;
834
1171
  }
835
1172
 
836
- /** Build WHERE conditions from filters (shared by list and count) */
837
- function buildFilterConditions(filters: PostFilters) {
838
- const conditions = [eq(posts.siteId, siteId)];
839
-
840
- if (filters.status) {
841
- conditions.push(eq(posts.status, filters.status));
842
- }
843
- if (filters.visibility !== undefined) {
844
- conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
1173
+ /**
1174
+ * Load a Post and assert it can carry a translation group.
1175
+ *
1176
+ * Groups live on Thread roots only, and both sides must belong to this site.
1177
+ * These two rules replace the table CHECK the SQLite schema cannot add, so
1178
+ * every write path that touches `translation_group_id` goes through here.
1179
+ */
1180
+ async function requireTranslatableRoot(
1181
+ postId: string,
1182
+ ): Promise<typeof posts.$inferSelect> {
1183
+ const rows = await db
1184
+ .select()
1185
+ .from(posts)
1186
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
1187
+ .limit(1);
1188
+ const row = rows[0];
1189
+ if (!row) {
1190
+ throw new NotFoundError("Post");
845
1191
  }
846
- if (filters.excludeLatestHidden) {
847
- conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1192
+ if (row.threadId !== row.id) {
1193
+ throw new ValidationError(
1194
+ "Translations link whole threads. Use the thread's first post.",
1195
+ );
848
1196
  }
849
- if (filters.excludePrivate) {
850
- conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1197
+ return row;
1198
+ }
1199
+
1200
+ /** Reject a language another Post in the same translation group already holds. */
1201
+ async function assertTranslationLanguageFree(
1202
+ groupId: string,
1203
+ language: string,
1204
+ excludePostId?: string,
1205
+ ): Promise<void> {
1206
+ const rows = await db
1207
+ .select({ id: posts.id, title: posts.title })
1208
+ .from(posts)
1209
+ .where(
1210
+ and(
1211
+ eq(posts.siteId, siteId),
1212
+ eq(posts.translationGroupId, groupId),
1213
+ eq(posts.language, language),
1214
+ ),
1215
+ )
1216
+ .limit(2);
1217
+ const clash = rows.find((row) => row.id !== excludePostId);
1218
+ if (clash) {
1219
+ // The language's own name, not its tag: this message is shown to the
1220
+ // author, and "简体中文" is what they picked in the menu — "zh-Hans" is
1221
+ // an implementation detail they never chose.
1222
+ const languageName = getOrBuildEntry(language).native;
1223
+ throw new ConflictError(
1224
+ clash.title
1225
+ ? `"${clash.title}" is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`
1226
+ : `Another post is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`,
1227
+ );
851
1228
  }
1229
+ }
1230
+
1231
+ /** Build WHERE conditions from filters (shared by list and count) */
1232
+ function buildFilterConditions(filters: PostFilters) {
1233
+ const conditions = [
1234
+ eq(posts.siteId, siteId),
1235
+ ...buildReaderVisibilityConditions(posts, siteId, filters),
1236
+ ];
1237
+
852
1238
  if (filters.pinned !== undefined) {
853
1239
  conditions.push(
854
1240
  filters.pinned
@@ -878,6 +1264,9 @@ export function createPostService(
878
1264
  if (filters.threadId) {
879
1265
  conditions.push(eq(posts.threadId, filters.threadId));
880
1266
  }
1267
+ if (filters.ids !== undefined) {
1268
+ conditions.push(inArray(posts.id, filters.ids));
1269
+ }
881
1270
  if (filters.excludeReplies) {
882
1271
  conditions.push(isNull(posts.replyToId));
883
1272
  }
@@ -887,6 +1276,15 @@ export function createPostService(
887
1276
  if (filters.publishedBefore !== undefined) {
888
1277
  conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
889
1278
  }
1279
+ if (filters.axisAfter !== undefined || filters.axisBefore !== undefined) {
1280
+ const axis = timeAxisColumn(filters);
1281
+ if (filters.axisAfter !== undefined) {
1282
+ conditions.push(sql`${axis} >= ${filters.axisAfter}`);
1283
+ }
1284
+ if (filters.axisBefore !== undefined) {
1285
+ conditions.push(sql`${axis} < ${filters.axisBefore}`);
1286
+ }
1287
+ }
890
1288
  if (filters.mediaKinds && filters.mediaKinds.length > 0) {
891
1289
  const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
892
1290
  conditions.push(
@@ -899,19 +1297,13 @@ export function createPostService(
899
1297
  );
900
1298
  }
901
1299
  if (filters.hasMedia !== undefined) {
902
- if (filters.hasMedia) {
903
- conditions.push(
904
- sql`${posts.id} IN (
905
- SELECT post_id FROM media WHERE site_id = ${siteId}
906
- )`,
907
- );
908
- } else {
909
- conditions.push(
910
- sql`${posts.id} NOT IN (
911
- SELECT post_id FROM media WHERE site_id = ${siteId}
912
- )`,
913
- );
914
- }
1300
+ const mediaExists = sql`EXISTS (
1301
+ SELECT 1
1302
+ FROM media
1303
+ WHERE site_id = ${siteId}
1304
+ AND post_id = ${posts.id}
1305
+ )`;
1306
+ conditions.push(filters.hasMedia ? mediaExists : sql`NOT ${mediaExists}`);
915
1307
  }
916
1308
  if (filters.hasTitle !== undefined) {
917
1309
  if (filters.hasTitle) {
@@ -946,19 +1338,6 @@ export function createPostService(
946
1338
  return conditions;
947
1339
  }
948
1340
 
949
- async function getLastLivePostIdInThread(
950
- threadId: string,
951
- ): Promise<string | null> {
952
- const rows = await db
953
- .select({ id: posts.id })
954
- .from(posts)
955
- .where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId)))
956
- .orderBy(desc(posts.createdAt), desc(posts.id))
957
- .limit(1);
958
-
959
- return rows[0]?.id ?? null;
960
- }
961
-
962
1341
  function getCursorSortTimestamp(
963
1342
  row: typeof posts.$inferSelect,
964
1343
  filters: PostFilters,
@@ -966,9 +1345,33 @@ export function createPostService(
966
1345
  if (filters.sortBy === "published") {
967
1346
  return row.publishedAt ?? row.createdAt;
968
1347
  }
1348
+ if (filters.sortBy === "thread_updated") {
1349
+ return row.threadUpdatedAt ?? -1;
1350
+ }
969
1351
  return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
970
1352
  }
971
1353
 
1354
+ /**
1355
+ * Chronological sort key for `list()`.
1356
+ *
1357
+ * Shared by the ORDER BY and the keyset cursor comparison — they must read
1358
+ * the same expression or pagination silently skips or repeats rows.
1359
+ */
1360
+ function buildSortTimestampExpr(filters: PostFilters): SQLWrapper {
1361
+ if (filters.sortBy === "published") {
1362
+ return sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`;
1363
+ }
1364
+ if (filters.sortBy === "thread_updated") {
1365
+ return posts.threadUpdatedAt;
1366
+ }
1367
+ if (filters.status === "draft") return posts.updatedAt;
1368
+ if (filters.status === "published") return posts.lastActivityAt;
1369
+ return sql<number>`CASE
1370
+ WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1371
+ ELSE ${posts.lastActivityAt}
1372
+ END`;
1373
+ }
1374
+
972
1375
  function buildLexicographicCursorCondition(
973
1376
  keys: [CursorSortKey, ...CursorSortKey[]],
974
1377
  ): SQL<unknown> {
@@ -1008,17 +1411,7 @@ export function createPostService(
1008
1411
  return null;
1009
1412
  }
1010
1413
 
1011
- const sortTimestampExpr =
1012
- filters.sortBy === "published"
1013
- ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1014
- : filters.status === "draft"
1015
- ? posts.updatedAt
1016
- : filters.status === "published"
1017
- ? posts.lastActivityAt
1018
- : sql<number>`CASE
1019
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1020
- ELSE ${posts.lastActivityAt}
1021
- END`;
1414
+ const sortTimestampExpr = buildSortTimestampExpr(filters);
1022
1415
  const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
1023
1416
  const featuredPublishedSortExpr = sql<number>`coalesce(
1024
1417
  ${posts.publishedAt}, ${posts.createdAt}, -1
@@ -1045,80 +1438,61 @@ export function createPostService(
1045
1438
  ...keys,
1046
1439
  ];
1047
1440
 
1048
- if (filters.featured || filters.sortOrder === undefined) {
1049
- if (filters.featured) {
1050
- return buildLexicographicCursorCondition(
1051
- withPinnedSortKey([
1052
- {
1053
- direction: "desc",
1054
- expr: featuredPublishedSortExpr,
1055
- value: cursorFeaturedPublishedAt,
1056
- },
1057
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1058
- ]),
1059
- );
1060
- }
1061
-
1441
+ if (filters.featured) {
1062
1442
  return buildLexicographicCursorCondition(
1063
1443
  withPinnedSortKey([
1064
1444
  {
1065
1445
  direction: "desc",
1066
- expr: sortTimestampSortExpr,
1067
- value: cursorSortTimestamp,
1446
+ expr: featuredPublishedSortExpr,
1447
+ value: cursorFeaturedPublishedAt,
1068
1448
  },
1069
1449
  { direction: "desc", expr: posts.id, value: cursorPost.id },
1070
1450
  ]),
1071
1451
  );
1072
1452
  }
1073
1453
 
1074
- if (filters.sortOrder === "oldest") {
1075
- return buildLexicographicCursorCondition(
1076
- withPinnedSortKey([
1077
- {
1078
- direction: "asc",
1079
- expr: sortTimestampSortExpr,
1080
- value: cursorSortTimestamp,
1081
- },
1082
- { direction: "asc", expr: posts.id, value: cursorPost.id },
1083
- ]),
1084
- );
1085
- }
1086
-
1087
- if (filters.sortOrder === "rating_desc") {
1088
- return buildLexicographicCursorCondition(
1089
- withPinnedSortKey([
1090
- {
1091
- direction: "desc",
1092
- expr: ratingPresenceExpr,
1093
- value: cursorRatingPresence,
1094
- },
1095
- { direction: "desc", expr: ratingSortExpr, value: cursorRating },
1096
- {
1097
- direction: "desc",
1098
- expr: sortTimestampSortExpr,
1099
- value: cursorSortTimestamp,
1100
- },
1101
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1102
- ]),
1103
- );
1454
+ // One case per sort order, mirroring `list()` key for key.
1455
+ switch (filters.sortOrder ?? "newest") {
1456
+ case "oldest":
1457
+ return buildLexicographicCursorCondition(
1458
+ withPinnedSortKey([
1459
+ {
1460
+ direction: "asc",
1461
+ expr: sortTimestampSortExpr,
1462
+ value: cursorSortTimestamp,
1463
+ },
1464
+ { direction: "asc", expr: posts.id, value: cursorPost.id },
1465
+ ]),
1466
+ );
1467
+ case "rating_desc":
1468
+ return buildLexicographicCursorCondition(
1469
+ withPinnedSortKey([
1470
+ {
1471
+ direction: "desc",
1472
+ expr: ratingPresenceExpr,
1473
+ value: cursorRatingPresence,
1474
+ },
1475
+ { direction: "desc", expr: ratingSortExpr, value: cursorRating },
1476
+ {
1477
+ direction: "desc",
1478
+ expr: sortTimestampSortExpr,
1479
+ value: cursorSortTimestamp,
1480
+ },
1481
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1482
+ ]),
1483
+ );
1484
+ case "newest":
1485
+ return buildLexicographicCursorCondition(
1486
+ withPinnedSortKey([
1487
+ {
1488
+ direction: "desc",
1489
+ expr: sortTimestampSortExpr,
1490
+ value: cursorSortTimestamp,
1491
+ },
1492
+ { direction: "desc", expr: posts.id, value: cursorPost.id },
1493
+ ]),
1494
+ );
1104
1495
  }
1105
-
1106
- return buildLexicographicCursorCondition(
1107
- withPinnedSortKey([
1108
- {
1109
- direction: "desc",
1110
- expr: ratingPresenceExpr,
1111
- value: cursorRatingPresence,
1112
- },
1113
- { direction: "asc", expr: ratingSortExpr, value: cursorRating },
1114
- {
1115
- direction: "desc",
1116
- expr: sortTimestampSortExpr,
1117
- value: cursorSortTimestamp,
1118
- },
1119
- { direction: "desc", expr: posts.id, value: cursorPost.id },
1120
- ]),
1121
- );
1122
1496
  }
1123
1497
 
1124
1498
  function toPost(
@@ -1153,8 +1527,16 @@ export function createPostService(
1153
1527
  previewProvider: row.previewProvider,
1154
1528
  replyToId: row.replyToId,
1155
1529
  threadId: row.threadId,
1530
+ language: row.language,
1531
+ translationGroupId: row.translationGroupId,
1532
+ quietReply: row.quietReply,
1156
1533
  publishedAt: row.publishedAt,
1157
1534
  lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
1535
+ threadUpdatedAt:
1536
+ row.threadUpdatedAt ??
1537
+ row.lastActivityAt ??
1538
+ row.publishedAt ??
1539
+ row.updatedAt,
1158
1540
  createdAt: row.createdAt,
1159
1541
  updatedAt: row.updatedAt,
1160
1542
  };
@@ -1244,18 +1626,10 @@ export function createPostService(
1244
1626
  }
1245
1627
 
1246
1628
  function buildThreadRootPageConditions(options?: ThreadRootPageOptions) {
1247
- const conditions: SQL[] = [eq(posts.siteId, siteId)];
1248
- const status = options?.status;
1249
-
1250
- if (status) {
1251
- conditions.push(eq(posts.status, status));
1252
- }
1253
- if (options?.excludePrivate) {
1254
- conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
1255
- }
1256
- if (options?.excludeLatestHidden) {
1257
- conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
1258
- }
1629
+ const conditions: SQL[] = [
1630
+ eq(posts.siteId, siteId),
1631
+ ...buildReaderVisibilityConditions(posts, siteId, options ?? {}),
1632
+ ];
1259
1633
  if (options?.publishedBefore !== undefined) {
1260
1634
  conditions.push(
1261
1635
  sql`${posts.publishedAt} < ${options.publishedBefore}`,
@@ -1280,20 +1654,26 @@ export function createPostService(
1280
1654
  if (options?.hasRating) {
1281
1655
  conditions.push(isNotNull(posts.rating));
1282
1656
  }
1657
+ if (options?.threadIds !== undefined) {
1658
+ conditions.push(inArray(posts.threadId, options.threadIds));
1659
+ }
1283
1660
 
1284
1661
  return conditions;
1285
1662
  }
1286
1663
 
1664
+ /**
1665
+ * Thread activity timestamp used to order collection threads.
1666
+ *
1667
+ * Rows here are Thread *members* grouped by thread_id, so the definition is
1668
+ * reached through a subquery to the root. The outer MAX only collapses the
1669
+ * group — the subquery returns one value per thread. The outer COALESCE
1670
+ * covers a member whose root row is missing.
1671
+ */
1287
1672
  function buildCollectionThreadActivityExpr(alias: string) {
1288
1673
  return sql<number>`MAX(
1289
1674
  COALESCE(
1290
1675
  (
1291
- SELECT CASE
1292
- WHEN root.updated_at > root.created_at
1293
- AND root.updated_at > COALESCE(root.last_activity_at, -1)
1294
- THEN root.updated_at
1295
- ELSE COALESCE(root.last_activity_at, root.updated_at)
1296
- END
1676
+ SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
1297
1677
  FROM post AS root
1298
1678
  WHERE root.site_id = ${siteId}
1299
1679
  AND root.id = ${posts.threadId}
@@ -1553,17 +1933,7 @@ export function createPostService(
1553
1933
  if (filters.cursor && !cursorCondition) {
1554
1934
  return [];
1555
1935
  }
1556
- const sortTimestamp =
1557
- filters.sortBy === "published"
1558
- ? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
1559
- : filters.status === "draft"
1560
- ? posts.updatedAt
1561
- : filters.status === "published"
1562
- ? posts.lastActivityAt
1563
- : sql<number>`CASE
1564
- WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
1565
- ELSE ${posts.lastActivityAt}
1566
- END`;
1936
+ const sortTimestamp = buildSortTimestampExpr(filters);
1567
1937
 
1568
1938
  if (cursorCondition) {
1569
1939
  conditions.push(cursorCondition);
@@ -1594,36 +1964,39 @@ export function createPostService(
1594
1964
  .where(conditions.length > 0 ? and(...conditions) : undefined)
1595
1965
  .limit(filters.limit ?? 100);
1596
1966
 
1597
- let query =
1598
- filters.featured || filters.sortOrder === undefined
1599
- ? baseQuery.orderBy(
1967
+ // A case per sort order, and no shared fallthrough. This was an if/else
1968
+ // chain whose last branch was written for one order and silently caught
1969
+ // another, so `newest` ordered by rating instead of by time wherever it
1970
+ // was passed explicitly — a smart collection page, most visibly.
1971
+ const buildOrderBy = (): SQL<unknown>[] => {
1972
+ if (filters.featured) {
1973
+ return [
1974
+ ...pinnedOrder,
1975
+ desc(featuredPublishedSortExpr),
1976
+ desc(posts.id),
1977
+ ];
1978
+ }
1979
+ switch (filters.sortOrder ?? "newest") {
1980
+ case "oldest":
1981
+ return [...pinnedOrder, asc(sortTimestampSortExpr), asc(posts.id)];
1982
+ case "rating_desc":
1983
+ return [
1600
1984
  ...pinnedOrder,
1601
- filters.featured
1602
- ? desc(featuredPublishedSortExpr)
1603
- : desc(sortTimestampSortExpr),
1985
+ desc(ratingPresence),
1986
+ desc(posts.rating),
1987
+ desc(sortTimestampSortExpr),
1604
1988
  desc(posts.id),
1605
- )
1606
- : filters.sortOrder === "oldest"
1607
- ? baseQuery.orderBy(
1608
- ...pinnedOrder,
1609
- asc(sortTimestampSortExpr),
1610
- asc(posts.id),
1611
- )
1612
- : filters.sortOrder === "rating_desc"
1613
- ? baseQuery.orderBy(
1614
- ...pinnedOrder,
1615
- desc(ratingPresence),
1616
- desc(posts.rating),
1617
- desc(sortTimestampSortExpr),
1618
- desc(posts.id),
1619
- )
1620
- : baseQuery.orderBy(
1621
- ...pinnedOrder,
1622
- desc(ratingPresence),
1623
- asc(posts.rating),
1624
- desc(sortTimestampSortExpr),
1625
- desc(posts.id),
1626
- );
1989
+ ];
1990
+ case "newest":
1991
+ return [
1992
+ ...pinnedOrder,
1993
+ desc(sortTimestampSortExpr),
1994
+ desc(posts.id),
1995
+ ];
1996
+ }
1997
+ };
1998
+
1999
+ let query = baseQuery.orderBy(...buildOrderBy());
1627
2000
 
1628
2001
  if (filters.offset !== undefined) {
1629
2002
  query = query.offset(filters.offset) as typeof query;
@@ -1645,11 +2018,33 @@ export function createPostService(
1645
2018
  conditions.push(sql`${posts.id} > ${afterId}`);
1646
2019
  }
1647
2020
 
2021
+ // `lastmod` describes the page, and a Thread root's page renders its
2022
+ // replies too — so an added or edited reply changes it even though the
2023
+ // root row is untouched. Unlike ordering, edits do count here: the page
2024
+ // really did change. Mirrors `articleModifiedTime` in lib/post-display.
2025
+ //
2026
+ // The outer columns are written out rather than interpolated: Drizzle
2027
+ // only qualifies projection columns when the query has a join, so
2028
+ // `${posts.id}` would render as a bare "id" here and bind to the
2029
+ // subquery's own alias instead of correlating to the outer row.
2030
+ const threadModifiedAt = sql<number>`COALESCE(
2031
+ (
2032
+ SELECT MAX(member.updated_at)
2033
+ FROM post AS member
2034
+ WHERE member.site_id = ${siteId}
2035
+ AND member.thread_id = "post"."id"
2036
+ AND member.status = 'published'
2037
+ ),
2038
+ "post"."updated_at"
2039
+ )`;
2040
+
1648
2041
  const rows = await db
1649
2042
  .select({
1650
2043
  id: posts.id,
1651
- updatedAt: posts.updatedAt,
2044
+ updatedAt: threadModifiedAt,
1652
2045
  featuredAt: posts.featuredAt,
2046
+ language: posts.language,
2047
+ translationGroupId: posts.translationGroupId,
1653
2048
  })
1654
2049
  .from(posts)
1655
2050
  .where(conditions.length > 0 ? and(...conditions) : undefined)
@@ -1675,6 +2070,8 @@ export function createPostService(
1675
2070
  alias,
1676
2071
  updatedAt: row.updatedAt,
1677
2072
  featuredAt: row.featuredAt,
2073
+ language: row.language,
2074
+ translationGroupId: row.translationGroupId,
1678
2075
  };
1679
2076
  })
1680
2077
  .filter((entry): entry is SitemapPostEntry => entry !== null);
@@ -1721,6 +2118,59 @@ export function createPostService(
1721
2118
  return result[0]?.count ?? 0;
1722
2119
  },
1723
2120
 
2121
+ async aggregateMany(filters, base) {
2122
+ if (filters.length === 0) return [];
2123
+
2124
+ const baseConditions = buildFilterConditions(base);
2125
+ // The row is its own Thread root here — callers exclude replies — so the
2126
+ // activity expression reads the columns in hand. No correlated subquery.
2127
+ const activityAt = buildRootActivityExpr({
2128
+ lastActivityAt: posts.lastActivityAt,
2129
+ publishedAt: posts.publishedAt,
2130
+ updatedAt: posts.updatedAt,
2131
+ });
2132
+ // A `SUM(CASE …)` and a `MAX(CASE …)` per filter, over one pass of the
2133
+ // rows the base predicate already narrows to. The aliases are positional
2134
+ // and generated here, so nothing a caller supplies reaches the SQL as an
2135
+ // identifier.
2136
+ const columns = Object.fromEntries(
2137
+ filters.flatMap((filter, index) => {
2138
+ const conditions = buildFilterConditions({ ...base, ...filter });
2139
+ const predicate =
2140
+ conditions.length > 0 ? and(...conditions) : sql`1 = 1`;
2141
+ return [
2142
+ [
2143
+ `n${index}`,
2144
+ sql<number>`CAST(SUM(CASE WHEN (${predicate}) THEN 1 ELSE 0 END) AS INTEGER)`,
2145
+ ],
2146
+ [
2147
+ `a${index}`,
2148
+ sql<
2149
+ number | null
2150
+ >`MAX(CASE WHEN (${predicate}) THEN ${activityAt} END)`,
2151
+ ],
2152
+ ];
2153
+ }),
2154
+ );
2155
+
2156
+ const rows = await db
2157
+ .select(columns)
2158
+ .from(posts)
2159
+ .where(baseConditions.length > 0 ? and(...baseConditions) : undefined);
2160
+
2161
+ const row = rows[0] as Record<string, number | null> | undefined;
2162
+ return filters.map((_, index) => {
2163
+ const activity = row?.[`a${index}`];
2164
+ return {
2165
+ count: Number(row?.[`n${index}`] ?? 0),
2166
+ recentActivityAt:
2167
+ activity === null || activity === undefined
2168
+ ? null
2169
+ : Number(activity),
2170
+ };
2171
+ });
2172
+ },
2173
+
1724
2174
  async countUpTo(filters = {}, limit) {
1725
2175
  const normalizedLimit = Math.max(0, Math.trunc(limit));
1726
2176
  if (normalizedLimit === 0) {
@@ -1738,11 +2188,9 @@ export function createPostService(
1738
2188
  },
1739
2189
 
1740
2190
  async countByYearMonth(filters = {}) {
1741
- const conditions = [
1742
- ...buildFilterConditions(filters),
1743
- isNotNull(posts.publishedAt),
1744
- ];
1745
- const publishedYearMonthExpr = buildPublishedYearMonthExpr();
2191
+ const axis = timeAxisColumn(filters);
2192
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
2193
+ const publishedYearMonthExpr = buildYearMonthExpr(axis);
1746
2194
 
1747
2195
  return db
1748
2196
  .select({
@@ -1766,6 +2214,8 @@ export function createPostService(
1766
2214
  ? ensurePostVisibility(data.visibility)
1767
2215
  : undefined;
1768
2216
  const rating = ensurePostRating(data.rating);
2217
+ // Only replies can be quiet — a root has nothing to stay quiet about.
2218
+ const isQuietReply = Boolean(data.replyToId) && data.quietReply === true;
1769
2219
 
1770
2220
  const rawBody = data.bodyMarkdown
1771
2221
  ? markdownToTiptapJson(data.bodyMarkdown)
@@ -1813,6 +2263,11 @@ export function createPostService(
1813
2263
  let threadId = id;
1814
2264
  let status: Status = requestedStatus ?? "published";
1815
2265
  let visibility: Visibility | null = requestedVisibility ?? "public";
2266
+ // Roots carry the requested language; replies overwrite this with the
2267
+ // root's value below so a Thread never mixes languages.
2268
+ let language = normalizePostLanguage(data.language);
2269
+ let translationGroupId: string | null = null;
2270
+ let sourceTranslationPostId: string | null = null;
1816
2271
 
1817
2272
  // Collapse the two-input (`flag` + `flagAt`) pair the DTO exposes
1818
2273
  // into the single timestamp form the DB column stores. Explicit
@@ -1846,10 +2301,19 @@ export function createPostService(
1846
2301
  );
1847
2302
  }
1848
2303
 
1849
- const lastLivePostId = await getLastLivePostIdInThread(parent.threadId);
1850
- if (lastLivePostId && lastLivePostId !== parent.id) {
2304
+ // Drafts count here: an unpublished reply already owns the tail slot,
2305
+ // and a sibling attached to the same parent would fork the chain.
2306
+ const tailId = (
2307
+ await this.getThreadTailIds([parent.threadId], {
2308
+ includeDrafts: true,
2309
+ })
2310
+ ).get(parent.threadId);
2311
+ if (tailId && tailId !== parent.id) {
2312
+ const tail = await this.getById(tailId);
1851
2313
  throw new ConflictError(
1852
- "This post is no longer the end of the thread. Reply to the latest post instead.",
2314
+ tail?.status === "draft"
2315
+ ? "This thread ends with an unpublished draft. Finish that draft or discard it, then reply."
2316
+ : "This post is no longer the end of the thread. Reply to the latest post instead.",
1853
2317
  );
1854
2318
  }
1855
2319
 
@@ -1860,12 +2324,18 @@ export function createPostService(
1860
2324
  parent.threadId === parent.id
1861
2325
  ? parent
1862
2326
  : await this.getById(parent.threadId);
1863
- if (root) {
1864
- if (data.status !== "draft") {
1865
- status = root.status;
1866
- }
2327
+ // A reply takes the root's status. A draft earlier in the chain does
2328
+ // not hold it back: readers never saw that post, so publishing past it
2329
+ // reads as continuous, and the draft stays parked until it is dealt
2330
+ // with on its own.
2331
+ if (root && data.status !== "draft") {
2332
+ status = root.status;
1867
2333
  }
1868
2334
  visibility = null;
2335
+ // A reply is part of the root's Thread, so it is written in the
2336
+ // Thread's language whatever the caller asked for. This is what keeps
2337
+ // language filters correct on member-grained queries.
2338
+ language = root?.language ?? parent.language ?? null;
1869
2339
 
1870
2340
  if (
1871
2341
  (data.collectionIds?.length ?? 0) > 0 ||
@@ -1877,6 +2347,60 @@ export function createPostService(
1877
2347
  }
1878
2348
  }
1879
2349
 
2350
+ const { primary, languages, reservedPrefixes } =
2351
+ await readLanguageSettings(db, siteId, databaseSchema);
2352
+
2353
+ // Nobody named a language, so read one out of the text. Every write path
2354
+ // that bypasses compose — the API, the Telegram bot, MCP — lands here, so
2355
+ // a multilingual site never accumulates unlabelled posts. An explicit
2356
+ // choice is the author's and is left alone; replies already carry the
2357
+ // Thread's language.
2358
+ if (!data.replyToId && !language) {
2359
+ language = suggestPostLanguage({
2360
+ text: bodyText ?? title ?? quoteText,
2361
+ languages,
2362
+ primary,
2363
+ });
2364
+ }
2365
+
2366
+ // "Add a translation" carries only the source ID until the author
2367
+ // submits; the group is minted here so abandoning the composer leaves
2368
+ // nothing behind (no draft row, no slug, no single-member group).
2369
+ if (data.translationOfId) {
2370
+ if (data.replyToId) {
2371
+ throw new ValidationError(
2372
+ "Translations link whole threads. Add the translation from the thread's first post.",
2373
+ );
2374
+ }
2375
+ const source = await requireTranslatableRoot(data.translationOfId);
2376
+ if (!language) {
2377
+ throw new ValidationError(
2378
+ "Choose a language for this translation before saving it.",
2379
+ );
2380
+ }
2381
+ if (source.language === language) {
2382
+ throw new ConflictError(
2383
+ "That post is already written in this language. Pick a different language for the translation.",
2384
+ );
2385
+ }
2386
+ if (source.translationGroupId) {
2387
+ await assertTranslationLanguageFree(
2388
+ source.translationGroupId,
2389
+ language,
2390
+ );
2391
+ translationGroupId = source.translationGroupId;
2392
+ } else {
2393
+ translationGroupId = createEntityId("translationGroup");
2394
+ sourceTranslationPostId = source.id;
2395
+ }
2396
+ }
2397
+
2398
+ // Featured surfaces only list published posts, so featuring a draft
2399
+ // writes a flag that does nothing and shows nowhere. Say so instead.
2400
+ if (status === "draft" && resolvedFeaturedAt !== null) {
2401
+ throw new ConflictError("Publish this post before featuring it.");
2402
+ }
2403
+
1880
2404
  assertDraftPublishedAt(status, data.publishedAt);
1881
2405
  const publishedAt =
1882
2406
  status === "published" ? (data.publishedAt ?? timestamp) : null;
@@ -1892,10 +2416,17 @@ export function createPostService(
1892
2416
  // readable slug can still set one explicitly.
1893
2417
  const titleForSlug =
1894
2418
  format === "quote" ? undefined : (title ?? undefined);
2419
+ // Only a non-primary language earns a readable language suffix: the bare
2420
+ // namespace belongs to the primary language, and a suffix shared by two
2421
+ // same-language posts distinguishes nothing.
2422
+ const languageSuffix =
2423
+ language && language !== primary
2424
+ ? toLanguagePrefix(language)
2425
+ : undefined;
1895
2426
 
1896
2427
  if (data.path) {
1897
2428
  const normalized = normalizePath(data.path);
1898
- if (isReservedPath(normalized)) {
2429
+ if (isReservedPath(normalized, reservedPrefixes)) {
1899
2430
  throw new ValidationError(
1900
2431
  `Path "${normalized}" is reserved and cannot be used`,
1901
2432
  );
@@ -1905,6 +2436,7 @@ export function createPostService(
1905
2436
  slug = await generatePostSlug({
1906
2437
  slug: normalized,
1907
2438
  idLength: config.slugIdLength,
2439
+ reservedPrefixes,
1908
2440
  isAvailable: isSlugAvailable,
1909
2441
  });
1910
2442
  } else {
@@ -1914,6 +2446,8 @@ export function createPostService(
1914
2446
  slug: slugified || undefined,
1915
2447
  title: titleForSlug,
1916
2448
  idLength: config.slugIdLength,
2449
+ languageSuffix,
2450
+ reservedPrefixes,
1917
2451
  isAvailable: isSlugAvailable,
1918
2452
  });
1919
2453
  // Verify the alias path is available before proceeding
@@ -1927,6 +2461,8 @@ export function createPostService(
1927
2461
  slug: data.slug,
1928
2462
  title: titleForSlug,
1929
2463
  idLength: config.slugIdLength,
2464
+ languageSuffix,
2465
+ reservedPrefixes,
1930
2466
  isAvailable: isSlugAvailable,
1931
2467
  });
1932
2468
  }
@@ -1986,6 +2522,23 @@ export function createPostService(
1986
2522
  if (usesBatchWrites) {
1987
2523
  const writeQueries = [];
1988
2524
 
2525
+ // Mint the group on the source Post in the same write as the
2526
+ // translation itself, so a failure never leaves a one-member group.
2527
+ if (sourceTranslationPostId && translationGroupId) {
2528
+ writeQueries.push(
2529
+ db
2530
+ .update(posts)
2531
+ .set({ translationGroupId, updatedAt: timestamp })
2532
+ .where(
2533
+ and(
2534
+ eq(posts.siteId, siteId),
2535
+ eq(posts.id, sourceTranslationPostId),
2536
+ isNull(posts.translationGroupId),
2537
+ ),
2538
+ ),
2539
+ );
2540
+ }
2541
+
1989
2542
  writeQueries.push(
1990
2543
  db.insert(posts).values({
1991
2544
  id,
@@ -2006,8 +2559,12 @@ export function createPostService(
2006
2559
  rating,
2007
2560
  replyToId: data.replyToId ?? null,
2008
2561
  threadId,
2562
+ language,
2563
+ translationGroupId,
2564
+ quietReply: isQuietReply,
2009
2565
  publishedAt,
2010
2566
  lastActivityAt: publishedAt ?? timestamp,
2567
+ threadUpdatedAt: publishedAt ?? timestamp,
2011
2568
  createdAt: timestamp,
2012
2569
  updatedAt: timestamp,
2013
2570
  }),
@@ -2064,6 +2621,19 @@ export function createPostService(
2064
2621
  );
2065
2622
  } else {
2066
2623
  await db.transaction(async (tx) => {
2624
+ if (sourceTranslationPostId && translationGroupId) {
2625
+ await tx
2626
+ .update(posts)
2627
+ .set({ translationGroupId, updatedAt: timestamp })
2628
+ .where(
2629
+ and(
2630
+ eq(posts.siteId, siteId),
2631
+ eq(posts.id, sourceTranslationPostId),
2632
+ isNull(posts.translationGroupId),
2633
+ ),
2634
+ );
2635
+ }
2636
+
2067
2637
  await tx.insert(posts).values({
2068
2638
  id,
2069
2639
  siteId,
@@ -2083,8 +2653,12 @@ export function createPostService(
2083
2653
  rating,
2084
2654
  replyToId: data.replyToId ?? null,
2085
2655
  threadId,
2656
+ language,
2657
+ translationGroupId,
2658
+ quietReply: isQuietReply,
2086
2659
  publishedAt,
2087
2660
  lastActivityAt: publishedAt ?? timestamp,
2661
+ threadUpdatedAt: publishedAt ?? timestamp,
2088
2662
  createdAt: timestamp,
2089
2663
  updatedAt: timestamp,
2090
2664
  });
@@ -2144,9 +2718,11 @@ export function createPostService(
2144
2718
  throw new ConflictError(`Slug "${slug}" could not be resolved`);
2145
2719
  }
2146
2720
 
2147
- // Bump thread root's lastActivityAt when creating a published reply
2148
- if (data.replyToId && status === "published" && !data.quietReply) {
2149
- await recalculateThreadLastActivity(threadId);
2721
+ // A quiet reply still changes the Thread, it just isn't announced —
2722
+ // the recalculation reads the persisted quiet_reply flag and keeps
2723
+ // lastActivityAt where it was while moving threadUpdatedAt forward.
2724
+ if (data.replyToId && status === "published") {
2725
+ await recalculateThreadActivity(threadId);
2150
2726
  }
2151
2727
 
2152
2728
  return post;
@@ -2208,11 +2784,22 @@ export function createPostService(
2208
2784
  if (!item) continue;
2209
2785
  const { data, attachments } = item;
2210
2786
  const prevPost = created[i - 1];
2787
+ // A reply with no date of its own belongs to the root's moment, not to
2788
+ // whenever the request happened to run. Without this, backdating the
2789
+ // root leaves its replies stamped today and the thread reads as if it
2790
+ // spanned years. Thread order is by `createdAt`/`id` (see the thread
2791
+ // queries), so sharing one `publishedAt` cannot reorder anything.
2792
+ const rootPublishedAt = created[0]?.publishedAt ?? undefined;
2211
2793
  const postData: CreatePost = {
2212
2794
  ...data,
2213
2795
  // Chain each post as a reply to the previous one (server-side chaining)
2214
2796
  replyToId: i === 0 ? data.replyToId : prevPost?.id,
2797
+ // Translation groups and languages are Thread-level: the root carries
2798
+ // them and every reply inherits, so later items must not restate them.
2799
+ translationOfId: i === 0 ? data.translationOfId : undefined,
2215
2800
  quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
2801
+ publishedAt:
2802
+ i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
2216
2803
  };
2217
2804
 
2218
2805
  try {
@@ -2242,6 +2829,17 @@ export function createPostService(
2242
2829
  const existing = await this.getById(id);
2243
2830
  if (!existing) return null;
2244
2831
 
2832
+ // Thread-wide, so it cannot ride along on the single-row write below.
2833
+ // Runs first: if the language is refused — the Thread's translation group
2834
+ // already holds it — nothing else should have been written either.
2835
+ if (
2836
+ data.language &&
2837
+ !existing.replyToId &&
2838
+ data.language !== existing.language
2839
+ ) {
2840
+ await this.setThreadLanguage(id, data.language);
2841
+ }
2842
+
2245
2843
  const timestamp = now();
2246
2844
  const nextFormat =
2247
2845
  data.format !== undefined
@@ -2334,6 +2932,12 @@ export function createPostService(
2334
2932
  } else if (data.featured !== undefined) {
2335
2933
  updates.featuredAt = data.featured ? timestamp : null;
2336
2934
  }
2935
+ // Same rule `create` enforces: nothing unpublished can be featured.
2936
+ // Only guards turning it on — an existing flag survives a trip back to
2937
+ // draft and takes effect again when the post is republished.
2938
+ if (nextStatus === "draft" && updates.featuredAt) {
2939
+ throw new ConflictError("Publish this post before featuring it.");
2940
+ }
2337
2941
 
2338
2942
  let updatedBody: string | null | undefined;
2339
2943
  if (data.body !== undefined || data.bodyMarkdown !== undefined) {
@@ -2445,7 +3049,7 @@ export function createPostService(
2445
3049
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
2446
3050
  .returning();
2447
3051
  if (needsThreadActivityRecalc) {
2448
- await recalculateThreadLastActivity(existing.threadId);
3052
+ await recalculateThreadActivity(existing.threadId);
2449
3053
  return this.getById(id);
2450
3054
  }
2451
3055
  return hydratePost(result[0]);
@@ -2600,8 +3204,7 @@ export function createPostService(
2600
3204
  ],
2601
3205
  );
2602
3206
  updateResult = results[updateIdx] as
2603
- | (typeof posts.$inferSelect)[]
2604
- | undefined;
3207
+ (typeof posts.$inferSelect)[] | undefined;
2605
3208
  } else {
2606
3209
  await db.transaction(async (tx) => {
2607
3210
  if (needsCascade) {
@@ -2680,7 +3283,7 @@ export function createPostService(
2680
3283
  }
2681
3284
 
2682
3285
  if (needsThreadActivityRecalc) {
2683
- await recalculateThreadLastActivity(existing.threadId);
3286
+ await recalculateThreadActivity(existing.threadId);
2684
3287
  return this.getById(id);
2685
3288
  }
2686
3289
  return hydratePost(updateResult?.[0]);
@@ -2868,7 +3471,7 @@ export function createPostService(
2868
3471
  await db
2869
3472
  .delete(posts)
2870
3473
  .where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
2871
- await recalculateThreadLastActivity(existing.threadId);
3474
+ await recalculateThreadActivity(existing.threadId);
2872
3475
  }
2873
3476
 
2874
3477
  return true;
@@ -2888,6 +3491,44 @@ export function createPostService(
2888
3491
  return hydratePosts(rows);
2889
3492
  },
2890
3493
 
3494
+ async getThreadPosition(postId) {
3495
+ const targetRows = await db
3496
+ .select({ replyToId: posts.replyToId, threadId: posts.threadId })
3497
+ .from(posts)
3498
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3499
+ .limit(1);
3500
+
3501
+ const target = targetRows[0];
3502
+ if (!target) return 0;
3503
+ if (!target.replyToId) return 1;
3504
+
3505
+ // One read for the whole Thread, then walk up in memory: a chain of N
3506
+ // Posts would otherwise be N round trips.
3507
+ const threadRows = await db
3508
+ .select({ id: posts.id, replyToId: posts.replyToId })
3509
+ .from(posts)
3510
+ .where(
3511
+ and(eq(posts.siteId, siteId), eq(posts.threadId, target.threadId)),
3512
+ );
3513
+
3514
+ const parentOf = new Map(
3515
+ threadRows.map((row) => [row.id, row.replyToId] as const),
3516
+ );
3517
+
3518
+ let position = 1;
3519
+ let cursor: string | null = target.replyToId;
3520
+ // A cycle is impossible per the schema's CHECK constraints, but walking a
3521
+ // parent chain unguarded turns a bad row into a hung request.
3522
+ const visited = new Set<string>([postId]);
3523
+ while (cursor && !visited.has(cursor)) {
3524
+ visited.add(cursor);
3525
+ position += 1;
3526
+ cursor = parentOf.get(cursor) ?? null;
3527
+ }
3528
+
3529
+ return position;
3530
+ },
3531
+
2891
3532
  async updateThreadStatusAndVisibility(rootId, status, visibility) {
2892
3533
  const nextStatus = ensurePostStatus(status);
2893
3534
  const nextVisibility = ensurePostVisibility(visibility);
@@ -2952,7 +3593,398 @@ export function createPostService(
2952
3593
  );
2953
3594
  });
2954
3595
  }
2955
- await recalculateThreadLastActivity(rootId);
3596
+ await recalculateThreadActivity(rootId);
3597
+ },
3598
+
3599
+ async setThreadLanguage(postId, language) {
3600
+ const normalized = normalizePostLanguage(language);
3601
+ if (!normalized) {
3602
+ throw new ValidationError("Choose a language for this post.");
3603
+ }
3604
+
3605
+ // A language the site does not publish has no view, no feed and no
3606
+ // switcher entry, so the post would simply vanish. Only checked once the
3607
+ // site actually has a language set to belong to.
3608
+ const { languages } = await readLanguageSettings(
3609
+ db,
3610
+ siteId,
3611
+ databaseSchema,
3612
+ );
3613
+ if (
3614
+ languages.length > 0 &&
3615
+ !languages.some(
3616
+ (tag) => toLanguagePrefix(tag) === toLanguagePrefix(normalized),
3617
+ )
3618
+ ) {
3619
+ throw new ValidationError(
3620
+ "This site does not publish that language. Add it in Settings → Language first.",
3621
+ );
3622
+ }
3623
+
3624
+ const rows = await db
3625
+ .select({
3626
+ threadId: posts.threadId,
3627
+ id: posts.id,
3628
+ translationGroupId: posts.translationGroupId,
3629
+ })
3630
+ .from(posts)
3631
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
3632
+ .limit(1);
3633
+ const row = rows[0];
3634
+ if (!row) {
3635
+ throw new NotFoundError("Post");
3636
+ }
3637
+
3638
+ // The group lives on the root, so a reply's Thread has to be resolved
3639
+ // before the translation-group check can run.
3640
+ const rootRows =
3641
+ row.id === row.threadId
3642
+ ? [row]
3643
+ : await db
3644
+ .select({
3645
+ threadId: posts.threadId,
3646
+ id: posts.id,
3647
+ translationGroupId: posts.translationGroupId,
3648
+ })
3649
+ .from(posts)
3650
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, row.threadId)))
3651
+ .limit(1);
3652
+ const root = rootRows[0];
3653
+ if (root?.translationGroupId) {
3654
+ await assertTranslationLanguageFree(
3655
+ root.translationGroupId,
3656
+ normalized,
3657
+ root.id,
3658
+ );
3659
+ }
3660
+
3661
+ const timestamp = now();
3662
+ await db
3663
+ .update(posts)
3664
+ .set({ language: normalized, updatedAt: timestamp })
3665
+ .where(and(eq(posts.siteId, siteId), eq(posts.threadId, row.threadId)));
3666
+ },
3667
+
3668
+ async materializeMissingLanguage(language) {
3669
+ const normalized = normalizePostLanguage(language);
3670
+ if (!normalized) {
3671
+ throw new ValidationError("Choose a primary language for this site.");
3672
+ }
3673
+
3674
+ // Counted before the write rather than read back from it: the three
3675
+ // supported drivers report affected rows differently, and the caller only
3676
+ // needs the number for its confirmation copy.
3677
+ const pending = await this.countMissingLanguage();
3678
+ if (pending === 0) return 0;
3679
+
3680
+ await db
3681
+ .update(posts)
3682
+ .set({ language: normalized })
3683
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3684
+ return pending;
3685
+ },
3686
+
3687
+ async countMissingLanguage() {
3688
+ const rows = await db
3689
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3690
+ .from(posts)
3691
+ .where(and(eq(posts.siteId, siteId), isNull(posts.language)));
3692
+ return Number(rows[0]?.count ?? 0);
3693
+ },
3694
+
3695
+ async countByLanguage(language) {
3696
+ const normalized = normalizePostLanguage(language);
3697
+ if (!normalized) return 0;
3698
+
3699
+ const rows = await db
3700
+ .select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
3701
+ .from(posts)
3702
+ .where(and(eq(posts.siteId, siteId), eq(posts.language, normalized)));
3703
+ return Number(rows[0]?.count ?? 0);
3704
+ },
3705
+
3706
+ async listLanguagesInUse() {
3707
+ const rows = await db
3708
+ .select({
3709
+ language: posts.language,
3710
+ count: sql<number>`CAST(count(*) AS INTEGER)`,
3711
+ })
3712
+ .from(posts)
3713
+ .where(and(eq(posts.siteId, siteId), isNotNull(posts.language)))
3714
+ .groupBy(posts.language);
3715
+ return rows
3716
+ .filter((row): row is { language: string; count: number } =>
3717
+ Boolean(row.language),
3718
+ )
3719
+ .map((row) => ({ language: row.language, count: Number(row.count) }));
3720
+ },
3721
+
3722
+ async listTranslations(postId) {
3723
+ const map = await this.getTranslationsMap([postId]);
3724
+ return map.get(postId) ?? [];
3725
+ },
3726
+
3727
+ async listTranslationCandidates(postId, options) {
3728
+ const term = options.query.trim();
3729
+ if (!term) return [];
3730
+ const limit = options.limit ?? 8;
3731
+
3732
+ const source = await this.getById(postId);
3733
+ if (!source) return [];
3734
+ const root =
3735
+ source.replyToId === null
3736
+ ? source
3737
+ : await this.getById(source.threadId);
3738
+ if (!root?.language) return [];
3739
+
3740
+ // Languages this Thread's group already speaks for, its own included.
3741
+ const taken = new Set([root.language]);
3742
+ if (root.translationGroupId) {
3743
+ for (const sibling of await this.listTranslations(root.id)) {
3744
+ if (sibling.language) taken.add(sibling.language);
3745
+ }
3746
+ }
3747
+
3748
+ // Escaped with an explicit ESCAPE clause: the two dialects disagree on
3749
+ // the default escape character (see `paths.findPathsUnderSegment`).
3750
+ const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
3751
+ const conditions: SQL[] = [
3752
+ eq(posts.siteId, siteId),
3753
+ eq(posts.status, "published"),
3754
+ // Thread roots only: a translation links whole Threads.
3755
+ sql`${posts.id} = ${posts.threadId}`,
3756
+ sql`${posts.id} != ${root.id}`,
3757
+ isNotNull(posts.language),
3758
+ notInArray(posts.language, [...taken]),
3759
+ sql`(${posts.title} LIKE ${like} ESCAPE '\\' OR ${posts.bodyText} LIKE ${like} ESCAPE '\\')`,
3760
+ ];
3761
+
3762
+ if (root.translationGroupId) {
3763
+ // Both sides having a group would ask for a merge, which is refused.
3764
+ conditions.push(isNull(posts.translationGroupId));
3765
+ } else {
3766
+ // The candidate may carry a group, as long as joining it would not put
3767
+ // two Posts of this language in it.
3768
+ conditions.push(
3769
+ sql`(${posts.translationGroupId} IS NULL OR NOT EXISTS (
3770
+ SELECT 1
3771
+ FROM post AS group_member
3772
+ WHERE group_member.site_id = ${siteId}
3773
+ AND group_member.translation_group_id = "post"."translation_group_id"
3774
+ AND group_member.language = ${root.language}
3775
+ ))`,
3776
+ );
3777
+ }
3778
+
3779
+ const rows = await db
3780
+ .select()
3781
+ .from(posts)
3782
+ .where(and(...conditions))
3783
+ .orderBy(desc(posts.publishedAt))
3784
+ .limit(limit);
3785
+
3786
+ return hydratePosts(rows);
3787
+ },
3788
+
3789
+ async resolveTranslationCandidate(postId, path) {
3790
+ const source = await this.getById(postId);
3791
+ if (!source) return { status: "not_found" };
3792
+ const root =
3793
+ source.replyToId === null
3794
+ ? source
3795
+ : await this.getById(source.threadId);
3796
+ if (!root?.language) return { status: "not_found" };
3797
+
3798
+ const target = await resolvedPaths.resolveTarget(path);
3799
+ if (!target) return { status: "not_found" };
3800
+ if (target.targetType !== "post" || !target.postId) {
3801
+ return { status: "not_a_post" };
3802
+ }
3803
+
3804
+ const found = await this.getById(target.postId);
3805
+ if (!found) return { status: "not_found" };
3806
+ // Translations link whole Threads, so an address that lands on a reply
3807
+ // is taken as naming the Thread it belongs to.
3808
+ const candidate =
3809
+ found.replyToId === null ? found : await this.getById(found.threadId);
3810
+ if (!candidate) return { status: "not_found" };
3811
+
3812
+ if (candidate.id === root.id) return { status: "same_thread" };
3813
+ if (candidate.status !== "published") return { status: "unpublished" };
3814
+ if (!candidate.language) return { status: "no_language" };
3815
+ if (candidate.language === root.language) {
3816
+ return { status: "same_language" };
3817
+ }
3818
+
3819
+ const siblings = root.translationGroupId
3820
+ ? await this.listTranslations(root.id)
3821
+ : [];
3822
+ if (siblings.some((post) => post.language === candidate.language)) {
3823
+ return { status: "language_taken", language: candidate.language };
3824
+ }
3825
+
3826
+ if (candidate.translationGroupId) {
3827
+ // `linkTranslation` refuses to merge two groups, and refuses to join
3828
+ // one that already speaks for this Thread's language.
3829
+ if (root.translationGroupId) return { status: "group_conflict" };
3830
+
3831
+ const theirGroup = await this.listTranslations(candidate.id);
3832
+ if (theirGroup.some((post) => post.language === root.language)) {
3833
+ return { status: "group_language_taken", language: root.language };
3834
+ }
3835
+ }
3836
+
3837
+ return { status: "ok", post: candidate };
3838
+ },
3839
+
3840
+ async getTranslationsMap(postIds) {
3841
+ const result = new Map<string, Post[]>();
3842
+ const uniqueIds = [...new Set(postIds)].filter(Boolean);
3843
+ if (uniqueIds.length === 0) return result;
3844
+
3845
+ const seedRows = await db
3846
+ .select({ id: posts.id, translationGroupId: posts.translationGroupId })
3847
+ .from(posts)
3848
+ .where(
3849
+ and(
3850
+ eq(posts.siteId, siteId),
3851
+ inArray(posts.id, uniqueIds),
3852
+ isNotNull(posts.translationGroupId),
3853
+ ),
3854
+ );
3855
+ if (seedRows.length === 0) return result;
3856
+
3857
+ const groupIds = [
3858
+ ...new Set(
3859
+ seedRows
3860
+ .map((row) => row.translationGroupId)
3861
+ .filter((groupId): groupId is string => groupId !== null),
3862
+ ),
3863
+ ];
3864
+ const memberRows = await db
3865
+ .select()
3866
+ .from(posts)
3867
+ .where(
3868
+ and(
3869
+ eq(posts.siteId, siteId),
3870
+ inArray(posts.translationGroupId, groupIds),
3871
+ ),
3872
+ );
3873
+ const members = await hydratePosts(memberRows);
3874
+ const byGroup = new Map<string, Post[]>();
3875
+ for (const member of members) {
3876
+ const groupId = member.translationGroupId;
3877
+ if (!groupId) continue;
3878
+ const bucket = byGroup.get(groupId);
3879
+ if (bucket) bucket.push(member);
3880
+ else byGroup.set(groupId, [member]);
3881
+ }
3882
+
3883
+ for (const seed of seedRows) {
3884
+ const groupId = seed.translationGroupId;
3885
+ if (!groupId) continue;
3886
+ const siblings = (byGroup.get(groupId) ?? []).filter(
3887
+ (member) => member.id !== seed.id,
3888
+ );
3889
+ if (siblings.length > 0) result.set(seed.id, siblings);
3890
+ }
3891
+ return result;
3892
+ },
3893
+
3894
+ async linkTranslation(postId, otherPostId) {
3895
+ if (postId === otherPostId) {
3896
+ throw new ValidationError("Pick a different post to link.");
3897
+ }
3898
+
3899
+ const [post, other] = await Promise.all([
3900
+ requireTranslatableRoot(postId),
3901
+ requireTranslatableRoot(otherPostId),
3902
+ ]);
3903
+
3904
+ if (!post.language || !other.language) {
3905
+ throw new ValidationError(
3906
+ "Both posts need a language before they can be linked as translations.",
3907
+ );
3908
+ }
3909
+ if (post.language === other.language) {
3910
+ throw new ConflictError(
3911
+ "These two posts are written in the same language. Translations need different languages.",
3912
+ );
3913
+ }
3914
+ if (post.translationGroupId && other.translationGroupId) {
3915
+ if (post.translationGroupId === other.translationGroupId) return;
3916
+ throw new ConflictError(
3917
+ "Both posts already belong to translation groups. Unlink one of them first.",
3918
+ );
3919
+ }
3920
+
3921
+ const timestamp = now();
3922
+ if (post.translationGroupId ?? other.translationGroupId) {
3923
+ const groupId = (post.translationGroupId ??
3924
+ other.translationGroupId) as string;
3925
+ const joiningId = post.translationGroupId ? other.id : post.id;
3926
+ const joiningLanguage = post.translationGroupId
3927
+ ? other.language
3928
+ : post.language;
3929
+ await assertTranslationLanguageFree(groupId, joiningLanguage);
3930
+ await db
3931
+ .update(posts)
3932
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3933
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, joiningId)));
3934
+ return;
3935
+ }
3936
+
3937
+ const groupId = createEntityId("translationGroup");
3938
+ const setGroup = (targetId: string) =>
3939
+ db
3940
+ .update(posts)
3941
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3942
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, targetId)));
3943
+
3944
+ if (usesBatchWrites) {
3945
+ await db.batch([setGroup(post.id), setGroup(other.id)]);
3946
+ } else {
3947
+ await db.transaction(async (tx) => {
3948
+ await tx
3949
+ .update(posts)
3950
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3951
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
3952
+ await tx
3953
+ .update(posts)
3954
+ .set({ translationGroupId: groupId, updatedAt: timestamp })
3955
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, other.id)));
3956
+ });
3957
+ }
3958
+ },
3959
+
3960
+ async unlinkTranslation(postId) {
3961
+ const post = await requireTranslatableRoot(postId);
3962
+ const groupId = post.translationGroupId;
3963
+ if (!groupId) return;
3964
+
3965
+ const timestamp = now();
3966
+ await db
3967
+ .update(posts)
3968
+ .set({ translationGroupId: null, updatedAt: timestamp })
3969
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
3970
+
3971
+ // A group of one is indistinguishable from no group and would linger as
3972
+ // a dangling key, so collapse it as soon as the second-to-last member
3973
+ // leaves.
3974
+ const remaining = await db
3975
+ .select({ id: posts.id })
3976
+ .from(posts)
3977
+ .where(
3978
+ and(eq(posts.siteId, siteId), eq(posts.translationGroupId, groupId)),
3979
+ )
3980
+ .limit(2);
3981
+ const lastMember = remaining.length === 1 ? remaining[0] : undefined;
3982
+ if (lastMember) {
3983
+ await db
3984
+ .update(posts)
3985
+ .set({ translationGroupId: null, updatedAt: timestamp })
3986
+ .where(and(eq(posts.siteId, siteId), eq(posts.id, lastMember.id)));
3987
+ }
2956
3988
  },
2957
3989
 
2958
3990
  async getReplyCounts(postIds) {
@@ -3079,8 +4111,11 @@ export function createPostService(
3079
4111
  .from(rankedReplies)
3080
4112
  .where(
3081
4113
  or(
3082
- lte(rankedReplies.firstReplyRank, 2),
3083
- lte(rankedReplies.latestReplyRank, 3),
4114
+ // One past the leading window: that extra row is never rendered,
4115
+ // it is only where the gap link points. Without it the gap has to
4116
+ // aim at a reply already on screen.
4117
+ lte(rankedReplies.firstReplyRank, THREAD_LEADING_REPLIES + 1),
4118
+ lte(rankedReplies.latestReplyRank, THREAD_TRAILING_REPLIES),
3084
4119
  ),
3085
4120
  );
3086
4121
 
@@ -3094,6 +4129,7 @@ export function createPostService(
3094
4129
  leadingReplyIds: Map<number, string>;
3095
4130
  trailingReplyIds: Map<number, string>;
3096
4131
  latestReplyId: string | null;
4132
+ firstHiddenReplyId: string | null;
3097
4133
  totalReplyCount: number;
3098
4134
  }
3099
4135
  >();
@@ -3102,13 +4138,20 @@ export function createPostService(
3102
4138
  leadingReplyIds: new Map<number, string>(),
3103
4139
  trailingReplyIds: new Map<number, string>(),
3104
4140
  latestReplyId: null,
4141
+ firstHiddenReplyId: null,
3105
4142
  totalReplyCount: row.totalReplyCount,
3106
4143
  };
3107
4144
 
3108
- if (row.firstReplyRank <= 2) {
4145
+ if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
3109
4146
  existing.leadingReplyIds.set(row.firstReplyRank, row.id);
3110
4147
  }
3111
- if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
4148
+ if (row.firstReplyRank === THREAD_LEADING_REPLIES + 1) {
4149
+ existing.firstHiddenReplyId = row.id;
4150
+ }
4151
+ if (
4152
+ row.latestReplyRank > 1 &&
4153
+ row.latestReplyRank <= THREAD_TRAILING_REPLIES
4154
+ ) {
3112
4155
  existing.trailingReplyIds.set(row.latestReplyRank, row.id);
3113
4156
  }
3114
4157
  if (row.latestReplyRank === 1) {
@@ -3130,16 +4173,36 @@ export function createPostService(
3130
4173
  .map((id) => (id ? hydratedPosts.get(id) : undefined))
3131
4174
  .filter((post): post is Post => post !== undefined);
3132
4175
 
4176
+ const leadingReplies = hydrateReplyIds(
4177
+ Array.from({ length: THREAD_LEADING_REPLIES }, (_unused, index) =>
4178
+ context.leadingReplyIds.get(index + 1),
4179
+ ),
4180
+ );
4181
+ // Ranked newest-first, so walking down to rank 2 puts them back in
4182
+ // thread order. Rank 1 is the hero and is carried separately.
4183
+ const trailingReplies = hydrateReplyIds(
4184
+ Array.from(
4185
+ { length: THREAD_TRAILING_REPLIES - 1 },
4186
+ (_unused, index) =>
4187
+ context.trailingReplyIds.get(THREAD_TRAILING_REPLIES - index),
4188
+ ),
4189
+ );
4190
+ const firstHiddenReply =
4191
+ (context.firstHiddenReplyId
4192
+ ? hydratedPosts.get(context.firstHiddenReplyId)
4193
+ : undefined) ?? null;
4194
+
3133
4195
  result.set(threadId, {
3134
- leadingReplies: hydrateReplyIds([
3135
- context.leadingReplyIds.get(1),
3136
- context.leadingReplyIds.get(2),
3137
- ]),
3138
- trailingReplies: hydrateReplyIds([
3139
- context.trailingReplyIds.get(3),
3140
- context.trailingReplyIds.get(2),
3141
- ]),
4196
+ leadingReplies,
4197
+ // Left overlapping on purpose. These are the ranked buckets, not the
4198
+ // rendered fold: on a short thread a reply lands in both windows, and
4199
+ // dropping the duplicate is a presentation rule that `ThreadPreview`
4200
+ // and `getThreadHiddenCount` each already apply.
4201
+ trailingReplies,
3142
4202
  latestReply,
4203
+ // Only a gap target: the query fetches it one past the leading
4204
+ // window, and nothing renders it.
4205
+ firstHiddenReply,
3143
4206
  totalReplyCount: context.totalReplyCount,
3144
4207
  });
3145
4208
  }
@@ -3529,20 +4592,25 @@ export function createPostService(
3529
4592
  const result = new Map<string, Post[]>();
3530
4593
  if (rootIds.length === 0) return result;
3531
4594
 
3532
- const unique = [...new Set(rootIds)];
3533
- const conditions = [
3534
- eq(posts.siteId, siteId),
3535
- inArray(posts.threadId, unique),
3536
- eq(posts.status, "published"),
3537
- ];
3538
- if (options.publishedBefore !== undefined) {
3539
- conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
3540
- }
3541
- const rows = await db
3542
- .select()
3543
- .from(posts)
3544
- .where(and(...conditions))
3545
- .orderBy(posts.threadId, posts.createdAt, posts.id);
4595
+ // A feed loads every Thread it renders at once, which can be hundreds.
4596
+ // Each Thread lands in one chunk, so its posts keep their order.
4597
+ const rows = await batchQueryRows([...new Set(rootIds)], (chunk) => {
4598
+ const conditions = [
4599
+ eq(posts.siteId, siteId),
4600
+ inArray(posts.threadId, chunk),
4601
+ eq(posts.status, "published"),
4602
+ ];
4603
+ if (options.publishedBefore !== undefined) {
4604
+ conditions.push(
4605
+ sql`${posts.publishedAt} < ${options.publishedBefore}`,
4606
+ );
4607
+ }
4608
+ return db
4609
+ .select()
4610
+ .from(posts)
4611
+ .where(and(...conditions))
4612
+ .orderBy(posts.threadId, posts.createdAt, posts.id);
4613
+ });
3546
4614
 
3547
4615
  for (const post of await hydratePosts(rows)) {
3548
4616
  const thread = result.get(post.threadId);
@@ -3556,7 +4624,7 @@ export function createPostService(
3556
4624
  return result;
3557
4625
  },
3558
4626
 
3559
- async getLastPostIdsByThread(threadIds) {
4627
+ async getThreadTailIds(threadIds, options) {
3560
4628
  const result = new Map<string, string>();
3561
4629
  if (threadIds.length === 0) return result;
3562
4630
 
@@ -3571,7 +4639,7 @@ export function createPostService(
3571
4639
  and(
3572
4640
  eq(posts.siteId, siteId),
3573
4641
  inArray(posts.threadId, unique),
3574
- eq(posts.status, "published"),
4642
+ ...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
3575
4643
  ),
3576
4644
  )
3577
4645
  .orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
@@ -3585,11 +4653,9 @@ export function createPostService(
3585
4653
  },
3586
4654
 
3587
4655
  async getDistinctYears(filters = {}) {
3588
- const conditions = [
3589
- ...buildFilterConditions(filters),
3590
- isNotNull(posts.publishedAt),
3591
- ];
3592
- const publishedYearExpr = buildPublishedYearExpr();
4656
+ const axis = timeAxisColumn(filters);
4657
+ const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
4658
+ const publishedYearExpr = buildYearExpr(axis);
3593
4659
 
3594
4660
  const rows = await db
3595
4661
  .select({