@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.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +67 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C-oi_t8W.js → app-K_Aa1MMn.js} +52623 -13193
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-CsFx6F_M.js → github-sync-BPAvT999.js} +2343 -110
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +524 -2
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +57 -8
- package/src/client/__tests__/compose-triggers.test.ts +90 -0
- package/src/client/__tests__/copy-field.test.ts +162 -0
- package/src/client/__tests__/custom-url-menu.test.ts +127 -6
- package/src/client/__tests__/image-processor.test.ts +324 -2
- package/src/client/__tests__/lazy-entries.test.ts +92 -0
- package/src/client/__tests__/media-scroll-hint.test.ts +155 -0
- package/src/client/__tests__/runtime-paths.test.ts +84 -0
- package/src/client/__tests__/smart-collection-page-actions.test.ts +158 -0
- package/src/client/__tests__/toast.test.ts +88 -1
- package/src/client/__tests__/video-processor-probe.test.ts +175 -0
- package/src/client/__tests__/video-processor.test.ts +181 -0
- package/src/client/audio-processor.ts +24 -15
- package/src/client/audio-waveform.ts +49 -0
- package/src/client/collection-dialog-host.ts +72 -0
- package/src/client/collection-navigation.ts +51 -16
- package/src/client/collection-page-actions.ts +26 -0
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-dialog.test.ts +329 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +274 -10
- package/src/client/components/__tests__/jant-collection-form.test.ts +59 -178
- package/src/client/components/__tests__/jant-command-palette.test.ts +79 -0
- package/src/client/components/__tests__/jant-compose-dialog-draft-storage.test.ts +340 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +3283 -389
- package/src/client/components/__tests__/jant-compose-editor.test.ts +581 -86
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +34 -89
- package/src/client/components/__tests__/jant-locale-picker.test.ts +207 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +234 -15
- package/src/client/components/__tests__/jant-post-menu.test.ts +413 -2
- package/src/client/components/__tests__/jant-post-picker.test.ts +197 -0
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +15 -12
- package/src/client/components/__tests__/jant-settings-general.test.ts +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +121 -3
- package/src/client/components/jant-collection-dialog.ts +722 -0
- package/src/client/components/jant-collection-directory.ts +567 -182
- package/src/client/components/jant-collection-form.ts +53 -232
- package/src/client/components/jant-command-palette.ts +106 -49
- package/src/client/components/jant-compose-dialog.ts +2757 -1194
- package/src/client/components/jant-compose-editor.ts +866 -397
- package/src/client/components/jant-compose-fullscreen.ts +53 -47
- package/src/client/components/jant-config-editor.ts +65 -53
- package/src/client/components/jant-confirm-dialog.ts +13 -11
- package/src/client/components/jant-locale-picker.ts +418 -0
- package/src/client/components/jant-media-lightbox.ts +146 -136
- package/src/client/components/jant-nav-manager.ts +690 -366
- package/src/client/components/jant-post-menu.ts +1142 -268
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +336 -242
- package/src/client/compose-launch.ts +89 -2
- package/src/client/compose-shortcuts.ts +9 -7
- package/src/client/compose-triggers.ts +50 -0
- package/src/client/copy-field.ts +108 -0
- package/src/client/custom-url-menu.ts +60 -4
- package/src/client/icons.ts +14 -0
- package/src/client/image-plan.ts +165 -0
- package/src/client/image-processor.ts +195 -117
- package/src/client/image-worker-client.ts +45 -0
- package/src/client/image-worker.ts +135 -0
- package/src/client/lazy-entries.ts +95 -0
- package/src/client/lazy-slugify.ts +2 -2
- package/src/client/media-scroll-hint.ts +43 -2
- package/src/client/mediabunny.ts +29 -0
- package/src/client/post-picker.ts +41 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/runtime-paths.ts +74 -1
- package/src/client/settings-bridge.ts +4 -10
- package/src/client/site-header-nav.js +18 -6
- package/src/client/smart-collection-dialog-host.ts +116 -0
- package/src/client/smart-collection-page-actions.ts +208 -0
- package/src/client/tiptap/__tests__/bubble-menu.test.ts +110 -0
- package/src/client/tiptap/__tests__/floating-position.test.ts +136 -1
- package/src/client/tiptap/__tests__/inline-image-upload.test.ts +37 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/toast.ts +143 -38
- package/src/client/types/vite-worker.d.ts +12 -0
- package/src/client/upload-with-metadata.ts +3 -13
- package/src/client/video-processor.ts +351 -107
- package/src/client-auth.ts +14 -31
- package/src/client-compose.ts +19 -0
- package/src/client-manage.ts +13 -0
- package/src/client-settings.ts +14 -0
- package/src/client-site.ts +10 -3
- package/src/client.ts +4 -0
- package/src/db/__tests__/backfill-nav-label-mirrors.test.ts +149 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/schema-enum-sources.test.ts +33 -0
- package/src/db/__tests__/smart-collection-placement.test.ts +402 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/0032_married_tomas.sql +3 -0
- package/src/db/migrations/0033_busy_johnny_storm.sql +198 -0
- package/src/db/migrations/0034_cheerful_rumiko_fujikawa.sql +35 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/0032_snapshot.json +2582 -0
- package/src/db/migrations/meta/0033_snapshot.json +2816 -0
- package/src/db/migrations/meta/0034_snapshot.json +2816 -0
- package/src/db/migrations/meta/_journal.json +28 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/0030_abandoned_nighthawk.sql +3 -0
- package/src/db/migrations/pg/0031_strong_ultimatum.sql +128 -0
- package/src/db/migrations/pg/0032_furry_multiple_man.sql +6 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/0030_snapshot.json +3329 -0
- package/src/db/migrations/pg/meta/0031_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/0032_snapshot.json +3660 -0
- package/src/db/migrations/pg/meta/_journal.json +28 -0
- package/src/db/pg/schema.ts +213 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +242 -27
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +26 -81
- package/src/i18n/__tests__/supported-locales.test.ts +83 -0
- package/src/i18n/context.tsx +12 -2
- package/src/i18n/detect.ts +7 -66
- package/src/i18n/locales/glossary.zh-Hans.yml +44 -4
- package/src/i18n/locales/glossary.zh-Hant.yml +44 -4
- package/src/i18n/locales/public/en.po +568 -168
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +562 -162
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +562 -162
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1691 -12
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +88 -0
- package/src/lib/__tests__/slugify.test.ts +85 -0
- package/src/lib/__tests__/storage.test.ts +32 -0
- package/src/lib/__tests__/thread-fold.test.ts +113 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/upload-sideload.test.ts +14 -0
- package/src/lib/__tests__/url-fetch.test.ts +1 -2
- package/src/lib/__tests__/url.test.ts +93 -34
- package/src/lib/__tests__/view-language.test.ts +413 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +90 -0
- package/src/lib/address.ts +57 -0
- package/src/lib/api-posts.ts +12 -0
- package/src/lib/asset-path.ts +98 -1
- package/src/lib/build-env.ts +16 -0
- package/src/lib/collection-groups.ts +5 -1
- package/src/lib/collection-paths.ts +3 -10
- package/src/lib/constants.ts +28 -3
- package/src/lib/copy-field.ts +36 -0
- package/src/lib/crypto.ts +40 -0
- package/src/lib/csp-builder.ts +2 -1
- package/src/lib/deferred.ts +79 -0
- package/src/lib/discover-ping.ts +141 -0
- package/src/lib/discover.ts +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +1021 -66
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +21 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/media-helpers.ts +13 -1
- package/src/lib/navigation.ts +78 -11
- package/src/lib/pagination.ts +28 -24
- package/src/lib/per-language-surfaces.ts +67 -0
- package/src/lib/post-display.ts +42 -9
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +81 -10
- package/src/lib/tiptap-render.ts +5 -4
- package/src/lib/translit.ts +288 -0
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +188 -24
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +79 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +172 -9
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +30 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +11 -2
- package/src/routes/dash/__tests__/custom-urls-archive.test.ts +44 -0
- package/src/routes/dash/__tests__/custom-urls-page.test.ts +49 -0
- package/src/routes/dash/__tests__/delete-account.test.ts +106 -0
- package/src/routes/dash/__tests__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +69 -40
- package/src/routes/feed/sitemap.ts +95 -11
- package/src/routes/pages/__tests__/archive-indexing.test.ts +200 -0
- package/src/routes/pages/__tests__/archive-params.test.ts +657 -1
- package/src/routes/pages/__tests__/collection-routing.test.ts +16 -0
- package/src/routes/pages/__tests__/collections-feed-links.test.ts +72 -0
- package/src/routes/pages/__tests__/collections.test.ts +9 -3
- package/src/routes/pages/__tests__/custom-archive-url.test.ts +119 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/__tests__/smart-collection.test.ts +298 -0
- package/src/routes/pages/__tests__/subscribe.test.ts +248 -0
- package/src/routes/pages/archive.tsx +507 -357
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +183 -19
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +139 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +323 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +330 -47
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +281 -24
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +225 -7
- package/src/services/post.ts +1318 -252
- package/src/services/search.ts +48 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -1585
- package/src/styles/site-media.css +20 -6
- package/src/styles/tokens.css +144 -13
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1224 -7276
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +92 -1
- package/src/types/operations.ts +64 -2
- package/src/types/props.ts +168 -30
- package/src/types/views.ts +61 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +215 -21
- package/src/ui/compose/ComposePrompt.tsx +1 -5
- package/src/ui/compose/__tests__/ComposeDialog.test.tsx +125 -0
- package/src/ui/dash/appearance/AdvancedContent.tsx +2 -2
- package/src/ui/dash/appearance/CodeInjectionContent.tsx +12 -16
- package/src/ui/dash/appearance/NavigationContent.tsx +117 -16
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +33 -11
- package/src/ui/dash/appearance/__tests__/system-nav-descriptions.test.ts +44 -0
- package/src/ui/dash/appearance/system-nav-descriptions.ts +75 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +2 -2
- package/src/ui/dash/settings/ApiTokensContent.tsx +2 -1
- package/src/ui/dash/settings/ConfigEditorContent.tsx +35 -16
- package/src/ui/dash/settings/GeneralContent.tsx +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +103 -17
- package/src/ui/feed/QuoteCard.tsx +55 -30
- package/src/ui/feed/ThreadPreview.tsx +6 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +13 -16
- package/src/ui/feed/__tests__/timeline-cards.test.ts +314 -49
- package/src/ui/feed/thread-preview-state.ts +20 -6
- package/src/ui/font-themes.ts +39 -29
- package/src/ui/layouts/BaseLayout.tsx +155 -10
- package/src/ui/layouts/SiteLayout.tsx +192 -34
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +196 -17
- package/src/ui/layouts/__tests__/SiteLayout.test.tsx +109 -85
- package/src/ui/pages/ArchivePage.tsx +401 -301
- package/src/ui/pages/CollectionPage.tsx +86 -76
- package/src/ui/pages/CollectionsPage.tsx +5 -0
- package/src/ui/pages/ComposePage.tsx +8 -0
- package/src/ui/pages/PostPage.tsx +47 -0
- package/src/ui/pages/SearchPage.tsx +3 -3
- package/src/ui/pages/SmartCollectionPage.tsx +434 -0
- package/src/ui/pages/SubscribePage.tsx +236 -0
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +122 -3
- package/src/ui/pages/__tests__/collections-directory-rows.test.tsx +92 -0
- package/src/ui/pages/__tests__/collections-view-links.test.tsx +81 -0
- package/src/ui/shared/CollectionDirectory.tsx +178 -3
- package/src/ui/shared/CollectionsManager.tsx +70 -17
- package/src/ui/shared/CopyField.tsx +127 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/EmptyPostContent.tsx +23 -0
- package/src/ui/shared/MediaGallery.tsx +125 -46
- package/src/ui/shared/Pagination.tsx +20 -5
- package/src/ui/shared/PostFooter.tsx +55 -23
- package/src/ui/shared/__tests__/archive-labels.test.ts +100 -0
- package/src/ui/shared/__tests__/media-gallery.test.ts +210 -2
- package/src/ui/shared/__tests__/navigation-labels.test.ts +16 -22
- package/src/ui/shared/__tests__/pagination.test.ts +38 -24
- package/src/ui/shared/__tests__/post-footer.test.ts +32 -0
- package/src/ui/shared/archive-labels.ts +98 -0
- package/src/ui/shared/collection-management-labels.ts +94 -16
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +8 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/chunks/heic-to-DUUaO23q.js +0 -1
- package/dist/client/_assets/chunks/module-DcsAZQZ_.js +0 -716
- package/dist/client/_assets/chunks/url-pLre2DM_.js +0 -1
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
- package/dist/client/_assets/client-DxY5BFe8.js +0 -296
- package/dist/client/_assets/client-auth-CakPESv9.js +0 -5533
- package/dist/env-OHRKGcMj.js +0 -281
- package/dist/github-api-BgSiE71w.js +0 -176
- package/dist/github-app-BbklkFmU.js +0 -275
- package/dist/github-sync-Cq1pzzOI.js +0 -431
- package/dist/github-sync-ppWXN3jb.js +0 -4
- package/dist/url-BMYO-Zlt.js +0 -395
- package/src/client/__tests__/collection-created-notice.test.ts +0 -36
- package/src/client/__tests__/collection-form-bridge.test.ts +0 -216
- package/src/client/__tests__/post-form-bridge.test.ts +0 -82
- package/src/client/collection-created-notice.ts +0 -66
- package/src/client/collection-form-bridge.ts +0 -191
- package/src/client/components/__tests__/jant-post-form.test.ts +0 -193
- package/src/client/components/jant-post-form.ts +0 -422
- package/src/client/components/post-form-template.ts +0 -352
- package/src/client/components/post-form-types.ts +0 -96
- package/src/client/media-metadata.ts +0 -247
- package/src/client/post-form-bridge.ts +0 -193
- package/src/ui/pages/CollectionEditorPage.tsx +0 -175
package/src/services/post.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
160
|
-
|
|
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
|
-
/**
|
|
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
|
|
671
|
+
/** Get distinct years with posts, bucketed on the `sortBy` time axis */
|
|
420
672
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
421
|
-
/**
|
|
422
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
955
|
+
function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
|
|
650
956
|
return databaseDialect === "pg"
|
|
651
|
-
? sql<string>`to_char(timezone('UTC', to_timestamp(${
|
|
652
|
-
: sql<string>`strftime('%Y', ${
|
|
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
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
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
|
-
|
|
697
|
-
|
|
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(
|
|
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
|
|
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
|
|
1044
|
+
const fallback = root[0]?.updatedAt ?? now();
|
|
711
1045
|
|
|
712
1046
|
await db
|
|
713
1047
|
.update(posts)
|
|
714
|
-
.set({
|
|
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
|
-
/**
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
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 (
|
|
847
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
|
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:
|
|
1067
|
-
value:
|
|
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
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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[] = [
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1985
|
+
desc(ratingPresence),
|
|
1986
|
+
desc(posts.rating),
|
|
1987
|
+
desc(sortTimestampSortExpr),
|
|
1604
1988
|
desc(posts.id),
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
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:
|
|
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
|
|
1742
|
-
|
|
1743
|
-
|
|
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
|
-
|
|
1850
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
-
//
|
|
2148
|
-
|
|
2149
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
3083
|
-
|
|
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 <=
|
|
4145
|
+
if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
|
|
3109
4146
|
existing.leadingReplyIds.set(row.firstReplyRank, row.id);
|
|
3110
4147
|
}
|
|
3111
|
-
if (row.
|
|
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
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
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
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
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
|
|
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
|
|
3589
|
-
|
|
3590
|
-
|
|
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({
|