@jant/core 0.6.16 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +749 -220
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +19 -61
- package/bin/commands/site/snapshot/import.js +23 -24
- package/bin/lib/d1-query.js +92 -18
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +94 -29
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +37 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-C7_4oab9.js → app-ZcaI1kPN.js} +53190 -13529
- 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-B-m7X7S5.js +261 -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-BJyd-LXE.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-BkscJx69.js +2 -0
- package/dist/client/_assets/client-DAhoqPdr.css +2 -0
- package/dist/client/_assets/client-auth-CAMfTrKW.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-D6rExKVK.js +2004 -0
- package/dist/client/_assets/client-knSEyUJO.js +295 -0
- package/dist/client/_assets/client-manage-sVOkqVbH.js +2170 -0
- package/dist/client/_assets/client-settings-CGR_nZ78.js +1112 -0
- package/dist/github-sync-Gw4orAHk.js +8398 -0
- package/dist/index.js +2 -5
- package/dist/node.js +11 -9
- package/package.json +15 -5
- 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__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +390 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +842 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -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__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +86 -22
- 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 +272 -0
- package/src/client/__tests__/compose-launch.test.ts +62 -1
- package/src/client/__tests__/compose-shortcuts.test.ts +38 -9
- 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 +23 -0
- 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__/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 +28 -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 +1250 -53
- package/src/client/components/__tests__/jant-compose-editor.test.ts +544 -40
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +0 -1
- 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 +561 -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-types.ts +75 -1
- 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 +100 -49
- package/src/client/components/jant-compose-dialog.ts +1486 -707
- package/src/client/components/jant-compose-editor.ts +673 -336
- package/src/client/components/jant-compose-fullscreen.ts +51 -43
- 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 +1086 -282
- package/src/client/components/jant-post-picker.ts +324 -0
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +137 -108
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +622 -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 +214 -141
- package/src/client/compose-launch.ts +76 -2
- package/src/client/compose-shortcuts.ts +6 -8
- 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/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__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/bubble-menu.ts +47 -16
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/client/toast.ts +13 -0
- 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__/d1-query.test.ts +56 -1
- 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/backfills/0006_clear_mirrored_page_nav_labels.sql +31 -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/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 +21 -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/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 +21 -0
- package/src/db/pg/schema.ts +195 -27
- package/src/db/post-visibility.ts +117 -0
- package/src/db/schema.ts +222 -27
- 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 +490 -158
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +487 -155
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +487 -155
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +555 -73
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +601 -119
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +614 -132
- 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 +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -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__/image.test.ts +27 -1
- 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-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -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 +131 -3
- package/src/lib/__tests__/schemas.test.ts +69 -5
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +70 -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__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- 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 +78 -72
- 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 +439 -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 +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-repo-name.ts +45 -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 +62 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/markdown-manager.ts +392 -2
- 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-body-html.ts +11 -4
- package/src/lib/post-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +111 -6
- package/src/lib/schemas.ts +251 -15
- 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 +43 -2
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +159 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +53 -25
- 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-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +75 -0
- package/src/node/index.ts +3 -0
- package/src/node/request-handler.ts +3 -1
- 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__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/posts.test.ts +24 -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/__tests__/upload.test.ts +34 -0
- 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 +6 -3
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +161 -0
- package/src/routes/api/public/__tests__/archive.test.ts +111 -0
- package/src/routes/api/public/archive.ts +77 -81
- package/src/routes/api/public/posts.ts +44 -7
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/upload.ts +10 -3
- 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 +9 -0
- 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__/github-sync-app.test.ts +365 -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 +714 -111
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- 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 +370 -6
- 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__/feed-autodiscovery.test.ts +148 -0
- package/src/routes/pages/__tests__/language-routing.test.ts +679 -0
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/post-preview-partial.test.ts +96 -0
- 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/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +487 -379
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +43 -18
- 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 +228 -45
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +22 -5
- 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/__tests__/readiness.test.ts +23 -0
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +67 -1
- package/src/runtime/readiness.ts +15 -0
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +236 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +120 -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 +408 -39
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +29 -24
- 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/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +103 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- 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/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +41 -31
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +13 -2
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +880 -72
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +22 -2
- package/src/services/media.ts +22 -7
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +254 -6
- package/src/services/post.ts +1234 -190
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +27 -14
- package/src/services/site.ts +160 -32
- 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 -1571
- package/src/styles/site-media.css +16 -2
- package/src/styles/tokens.css +82 -12
- package/src/styles/ui-author.css +7602 -0
- package/src/styles/ui.css +1090 -7216
- package/src/types/app-context.ts +23 -0
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +74 -2
- package/src/types/props.ts +157 -31
- package/src/types/views.ts +55 -1
- package/src/ui/__tests__/font-themes.test.ts +52 -12
- package/src/ui/compose/ComposeDialog.tsx +201 -7
- 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 +277 -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/NoteCard.tsx +9 -4
- package/src/ui/feed/PostStatusBadges.tsx +90 -43
- 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 +282 -289
- 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 +65 -9
- 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/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 +20 -11
- 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/navigation-labels.ts +45 -71
- package/src/ui/shared/post-article-attributes.ts +2 -0
- package/src/ui/shared/smart-collection-labels.ts +343 -0
- package/dist/app-C75Zc8dD.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-CU9pEbk5.js +0 -1
- package/dist/client/_assets/client-Bq0vre8Y.js +0 -296
- package/dist/client/_assets/client-D3VZyG4L.css +0 -2
- package/dist/client/_assets/client-auth-F2rb9WfF.js +0 -5571
- package/dist/env-BPYViDJJ.js +0 -281
- package/dist/export-Dl5KCiEs.js +0 -5562
- package/dist/github-api-BNF35Lkx.js +0 -176
- package/dist/github-app-DdiD-bC4.js +0 -275
- package/dist/github-sync-Bqg62IvV.js +0 -4
- package/dist/github-sync-HdK2EgvJ.js +0 -431
- package/dist/url-CbLAtlZe.js +0 -900
- 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/client/tiptap/exitable-marks.ts +0 -73
- 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,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
buildRootActivityExpr,
|
|
37
38
|
rootActivityColumns,
|
|
38
39
|
} from "../db/thread-activity.js";
|
|
40
|
+
import { buildReaderVisibilityConditions } from "../db/post-visibility.js";
|
|
39
41
|
import { createEntityId } from "../lib/ids.js";
|
|
40
42
|
import { now } from "../lib/time.js";
|
|
41
43
|
import { trimTiptapBody } from "../lib/tiptap-render.js";
|
|
@@ -46,12 +48,17 @@ import {
|
|
|
46
48
|
tryPreparePostBodyHtml,
|
|
47
49
|
} from "../lib/post-body-html.js";
|
|
48
50
|
import { extractSummary, extractBodyText } from "../lib/summary.js";
|
|
51
|
+
import {
|
|
52
|
+
THREAD_LEADING_REPLIES,
|
|
53
|
+
THREAD_TRAILING_REPLIES,
|
|
54
|
+
} from "../lib/thread-fold.js";
|
|
49
55
|
import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
|
|
50
56
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
51
57
|
import { generatePostSlug } from "../lib/slug.js";
|
|
52
58
|
import { getSlugValidationIssue } from "../lib/slug-format.js";
|
|
53
59
|
import { isReservedPath } from "../lib/constants.js";
|
|
54
|
-
import { normalizePath
|
|
60
|
+
import { normalizePath } from "../lib/url.js";
|
|
61
|
+
import { slugify } from "../lib/slugify.js";
|
|
55
62
|
import type { StorageDriver } from "../lib/storage.js";
|
|
56
63
|
import type { MediaService } from "./media.js";
|
|
57
64
|
import {
|
|
@@ -78,6 +85,14 @@ import {
|
|
|
78
85
|
ValidationError,
|
|
79
86
|
NotFoundError,
|
|
80
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";
|
|
81
96
|
import { createPathService, type PathService } from "./path.js";
|
|
82
97
|
import {
|
|
83
98
|
extractYouTubeVideoId,
|
|
@@ -141,6 +156,12 @@ export interface PostFilters {
|
|
|
141
156
|
/** Exclude private posts from results */
|
|
142
157
|
excludePrivate?: boolean;
|
|
143
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[];
|
|
144
165
|
/** Unix timestamp (inclusive) — only posts published at or after this time */
|
|
145
166
|
publishedAfter?: number;
|
|
146
167
|
/** Unix timestamp (exclusive) — only posts published before this time */
|
|
@@ -155,6 +176,16 @@ export interface PostFilters {
|
|
|
155
176
|
axisBefore?: number;
|
|
156
177
|
/** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
|
|
157
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;
|
|
158
189
|
/** Filter by media presence */
|
|
159
190
|
hasMedia?: boolean;
|
|
160
191
|
/** Filter by title presence */
|
|
@@ -189,18 +220,29 @@ export interface PostFilters {
|
|
|
189
220
|
offset?: number; // offset for page-based pagination
|
|
190
221
|
}
|
|
191
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
|
+
|
|
192
230
|
/** Config for automatic summary extraction */
|
|
193
231
|
export interface SummaryConfig {
|
|
194
232
|
maxParagraphs: number;
|
|
195
233
|
maxChars: number;
|
|
196
234
|
}
|
|
197
235
|
|
|
198
|
-
interface ThreadRootPageOptions {
|
|
236
|
+
export interface ThreadRootPageOptions {
|
|
199
237
|
status?: Status;
|
|
200
238
|
excludePrivate?: boolean;
|
|
201
239
|
excludeLatestHidden?: boolean;
|
|
240
|
+
/** See `PostFilters.lang`. */
|
|
241
|
+
lang?: string;
|
|
202
242
|
/** Exclude Posts published at or after this Unix timestamp. */
|
|
203
243
|
publishedBefore?: number;
|
|
244
|
+
/** Restrict to these Threads, by root ID. Same budget as `PostFilters.ids`. */
|
|
245
|
+
threadIds?: string[];
|
|
204
246
|
/** Restrict by the Thread root's format without excluding Child Posts. */
|
|
205
247
|
rootFormat?: Format;
|
|
206
248
|
/** Restrict to Threads with at least one rated published post. */
|
|
@@ -233,7 +275,7 @@ export interface CollectionFeedEntry {
|
|
|
233
275
|
|
|
234
276
|
export interface FeaturedThreadTimelinePost {
|
|
235
277
|
post: Post;
|
|
236
|
-
/** Zero-based position among the Thread's published Posts. */
|
|
278
|
+
/** Zero-based position among the Thread's published Posts; the root is 0. */
|
|
237
279
|
position: number;
|
|
238
280
|
}
|
|
239
281
|
|
|
@@ -267,10 +309,72 @@ export interface SitemapPostEntry {
|
|
|
267
309
|
alias: string | null;
|
|
268
310
|
updatedAt: number;
|
|
269
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;
|
|
316
|
+
}
|
|
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
|
+
|
|
347
|
+
/** A Post together with the custom URL that has taken over as its address. */
|
|
348
|
+
export interface PostWithCanonicalAlias {
|
|
349
|
+
post: Post;
|
|
350
|
+
/**
|
|
351
|
+
* The newest registered `alias`, which is the address a slug URL redirects
|
|
352
|
+
* to. Null when the Post still answers at its slug.
|
|
353
|
+
*/
|
|
354
|
+
canonicalAlias: string | null;
|
|
270
355
|
}
|
|
271
356
|
|
|
272
357
|
export interface PostService {
|
|
273
358
|
getById(id: string): Promise<Post | null>;
|
|
359
|
+
/**
|
|
360
|
+
* The Post a resolved path points at, plus its canonical address, in one
|
|
361
|
+
* read.
|
|
362
|
+
*
|
|
363
|
+
* Serving a Post URL needs three things the registry and the Post row hold
|
|
364
|
+
* between them: the row, the Post's slug, and whether a custom URL has taken
|
|
365
|
+
* over as its address. Asking for those separately put three round trips at
|
|
366
|
+
* the head of every Post page, so they come back together.
|
|
367
|
+
*
|
|
368
|
+
* @param postId - TypeID of the Post
|
|
369
|
+
* @returns The hydrated Post and its canonical alias, or null when no such
|
|
370
|
+
* Post exists or it has no slug
|
|
371
|
+
* @example
|
|
372
|
+
* ```ts
|
|
373
|
+
* const loaded = await posts.getWithCanonicalAlias(resolved.postId);
|
|
374
|
+
* if (loaded?.canonicalAlias) redirect(loaded.canonicalAlias);
|
|
375
|
+
* ```
|
|
376
|
+
*/
|
|
377
|
+
getWithCanonicalAlias(postId: string): Promise<PostWithCanonicalAlias | null>;
|
|
274
378
|
getBodyContent(id: string): Promise<PostBodyContent | null>;
|
|
275
379
|
getBySlug(slug: string): Promise<Post | null>;
|
|
276
380
|
suggestSlug(input: {
|
|
@@ -313,6 +417,38 @@ export interface PostService {
|
|
|
313
417
|
count(filters?: PostFilters): Promise<number>;
|
|
314
418
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
315
419
|
countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
|
|
420
|
+
/**
|
|
421
|
+
* Measure several filters at once, in a single round trip.
|
|
422
|
+
*
|
|
423
|
+
* One table scan with one `SUM(CASE …)` and one `MAX(CASE …)` per filter,
|
|
424
|
+
* rather than one query per filter. On Workers the round trip is the cost
|
|
425
|
+
* that matters, and a page that lists twenty smart collections would
|
|
426
|
+
* otherwise pay twenty of them — `Promise.all` only turns "slow in sequence"
|
|
427
|
+
* into "expensive in parallel".
|
|
428
|
+
*
|
|
429
|
+
* Every predicate is built by the same `buildFilterConditions` a single
|
|
430
|
+
* `count` uses, so the numbers here and the numbers on the pages they link to
|
|
431
|
+
* cannot drift.
|
|
432
|
+
*
|
|
433
|
+
* `recentActivityAt` is the newest activity among the matched rows, on the
|
|
434
|
+
* one definition {@link buildRootActivityExpr} holds for everything that
|
|
435
|
+
* orders or dates a Thread. It reads the matched row's own columns, so it
|
|
436
|
+
* describes a Thread only when `base` excludes replies — which is what every
|
|
437
|
+
* caller does, because a reply is not a member of anything.
|
|
438
|
+
*
|
|
439
|
+
* @param filters - One filter per measurement, in order
|
|
440
|
+
* @param base - Applied to every measurement: the site, the reader's
|
|
441
|
+
* visibility floor, and anything else common to all of them
|
|
442
|
+
* @returns Count and newest activity, positionally matching `filters`;
|
|
443
|
+
* `recentActivityAt` is `null` where nothing matched
|
|
444
|
+
* @example
|
|
445
|
+
* await posts.aggregateMany([{ format: "note" }, { format: "quote" }], base);
|
|
446
|
+
* // [{ count: 12, recentActivityAt: 1706100000 }, { count: 5, … }]
|
|
447
|
+
*/
|
|
448
|
+
aggregateMany(
|
|
449
|
+
filters: readonly PostFilters[],
|
|
450
|
+
base: PostFilters,
|
|
451
|
+
): Promise<PostFilterAggregate[]>;
|
|
316
452
|
/** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
|
|
317
453
|
countByYearMonth(
|
|
318
454
|
filters?: PostFilters,
|
|
@@ -369,6 +505,10 @@ export interface PostService {
|
|
|
369
505
|
* Used when replacing a saved thread draft with a new version.
|
|
370
506
|
*/
|
|
371
507
|
deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
508
|
+
/**
|
|
509
|
+
* Every Post in a Thread, drafts included, in Thread order: the root first,
|
|
510
|
+
* then replies by creation time, then ID.
|
|
511
|
+
*/
|
|
372
512
|
getThread(rootId: string): Promise<Post[]>;
|
|
373
513
|
/**
|
|
374
514
|
* 1-based position of a Post in the reply chain running from its Thread root
|
|
@@ -390,6 +530,112 @@ export interface PostService {
|
|
|
390
530
|
status: Status,
|
|
391
531
|
visibility: Visibility,
|
|
392
532
|
): Promise<void>;
|
|
533
|
+
/**
|
|
534
|
+
* Set the content language of a whole Thread (root and every reply).
|
|
535
|
+
*
|
|
536
|
+
* Thread-wide by design: `post.language` is uniform inside a Thread, which is
|
|
537
|
+
* what lets every language filter stay a plain column predicate. Rejects a
|
|
538
|
+
* language another Post in the same translation group already holds.
|
|
539
|
+
*
|
|
540
|
+
* @param postId - Any Post in the Thread; the Thread is resolved from it
|
|
541
|
+
* @param language - Canonical BCP 47 tag
|
|
542
|
+
* @throws {NotFoundError} When no such Post exists on this site
|
|
543
|
+
* @throws {ConflictError} When the translation group already has that language
|
|
544
|
+
* @example
|
|
545
|
+
* await posts.setThreadLanguage(post.id, "zh-Hans");
|
|
546
|
+
*/
|
|
547
|
+
setThreadLanguage(postId: string, language: string): Promise<void>;
|
|
548
|
+
/**
|
|
549
|
+
* Stamp every Post that has no language yet with `language`.
|
|
550
|
+
*
|
|
551
|
+
* Runs once when an author turns multilingual content on, and is idempotent
|
|
552
|
+
* so re-enabling later only touches Posts written while it was off. Scoped to
|
|
553
|
+
* this site — hosted deployments share one database.
|
|
554
|
+
*
|
|
555
|
+
* @param language - Canonical BCP 47 tag of the site's primary language
|
|
556
|
+
* @returns Number of Posts stamped
|
|
557
|
+
* @example
|
|
558
|
+
* await posts.materializeMissingLanguage("zh-Hans"); // => 347
|
|
559
|
+
*/
|
|
560
|
+
materializeMissingLanguage(language: string): Promise<number>;
|
|
561
|
+
/** Count Posts on this site that still have no language. */
|
|
562
|
+
countMissingLanguage(): Promise<number>;
|
|
563
|
+
/** Count Posts on this site written in `language`, drafts included. */
|
|
564
|
+
countByLanguage(language: string): Promise<number>;
|
|
565
|
+
/**
|
|
566
|
+
* Every language stamped on this site's Posts, with how many carry it.
|
|
567
|
+
* Drafts included; the pre-multilingual NULL rows are not.
|
|
568
|
+
*/
|
|
569
|
+
listLanguagesInUse(): Promise<Array<{ language: string; count: number }>>;
|
|
570
|
+
/**
|
|
571
|
+
* List the Thread roots that are translations of the given Post, excluding
|
|
572
|
+
* the Post itself. Empty when it belongs to no translation group.
|
|
573
|
+
*/
|
|
574
|
+
listTranslations(postId: string): Promise<Post[]>;
|
|
575
|
+
/**
|
|
576
|
+
* List translations for many Thread roots in one round trip, keyed by the
|
|
577
|
+
* root ID that was asked about. Roots with no group are omitted.
|
|
578
|
+
*/
|
|
579
|
+
getTranslationsMap(postIds: string[]): Promise<Map<string, Post[]>>;
|
|
580
|
+
/**
|
|
581
|
+
* Find published Thread roots this Post could be linked to as a translation.
|
|
582
|
+
*
|
|
583
|
+
* Filtered server-side rather than in the menu, because "can these two be
|
|
584
|
+
* linked" is a rule the service owns: a candidate has to be a Thread root in
|
|
585
|
+
* a language this Post's translation group does not already hold, and the two
|
|
586
|
+
* groups must not both exist — `linkTranslation` refuses to merge them.
|
|
587
|
+
* Offering a post that will be rejected teaches the author nothing.
|
|
588
|
+
*
|
|
589
|
+
* The match is a plain substring scan over title and body text. It is a
|
|
590
|
+
* deliberate, authenticated action over one author's own posts, so the cost
|
|
591
|
+
* is bounded and the shared search index (which cannot express these filters)
|
|
592
|
+
* is not worth involving.
|
|
593
|
+
*
|
|
594
|
+
* @param postId - Any Post in the Thread looking for a translation
|
|
595
|
+
* @param options - Search text and how many candidates to return
|
|
596
|
+
* @returns Matching Thread roots, newest first
|
|
597
|
+
* @example
|
|
598
|
+
* await posts.listTranslationCandidates(id, { query: "recipe" });
|
|
599
|
+
*/
|
|
600
|
+
listTranslationCandidates(
|
|
601
|
+
postId: string,
|
|
602
|
+
options: { query: string; limit?: number },
|
|
603
|
+
): Promise<Post[]>;
|
|
604
|
+
/**
|
|
605
|
+
* Resolve an address the author pasted into the Thread it names, and say
|
|
606
|
+
* whether this Post could be linked to it as a translation.
|
|
607
|
+
*
|
|
608
|
+
* The same rules as {@link PostService.listTranslationCandidates}, asked
|
|
609
|
+
* about one known Thread instead of searched for — so the answer can be a
|
|
610
|
+
* reason rather than an empty list. An address that lands on a reply names
|
|
611
|
+
* its Thread, because translations link whole Threads.
|
|
612
|
+
*
|
|
613
|
+
* @param postId - Any Post in the Thread looking for a translation
|
|
614
|
+
* @param path - Internal path, as produced by `toInternalPath()`
|
|
615
|
+
* @returns The Thread root to link, or why it cannot be
|
|
616
|
+
* @example
|
|
617
|
+
* await posts.resolveTranslationCandidate(id, "/hello-world");
|
|
618
|
+
* // { status: "unpublished" }
|
|
619
|
+
*/
|
|
620
|
+
resolveTranslationCandidate(
|
|
621
|
+
postId: string,
|
|
622
|
+
path: string,
|
|
623
|
+
): Promise<TranslationCandidateResolution>;
|
|
624
|
+
/**
|
|
625
|
+
* Link two already-published Thread roots as translations of each other.
|
|
626
|
+
*
|
|
627
|
+
* Joins the side without a group into the other's group, minting one when
|
|
628
|
+
* neither has any. Merging two existing groups is refused — that would
|
|
629
|
+
* silently restructure both sides.
|
|
630
|
+
*
|
|
631
|
+
* @throws {ConflictError} When the languages clash or both sides have groups
|
|
632
|
+
*/
|
|
633
|
+
linkTranslation(postId: string, otherPostId: string): Promise<void>;
|
|
634
|
+
/**
|
|
635
|
+
* Remove a Thread root from its translation group. When that leaves a single
|
|
636
|
+
* Post behind, its group is cleared too so no one-member groups survive.
|
|
637
|
+
*/
|
|
638
|
+
unlinkTranslation(postId: string): Promise<void>;
|
|
393
639
|
/** Get reply counts for multiple posts */
|
|
394
640
|
getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
|
|
395
641
|
/** Get preview replies for multiple thread roots */
|
|
@@ -450,7 +696,10 @@ export interface PostService {
|
|
|
450
696
|
collectionIds: string[],
|
|
451
697
|
options?: CollectionFeedEntryOptions,
|
|
452
698
|
): Promise<CollectionFeedEntry[]>;
|
|
453
|
-
/**
|
|
699
|
+
/**
|
|
700
|
+
* Fetch published Posts for each requested Thread root, in Thread order: the
|
|
701
|
+
* root first, then replies by creation time, then ID.
|
|
702
|
+
*/
|
|
454
703
|
getPublishedThreads(
|
|
455
704
|
rootIds: string[],
|
|
456
705
|
options?: Pick<PostFilters, "publishedBefore">,
|
|
@@ -458,7 +707,8 @@ export interface PostService {
|
|
|
458
707
|
/** Get distinct years with posts, bucketed on the `sortBy` time axis */
|
|
459
708
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
460
709
|
/**
|
|
461
|
-
* For each Thread ID, resolve the Post that currently ends the chain
|
|
710
|
+
* For each Thread ID, resolve the Post that currently ends the chain: the
|
|
711
|
+
* last in Thread order, so the root only while it has no replies.
|
|
462
712
|
*
|
|
463
713
|
* Two callers need two different answers. Readers ask what the audience can
|
|
464
714
|
* see, so drafts must not count — the Reply affordance belongs on the last
|
|
@@ -544,6 +794,9 @@ export async function rebuildPostBodyHtmlWithRuntimeSettings(
|
|
|
544
794
|
|
|
545
795
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
546
796
|
|
|
797
|
+
const INVALID_POST_BODY_MESSAGE =
|
|
798
|
+
"The body isn't a TipTap JSON document. Send a doc node as a JSON string, or send Markdown as bodyMarkdown.";
|
|
799
|
+
|
|
547
800
|
function isValidSlug(value: string): boolean {
|
|
548
801
|
return SLUG_RE.test(value);
|
|
549
802
|
}
|
|
@@ -619,6 +872,34 @@ function ensurePostRating(
|
|
|
619
872
|
throw new ErrorCtor("Rating must be an integer between 1 and 5.");
|
|
620
873
|
}
|
|
621
874
|
|
|
875
|
+
/**
|
|
876
|
+
* Normalize a caller-supplied content language to what the column stores.
|
|
877
|
+
*
|
|
878
|
+
* Empty, missing, and unparseable values all become `null` — the single-language
|
|
879
|
+
* state — so the column never holds a half-set value. Whether the tag is one
|
|
880
|
+
* the site actually offers is a settings-layer question, checked where the site
|
|
881
|
+
* language configuration is in scope.
|
|
882
|
+
*
|
|
883
|
+
* @param value - Raw BCP 47 tag from a request or import
|
|
884
|
+
* @returns Canonical tag (`zh-Hans`), or `null` when there is nothing to store
|
|
885
|
+
* @example
|
|
886
|
+
* normalizePostLanguage("ZH-hans"); // "zh-Hans"
|
|
887
|
+
* normalizePostLanguage(" "); // null
|
|
888
|
+
*/
|
|
889
|
+
export function normalizePostLanguage(
|
|
890
|
+
value: string | null | undefined,
|
|
891
|
+
): string | null {
|
|
892
|
+
if (typeof value !== "string") return null;
|
|
893
|
+
const trimmed = value.trim();
|
|
894
|
+
if (!trimmed) return null;
|
|
895
|
+
if (!isValidContentLanguage(trimmed)) {
|
|
896
|
+
throw new ValidationError(
|
|
897
|
+
"Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
|
|
898
|
+
);
|
|
899
|
+
}
|
|
900
|
+
return normalizeContentLanguage(trimmed);
|
|
901
|
+
}
|
|
902
|
+
|
|
622
903
|
function assertPostFormatShape(data: {
|
|
623
904
|
format: Format;
|
|
624
905
|
title?: string | null;
|
|
@@ -711,6 +992,33 @@ export function createPostService(
|
|
|
711
992
|
return posts.publishedAt;
|
|
712
993
|
}
|
|
713
994
|
|
|
995
|
+
/**
|
|
996
|
+
* Thread order: the root first, then replies by creation time, then ID.
|
|
997
|
+
*
|
|
998
|
+
* The root leads whatever its own `createdAt` says. A reply can be older
|
|
999
|
+
* than its root — a post moved into a Thread keeps its creation time, and an
|
|
1000
|
+
* export without `created` dates every post by publication — and ordering
|
|
1001
|
+
* on time alone would open such a Thread on a reply. Every surface that
|
|
1002
|
+
* reads the first post as the root would then lose it: the Thread page's
|
|
1003
|
+
* canonical URL, Featured, Collections, the feeds, and the reply guard's
|
|
1004
|
+
* idea of where the Thread ends. Every query that orders a Thread's posts
|
|
1005
|
+
* goes through here so they agree.
|
|
1006
|
+
*
|
|
1007
|
+
* @param direction - `desc` walks from the Thread's end back to its root
|
|
1008
|
+
* @returns ORDER BY terms, placed after any partitioning column
|
|
1009
|
+
* @example
|
|
1010
|
+
* db.select().from(posts).orderBy(posts.threadId, ...threadOrder());
|
|
1011
|
+
* sql`ROW_NUMBER() OVER (ORDER BY ${sql.join(threadOrder(), sql`, `)})`;
|
|
1012
|
+
*/
|
|
1013
|
+
function threadOrder(direction: "asc" | "desc" = "asc"): SQL[] {
|
|
1014
|
+
const terms = [
|
|
1015
|
+
sql`CASE WHEN ${posts.replyToId} IS NULL THEN 0 ELSE 1 END`,
|
|
1016
|
+
sql`${posts.createdAt}`,
|
|
1017
|
+
sql`${posts.id}`,
|
|
1018
|
+
];
|
|
1019
|
+
return direction === "asc" ? terms : terms.map((term) => desc(term));
|
|
1020
|
+
}
|
|
1021
|
+
|
|
714
1022
|
function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
|
|
715
1023
|
return databaseDialect === "pg"
|
|
716
1024
|
? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
|
|
@@ -723,11 +1031,6 @@ export function createPostService(
|
|
|
723
1031
|
: sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
|
|
724
1032
|
}
|
|
725
1033
|
|
|
726
|
-
const effectiveVisibilityExpr = sql<string>`coalesce(
|
|
727
|
-
${posts.visibility},
|
|
728
|
-
(SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId} AND root.site_id = ${siteId})
|
|
729
|
-
)`;
|
|
730
|
-
|
|
731
1034
|
/** Check if a slug is available (not used by posts or path_registry) */
|
|
732
1035
|
async function isSlugAvailable(slug: string): Promise<boolean> {
|
|
733
1036
|
return resolvedPaths.isPathAvailable(slug);
|
|
@@ -934,22 +1237,71 @@ export function createPostService(
|
|
|
934
1237
|
)`;
|
|
935
1238
|
}
|
|
936
1239
|
|
|
937
|
-
/**
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1240
|
+
/**
|
|
1241
|
+
* Load a Post and assert it can carry a translation group.
|
|
1242
|
+
*
|
|
1243
|
+
* Groups live on Thread roots only, and both sides must belong to this site.
|
|
1244
|
+
* These two rules replace the table CHECK the SQLite schema cannot add, so
|
|
1245
|
+
* every write path that touches `translation_group_id` goes through here.
|
|
1246
|
+
*/
|
|
1247
|
+
async function requireTranslatableRoot(
|
|
1248
|
+
postId: string,
|
|
1249
|
+
): Promise<typeof posts.$inferSelect> {
|
|
1250
|
+
const rows = await db
|
|
1251
|
+
.select()
|
|
1252
|
+
.from(posts)
|
|
1253
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
1254
|
+
.limit(1);
|
|
1255
|
+
const row = rows[0];
|
|
1256
|
+
if (!row) {
|
|
1257
|
+
throw new NotFoundError("Post");
|
|
946
1258
|
}
|
|
947
|
-
if (
|
|
948
|
-
|
|
1259
|
+
if (row.threadId !== row.id) {
|
|
1260
|
+
throw new ValidationError(
|
|
1261
|
+
"Translations link whole threads. Use the thread's first post.",
|
|
1262
|
+
);
|
|
949
1263
|
}
|
|
950
|
-
|
|
951
|
-
|
|
1264
|
+
return row;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** Reject a language another Post in the same translation group already holds. */
|
|
1268
|
+
async function assertTranslationLanguageFree(
|
|
1269
|
+
groupId: string,
|
|
1270
|
+
language: string,
|
|
1271
|
+
excludePostId?: string,
|
|
1272
|
+
): Promise<void> {
|
|
1273
|
+
const rows = await db
|
|
1274
|
+
.select({ id: posts.id, title: posts.title })
|
|
1275
|
+
.from(posts)
|
|
1276
|
+
.where(
|
|
1277
|
+
and(
|
|
1278
|
+
eq(posts.siteId, siteId),
|
|
1279
|
+
eq(posts.translationGroupId, groupId),
|
|
1280
|
+
eq(posts.language, language),
|
|
1281
|
+
),
|
|
1282
|
+
)
|
|
1283
|
+
.limit(2);
|
|
1284
|
+
const clash = rows.find((row) => row.id !== excludePostId);
|
|
1285
|
+
if (clash) {
|
|
1286
|
+
// The language's own name, not its tag: this message is shown to the
|
|
1287
|
+
// author, and "简体中文" is what they picked in the menu — "zh-Hans" is
|
|
1288
|
+
// an implementation detail they never chose.
|
|
1289
|
+
const languageName = getOrBuildEntry(language).native;
|
|
1290
|
+
throw new ConflictError(
|
|
1291
|
+
clash.title
|
|
1292
|
+
? `"${clash.title}" is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`
|
|
1293
|
+
: `Another post is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`,
|
|
1294
|
+
);
|
|
952
1295
|
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
/** Build WHERE conditions from filters (shared by list and count) */
|
|
1299
|
+
function buildFilterConditions(filters: PostFilters) {
|
|
1300
|
+
const conditions = [
|
|
1301
|
+
eq(posts.siteId, siteId),
|
|
1302
|
+
...buildReaderVisibilityConditions(posts, siteId, filters),
|
|
1303
|
+
];
|
|
1304
|
+
|
|
953
1305
|
if (filters.pinned !== undefined) {
|
|
954
1306
|
conditions.push(
|
|
955
1307
|
filters.pinned
|
|
@@ -979,6 +1331,9 @@ export function createPostService(
|
|
|
979
1331
|
if (filters.threadId) {
|
|
980
1332
|
conditions.push(eq(posts.threadId, filters.threadId));
|
|
981
1333
|
}
|
|
1334
|
+
if (filters.ids !== undefined) {
|
|
1335
|
+
conditions.push(inArray(posts.id, filters.ids));
|
|
1336
|
+
}
|
|
982
1337
|
if (filters.excludeReplies) {
|
|
983
1338
|
conditions.push(isNull(posts.replyToId));
|
|
984
1339
|
}
|
|
@@ -1150,80 +1505,61 @@ export function createPostService(
|
|
|
1150
1505
|
...keys,
|
|
1151
1506
|
];
|
|
1152
1507
|
|
|
1153
|
-
if (filters.featured
|
|
1154
|
-
if (filters.featured) {
|
|
1155
|
-
return buildLexicographicCursorCondition(
|
|
1156
|
-
withPinnedSortKey([
|
|
1157
|
-
{
|
|
1158
|
-
direction: "desc",
|
|
1159
|
-
expr: featuredPublishedSortExpr,
|
|
1160
|
-
value: cursorFeaturedPublishedAt,
|
|
1161
|
-
},
|
|
1162
|
-
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1163
|
-
]),
|
|
1164
|
-
);
|
|
1165
|
-
}
|
|
1166
|
-
|
|
1508
|
+
if (filters.featured) {
|
|
1167
1509
|
return buildLexicographicCursorCondition(
|
|
1168
1510
|
withPinnedSortKey([
|
|
1169
1511
|
{
|
|
1170
1512
|
direction: "desc",
|
|
1171
|
-
expr:
|
|
1172
|
-
value:
|
|
1513
|
+
expr: featuredPublishedSortExpr,
|
|
1514
|
+
value: cursorFeaturedPublishedAt,
|
|
1173
1515
|
},
|
|
1174
1516
|
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1175
1517
|
]),
|
|
1176
1518
|
);
|
|
1177
1519
|
}
|
|
1178
1520
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1521
|
+
// One case per sort order, mirroring `list()` key for key.
|
|
1522
|
+
switch (filters.sortOrder ?? "newest") {
|
|
1523
|
+
case "oldest":
|
|
1524
|
+
return buildLexicographicCursorCondition(
|
|
1525
|
+
withPinnedSortKey([
|
|
1526
|
+
{
|
|
1527
|
+
direction: "asc",
|
|
1528
|
+
expr: sortTimestampSortExpr,
|
|
1529
|
+
value: cursorSortTimestamp,
|
|
1530
|
+
},
|
|
1531
|
+
{ direction: "asc", expr: posts.id, value: cursorPost.id },
|
|
1532
|
+
]),
|
|
1533
|
+
);
|
|
1534
|
+
case "rating_desc":
|
|
1535
|
+
return buildLexicographicCursorCondition(
|
|
1536
|
+
withPinnedSortKey([
|
|
1537
|
+
{
|
|
1538
|
+
direction: "desc",
|
|
1539
|
+
expr: ratingPresenceExpr,
|
|
1540
|
+
value: cursorRatingPresence,
|
|
1541
|
+
},
|
|
1542
|
+
{ direction: "desc", expr: ratingSortExpr, value: cursorRating },
|
|
1543
|
+
{
|
|
1544
|
+
direction: "desc",
|
|
1545
|
+
expr: sortTimestampSortExpr,
|
|
1546
|
+
value: cursorSortTimestamp,
|
|
1547
|
+
},
|
|
1548
|
+
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1549
|
+
]),
|
|
1550
|
+
);
|
|
1551
|
+
case "newest":
|
|
1552
|
+
return buildLexicographicCursorCondition(
|
|
1553
|
+
withPinnedSortKey([
|
|
1554
|
+
{
|
|
1555
|
+
direction: "desc",
|
|
1556
|
+
expr: sortTimestampSortExpr,
|
|
1557
|
+
value: cursorSortTimestamp,
|
|
1558
|
+
},
|
|
1559
|
+
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1560
|
+
]),
|
|
1561
|
+
);
|
|
1209
1562
|
}
|
|
1210
|
-
|
|
1211
|
-
return buildLexicographicCursorCondition(
|
|
1212
|
-
withPinnedSortKey([
|
|
1213
|
-
{
|
|
1214
|
-
direction: "desc",
|
|
1215
|
-
expr: ratingPresenceExpr,
|
|
1216
|
-
value: cursorRatingPresence,
|
|
1217
|
-
},
|
|
1218
|
-
{ direction: "asc", expr: ratingSortExpr, value: cursorRating },
|
|
1219
|
-
{
|
|
1220
|
-
direction: "desc",
|
|
1221
|
-
expr: sortTimestampSortExpr,
|
|
1222
|
-
value: cursorSortTimestamp,
|
|
1223
|
-
},
|
|
1224
|
-
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1225
|
-
]),
|
|
1226
|
-
);
|
|
1227
1563
|
}
|
|
1228
1564
|
|
|
1229
1565
|
function toPost(
|
|
@@ -1258,6 +1594,8 @@ export function createPostService(
|
|
|
1258
1594
|
previewProvider: row.previewProvider,
|
|
1259
1595
|
replyToId: row.replyToId,
|
|
1260
1596
|
threadId: row.threadId,
|
|
1597
|
+
language: row.language,
|
|
1598
|
+
translationGroupId: row.translationGroupId,
|
|
1261
1599
|
quietReply: row.quietReply,
|
|
1262
1600
|
publishedAt: row.publishedAt,
|
|
1263
1601
|
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
@@ -1271,14 +1609,80 @@ export function createPostService(
|
|
|
1271
1609
|
};
|
|
1272
1610
|
}
|
|
1273
1611
|
|
|
1612
|
+
/**
|
|
1613
|
+
* Thread visibility the loaded rows already answer, plus the thread ids they
|
|
1614
|
+
* do not.
|
|
1615
|
+
*
|
|
1616
|
+
* A reply's own `visibility` column is null — it inherits the root's — so a
|
|
1617
|
+
* row only speaks for its thread when it *is* the root. List surfaces query
|
|
1618
|
+
* thread roots exclusively (`excludeReplies`), so for them this covers every
|
|
1619
|
+
* thread and the follow-up read never runs.
|
|
1620
|
+
*
|
|
1621
|
+
* Entries are set only for a non-null visibility, matching
|
|
1622
|
+
* {@link getThreadVisibilityMap}: a root row with no visibility is treated as
|
|
1623
|
+
* unreadable by both paths.
|
|
1624
|
+
*/
|
|
1625
|
+
function seedThreadVisibility(rows: (typeof posts.$inferSelect)[]): {
|
|
1626
|
+
known: Map<string, Visibility>;
|
|
1627
|
+
missingThreadIds: string[];
|
|
1628
|
+
} {
|
|
1629
|
+
const known = new Map<string, Visibility>();
|
|
1630
|
+
const covered = new Set<string>();
|
|
1631
|
+
for (const row of rows) {
|
|
1632
|
+
if (row.id !== row.threadId) continue;
|
|
1633
|
+
covered.add(row.threadId);
|
|
1634
|
+
if (row.visibility) {
|
|
1635
|
+
known.set(row.threadId, ensurePostVisibility(row.visibility, Error));
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
const missingThreadIds = [
|
|
1639
|
+
...new Set(
|
|
1640
|
+
rows
|
|
1641
|
+
.map((row) => row.threadId)
|
|
1642
|
+
.filter((threadId) => !covered.has(threadId)),
|
|
1643
|
+
),
|
|
1644
|
+
];
|
|
1645
|
+
return { known, missingThreadIds };
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* Slug lookup and root-visibility lookup are independent, so they go out
|
|
1650
|
+
* together rather than one after the other.
|
|
1651
|
+
*/
|
|
1652
|
+
async function loadHydrationMaps(
|
|
1653
|
+
rows: (typeof posts.$inferSelect)[],
|
|
1654
|
+
): Promise<{
|
|
1655
|
+
slugMap: Map<string, string>;
|
|
1656
|
+
visibilityMap: Map<string, Visibility>;
|
|
1657
|
+
}> {
|
|
1658
|
+
const { known, missingThreadIds } = seedThreadVisibility(rows);
|
|
1659
|
+
const [slugMap, fetched] = await Promise.all([
|
|
1660
|
+
resolvedPaths.getPostSlugMap(rows.map((row) => row.id)),
|
|
1661
|
+
missingThreadIds.length > 0
|
|
1662
|
+
? getThreadVisibilityMap(missingThreadIds)
|
|
1663
|
+
: Promise.resolve(new Map<string, Visibility>()),
|
|
1664
|
+
]);
|
|
1665
|
+
for (const [threadId, visibility] of fetched) {
|
|
1666
|
+
known.set(threadId, visibility);
|
|
1667
|
+
}
|
|
1668
|
+
return { slugMap, visibilityMap: known };
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1274
1671
|
async function hydratePost(
|
|
1275
1672
|
row: typeof posts.$inferSelect | undefined,
|
|
1673
|
+
knownSlug?: string,
|
|
1276
1674
|
): Promise<Post | null> {
|
|
1277
1675
|
if (!row) return null;
|
|
1278
|
-
const
|
|
1676
|
+
const { known, missingThreadIds } = seedThreadVisibility([row]);
|
|
1677
|
+
const [slug, fetched] = await Promise.all([
|
|
1678
|
+
knownSlug ?? resolvedPaths.getPostSlug(row.id),
|
|
1679
|
+
missingThreadIds.length > 0
|
|
1680
|
+
? getThreadVisibilityMap(missingThreadIds)
|
|
1681
|
+
: Promise.resolve(new Map<string, Visibility>()),
|
|
1682
|
+
]);
|
|
1279
1683
|
if (!slug) return null;
|
|
1280
|
-
const
|
|
1281
|
-
|
|
1684
|
+
const visibility =
|
|
1685
|
+
known.get(row.threadId) ?? fetched.get(row.threadId) ?? row.visibility;
|
|
1282
1686
|
if (!visibility) return null;
|
|
1283
1687
|
return toPost(row, slug, visibility);
|
|
1284
1688
|
}
|
|
@@ -1287,17 +1691,11 @@ export function createPostService(
|
|
|
1287
1691
|
rows: (typeof posts.$inferSelect)[],
|
|
1288
1692
|
): Promise<Post[]> {
|
|
1289
1693
|
if (rows.length === 0) return [];
|
|
1290
|
-
const slugMap = await
|
|
1291
|
-
rows.map((row) => row.id),
|
|
1292
|
-
);
|
|
1293
|
-
const rootVisibilityMap = await getThreadVisibilityMap(
|
|
1294
|
-
rows.map((row) => row.threadId),
|
|
1295
|
-
);
|
|
1694
|
+
const { slugMap, visibilityMap } = await loadHydrationMaps(rows);
|
|
1296
1695
|
return rows
|
|
1297
1696
|
.map((row) => {
|
|
1298
1697
|
const slug = slugMap.get(row.id);
|
|
1299
|
-
const visibility =
|
|
1300
|
-
rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
1698
|
+
const visibility = visibilityMap.get(row.threadId) ?? row.visibility;
|
|
1301
1699
|
return slug && visibility ? toPost(row, slug, visibility) : null;
|
|
1302
1700
|
})
|
|
1303
1701
|
.filter((row): row is Post => row !== null);
|
|
@@ -1355,18 +1753,10 @@ export function createPostService(
|
|
|
1355
1753
|
}
|
|
1356
1754
|
|
|
1357
1755
|
function buildThreadRootPageConditions(options?: ThreadRootPageOptions) {
|
|
1358
|
-
const conditions: SQL[] = [
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
conditions.push(eq(posts.status, status));
|
|
1363
|
-
}
|
|
1364
|
-
if (options?.excludePrivate) {
|
|
1365
|
-
conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
|
|
1366
|
-
}
|
|
1367
|
-
if (options?.excludeLatestHidden) {
|
|
1368
|
-
conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
|
|
1369
|
-
}
|
|
1756
|
+
const conditions: SQL[] = [
|
|
1757
|
+
eq(posts.siteId, siteId),
|
|
1758
|
+
...buildReaderVisibilityConditions(posts, siteId, options ?? {}),
|
|
1759
|
+
];
|
|
1370
1760
|
if (options?.publishedBefore !== undefined) {
|
|
1371
1761
|
conditions.push(
|
|
1372
1762
|
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
@@ -1391,6 +1781,9 @@ export function createPostService(
|
|
|
1391
1781
|
if (options?.hasRating) {
|
|
1392
1782
|
conditions.push(isNotNull(posts.rating));
|
|
1393
1783
|
}
|
|
1784
|
+
if (options?.threadIds !== undefined) {
|
|
1785
|
+
conditions.push(inArray(posts.threadId, options.threadIds));
|
|
1786
|
+
}
|
|
1394
1787
|
|
|
1395
1788
|
return conditions;
|
|
1396
1789
|
}
|
|
@@ -1617,6 +2010,45 @@ export function createPostService(
|
|
|
1617
2010
|
return hydratePost(result[0]);
|
|
1618
2011
|
},
|
|
1619
2012
|
|
|
2013
|
+
async getWithCanonicalAlias(postId) {
|
|
2014
|
+
const rows = await db
|
|
2015
|
+
.select({ post: posts, path: pathRegistry })
|
|
2016
|
+
.from(posts)
|
|
2017
|
+
.leftJoin(
|
|
2018
|
+
pathRegistry,
|
|
2019
|
+
and(
|
|
2020
|
+
eq(pathRegistry.siteId, siteId),
|
|
2021
|
+
eq(pathRegistry.postId, posts.id),
|
|
2022
|
+
),
|
|
2023
|
+
)
|
|
2024
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
2025
|
+
.orderBy(asc(pathRegistry.createdAt), asc(pathRegistry.id));
|
|
2026
|
+
|
|
2027
|
+
const row = rows[0];
|
|
2028
|
+
if (!row) return null;
|
|
2029
|
+
|
|
2030
|
+
let slug: string | null = null;
|
|
2031
|
+
let canonicalAlias: string | null = null;
|
|
2032
|
+
for (const { path } of rows) {
|
|
2033
|
+
if (!path) continue;
|
|
2034
|
+
if (path.kind === "slug") {
|
|
2035
|
+
slug = path.path;
|
|
2036
|
+
continue;
|
|
2037
|
+
}
|
|
2038
|
+
// Rows arrive oldest first, so the last `alias` seen is the newest —
|
|
2039
|
+
// the one a slug URL redirects to. Ties within a second fall to the
|
|
2040
|
+
// higher id rather than to whatever the storage engine returned first.
|
|
2041
|
+
if (path.kind === "alias") canonicalAlias = `/${path.path}`;
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
// A Post with no slug row has no address, which is what `hydratePost`
|
|
2045
|
+
// says by returning null for it.
|
|
2046
|
+
if (!slug) return null;
|
|
2047
|
+
|
|
2048
|
+
const post = await hydratePost(row.post, slug);
|
|
2049
|
+
return post ? { post, canonicalAlias } : null;
|
|
2050
|
+
},
|
|
2051
|
+
|
|
1620
2052
|
async getBodyContent(id) {
|
|
1621
2053
|
const post = await this.getById(id);
|
|
1622
2054
|
if (!post) return null;
|
|
@@ -1636,7 +2068,14 @@ export function createPostService(
|
|
|
1636
2068
|
if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
|
|
1637
2069
|
return null;
|
|
1638
2070
|
}
|
|
1639
|
-
|
|
2071
|
+
const result = await db
|
|
2072
|
+
.select()
|
|
2073
|
+
.from(posts)
|
|
2074
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, resolved.postId)))
|
|
2075
|
+
.limit(1);
|
|
2076
|
+
// The registry row that answered is the slug, so hydration does not go
|
|
2077
|
+
// back for it.
|
|
2078
|
+
return hydratePost(result[0], resolved.path);
|
|
1640
2079
|
},
|
|
1641
2080
|
|
|
1642
2081
|
async suggestSlug(input) {
|
|
@@ -1698,36 +2137,39 @@ export function createPostService(
|
|
|
1698
2137
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1699
2138
|
.limit(filters.limit ?? 100);
|
|
1700
2139
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2140
|
+
// A case per sort order, and no shared fallthrough. This was an if/else
|
|
2141
|
+
// chain whose last branch was written for one order and silently caught
|
|
2142
|
+
// another, so `newest` ordered by rating instead of by time wherever it
|
|
2143
|
+
// was passed explicitly — a smart collection page, most visibly.
|
|
2144
|
+
const buildOrderBy = (): SQL<unknown>[] => {
|
|
2145
|
+
if (filters.featured) {
|
|
2146
|
+
return [
|
|
2147
|
+
...pinnedOrder,
|
|
2148
|
+
desc(featuredPublishedSortExpr),
|
|
2149
|
+
desc(posts.id),
|
|
2150
|
+
];
|
|
2151
|
+
}
|
|
2152
|
+
switch (filters.sortOrder ?? "newest") {
|
|
2153
|
+
case "oldest":
|
|
2154
|
+
return [...pinnedOrder, asc(sortTimestampSortExpr), asc(posts.id)];
|
|
2155
|
+
case "rating_desc":
|
|
2156
|
+
return [
|
|
1704
2157
|
...pinnedOrder,
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
2158
|
+
desc(ratingPresence),
|
|
2159
|
+
desc(posts.rating),
|
|
2160
|
+
desc(sortTimestampSortExpr),
|
|
1708
2161
|
desc(posts.id),
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
desc(posts.rating),
|
|
1721
|
-
desc(sortTimestampSortExpr),
|
|
1722
|
-
desc(posts.id),
|
|
1723
|
-
)
|
|
1724
|
-
: baseQuery.orderBy(
|
|
1725
|
-
...pinnedOrder,
|
|
1726
|
-
desc(ratingPresence),
|
|
1727
|
-
asc(posts.rating),
|
|
1728
|
-
desc(sortTimestampSortExpr),
|
|
1729
|
-
desc(posts.id),
|
|
1730
|
-
);
|
|
2162
|
+
];
|
|
2163
|
+
case "newest":
|
|
2164
|
+
return [
|
|
2165
|
+
...pinnedOrder,
|
|
2166
|
+
desc(sortTimestampSortExpr),
|
|
2167
|
+
desc(posts.id),
|
|
2168
|
+
];
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
|
|
2172
|
+
let query = baseQuery.orderBy(...buildOrderBy());
|
|
1731
2173
|
|
|
1732
2174
|
if (filters.offset !== undefined) {
|
|
1733
2175
|
query = query.offset(filters.offset) as typeof query;
|
|
@@ -1774,6 +2216,8 @@ export function createPostService(
|
|
|
1774
2216
|
id: posts.id,
|
|
1775
2217
|
updatedAt: threadModifiedAt,
|
|
1776
2218
|
featuredAt: posts.featuredAt,
|
|
2219
|
+
language: posts.language,
|
|
2220
|
+
translationGroupId: posts.translationGroupId,
|
|
1777
2221
|
})
|
|
1778
2222
|
.from(posts)
|
|
1779
2223
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
@@ -1799,6 +2243,8 @@ export function createPostService(
|
|
|
1799
2243
|
alias,
|
|
1800
2244
|
updatedAt: row.updatedAt,
|
|
1801
2245
|
featuredAt: row.featuredAt,
|
|
2246
|
+
language: row.language,
|
|
2247
|
+
translationGroupId: row.translationGroupId,
|
|
1802
2248
|
};
|
|
1803
2249
|
})
|
|
1804
2250
|
.filter((entry): entry is SitemapPostEntry => entry !== null);
|
|
@@ -1845,6 +2291,59 @@ export function createPostService(
|
|
|
1845
2291
|
return result[0]?.count ?? 0;
|
|
1846
2292
|
},
|
|
1847
2293
|
|
|
2294
|
+
async aggregateMany(filters, base) {
|
|
2295
|
+
if (filters.length === 0) return [];
|
|
2296
|
+
|
|
2297
|
+
const baseConditions = buildFilterConditions(base);
|
|
2298
|
+
// The row is its own Thread root here — callers exclude replies — so the
|
|
2299
|
+
// activity expression reads the columns in hand. No correlated subquery.
|
|
2300
|
+
const activityAt = buildRootActivityExpr({
|
|
2301
|
+
lastActivityAt: posts.lastActivityAt,
|
|
2302
|
+
publishedAt: posts.publishedAt,
|
|
2303
|
+
updatedAt: posts.updatedAt,
|
|
2304
|
+
});
|
|
2305
|
+
// A `SUM(CASE …)` and a `MAX(CASE …)` per filter, over one pass of the
|
|
2306
|
+
// rows the base predicate already narrows to. The aliases are positional
|
|
2307
|
+
// and generated here, so nothing a caller supplies reaches the SQL as an
|
|
2308
|
+
// identifier.
|
|
2309
|
+
const columns = Object.fromEntries(
|
|
2310
|
+
filters.flatMap((filter, index) => {
|
|
2311
|
+
const conditions = buildFilterConditions({ ...base, ...filter });
|
|
2312
|
+
const predicate =
|
|
2313
|
+
conditions.length > 0 ? and(...conditions) : sql`1 = 1`;
|
|
2314
|
+
return [
|
|
2315
|
+
[
|
|
2316
|
+
`n${index}`,
|
|
2317
|
+
sql<number>`CAST(SUM(CASE WHEN (${predicate}) THEN 1 ELSE 0 END) AS INTEGER)`,
|
|
2318
|
+
],
|
|
2319
|
+
[
|
|
2320
|
+
`a${index}`,
|
|
2321
|
+
sql<
|
|
2322
|
+
number | null
|
|
2323
|
+
>`MAX(CASE WHEN (${predicate}) THEN ${activityAt} END)`,
|
|
2324
|
+
],
|
|
2325
|
+
];
|
|
2326
|
+
}),
|
|
2327
|
+
);
|
|
2328
|
+
|
|
2329
|
+
const rows = await db
|
|
2330
|
+
.select(columns)
|
|
2331
|
+
.from(posts)
|
|
2332
|
+
.where(baseConditions.length > 0 ? and(...baseConditions) : undefined);
|
|
2333
|
+
|
|
2334
|
+
const row = rows[0] as Record<string, number | null> | undefined;
|
|
2335
|
+
return filters.map((_, index) => {
|
|
2336
|
+
const activity = row?.[`a${index}`];
|
|
2337
|
+
return {
|
|
2338
|
+
count: Number(row?.[`n${index}`] ?? 0),
|
|
2339
|
+
recentActivityAt:
|
|
2340
|
+
activity === null || activity === undefined
|
|
2341
|
+
? null
|
|
2342
|
+
: Number(activity),
|
|
2343
|
+
};
|
|
2344
|
+
});
|
|
2345
|
+
},
|
|
2346
|
+
|
|
1848
2347
|
async countUpTo(filters = {}, limit) {
|
|
1849
2348
|
const normalizedLimit = Math.max(0, Math.trunc(limit));
|
|
1850
2349
|
if (normalizedLimit === 0) {
|
|
@@ -1898,6 +2397,9 @@ export function createPostService(
|
|
|
1898
2397
|
const preparedBody = trimmedBody
|
|
1899
2398
|
? tryPreparePostBodyHtml(id, trimmedBody)
|
|
1900
2399
|
: null;
|
|
2400
|
+
if (preparedBody && !preparedBody.ok) {
|
|
2401
|
+
throw new ValidationError(INVALID_POST_BODY_MESSAGE);
|
|
2402
|
+
}
|
|
1901
2403
|
const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
|
|
1902
2404
|
const title = data.title?.trim() || null;
|
|
1903
2405
|
const quoteText = data.quoteText?.trim() || null;
|
|
@@ -1937,6 +2439,11 @@ export function createPostService(
|
|
|
1937
2439
|
let threadId = id;
|
|
1938
2440
|
let status: Status = requestedStatus ?? "published";
|
|
1939
2441
|
let visibility: Visibility | null = requestedVisibility ?? "public";
|
|
2442
|
+
// Roots carry the requested language; replies overwrite this with the
|
|
2443
|
+
// root's value below so a Thread never mixes languages.
|
|
2444
|
+
let language = normalizePostLanguage(data.language);
|
|
2445
|
+
let translationGroupId: string | null = null;
|
|
2446
|
+
let sourceTranslationPostId: string | null = null;
|
|
1940
2447
|
|
|
1941
2448
|
// Collapse the two-input (`flag` + `flagAt`) pair the DTO exposes
|
|
1942
2449
|
// into the single timestamp form the DB column stores. Explicit
|
|
@@ -2001,6 +2508,10 @@ export function createPostService(
|
|
|
2001
2508
|
status = root.status;
|
|
2002
2509
|
}
|
|
2003
2510
|
visibility = null;
|
|
2511
|
+
// A reply is part of the root's Thread, so it is written in the
|
|
2512
|
+
// Thread's language whatever the caller asked for. This is what keeps
|
|
2513
|
+
// language filters correct on member-grained queries.
|
|
2514
|
+
language = root?.language ?? parent.language ?? null;
|
|
2004
2515
|
|
|
2005
2516
|
if (
|
|
2006
2517
|
(data.collectionIds?.length ?? 0) > 0 ||
|
|
@@ -2012,6 +2523,54 @@ export function createPostService(
|
|
|
2012
2523
|
}
|
|
2013
2524
|
}
|
|
2014
2525
|
|
|
2526
|
+
const { primary, languages, reservedPrefixes } =
|
|
2527
|
+
await readLanguageSettings(db, siteId, databaseSchema);
|
|
2528
|
+
|
|
2529
|
+
// Nobody named a language, so read one out of the text. Every write path
|
|
2530
|
+
// that bypasses compose — the API, the Telegram bot, MCP — lands here, so
|
|
2531
|
+
// a multilingual site never accumulates unlabelled posts. An explicit
|
|
2532
|
+
// choice is the author's and is left alone; replies already carry the
|
|
2533
|
+
// Thread's language.
|
|
2534
|
+
if (!data.replyToId && !language) {
|
|
2535
|
+
language = suggestPostLanguage({
|
|
2536
|
+
text: bodyText ?? title ?? quoteText,
|
|
2537
|
+
languages,
|
|
2538
|
+
primary,
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
// "Add a translation" carries only the source ID until the author
|
|
2543
|
+
// submits; the group is minted here so abandoning the composer leaves
|
|
2544
|
+
// nothing behind (no draft row, no slug, no single-member group).
|
|
2545
|
+
if (data.translationOfId) {
|
|
2546
|
+
if (data.replyToId) {
|
|
2547
|
+
throw new ValidationError(
|
|
2548
|
+
"Translations link whole threads. Add the translation from the thread's first post.",
|
|
2549
|
+
);
|
|
2550
|
+
}
|
|
2551
|
+
const source = await requireTranslatableRoot(data.translationOfId);
|
|
2552
|
+
if (!language) {
|
|
2553
|
+
throw new ValidationError(
|
|
2554
|
+
"Choose a language for this translation before saving it.",
|
|
2555
|
+
);
|
|
2556
|
+
}
|
|
2557
|
+
if (source.language === language) {
|
|
2558
|
+
throw new ConflictError(
|
|
2559
|
+
"That post is already written in this language. Pick a different language for the translation.",
|
|
2560
|
+
);
|
|
2561
|
+
}
|
|
2562
|
+
if (source.translationGroupId) {
|
|
2563
|
+
await assertTranslationLanguageFree(
|
|
2564
|
+
source.translationGroupId,
|
|
2565
|
+
language,
|
|
2566
|
+
);
|
|
2567
|
+
translationGroupId = source.translationGroupId;
|
|
2568
|
+
} else {
|
|
2569
|
+
translationGroupId = createEntityId("translationGroup");
|
|
2570
|
+
sourceTranslationPostId = source.id;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
|
|
2015
2574
|
// Featured surfaces only list published posts, so featuring a draft
|
|
2016
2575
|
// writes a flag that does nothing and shows nowhere. Say so instead.
|
|
2017
2576
|
if (status === "draft" && resolvedFeaturedAt !== null) {
|
|
@@ -2021,6 +2580,8 @@ export function createPostService(
|
|
|
2021
2580
|
assertDraftPublishedAt(status, data.publishedAt);
|
|
2022
2581
|
const publishedAt =
|
|
2023
2582
|
status === "published" ? (data.publishedAt ?? timestamp) : null;
|
|
2583
|
+
const createdAt = data.createdAt ?? timestamp;
|
|
2584
|
+
const updatedAt = data.updatedAt ?? createdAt;
|
|
2024
2585
|
|
|
2025
2586
|
// Resolve slug from slug, path, or title
|
|
2026
2587
|
let slug: string;
|
|
@@ -2033,10 +2594,17 @@ export function createPostService(
|
|
|
2033
2594
|
// readable slug can still set one explicitly.
|
|
2034
2595
|
const titleForSlug =
|
|
2035
2596
|
format === "quote" ? undefined : (title ?? undefined);
|
|
2597
|
+
// Only a non-primary language earns a readable language suffix: the bare
|
|
2598
|
+
// namespace belongs to the primary language, and a suffix shared by two
|
|
2599
|
+
// same-language posts distinguishes nothing.
|
|
2600
|
+
const languageSuffix =
|
|
2601
|
+
language && language !== primary
|
|
2602
|
+
? toLanguagePrefix(language)
|
|
2603
|
+
: undefined;
|
|
2036
2604
|
|
|
2037
2605
|
if (data.path) {
|
|
2038
2606
|
const normalized = normalizePath(data.path);
|
|
2039
|
-
if (isReservedPath(normalized)) {
|
|
2607
|
+
if (isReservedPath(normalized, reservedPrefixes)) {
|
|
2040
2608
|
throw new ValidationError(
|
|
2041
2609
|
`Path "${normalized}" is reserved and cannot be used`,
|
|
2042
2610
|
);
|
|
@@ -2046,6 +2614,7 @@ export function createPostService(
|
|
|
2046
2614
|
slug = await generatePostSlug({
|
|
2047
2615
|
slug: normalized,
|
|
2048
2616
|
idLength: config.slugIdLength,
|
|
2617
|
+
reservedPrefixes,
|
|
2049
2618
|
isAvailable: isSlugAvailable,
|
|
2050
2619
|
});
|
|
2051
2620
|
} else {
|
|
@@ -2055,6 +2624,8 @@ export function createPostService(
|
|
|
2055
2624
|
slug: slugified || undefined,
|
|
2056
2625
|
title: titleForSlug,
|
|
2057
2626
|
idLength: config.slugIdLength,
|
|
2627
|
+
languageSuffix,
|
|
2628
|
+
reservedPrefixes,
|
|
2058
2629
|
isAvailable: isSlugAvailable,
|
|
2059
2630
|
});
|
|
2060
2631
|
// Verify the alias path is available before proceeding
|
|
@@ -2068,6 +2639,8 @@ export function createPostService(
|
|
|
2068
2639
|
slug: data.slug,
|
|
2069
2640
|
title: titleForSlug,
|
|
2070
2641
|
idLength: config.slugIdLength,
|
|
2642
|
+
languageSuffix,
|
|
2643
|
+
reservedPrefixes,
|
|
2071
2644
|
isAvailable: isSlugAvailable,
|
|
2072
2645
|
});
|
|
2073
2646
|
}
|
|
@@ -2127,6 +2700,23 @@ export function createPostService(
|
|
|
2127
2700
|
if (usesBatchWrites) {
|
|
2128
2701
|
const writeQueries = [];
|
|
2129
2702
|
|
|
2703
|
+
// Mint the group on the source Post in the same write as the
|
|
2704
|
+
// translation itself, so a failure never leaves a one-member group.
|
|
2705
|
+
if (sourceTranslationPostId && translationGroupId) {
|
|
2706
|
+
writeQueries.push(
|
|
2707
|
+
db
|
|
2708
|
+
.update(posts)
|
|
2709
|
+
.set({ translationGroupId, updatedAt: timestamp })
|
|
2710
|
+
.where(
|
|
2711
|
+
and(
|
|
2712
|
+
eq(posts.siteId, siteId),
|
|
2713
|
+
eq(posts.id, sourceTranslationPostId),
|
|
2714
|
+
isNull(posts.translationGroupId),
|
|
2715
|
+
),
|
|
2716
|
+
),
|
|
2717
|
+
);
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2130
2720
|
writeQueries.push(
|
|
2131
2721
|
db.insert(posts).values({
|
|
2132
2722
|
id,
|
|
@@ -2147,12 +2737,14 @@ export function createPostService(
|
|
|
2147
2737
|
rating,
|
|
2148
2738
|
replyToId: data.replyToId ?? null,
|
|
2149
2739
|
threadId,
|
|
2740
|
+
language,
|
|
2741
|
+
translationGroupId,
|
|
2150
2742
|
quietReply: isQuietReply,
|
|
2151
2743
|
publishedAt,
|
|
2152
|
-
lastActivityAt: publishedAt ??
|
|
2153
|
-
threadUpdatedAt: publishedAt ??
|
|
2154
|
-
createdAt
|
|
2155
|
-
updatedAt
|
|
2744
|
+
lastActivityAt: publishedAt ?? updatedAt,
|
|
2745
|
+
threadUpdatedAt: publishedAt ?? updatedAt,
|
|
2746
|
+
createdAt,
|
|
2747
|
+
updatedAt,
|
|
2156
2748
|
}),
|
|
2157
2749
|
);
|
|
2158
2750
|
|
|
@@ -2207,6 +2799,19 @@ export function createPostService(
|
|
|
2207
2799
|
);
|
|
2208
2800
|
} else {
|
|
2209
2801
|
await db.transaction(async (tx) => {
|
|
2802
|
+
if (sourceTranslationPostId && translationGroupId) {
|
|
2803
|
+
await tx
|
|
2804
|
+
.update(posts)
|
|
2805
|
+
.set({ translationGroupId, updatedAt: timestamp })
|
|
2806
|
+
.where(
|
|
2807
|
+
and(
|
|
2808
|
+
eq(posts.siteId, siteId),
|
|
2809
|
+
eq(posts.id, sourceTranslationPostId),
|
|
2810
|
+
isNull(posts.translationGroupId),
|
|
2811
|
+
),
|
|
2812
|
+
);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2210
2815
|
await tx.insert(posts).values({
|
|
2211
2816
|
id,
|
|
2212
2817
|
siteId,
|
|
@@ -2226,12 +2831,14 @@ export function createPostService(
|
|
|
2226
2831
|
rating,
|
|
2227
2832
|
replyToId: data.replyToId ?? null,
|
|
2228
2833
|
threadId,
|
|
2834
|
+
language,
|
|
2835
|
+
translationGroupId,
|
|
2229
2836
|
quietReply: isQuietReply,
|
|
2230
2837
|
publishedAt,
|
|
2231
|
-
lastActivityAt: publishedAt ??
|
|
2232
|
-
threadUpdatedAt: publishedAt ??
|
|
2233
|
-
createdAt
|
|
2234
|
-
updatedAt
|
|
2838
|
+
lastActivityAt: publishedAt ?? updatedAt,
|
|
2839
|
+
threadUpdatedAt: publishedAt ?? updatedAt,
|
|
2840
|
+
createdAt,
|
|
2841
|
+
updatedAt,
|
|
2235
2842
|
});
|
|
2236
2843
|
|
|
2237
2844
|
await tx.insert(pathRegistry).values({
|
|
@@ -2358,13 +2965,16 @@ export function createPostService(
|
|
|
2358
2965
|
// A reply with no date of its own belongs to the root's moment, not to
|
|
2359
2966
|
// whenever the request happened to run. Without this, backdating the
|
|
2360
2967
|
// root leaves its replies stamped today and the thread reads as if it
|
|
2361
|
-
// spanned years. Thread order is
|
|
2362
|
-
//
|
|
2968
|
+
// spanned years. Thread order is the root, then `createdAt`/`id` (see
|
|
2969
|
+
// `threadOrder`), so sharing one `publishedAt` cannot reorder anything.
|
|
2363
2970
|
const rootPublishedAt = created[0]?.publishedAt ?? undefined;
|
|
2364
2971
|
const postData: CreatePost = {
|
|
2365
2972
|
...data,
|
|
2366
2973
|
// Chain each post as a reply to the previous one (server-side chaining)
|
|
2367
2974
|
replyToId: i === 0 ? data.replyToId : prevPost?.id,
|
|
2975
|
+
// Translation groups and languages are Thread-level: the root carries
|
|
2976
|
+
// them and every reply inherits, so later items must not restate them.
|
|
2977
|
+
translationOfId: i === 0 ? data.translationOfId : undefined,
|
|
2368
2978
|
quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
|
|
2369
2979
|
publishedAt:
|
|
2370
2980
|
i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
|
|
@@ -2397,6 +3007,17 @@ export function createPostService(
|
|
|
2397
3007
|
const existing = await this.getById(id);
|
|
2398
3008
|
if (!existing) return null;
|
|
2399
3009
|
|
|
3010
|
+
// Thread-wide, so it cannot ride along on the single-row write below.
|
|
3011
|
+
// Runs first: if the language is refused — the Thread's translation group
|
|
3012
|
+
// already holds it — nothing else should have been written either.
|
|
3013
|
+
if (
|
|
3014
|
+
data.language &&
|
|
3015
|
+
!existing.replyToId &&
|
|
3016
|
+
data.language !== existing.language
|
|
3017
|
+
) {
|
|
3018
|
+
await this.setThreadLanguage(id, data.language);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2400
3021
|
const timestamp = now();
|
|
2401
3022
|
const nextFormat =
|
|
2402
3023
|
data.format !== undefined
|
|
@@ -2505,6 +3126,9 @@ export function createPostService(
|
|
|
2505
3126
|
const preparedBody = normalizedBody
|
|
2506
3127
|
? tryPreparePostBodyHtml(existing.id, normalizedBody)
|
|
2507
3128
|
: null;
|
|
3129
|
+
if (preparedBody && !preparedBody.ok) {
|
|
3130
|
+
throw new ValidationError(INVALID_POST_BODY_MESSAGE);
|
|
3131
|
+
}
|
|
2508
3132
|
updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
|
|
2509
3133
|
updates.body = updatedBody;
|
|
2510
3134
|
updates.bodyHtml = preparedBody?.ok
|
|
@@ -2761,8 +3385,7 @@ export function createPostService(
|
|
|
2761
3385
|
],
|
|
2762
3386
|
);
|
|
2763
3387
|
updateResult = results[updateIdx] as
|
|
2764
|
-
|
|
2765
|
-
| undefined;
|
|
3388
|
+
(typeof posts.$inferSelect)[] | undefined;
|
|
2766
3389
|
} else {
|
|
2767
3390
|
await db.transaction(async (tx) => {
|
|
2768
3391
|
if (needsCascade) {
|
|
@@ -3044,7 +3667,7 @@ export function createPostService(
|
|
|
3044
3667
|
.select()
|
|
3045
3668
|
.from(posts)
|
|
3046
3669
|
.where(and(eq(posts.siteId, siteId), eq(posts.threadId, rootId)))
|
|
3047
|
-
.orderBy(
|
|
3670
|
+
.orderBy(...threadOrder());
|
|
3048
3671
|
|
|
3049
3672
|
return hydratePosts(rows);
|
|
3050
3673
|
},
|
|
@@ -3154,6 +3777,397 @@ export function createPostService(
|
|
|
3154
3777
|
await recalculateThreadActivity(rootId);
|
|
3155
3778
|
},
|
|
3156
3779
|
|
|
3780
|
+
async setThreadLanguage(postId, language) {
|
|
3781
|
+
const normalized = normalizePostLanguage(language);
|
|
3782
|
+
if (!normalized) {
|
|
3783
|
+
throw new ValidationError("Choose a language for this post.");
|
|
3784
|
+
}
|
|
3785
|
+
|
|
3786
|
+
// A language the site does not publish has no view, no feed and no
|
|
3787
|
+
// switcher entry, so the post would simply vanish. Only checked once the
|
|
3788
|
+
// site actually has a language set to belong to.
|
|
3789
|
+
const { languages } = await readLanguageSettings(
|
|
3790
|
+
db,
|
|
3791
|
+
siteId,
|
|
3792
|
+
databaseSchema,
|
|
3793
|
+
);
|
|
3794
|
+
if (
|
|
3795
|
+
languages.length > 0 &&
|
|
3796
|
+
!languages.some(
|
|
3797
|
+
(tag) => toLanguagePrefix(tag) === toLanguagePrefix(normalized),
|
|
3798
|
+
)
|
|
3799
|
+
) {
|
|
3800
|
+
throw new ValidationError(
|
|
3801
|
+
"This site does not publish that language. Add it in Settings → Language first.",
|
|
3802
|
+
);
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
const rows = await db
|
|
3806
|
+
.select({
|
|
3807
|
+
threadId: posts.threadId,
|
|
3808
|
+
id: posts.id,
|
|
3809
|
+
translationGroupId: posts.translationGroupId,
|
|
3810
|
+
})
|
|
3811
|
+
.from(posts)
|
|
3812
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
3813
|
+
.limit(1);
|
|
3814
|
+
const row = rows[0];
|
|
3815
|
+
if (!row) {
|
|
3816
|
+
throw new NotFoundError("Post");
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
// The group lives on the root, so a reply's Thread has to be resolved
|
|
3820
|
+
// before the translation-group check can run.
|
|
3821
|
+
const rootRows =
|
|
3822
|
+
row.id === row.threadId
|
|
3823
|
+
? [row]
|
|
3824
|
+
: await db
|
|
3825
|
+
.select({
|
|
3826
|
+
threadId: posts.threadId,
|
|
3827
|
+
id: posts.id,
|
|
3828
|
+
translationGroupId: posts.translationGroupId,
|
|
3829
|
+
})
|
|
3830
|
+
.from(posts)
|
|
3831
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, row.threadId)))
|
|
3832
|
+
.limit(1);
|
|
3833
|
+
const root = rootRows[0];
|
|
3834
|
+
if (root?.translationGroupId) {
|
|
3835
|
+
await assertTranslationLanguageFree(
|
|
3836
|
+
root.translationGroupId,
|
|
3837
|
+
normalized,
|
|
3838
|
+
root.id,
|
|
3839
|
+
);
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
const timestamp = now();
|
|
3843
|
+
await db
|
|
3844
|
+
.update(posts)
|
|
3845
|
+
.set({ language: normalized, updatedAt: timestamp })
|
|
3846
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.threadId, row.threadId)));
|
|
3847
|
+
},
|
|
3848
|
+
|
|
3849
|
+
async materializeMissingLanguage(language) {
|
|
3850
|
+
const normalized = normalizePostLanguage(language);
|
|
3851
|
+
if (!normalized) {
|
|
3852
|
+
throw new ValidationError("Choose a primary language for this site.");
|
|
3853
|
+
}
|
|
3854
|
+
|
|
3855
|
+
// Counted before the write rather than read back from it: the three
|
|
3856
|
+
// supported drivers report affected rows differently, and the caller only
|
|
3857
|
+
// needs the number for its confirmation copy.
|
|
3858
|
+
const pending = await this.countMissingLanguage();
|
|
3859
|
+
if (pending === 0) return 0;
|
|
3860
|
+
|
|
3861
|
+
await db
|
|
3862
|
+
.update(posts)
|
|
3863
|
+
.set({ language: normalized })
|
|
3864
|
+
.where(and(eq(posts.siteId, siteId), isNull(posts.language)));
|
|
3865
|
+
return pending;
|
|
3866
|
+
},
|
|
3867
|
+
|
|
3868
|
+
async countMissingLanguage() {
|
|
3869
|
+
const rows = await db
|
|
3870
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
|
|
3871
|
+
.from(posts)
|
|
3872
|
+
.where(and(eq(posts.siteId, siteId), isNull(posts.language)));
|
|
3873
|
+
return Number(rows[0]?.count ?? 0);
|
|
3874
|
+
},
|
|
3875
|
+
|
|
3876
|
+
async countByLanguage(language) {
|
|
3877
|
+
const normalized = normalizePostLanguage(language);
|
|
3878
|
+
if (!normalized) return 0;
|
|
3879
|
+
|
|
3880
|
+
const rows = await db
|
|
3881
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
|
|
3882
|
+
.from(posts)
|
|
3883
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.language, normalized)));
|
|
3884
|
+
return Number(rows[0]?.count ?? 0);
|
|
3885
|
+
},
|
|
3886
|
+
|
|
3887
|
+
async listLanguagesInUse() {
|
|
3888
|
+
const rows = await db
|
|
3889
|
+
.select({
|
|
3890
|
+
language: posts.language,
|
|
3891
|
+
count: sql<number>`CAST(count(*) AS INTEGER)`,
|
|
3892
|
+
})
|
|
3893
|
+
.from(posts)
|
|
3894
|
+
.where(and(eq(posts.siteId, siteId), isNotNull(posts.language)))
|
|
3895
|
+
.groupBy(posts.language);
|
|
3896
|
+
return rows
|
|
3897
|
+
.filter((row): row is { language: string; count: number } =>
|
|
3898
|
+
Boolean(row.language),
|
|
3899
|
+
)
|
|
3900
|
+
.map((row) => ({ language: row.language, count: Number(row.count) }));
|
|
3901
|
+
},
|
|
3902
|
+
|
|
3903
|
+
async listTranslations(postId) {
|
|
3904
|
+
const map = await this.getTranslationsMap([postId]);
|
|
3905
|
+
return map.get(postId) ?? [];
|
|
3906
|
+
},
|
|
3907
|
+
|
|
3908
|
+
async listTranslationCandidates(postId, options) {
|
|
3909
|
+
const term = options.query.trim();
|
|
3910
|
+
if (!term) return [];
|
|
3911
|
+
const limit = options.limit ?? 8;
|
|
3912
|
+
|
|
3913
|
+
const source = await this.getById(postId);
|
|
3914
|
+
if (!source) return [];
|
|
3915
|
+
const root =
|
|
3916
|
+
source.replyToId === null
|
|
3917
|
+
? source
|
|
3918
|
+
: await this.getById(source.threadId);
|
|
3919
|
+
if (!root?.language) return [];
|
|
3920
|
+
|
|
3921
|
+
// Languages this Thread's group already speaks for, its own included.
|
|
3922
|
+
const taken = new Set([root.language]);
|
|
3923
|
+
if (root.translationGroupId) {
|
|
3924
|
+
for (const sibling of await this.listTranslations(root.id)) {
|
|
3925
|
+
if (sibling.language) taken.add(sibling.language);
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
// Escaped with an explicit ESCAPE clause: the two dialects disagree on
|
|
3930
|
+
// the default escape character (see `paths.findPathsUnderSegment`).
|
|
3931
|
+
const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
3932
|
+
const conditions: SQL[] = [
|
|
3933
|
+
eq(posts.siteId, siteId),
|
|
3934
|
+
eq(posts.status, "published"),
|
|
3935
|
+
// Thread roots only: a translation links whole Threads.
|
|
3936
|
+
sql`${posts.id} = ${posts.threadId}`,
|
|
3937
|
+
sql`${posts.id} != ${root.id}`,
|
|
3938
|
+
isNotNull(posts.language),
|
|
3939
|
+
notInArray(posts.language, [...taken]),
|
|
3940
|
+
sql`(${posts.title} LIKE ${like} ESCAPE '\\' OR ${posts.bodyText} LIKE ${like} ESCAPE '\\')`,
|
|
3941
|
+
];
|
|
3942
|
+
|
|
3943
|
+
if (root.translationGroupId) {
|
|
3944
|
+
// Both sides having a group would ask for a merge, which is refused.
|
|
3945
|
+
conditions.push(isNull(posts.translationGroupId));
|
|
3946
|
+
} else {
|
|
3947
|
+
// The candidate may carry a group, as long as joining it would not put
|
|
3948
|
+
// two Posts of this language in it.
|
|
3949
|
+
conditions.push(
|
|
3950
|
+
sql`(${posts.translationGroupId} IS NULL OR NOT EXISTS (
|
|
3951
|
+
SELECT 1
|
|
3952
|
+
FROM post AS group_member
|
|
3953
|
+
WHERE group_member.site_id = ${siteId}
|
|
3954
|
+
AND group_member.translation_group_id = "post"."translation_group_id"
|
|
3955
|
+
AND group_member.language = ${root.language}
|
|
3956
|
+
))`,
|
|
3957
|
+
);
|
|
3958
|
+
}
|
|
3959
|
+
|
|
3960
|
+
const rows = await db
|
|
3961
|
+
.select()
|
|
3962
|
+
.from(posts)
|
|
3963
|
+
.where(and(...conditions))
|
|
3964
|
+
.orderBy(desc(posts.publishedAt))
|
|
3965
|
+
.limit(limit);
|
|
3966
|
+
|
|
3967
|
+
return hydratePosts(rows);
|
|
3968
|
+
},
|
|
3969
|
+
|
|
3970
|
+
async resolveTranslationCandidate(postId, path) {
|
|
3971
|
+
const source = await this.getById(postId);
|
|
3972
|
+
if (!source) return { status: "not_found" };
|
|
3973
|
+
const root =
|
|
3974
|
+
source.replyToId === null
|
|
3975
|
+
? source
|
|
3976
|
+
: await this.getById(source.threadId);
|
|
3977
|
+
if (!root?.language) return { status: "not_found" };
|
|
3978
|
+
|
|
3979
|
+
const target = await resolvedPaths.resolveTarget(path);
|
|
3980
|
+
if (!target) return { status: "not_found" };
|
|
3981
|
+
if (target.targetType !== "post" || !target.postId) {
|
|
3982
|
+
return { status: "not_a_post" };
|
|
3983
|
+
}
|
|
3984
|
+
|
|
3985
|
+
const found = await this.getById(target.postId);
|
|
3986
|
+
if (!found) return { status: "not_found" };
|
|
3987
|
+
// Translations link whole Threads, so an address that lands on a reply
|
|
3988
|
+
// is taken as naming the Thread it belongs to.
|
|
3989
|
+
const candidate =
|
|
3990
|
+
found.replyToId === null ? found : await this.getById(found.threadId);
|
|
3991
|
+
if (!candidate) return { status: "not_found" };
|
|
3992
|
+
|
|
3993
|
+
if (candidate.id === root.id) return { status: "same_thread" };
|
|
3994
|
+
if (candidate.status !== "published") return { status: "unpublished" };
|
|
3995
|
+
if (!candidate.language) return { status: "no_language" };
|
|
3996
|
+
if (candidate.language === root.language) {
|
|
3997
|
+
return { status: "same_language" };
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
const siblings = root.translationGroupId
|
|
4001
|
+
? await this.listTranslations(root.id)
|
|
4002
|
+
: [];
|
|
4003
|
+
if (siblings.some((post) => post.language === candidate.language)) {
|
|
4004
|
+
return { status: "language_taken", language: candidate.language };
|
|
4005
|
+
}
|
|
4006
|
+
|
|
4007
|
+
if (candidate.translationGroupId) {
|
|
4008
|
+
// `linkTranslation` refuses to merge two groups, and refuses to join
|
|
4009
|
+
// one that already speaks for this Thread's language.
|
|
4010
|
+
if (root.translationGroupId) return { status: "group_conflict" };
|
|
4011
|
+
|
|
4012
|
+
const theirGroup = await this.listTranslations(candidate.id);
|
|
4013
|
+
if (theirGroup.some((post) => post.language === root.language)) {
|
|
4014
|
+
return { status: "group_language_taken", language: root.language };
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
|
|
4018
|
+
return { status: "ok", post: candidate };
|
|
4019
|
+
},
|
|
4020
|
+
|
|
4021
|
+
async getTranslationsMap(postIds) {
|
|
4022
|
+
const result = new Map<string, Post[]>();
|
|
4023
|
+
const uniqueIds = [...new Set(postIds)].filter(Boolean);
|
|
4024
|
+
if (uniqueIds.length === 0) return result;
|
|
4025
|
+
|
|
4026
|
+
const seedRows = await db
|
|
4027
|
+
.select({ id: posts.id, translationGroupId: posts.translationGroupId })
|
|
4028
|
+
.from(posts)
|
|
4029
|
+
.where(
|
|
4030
|
+
and(
|
|
4031
|
+
eq(posts.siteId, siteId),
|
|
4032
|
+
inArray(posts.id, uniqueIds),
|
|
4033
|
+
isNotNull(posts.translationGroupId),
|
|
4034
|
+
),
|
|
4035
|
+
);
|
|
4036
|
+
if (seedRows.length === 0) return result;
|
|
4037
|
+
|
|
4038
|
+
const groupIds = [
|
|
4039
|
+
...new Set(
|
|
4040
|
+
seedRows
|
|
4041
|
+
.map((row) => row.translationGroupId)
|
|
4042
|
+
.filter((groupId): groupId is string => groupId !== null),
|
|
4043
|
+
),
|
|
4044
|
+
];
|
|
4045
|
+
const memberRows = await db
|
|
4046
|
+
.select()
|
|
4047
|
+
.from(posts)
|
|
4048
|
+
.where(
|
|
4049
|
+
and(
|
|
4050
|
+
eq(posts.siteId, siteId),
|
|
4051
|
+
inArray(posts.translationGroupId, groupIds),
|
|
4052
|
+
),
|
|
4053
|
+
);
|
|
4054
|
+
const members = await hydratePosts(memberRows);
|
|
4055
|
+
const byGroup = new Map<string, Post[]>();
|
|
4056
|
+
for (const member of members) {
|
|
4057
|
+
const groupId = member.translationGroupId;
|
|
4058
|
+
if (!groupId) continue;
|
|
4059
|
+
const bucket = byGroup.get(groupId);
|
|
4060
|
+
if (bucket) bucket.push(member);
|
|
4061
|
+
else byGroup.set(groupId, [member]);
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
for (const seed of seedRows) {
|
|
4065
|
+
const groupId = seed.translationGroupId;
|
|
4066
|
+
if (!groupId) continue;
|
|
4067
|
+
const siblings = (byGroup.get(groupId) ?? []).filter(
|
|
4068
|
+
(member) => member.id !== seed.id,
|
|
4069
|
+
);
|
|
4070
|
+
if (siblings.length > 0) result.set(seed.id, siblings);
|
|
4071
|
+
}
|
|
4072
|
+
return result;
|
|
4073
|
+
},
|
|
4074
|
+
|
|
4075
|
+
async linkTranslation(postId, otherPostId) {
|
|
4076
|
+
if (postId === otherPostId) {
|
|
4077
|
+
throw new ValidationError("Pick a different post to link.");
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
const [post, other] = await Promise.all([
|
|
4081
|
+
requireTranslatableRoot(postId),
|
|
4082
|
+
requireTranslatableRoot(otherPostId),
|
|
4083
|
+
]);
|
|
4084
|
+
|
|
4085
|
+
if (!post.language || !other.language) {
|
|
4086
|
+
throw new ValidationError(
|
|
4087
|
+
"Both posts need a language before they can be linked as translations.",
|
|
4088
|
+
);
|
|
4089
|
+
}
|
|
4090
|
+
if (post.language === other.language) {
|
|
4091
|
+
throw new ConflictError(
|
|
4092
|
+
"These two posts are written in the same language. Translations need different languages.",
|
|
4093
|
+
);
|
|
4094
|
+
}
|
|
4095
|
+
if (post.translationGroupId && other.translationGroupId) {
|
|
4096
|
+
if (post.translationGroupId === other.translationGroupId) return;
|
|
4097
|
+
throw new ConflictError(
|
|
4098
|
+
"Both posts already belong to translation groups. Unlink one of them first.",
|
|
4099
|
+
);
|
|
4100
|
+
}
|
|
4101
|
+
|
|
4102
|
+
const timestamp = now();
|
|
4103
|
+
if (post.translationGroupId ?? other.translationGroupId) {
|
|
4104
|
+
const groupId = (post.translationGroupId ??
|
|
4105
|
+
other.translationGroupId) as string;
|
|
4106
|
+
const joiningId = post.translationGroupId ? other.id : post.id;
|
|
4107
|
+
const joiningLanguage = post.translationGroupId
|
|
4108
|
+
? other.language
|
|
4109
|
+
: post.language;
|
|
4110
|
+
await assertTranslationLanguageFree(groupId, joiningLanguage);
|
|
4111
|
+
await db
|
|
4112
|
+
.update(posts)
|
|
4113
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
4114
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, joiningId)));
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
const groupId = createEntityId("translationGroup");
|
|
4119
|
+
const setGroup = (targetId: string) =>
|
|
4120
|
+
db
|
|
4121
|
+
.update(posts)
|
|
4122
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
4123
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, targetId)));
|
|
4124
|
+
|
|
4125
|
+
if (usesBatchWrites) {
|
|
4126
|
+
await db.batch([setGroup(post.id), setGroup(other.id)]);
|
|
4127
|
+
} else {
|
|
4128
|
+
await db.transaction(async (tx) => {
|
|
4129
|
+
await tx
|
|
4130
|
+
.update(posts)
|
|
4131
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
4132
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
|
|
4133
|
+
await tx
|
|
4134
|
+
.update(posts)
|
|
4135
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
4136
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, other.id)));
|
|
4137
|
+
});
|
|
4138
|
+
}
|
|
4139
|
+
},
|
|
4140
|
+
|
|
4141
|
+
async unlinkTranslation(postId) {
|
|
4142
|
+
const post = await requireTranslatableRoot(postId);
|
|
4143
|
+
const groupId = post.translationGroupId;
|
|
4144
|
+
if (!groupId) return;
|
|
4145
|
+
|
|
4146
|
+
const timestamp = now();
|
|
4147
|
+
await db
|
|
4148
|
+
.update(posts)
|
|
4149
|
+
.set({ translationGroupId: null, updatedAt: timestamp })
|
|
4150
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
|
|
4151
|
+
|
|
4152
|
+
// A group of one is indistinguishable from no group and would linger as
|
|
4153
|
+
// a dangling key, so collapse it as soon as the second-to-last member
|
|
4154
|
+
// leaves.
|
|
4155
|
+
const remaining = await db
|
|
4156
|
+
.select({ id: posts.id })
|
|
4157
|
+
.from(posts)
|
|
4158
|
+
.where(
|
|
4159
|
+
and(eq(posts.siteId, siteId), eq(posts.translationGroupId, groupId)),
|
|
4160
|
+
)
|
|
4161
|
+
.limit(2);
|
|
4162
|
+
const lastMember = remaining.length === 1 ? remaining[0] : undefined;
|
|
4163
|
+
if (lastMember) {
|
|
4164
|
+
await db
|
|
4165
|
+
.update(posts)
|
|
4166
|
+
.set({ translationGroupId: null, updatedAt: timestamp })
|
|
4167
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, lastMember.id)));
|
|
4168
|
+
}
|
|
4169
|
+
},
|
|
4170
|
+
|
|
3157
4171
|
async getReplyCounts(postIds) {
|
|
3158
4172
|
if (postIds.length === 0) return new Map();
|
|
3159
4173
|
|
|
@@ -3187,10 +4201,9 @@ export function createPostService(
|
|
|
3187
4201
|
.select({
|
|
3188
4202
|
id: posts.id,
|
|
3189
4203
|
threadId: posts.threadId,
|
|
3190
|
-
createdAt: posts.createdAt,
|
|
3191
4204
|
previewRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
3192
4205
|
PARTITION BY ${posts.threadId}
|
|
3193
|
-
ORDER BY ${
|
|
4206
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
3194
4207
|
) AS INTEGER)`.as("preview_rank"),
|
|
3195
4208
|
})
|
|
3196
4209
|
.from(posts)
|
|
@@ -3208,15 +4221,10 @@ export function createPostService(
|
|
|
3208
4221
|
.select({
|
|
3209
4222
|
id: rankedReplies.id,
|
|
3210
4223
|
threadId: rankedReplies.threadId,
|
|
3211
|
-
createdAt: rankedReplies.createdAt,
|
|
3212
4224
|
})
|
|
3213
4225
|
.from(rankedReplies)
|
|
3214
4226
|
.where(lte(rankedReplies.previewRank, previewCount))
|
|
3215
|
-
.orderBy(
|
|
3216
|
-
rankedReplies.threadId,
|
|
3217
|
-
rankedReplies.createdAt,
|
|
3218
|
-
rankedReplies.id,
|
|
3219
|
-
);
|
|
4227
|
+
.orderBy(rankedReplies.threadId, sql`${rankedReplies.previewRank}`);
|
|
3220
4228
|
|
|
3221
4229
|
const hydratedPosts = await hydratePostsById(
|
|
3222
4230
|
rankedRows.map((row) => row.id),
|
|
@@ -3246,11 +4254,11 @@ export function createPostService(
|
|
|
3246
4254
|
threadId: posts.threadId,
|
|
3247
4255
|
firstReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
3248
4256
|
PARTITION BY ${posts.threadId}
|
|
3249
|
-
ORDER BY ${
|
|
4257
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
3250
4258
|
) AS INTEGER)`.as("first_reply_rank"),
|
|
3251
4259
|
latestReplyRank: sql<number>`CAST(ROW_NUMBER() OVER (
|
|
3252
4260
|
PARTITION BY ${posts.threadId}
|
|
3253
|
-
ORDER BY ${
|
|
4261
|
+
ORDER BY ${sql.join(threadOrder("desc"), sql`, `)}
|
|
3254
4262
|
) AS INTEGER)`.as("latest_reply_rank"),
|
|
3255
4263
|
totalReplyCount: sql<number>`CAST(COUNT(*) OVER (
|
|
3256
4264
|
PARTITION BY ${posts.threadId}
|
|
@@ -3278,8 +4286,11 @@ export function createPostService(
|
|
|
3278
4286
|
.from(rankedReplies)
|
|
3279
4287
|
.where(
|
|
3280
4288
|
or(
|
|
3281
|
-
|
|
3282
|
-
|
|
4289
|
+
// One past the leading window: that extra row is never rendered,
|
|
4290
|
+
// it is only where the gap link points. Without it the gap has to
|
|
4291
|
+
// aim at a reply already on screen.
|
|
4292
|
+
lte(rankedReplies.firstReplyRank, THREAD_LEADING_REPLIES + 1),
|
|
4293
|
+
lte(rankedReplies.latestReplyRank, THREAD_TRAILING_REPLIES),
|
|
3283
4294
|
),
|
|
3284
4295
|
);
|
|
3285
4296
|
|
|
@@ -3293,6 +4304,7 @@ export function createPostService(
|
|
|
3293
4304
|
leadingReplyIds: Map<number, string>;
|
|
3294
4305
|
trailingReplyIds: Map<number, string>;
|
|
3295
4306
|
latestReplyId: string | null;
|
|
4307
|
+
firstHiddenReplyId: string | null;
|
|
3296
4308
|
totalReplyCount: number;
|
|
3297
4309
|
}
|
|
3298
4310
|
>();
|
|
@@ -3301,13 +4313,20 @@ export function createPostService(
|
|
|
3301
4313
|
leadingReplyIds: new Map<number, string>(),
|
|
3302
4314
|
trailingReplyIds: new Map<number, string>(),
|
|
3303
4315
|
latestReplyId: null,
|
|
4316
|
+
firstHiddenReplyId: null,
|
|
3304
4317
|
totalReplyCount: row.totalReplyCount,
|
|
3305
4318
|
};
|
|
3306
4319
|
|
|
3307
|
-
if (row.firstReplyRank <=
|
|
4320
|
+
if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
|
|
3308
4321
|
existing.leadingReplyIds.set(row.firstReplyRank, row.id);
|
|
3309
4322
|
}
|
|
3310
|
-
if (row.
|
|
4323
|
+
if (row.firstReplyRank === THREAD_LEADING_REPLIES + 1) {
|
|
4324
|
+
existing.firstHiddenReplyId = row.id;
|
|
4325
|
+
}
|
|
4326
|
+
if (
|
|
4327
|
+
row.latestReplyRank > 1 &&
|
|
4328
|
+
row.latestReplyRank <= THREAD_TRAILING_REPLIES
|
|
4329
|
+
) {
|
|
3311
4330
|
existing.trailingReplyIds.set(row.latestReplyRank, row.id);
|
|
3312
4331
|
}
|
|
3313
4332
|
if (row.latestReplyRank === 1) {
|
|
@@ -3329,16 +4348,36 @@ export function createPostService(
|
|
|
3329
4348
|
.map((id) => (id ? hydratedPosts.get(id) : undefined))
|
|
3330
4349
|
.filter((post): post is Post => post !== undefined);
|
|
3331
4350
|
|
|
4351
|
+
const leadingReplies = hydrateReplyIds(
|
|
4352
|
+
Array.from({ length: THREAD_LEADING_REPLIES }, (_unused, index) =>
|
|
4353
|
+
context.leadingReplyIds.get(index + 1),
|
|
4354
|
+
),
|
|
4355
|
+
);
|
|
4356
|
+
// Ranked newest-first, so walking down to rank 2 puts them back in
|
|
4357
|
+
// thread order. Rank 1 is the hero and is carried separately.
|
|
4358
|
+
const trailingReplies = hydrateReplyIds(
|
|
4359
|
+
Array.from(
|
|
4360
|
+
{ length: THREAD_TRAILING_REPLIES - 1 },
|
|
4361
|
+
(_unused, index) =>
|
|
4362
|
+
context.trailingReplyIds.get(THREAD_TRAILING_REPLIES - index),
|
|
4363
|
+
),
|
|
4364
|
+
);
|
|
4365
|
+
const firstHiddenReply =
|
|
4366
|
+
(context.firstHiddenReplyId
|
|
4367
|
+
? hydratedPosts.get(context.firstHiddenReplyId)
|
|
4368
|
+
: undefined) ?? null;
|
|
4369
|
+
|
|
3332
4370
|
result.set(threadId, {
|
|
3333
|
-
leadingReplies
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
context.trailingReplyIds.get(2),
|
|
3340
|
-
]),
|
|
4371
|
+
leadingReplies,
|
|
4372
|
+
// Left overlapping on purpose. These are the ranked buckets, not the
|
|
4373
|
+
// rendered fold: on a short thread a reply lands in both windows, and
|
|
4374
|
+
// dropping the duplicate is a presentation rule that `ThreadPreview`
|
|
4375
|
+
// and `getThreadHiddenCount` each already apply.
|
|
4376
|
+
trailingReplies,
|
|
3341
4377
|
latestReply,
|
|
4378
|
+
// Only a gap target: the query fetches it one past the leading
|
|
4379
|
+
// window, and nothing renders it.
|
|
4380
|
+
firstHiddenReply,
|
|
3342
4381
|
totalReplyCount: context.totalReplyCount,
|
|
3343
4382
|
});
|
|
3344
4383
|
}
|
|
@@ -3404,7 +4443,7 @@ export function createPostService(
|
|
|
3404
4443
|
const threadPosition = sql<number>`CAST(
|
|
3405
4444
|
row_number() OVER (
|
|
3406
4445
|
PARTITION BY ${posts.threadId}
|
|
3407
|
-
ORDER BY ${
|
|
4446
|
+
ORDER BY ${sql.join(threadOrder(), sql`, `)}
|
|
3408
4447
|
) - 1 AS INTEGER
|
|
3409
4448
|
)`.as("thread_position");
|
|
3410
4449
|
const threadPostCount = sql<number>`CAST(
|
|
@@ -3728,20 +4767,25 @@ export function createPostService(
|
|
|
3728
4767
|
const result = new Map<string, Post[]>();
|
|
3729
4768
|
if (rootIds.length === 0) return result;
|
|
3730
4769
|
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
4770
|
+
// A feed loads every Thread it renders at once, which can be hundreds.
|
|
4771
|
+
// Each Thread lands in one chunk, so its posts keep their order.
|
|
4772
|
+
const rows = await batchQueryRows([...new Set(rootIds)], (chunk) => {
|
|
4773
|
+
const conditions = [
|
|
4774
|
+
eq(posts.siteId, siteId),
|
|
4775
|
+
inArray(posts.threadId, chunk),
|
|
4776
|
+
eq(posts.status, "published"),
|
|
4777
|
+
];
|
|
4778
|
+
if (options.publishedBefore !== undefined) {
|
|
4779
|
+
conditions.push(
|
|
4780
|
+
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
4781
|
+
);
|
|
4782
|
+
}
|
|
4783
|
+
return db
|
|
4784
|
+
.select()
|
|
4785
|
+
.from(posts)
|
|
4786
|
+
.where(and(...conditions))
|
|
4787
|
+
.orderBy(posts.threadId, ...threadOrder());
|
|
4788
|
+
});
|
|
3745
4789
|
|
|
3746
4790
|
for (const post of await hydratePosts(rows)) {
|
|
3747
4791
|
const thread = result.get(post.threadId);
|
|
@@ -3773,7 +4817,7 @@ export function createPostService(
|
|
|
3773
4817
|
...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
|
|
3774
4818
|
),
|
|
3775
4819
|
)
|
|
3776
|
-
.orderBy(posts.threadId,
|
|
4820
|
+
.orderBy(posts.threadId, ...threadOrder("desc"));
|
|
3777
4821
|
|
|
3778
4822
|
for (const row of rows) {
|
|
3779
4823
|
if (!result.has(row.threadId)) {
|