@jant/core 0.6.16 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -11
- package/bin/commands/assets/upload.js +192 -15
- package/bin/commands/import-site.js +60 -4
- package/bin/commands/reset-password.js +21 -8
- package/bin/commands/site/snapshot/export.js +1 -1
- package/bin/commands/site/snapshot/import.js +1 -1
- package/bin/lib/d1-query.js +5 -16
- package/bin/lib/node-env.js +40 -4
- package/bin/lib/site-selection.js +85 -29
- package/bin/lib/wrangler-cli.js +28 -2
- package/bin/lib/wrangler-json.js +35 -0
- package/dist/{app-C7_4oab9.js → app-K_Aa1MMn.js} +52476 -13330
- package/dist/client/.vite/manifest.json +255 -90
- package/dist/client/_assets/chunks/confirm-D7GrwsCN.js +45 -0
- package/dist/client/_assets/chunks/create-editor-CD3FhrOB.js +257 -0
- package/dist/client/_assets/chunks/{native-DpcrFAPh.js → emoji-mart-C4RpRKcU.js} +716 -1
- package/dist/client/_assets/chunks/footnote-rail-CmeoVtnU.js +1 -0
- package/dist/client/_assets/chunks/heic-to-CuP2Qlsw.js +1 -0
- package/dist/client/_assets/chunks/jant-locale-picker-DL6mIOT5.js +69 -0
- package/dist/client/_assets/chunks/lit-DW8VmJAT.js +2 -0
- package/dist/client/_assets/chunks/mediabunny-DRaCQ1r-.js +31 -0
- package/dist/client/_assets/chunks/rolldown-runtime-hePW80VL.js +1 -0
- package/dist/client/_assets/chunks/settings-paths-BcdySNx6.js +84 -0
- package/dist/client/_assets/chunks/site-header-nav-DzXsFk05.js +1 -0
- package/dist/client/_assets/chunks/slug-format-CRXO1AL5.js +1 -0
- package/dist/client/_assets/chunks/slugify-373citbs.js +1 -0
- package/dist/client/_assets/chunks/sortable-list-CgaL2jCs.js +2 -0
- package/dist/client/_assets/chunks/types-D5iF3H1a.js +1 -0
- package/dist/client/_assets/chunks/unsafe-html-B5ZEXaxg.js +1 -0
- package/dist/client/_assets/chunks/unsafe-svg-0QCkP0vZ.js +2 -0
- package/dist/client/_assets/client-GiYENVw8.css +2 -0
- package/dist/client/_assets/client-VnFxJN7G.js +295 -0
- package/dist/client/_assets/client-auth-k8gJ6QJj.js +828 -0
- package/dist/client/_assets/client-author-DQ8R-8KR.css +2 -0
- package/dist/client/_assets/client-compose-B_kDtWkW.js +2004 -0
- package/dist/client/_assets/client-manage-M90aSTOk.js +2170 -0
- package/dist/client/_assets/client-settings-DfYs9n1F.js +1112 -0
- package/dist/{export-Dl5KCiEs.js → github-sync-BPAvT999.js} +2254 -107
- package/dist/index.js +2 -5
- package/dist/node.js +5 -7
- package/package.json +3 -3
- package/src/__tests__/auth-session-freshness.test.ts +57 -0
- package/src/__tests__/basecoat-variants.test.ts +121 -0
- package/src/__tests__/bin/assets-upload.test.ts +68 -0
- package/src/__tests__/export-hugo-build.test.ts +260 -0
- package/src/__tests__/export-service.test.ts +231 -0
- package/src/__tests__/helpers/component-css.ts +91 -0
- package/src/__tests__/helpers/db.ts +33 -0
- package/src/__tests__/helpers/export-fixtures.ts +3 -0
- package/src/__tests__/stylesheet-audience.test.ts +159 -0
- package/src/__tests__/stylesheet-breakpoints.test.ts +183 -0
- package/src/app.tsx +71 -16
- package/src/auth.ts +18 -1
- package/src/client/__tests__/collection-page-actions.test.ts +51 -2
- package/src/client/__tests__/compose-bridge-image-upload.test.ts +196 -0
- package/src/client/__tests__/compose-bridge-video-poster.test.ts +159 -0
- package/src/client/__tests__/compose-bridge.test.ts +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 +541 -172
- package/src/client/components/__tests__/jant-settings-language.test.ts +851 -0
- package/src/client/components/__tests__/jant-smart-collection-dialog.test.ts +665 -0
- package/src/client/components/collection-dialog-types.ts +49 -0
- package/src/client/components/collection-manager-types.ts +32 -1
- package/src/client/components/collection-types.ts +1 -15
- package/src/client/components/compose-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.ts +138 -106
- package/src/client/components/jant-settings-avatar.ts +31 -27
- package/src/client/components/jant-settings-general.ts +617 -543
- package/src/client/components/jant-settings-language.ts +1087 -0
- package/src/client/components/jant-smart-collection-dialog.ts +1021 -0
- package/src/client/components/jant-text-preview.ts +94 -86
- package/src/client/components/nav-manager-types.ts +62 -1
- package/src/client/components/settings-types.ts +14 -29
- package/src/client/components/smart-collection-conditions.ts +285 -0
- package/src/client/components/smart-collection-dialog-types.ts +68 -0
- package/src/client/compose-bridge.ts +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/bubble-menu.ts +47 -16
- package/src/client/tiptap/floating-position.ts +216 -2
- package/src/client/tiptap/inline-image-upload.ts +7 -3
- package/src/client/tiptap/link-toolbar.ts +22 -9
- 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__/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 +551 -69
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +597 -115
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +610 -128
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +94 -0
- package/src/i18n/middleware.ts +12 -2
- package/src/i18n/supported-locales.ts +80 -0
- package/src/lib/__tests__/api-settings.test.ts +11 -2
- package/src/lib/__tests__/asset-path.test.ts +92 -0
- package/src/lib/__tests__/deferred.test.ts +90 -0
- package/src/lib/__tests__/discover-ping.test.ts +89 -0
- package/src/lib/__tests__/discover.test.ts +410 -0
- package/src/lib/__tests__/feed-policy.test.ts +26 -0
- package/src/lib/__tests__/feed.test.ts +1526 -15
- package/src/lib/__tests__/filter-dimensions.test.ts +341 -0
- package/src/lib/__tests__/hosted-signin.test.ts +1 -1
- package/src/lib/__tests__/http-cache.test.ts +162 -0
- package/src/lib/__tests__/jant-docs-dev.test.ts +21 -0
- package/src/lib/__tests__/jant-docs.test.ts +23 -0
- package/src/lib/__tests__/lang-detect.test.ts +231 -0
- package/src/lib/__tests__/media-helpers.test.ts +81 -0
- package/src/lib/__tests__/navigation.test.ts +135 -1
- package/src/lib/__tests__/resolve-config.test.ts +64 -3
- package/src/lib/__tests__/schemas.test.ts +42 -4
- package/src/lib/__tests__/site-unavailable-page.test.ts +25 -0
- package/src/lib/__tests__/slug.test.ts +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__/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 +437 -0
- package/src/lib/env.ts +134 -0
- package/src/lib/errors.ts +33 -0
- package/src/lib/feed-policy.ts +236 -2
- package/src/lib/feed.ts +926 -73
- package/src/lib/filter-dimensions.ts +1349 -0
- package/src/lib/github-sync-site-config.ts +3 -0
- package/src/lib/hosted-signin.ts +1 -1
- package/src/lib/http-cache.ts +148 -0
- package/src/lib/hugo-markdown.ts +16 -0
- package/src/lib/icons.ts +9 -2
- package/src/lib/ids.ts +3 -0
- package/src/lib/jant-docs.ts +42 -0
- package/src/lib/lang-detect.ts +308 -0
- package/src/lib/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-meta.ts +19 -0
- package/src/lib/render.tsx +55 -7
- package/src/lib/resolve-config.ts +53 -5
- package/src/lib/schemas.ts +201 -10
- package/src/lib/site-header-fragment.tsx +2 -1
- package/src/lib/site-unavailable-page.ts +47 -0
- package/src/lib/slug.ts +51 -7
- package/src/lib/slugify.ts +113 -0
- package/src/lib/startup-config.ts +4 -6
- package/src/lib/storage.ts +7 -0
- package/src/lib/summary.ts +50 -0
- package/src/lib/thread-fold.ts +161 -0
- package/src/lib/time.ts +32 -0
- package/src/lib/timeline.ts +42 -1
- package/src/lib/tiptap-render.ts +1 -2
- package/src/lib/upload.ts +16 -13
- package/src/lib/url.ts +139 -86
- package/src/lib/version.ts +10 -8
- package/src/lib/view-language.ts +436 -0
- package/src/lib/view.ts +51 -23
- package/src/lib/viewer-context.ts +62 -0
- package/src/middleware/__tests__/auth.test.ts +70 -2
- package/src/middleware/__tests__/cache-control.test.ts +71 -3
- package/src/middleware/__tests__/onboarding.test.ts +76 -5
- package/src/middleware/__tests__/secure-headers.test.ts +1 -1
- package/src/middleware/__tests__/session.test.ts +181 -33
- package/src/middleware/auth.ts +50 -31
- package/src/middleware/cache-control.ts +75 -10
- package/src/middleware/config.ts +6 -6
- package/src/middleware/error-handler.ts +12 -0
- package/src/middleware/onboarding.ts +32 -10
- package/src/middleware/secure-headers.ts +1 -1
- package/src/middleware/session.ts +96 -8
- package/src/node/__tests__/cli-migrate.test.ts +21 -19
- package/src/node/__tests__/cli-reset-password.test.ts +81 -8
- package/src/node/__tests__/cli-runtime-target.test.ts +48 -1
- package/src/node/__tests__/cli-site-selection.test.ts +128 -38
- package/src/node/__tests__/runtime.test.ts +37 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +76 -22
- package/src/routes/__tests__/feed-validator.test.ts +156 -0
- package/src/routes/api/__tests__/collections.test.ts +114 -0
- package/src/routes/api/__tests__/discover.test.ts +222 -0
- package/src/routes/api/__tests__/nav-items.test.ts +212 -2
- package/src/routes/api/__tests__/post-translations.test.ts +829 -0
- package/src/routes/api/__tests__/settings.test.ts +24 -3
- package/src/routes/api/__tests__/telegram.test.ts +6 -5
- package/src/routes/api/collections.ts +49 -6
- package/src/routes/api/custom-urls.ts +0 -1
- package/src/routes/api/discover.ts +95 -0
- package/src/routes/api/export.ts +3 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +42 -2
- package/src/routes/api/nav-items.ts +55 -8
- package/src/routes/api/posts.ts +159 -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 +23 -6
- package/src/routes/api/smart-collections.ts +132 -0
- package/src/routes/api/telegram.ts +2 -40
- package/src/routes/api/uploads.ts +1 -2
- package/src/routes/auth/__tests__/setup-auth.test.ts +137 -0
- package/src/routes/auth/__tests__/setup-discover.test.ts +204 -0
- package/src/routes/auth/__tests__/setup-flow.test.ts +270 -0
- package/src/routes/auth/__tests__/setup-page.test.tsx +329 -0
- package/src/routes/auth/__tests__/setup-steps.test.ts +310 -0
- package/src/routes/auth/__tests__/setup.test.ts +278 -26
- package/src/routes/auth/__tests__/signin.test.ts +135 -0
- package/src/routes/auth/setup.tsx +934 -186
- package/src/routes/auth/signin.tsx +10 -8
- package/src/routes/compose.tsx +5 -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__/settings-discover.test.ts +213 -0
- package/src/routes/dash/custom-urls.tsx +1 -37
- package/src/routes/dash/settings.tsx +502 -41
- package/src/routes/discover-announce.ts +96 -0
- package/src/routes/feed/__tests__/feed.test.ts +165 -2
- package/src/routes/feed/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-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/archive.tsx +471 -369
- package/src/routes/pages/collection.tsx +94 -96
- package/src/routes/pages/collections.tsx +38 -50
- package/src/routes/pages/featured.tsx +32 -13
- package/src/routes/pages/home.tsx +25 -6
- package/src/routes/pages/language.tsx +113 -0
- package/src/routes/pages/latest.tsx +26 -16
- package/src/routes/pages/new.tsx +3 -0
- package/src/routes/pages/page.tsx +178 -17
- package/src/routes/pages/partials.tsx +41 -0
- package/src/routes/pages/search.tsx +18 -3
- package/src/routes/pages/smart-collection.tsx +340 -0
- package/src/routes/pages/subscribe.tsx +182 -0
- package/src/runtime/__tests__/index.test.ts +19 -0
- package/src/runtime/__tests__/node.test.ts +129 -4
- package/src/runtime/cloudflare.ts +21 -2
- package/src/runtime/index.ts +27 -1
- package/src/runtime/node.ts +18 -1
- package/src/runtime/site.ts +95 -24
- package/src/services/__tests__/auth.test.ts +116 -7
- package/src/services/__tests__/collection.test.ts +108 -4
- package/src/services/__tests__/language.test.ts +495 -0
- package/src/services/__tests__/navigation.test.ts +213 -10
- package/src/services/__tests__/path.test.ts +180 -0
- package/src/services/__tests__/post-language.test.ts +830 -0
- package/src/services/__tests__/post.test.ts +46 -9
- package/src/services/__tests__/settings.test.ts +228 -17
- package/src/services/__tests__/site-admin.test.ts +5 -5
- package/src/services/__tests__/smart-collection.test.ts +651 -0
- package/src/services/about-page.ts +22 -1
- package/src/services/auth.ts +51 -49
- package/src/services/bootstrap.ts +188 -38
- package/src/services/collection-directory-position.ts +76 -0
- package/src/services/collection.ts +331 -46
- package/src/services/custom-url.ts +27 -22
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +123 -123
- package/src/services/export-theme/layouts/_default/rss.xml +77 -1
- package/src/services/export-theme/layouts/partials/feed-post-content.xml +105 -2
- package/src/services/export-theme/layouts/partials/media-gallery.html +35 -21
- package/src/services/export-theme/layouts/partials/pagination.html +14 -13
- package/src/services/export-theme/layouts/partials/post-card.html +8 -4
- package/src/services/export-theme/layouts/partials/reply.html +2 -1
- package/src/services/export-theme/layouts/partials/thread-preview.html +40 -30
- package/src/services/export-theme/styles/main.css +13 -1
- package/src/services/export.ts +278 -19
- package/src/services/index.ts +34 -3
- package/src/services/language.ts +412 -0
- package/src/services/mcp.ts +3 -1
- package/src/services/media.ts +14 -6
- package/src/services/navigation.ts +429 -67
- package/src/services/path.ts +221 -6
- package/src/services/post.ts +1021 -152
- package/src/services/search.ts +39 -2
- package/src/services/settings.ts +238 -43
- package/src/services/site-admin.ts +24 -7
- package/src/services/site.ts +77 -1
- package/src/services/smart-collection.ts +636 -0
- package/src/services/telegram.ts +20 -22
- package/src/services/upload-session.ts +1 -3
- package/src/style-author.css +27 -0
- package/src/styles/components-author.css +1622 -0
- package/src/styles/components.css +24 -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 +1087 -7219
- package/src/types/app-context.ts +7 -0
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +94 -15
- package/src/types/constants.ts +188 -16
- package/src/types/entities.ts +87 -1
- package/src/types/operations.ts +64 -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 +275 -113
- package/src/ui/dash/settings/LanguageContent.tsx +409 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +19 -1
- package/src/ui/dash/settings/__tests__/GeneralContent.test.tsx +324 -4
- package/src/ui/dash/settings/__tests__/LanguageContent.test.tsx +144 -0
- package/src/ui/dash/settings/__tests__/discover-copy.test.ts +66 -0
- package/src/ui/dash/settings/discover-copy.ts +116 -0
- package/src/ui/feed/LinkCard.tsx +55 -45
- package/src/ui/feed/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/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/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. */
|
|
@@ -267,8 +309,41 @@ 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;
|
|
270
316
|
}
|
|
271
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
|
+
|
|
272
347
|
export interface PostService {
|
|
273
348
|
getById(id: string): Promise<Post | null>;
|
|
274
349
|
getBodyContent(id: string): Promise<PostBodyContent | null>;
|
|
@@ -313,6 +388,38 @@ export interface PostService {
|
|
|
313
388
|
count(filters?: PostFilters): Promise<number>;
|
|
314
389
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
315
390
|
countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
|
|
391
|
+
/**
|
|
392
|
+
* Measure several filters at once, in a single round trip.
|
|
393
|
+
*
|
|
394
|
+
* One table scan with one `SUM(CASE …)` and one `MAX(CASE …)` per filter,
|
|
395
|
+
* rather than one query per filter. On Workers the round trip is the cost
|
|
396
|
+
* that matters, and a page that lists twenty smart collections would
|
|
397
|
+
* otherwise pay twenty of them — `Promise.all` only turns "slow in sequence"
|
|
398
|
+
* into "expensive in parallel".
|
|
399
|
+
*
|
|
400
|
+
* Every predicate is built by the same `buildFilterConditions` a single
|
|
401
|
+
* `count` uses, so the numbers here and the numbers on the pages they link to
|
|
402
|
+
* cannot drift.
|
|
403
|
+
*
|
|
404
|
+
* `recentActivityAt` is the newest activity among the matched rows, on the
|
|
405
|
+
* one definition {@link buildRootActivityExpr} holds for everything that
|
|
406
|
+
* orders or dates a Thread. It reads the matched row's own columns, so it
|
|
407
|
+
* describes a Thread only when `base` excludes replies — which is what every
|
|
408
|
+
* caller does, because a reply is not a member of anything.
|
|
409
|
+
*
|
|
410
|
+
* @param filters - One filter per measurement, in order
|
|
411
|
+
* @param base - Applied to every measurement: the site, the reader's
|
|
412
|
+
* visibility floor, and anything else common to all of them
|
|
413
|
+
* @returns Count and newest activity, positionally matching `filters`;
|
|
414
|
+
* `recentActivityAt` is `null` where nothing matched
|
|
415
|
+
* @example
|
|
416
|
+
* await posts.aggregateMany([{ format: "note" }, { format: "quote" }], base);
|
|
417
|
+
* // [{ count: 12, recentActivityAt: 1706100000 }, { count: 5, … }]
|
|
418
|
+
*/
|
|
419
|
+
aggregateMany(
|
|
420
|
+
filters: readonly PostFilters[],
|
|
421
|
+
base: PostFilters,
|
|
422
|
+
): Promise<PostFilterAggregate[]>;
|
|
316
423
|
/** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
|
|
317
424
|
countByYearMonth(
|
|
318
425
|
filters?: PostFilters,
|
|
@@ -390,6 +497,112 @@ export interface PostService {
|
|
|
390
497
|
status: Status,
|
|
391
498
|
visibility: Visibility,
|
|
392
499
|
): Promise<void>;
|
|
500
|
+
/**
|
|
501
|
+
* Set the content language of a whole Thread (root and every reply).
|
|
502
|
+
*
|
|
503
|
+
* Thread-wide by design: `post.language` is uniform inside a Thread, which is
|
|
504
|
+
* what lets every language filter stay a plain column predicate. Rejects a
|
|
505
|
+
* language another Post in the same translation group already holds.
|
|
506
|
+
*
|
|
507
|
+
* @param postId - Any Post in the Thread; the Thread is resolved from it
|
|
508
|
+
* @param language - Canonical BCP 47 tag
|
|
509
|
+
* @throws {NotFoundError} When no such Post exists on this site
|
|
510
|
+
* @throws {ConflictError} When the translation group already has that language
|
|
511
|
+
* @example
|
|
512
|
+
* await posts.setThreadLanguage(post.id, "zh-Hans");
|
|
513
|
+
*/
|
|
514
|
+
setThreadLanguage(postId: string, language: string): Promise<void>;
|
|
515
|
+
/**
|
|
516
|
+
* Stamp every Post that has no language yet with `language`.
|
|
517
|
+
*
|
|
518
|
+
* Runs once when an author turns multilingual content on, and is idempotent
|
|
519
|
+
* so re-enabling later only touches Posts written while it was off. Scoped to
|
|
520
|
+
* this site — hosted deployments share one database.
|
|
521
|
+
*
|
|
522
|
+
* @param language - Canonical BCP 47 tag of the site's primary language
|
|
523
|
+
* @returns Number of Posts stamped
|
|
524
|
+
* @example
|
|
525
|
+
* await posts.materializeMissingLanguage("zh-Hans"); // => 347
|
|
526
|
+
*/
|
|
527
|
+
materializeMissingLanguage(language: string): Promise<number>;
|
|
528
|
+
/** Count Posts on this site that still have no language. */
|
|
529
|
+
countMissingLanguage(): Promise<number>;
|
|
530
|
+
/** Count Posts on this site written in `language`, drafts included. */
|
|
531
|
+
countByLanguage(language: string): Promise<number>;
|
|
532
|
+
/**
|
|
533
|
+
* Every language stamped on this site's Posts, with how many carry it.
|
|
534
|
+
* Drafts included; the pre-multilingual NULL rows are not.
|
|
535
|
+
*/
|
|
536
|
+
listLanguagesInUse(): Promise<Array<{ language: string; count: number }>>;
|
|
537
|
+
/**
|
|
538
|
+
* List the Thread roots that are translations of the given Post, excluding
|
|
539
|
+
* the Post itself. Empty when it belongs to no translation group.
|
|
540
|
+
*/
|
|
541
|
+
listTranslations(postId: string): Promise<Post[]>;
|
|
542
|
+
/**
|
|
543
|
+
* List translations for many Thread roots in one round trip, keyed by the
|
|
544
|
+
* root ID that was asked about. Roots with no group are omitted.
|
|
545
|
+
*/
|
|
546
|
+
getTranslationsMap(postIds: string[]): Promise<Map<string, Post[]>>;
|
|
547
|
+
/**
|
|
548
|
+
* Find published Thread roots this Post could be linked to as a translation.
|
|
549
|
+
*
|
|
550
|
+
* Filtered server-side rather than in the menu, because "can these two be
|
|
551
|
+
* linked" is a rule the service owns: a candidate has to be a Thread root in
|
|
552
|
+
* a language this Post's translation group does not already hold, and the two
|
|
553
|
+
* groups must not both exist — `linkTranslation` refuses to merge them.
|
|
554
|
+
* Offering a post that will be rejected teaches the author nothing.
|
|
555
|
+
*
|
|
556
|
+
* The match is a plain substring scan over title and body text. It is a
|
|
557
|
+
* deliberate, authenticated action over one author's own posts, so the cost
|
|
558
|
+
* is bounded and the shared search index (which cannot express these filters)
|
|
559
|
+
* is not worth involving.
|
|
560
|
+
*
|
|
561
|
+
* @param postId - Any Post in the Thread looking for a translation
|
|
562
|
+
* @param options - Search text and how many candidates to return
|
|
563
|
+
* @returns Matching Thread roots, newest first
|
|
564
|
+
* @example
|
|
565
|
+
* await posts.listTranslationCandidates(id, { query: "recipe" });
|
|
566
|
+
*/
|
|
567
|
+
listTranslationCandidates(
|
|
568
|
+
postId: string,
|
|
569
|
+
options: { query: string; limit?: number },
|
|
570
|
+
): Promise<Post[]>;
|
|
571
|
+
/**
|
|
572
|
+
* Resolve an address the author pasted into the Thread it names, and say
|
|
573
|
+
* whether this Post could be linked to it as a translation.
|
|
574
|
+
*
|
|
575
|
+
* The same rules as {@link PostService.listTranslationCandidates}, asked
|
|
576
|
+
* about one known Thread instead of searched for — so the answer can be a
|
|
577
|
+
* reason rather than an empty list. An address that lands on a reply names
|
|
578
|
+
* its Thread, because translations link whole Threads.
|
|
579
|
+
*
|
|
580
|
+
* @param postId - Any Post in the Thread looking for a translation
|
|
581
|
+
* @param path - Internal path, as produced by `toInternalPath()`
|
|
582
|
+
* @returns The Thread root to link, or why it cannot be
|
|
583
|
+
* @example
|
|
584
|
+
* await posts.resolveTranslationCandidate(id, "/hello-world");
|
|
585
|
+
* // { status: "unpublished" }
|
|
586
|
+
*/
|
|
587
|
+
resolveTranslationCandidate(
|
|
588
|
+
postId: string,
|
|
589
|
+
path: string,
|
|
590
|
+
): Promise<TranslationCandidateResolution>;
|
|
591
|
+
/**
|
|
592
|
+
* Link two already-published Thread roots as translations of each other.
|
|
593
|
+
*
|
|
594
|
+
* Joins the side without a group into the other's group, minting one when
|
|
595
|
+
* neither has any. Merging two existing groups is refused — that would
|
|
596
|
+
* silently restructure both sides.
|
|
597
|
+
*
|
|
598
|
+
* @throws {ConflictError} When the languages clash or both sides have groups
|
|
599
|
+
*/
|
|
600
|
+
linkTranslation(postId: string, otherPostId: string): Promise<void>;
|
|
601
|
+
/**
|
|
602
|
+
* Remove a Thread root from its translation group. When that leaves a single
|
|
603
|
+
* Post behind, its group is cleared too so no one-member groups survive.
|
|
604
|
+
*/
|
|
605
|
+
unlinkTranslation(postId: string): Promise<void>;
|
|
393
606
|
/** Get reply counts for multiple posts */
|
|
394
607
|
getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
|
|
395
608
|
/** Get preview replies for multiple thread roots */
|
|
@@ -619,6 +832,34 @@ function ensurePostRating(
|
|
|
619
832
|
throw new ErrorCtor("Rating must be an integer between 1 and 5.");
|
|
620
833
|
}
|
|
621
834
|
|
|
835
|
+
/**
|
|
836
|
+
* Normalize a caller-supplied content language to what the column stores.
|
|
837
|
+
*
|
|
838
|
+
* Empty, missing, and unparseable values all become `null` — the single-language
|
|
839
|
+
* state — so the column never holds a half-set value. Whether the tag is one
|
|
840
|
+
* the site actually offers is a settings-layer question, checked where the site
|
|
841
|
+
* language configuration is in scope.
|
|
842
|
+
*
|
|
843
|
+
* @param value - Raw BCP 47 tag from a request or import
|
|
844
|
+
* @returns Canonical tag (`zh-Hans`), or `null` when there is nothing to store
|
|
845
|
+
* @example
|
|
846
|
+
* normalizePostLanguage("ZH-hans"); // "zh-Hans"
|
|
847
|
+
* normalizePostLanguage(" "); // null
|
|
848
|
+
*/
|
|
849
|
+
export function normalizePostLanguage(
|
|
850
|
+
value: string | null | undefined,
|
|
851
|
+
): string | null {
|
|
852
|
+
if (typeof value !== "string") return null;
|
|
853
|
+
const trimmed = value.trim();
|
|
854
|
+
if (!trimmed) return null;
|
|
855
|
+
if (!isValidContentLanguage(trimmed)) {
|
|
856
|
+
throw new ValidationError(
|
|
857
|
+
"Enter a valid BCP 47 language tag (e.g. en, zh-Hans, ja).",
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
return normalizeContentLanguage(trimmed);
|
|
861
|
+
}
|
|
862
|
+
|
|
622
863
|
function assertPostFormatShape(data: {
|
|
623
864
|
format: Format;
|
|
624
865
|
title?: string | null;
|
|
@@ -723,11 +964,6 @@ export function createPostService(
|
|
|
723
964
|
: sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
|
|
724
965
|
}
|
|
725
966
|
|
|
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
967
|
/** Check if a slug is available (not used by posts or path_registry) */
|
|
732
968
|
async function isSlugAvailable(slug: string): Promise<boolean> {
|
|
733
969
|
return resolvedPaths.isPathAvailable(slug);
|
|
@@ -934,22 +1170,71 @@ export function createPostService(
|
|
|
934
1170
|
)`;
|
|
935
1171
|
}
|
|
936
1172
|
|
|
937
|
-
/**
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1173
|
+
/**
|
|
1174
|
+
* Load a Post and assert it can carry a translation group.
|
|
1175
|
+
*
|
|
1176
|
+
* Groups live on Thread roots only, and both sides must belong to this site.
|
|
1177
|
+
* These two rules replace the table CHECK the SQLite schema cannot add, so
|
|
1178
|
+
* every write path that touches `translation_group_id` goes through here.
|
|
1179
|
+
*/
|
|
1180
|
+
async function requireTranslatableRoot(
|
|
1181
|
+
postId: string,
|
|
1182
|
+
): Promise<typeof posts.$inferSelect> {
|
|
1183
|
+
const rows = await db
|
|
1184
|
+
.select()
|
|
1185
|
+
.from(posts)
|
|
1186
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
1187
|
+
.limit(1);
|
|
1188
|
+
const row = rows[0];
|
|
1189
|
+
if (!row) {
|
|
1190
|
+
throw new NotFoundError("Post");
|
|
946
1191
|
}
|
|
947
|
-
if (
|
|
948
|
-
|
|
1192
|
+
if (row.threadId !== row.id) {
|
|
1193
|
+
throw new ValidationError(
|
|
1194
|
+
"Translations link whole threads. Use the thread's first post.",
|
|
1195
|
+
);
|
|
949
1196
|
}
|
|
950
|
-
|
|
951
|
-
|
|
1197
|
+
return row;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
/** Reject a language another Post in the same translation group already holds. */
|
|
1201
|
+
async function assertTranslationLanguageFree(
|
|
1202
|
+
groupId: string,
|
|
1203
|
+
language: string,
|
|
1204
|
+
excludePostId?: string,
|
|
1205
|
+
): Promise<void> {
|
|
1206
|
+
const rows = await db
|
|
1207
|
+
.select({ id: posts.id, title: posts.title })
|
|
1208
|
+
.from(posts)
|
|
1209
|
+
.where(
|
|
1210
|
+
and(
|
|
1211
|
+
eq(posts.siteId, siteId),
|
|
1212
|
+
eq(posts.translationGroupId, groupId),
|
|
1213
|
+
eq(posts.language, language),
|
|
1214
|
+
),
|
|
1215
|
+
)
|
|
1216
|
+
.limit(2);
|
|
1217
|
+
const clash = rows.find((row) => row.id !== excludePostId);
|
|
1218
|
+
if (clash) {
|
|
1219
|
+
// The language's own name, not its tag: this message is shown to the
|
|
1220
|
+
// author, and "简体中文" is what they picked in the menu — "zh-Hans" is
|
|
1221
|
+
// an implementation detail they never chose.
|
|
1222
|
+
const languageName = getOrBuildEntry(language).native;
|
|
1223
|
+
throw new ConflictError(
|
|
1224
|
+
clash.title
|
|
1225
|
+
? `"${clash.title}" is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`
|
|
1226
|
+
: `Another post is already the ${languageName} version in this translation group. Unlink it first, or pick another language.`,
|
|
1227
|
+
);
|
|
952
1228
|
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/** Build WHERE conditions from filters (shared by list and count) */
|
|
1232
|
+
function buildFilterConditions(filters: PostFilters) {
|
|
1233
|
+
const conditions = [
|
|
1234
|
+
eq(posts.siteId, siteId),
|
|
1235
|
+
...buildReaderVisibilityConditions(posts, siteId, filters),
|
|
1236
|
+
];
|
|
1237
|
+
|
|
953
1238
|
if (filters.pinned !== undefined) {
|
|
954
1239
|
conditions.push(
|
|
955
1240
|
filters.pinned
|
|
@@ -979,6 +1264,9 @@ export function createPostService(
|
|
|
979
1264
|
if (filters.threadId) {
|
|
980
1265
|
conditions.push(eq(posts.threadId, filters.threadId));
|
|
981
1266
|
}
|
|
1267
|
+
if (filters.ids !== undefined) {
|
|
1268
|
+
conditions.push(inArray(posts.id, filters.ids));
|
|
1269
|
+
}
|
|
982
1270
|
if (filters.excludeReplies) {
|
|
983
1271
|
conditions.push(isNull(posts.replyToId));
|
|
984
1272
|
}
|
|
@@ -1150,80 +1438,61 @@ export function createPostService(
|
|
|
1150
1438
|
...keys,
|
|
1151
1439
|
];
|
|
1152
1440
|
|
|
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
|
-
|
|
1441
|
+
if (filters.featured) {
|
|
1167
1442
|
return buildLexicographicCursorCondition(
|
|
1168
1443
|
withPinnedSortKey([
|
|
1169
1444
|
{
|
|
1170
1445
|
direction: "desc",
|
|
1171
|
-
expr:
|
|
1172
|
-
value:
|
|
1446
|
+
expr: featuredPublishedSortExpr,
|
|
1447
|
+
value: cursorFeaturedPublishedAt,
|
|
1173
1448
|
},
|
|
1174
1449
|
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1175
1450
|
]),
|
|
1176
1451
|
);
|
|
1177
1452
|
}
|
|
1178
1453
|
|
|
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
|
-
|
|
1454
|
+
// One case per sort order, mirroring `list()` key for key.
|
|
1455
|
+
switch (filters.sortOrder ?? "newest") {
|
|
1456
|
+
case "oldest":
|
|
1457
|
+
return buildLexicographicCursorCondition(
|
|
1458
|
+
withPinnedSortKey([
|
|
1459
|
+
{
|
|
1460
|
+
direction: "asc",
|
|
1461
|
+
expr: sortTimestampSortExpr,
|
|
1462
|
+
value: cursorSortTimestamp,
|
|
1463
|
+
},
|
|
1464
|
+
{ direction: "asc", expr: posts.id, value: cursorPost.id },
|
|
1465
|
+
]),
|
|
1466
|
+
);
|
|
1467
|
+
case "rating_desc":
|
|
1468
|
+
return buildLexicographicCursorCondition(
|
|
1469
|
+
withPinnedSortKey([
|
|
1470
|
+
{
|
|
1471
|
+
direction: "desc",
|
|
1472
|
+
expr: ratingPresenceExpr,
|
|
1473
|
+
value: cursorRatingPresence,
|
|
1474
|
+
},
|
|
1475
|
+
{ direction: "desc", expr: ratingSortExpr, value: cursorRating },
|
|
1476
|
+
{
|
|
1477
|
+
direction: "desc",
|
|
1478
|
+
expr: sortTimestampSortExpr,
|
|
1479
|
+
value: cursorSortTimestamp,
|
|
1480
|
+
},
|
|
1481
|
+
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1482
|
+
]),
|
|
1483
|
+
);
|
|
1484
|
+
case "newest":
|
|
1485
|
+
return buildLexicographicCursorCondition(
|
|
1486
|
+
withPinnedSortKey([
|
|
1487
|
+
{
|
|
1488
|
+
direction: "desc",
|
|
1489
|
+
expr: sortTimestampSortExpr,
|
|
1490
|
+
value: cursorSortTimestamp,
|
|
1491
|
+
},
|
|
1492
|
+
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
1493
|
+
]),
|
|
1494
|
+
);
|
|
1209
1495
|
}
|
|
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
1496
|
}
|
|
1228
1497
|
|
|
1229
1498
|
function toPost(
|
|
@@ -1258,6 +1527,8 @@ export function createPostService(
|
|
|
1258
1527
|
previewProvider: row.previewProvider,
|
|
1259
1528
|
replyToId: row.replyToId,
|
|
1260
1529
|
threadId: row.threadId,
|
|
1530
|
+
language: row.language,
|
|
1531
|
+
translationGroupId: row.translationGroupId,
|
|
1261
1532
|
quietReply: row.quietReply,
|
|
1262
1533
|
publishedAt: row.publishedAt,
|
|
1263
1534
|
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
@@ -1355,18 +1626,10 @@ export function createPostService(
|
|
|
1355
1626
|
}
|
|
1356
1627
|
|
|
1357
1628
|
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
|
-
}
|
|
1629
|
+
const conditions: SQL[] = [
|
|
1630
|
+
eq(posts.siteId, siteId),
|
|
1631
|
+
...buildReaderVisibilityConditions(posts, siteId, options ?? {}),
|
|
1632
|
+
];
|
|
1370
1633
|
if (options?.publishedBefore !== undefined) {
|
|
1371
1634
|
conditions.push(
|
|
1372
1635
|
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
@@ -1391,6 +1654,9 @@ export function createPostService(
|
|
|
1391
1654
|
if (options?.hasRating) {
|
|
1392
1655
|
conditions.push(isNotNull(posts.rating));
|
|
1393
1656
|
}
|
|
1657
|
+
if (options?.threadIds !== undefined) {
|
|
1658
|
+
conditions.push(inArray(posts.threadId, options.threadIds));
|
|
1659
|
+
}
|
|
1394
1660
|
|
|
1395
1661
|
return conditions;
|
|
1396
1662
|
}
|
|
@@ -1698,36 +1964,39 @@ export function createPostService(
|
|
|
1698
1964
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1699
1965
|
.limit(filters.limit ?? 100);
|
|
1700
1966
|
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1967
|
+
// A case per sort order, and no shared fallthrough. This was an if/else
|
|
1968
|
+
// chain whose last branch was written for one order and silently caught
|
|
1969
|
+
// another, so `newest` ordered by rating instead of by time wherever it
|
|
1970
|
+
// was passed explicitly — a smart collection page, most visibly.
|
|
1971
|
+
const buildOrderBy = (): SQL<unknown>[] => {
|
|
1972
|
+
if (filters.featured) {
|
|
1973
|
+
return [
|
|
1974
|
+
...pinnedOrder,
|
|
1975
|
+
desc(featuredPublishedSortExpr),
|
|
1976
|
+
desc(posts.id),
|
|
1977
|
+
];
|
|
1978
|
+
}
|
|
1979
|
+
switch (filters.sortOrder ?? "newest") {
|
|
1980
|
+
case "oldest":
|
|
1981
|
+
return [...pinnedOrder, asc(sortTimestampSortExpr), asc(posts.id)];
|
|
1982
|
+
case "rating_desc":
|
|
1983
|
+
return [
|
|
1704
1984
|
...pinnedOrder,
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1985
|
+
desc(ratingPresence),
|
|
1986
|
+
desc(posts.rating),
|
|
1987
|
+
desc(sortTimestampSortExpr),
|
|
1708
1988
|
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
|
-
);
|
|
1989
|
+
];
|
|
1990
|
+
case "newest":
|
|
1991
|
+
return [
|
|
1992
|
+
...pinnedOrder,
|
|
1993
|
+
desc(sortTimestampSortExpr),
|
|
1994
|
+
desc(posts.id),
|
|
1995
|
+
];
|
|
1996
|
+
}
|
|
1997
|
+
};
|
|
1998
|
+
|
|
1999
|
+
let query = baseQuery.orderBy(...buildOrderBy());
|
|
1731
2000
|
|
|
1732
2001
|
if (filters.offset !== undefined) {
|
|
1733
2002
|
query = query.offset(filters.offset) as typeof query;
|
|
@@ -1774,6 +2043,8 @@ export function createPostService(
|
|
|
1774
2043
|
id: posts.id,
|
|
1775
2044
|
updatedAt: threadModifiedAt,
|
|
1776
2045
|
featuredAt: posts.featuredAt,
|
|
2046
|
+
language: posts.language,
|
|
2047
|
+
translationGroupId: posts.translationGroupId,
|
|
1777
2048
|
})
|
|
1778
2049
|
.from(posts)
|
|
1779
2050
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
@@ -1799,6 +2070,8 @@ export function createPostService(
|
|
|
1799
2070
|
alias,
|
|
1800
2071
|
updatedAt: row.updatedAt,
|
|
1801
2072
|
featuredAt: row.featuredAt,
|
|
2073
|
+
language: row.language,
|
|
2074
|
+
translationGroupId: row.translationGroupId,
|
|
1802
2075
|
};
|
|
1803
2076
|
})
|
|
1804
2077
|
.filter((entry): entry is SitemapPostEntry => entry !== null);
|
|
@@ -1845,6 +2118,59 @@ export function createPostService(
|
|
|
1845
2118
|
return result[0]?.count ?? 0;
|
|
1846
2119
|
},
|
|
1847
2120
|
|
|
2121
|
+
async aggregateMany(filters, base) {
|
|
2122
|
+
if (filters.length === 0) return [];
|
|
2123
|
+
|
|
2124
|
+
const baseConditions = buildFilterConditions(base);
|
|
2125
|
+
// The row is its own Thread root here — callers exclude replies — so the
|
|
2126
|
+
// activity expression reads the columns in hand. No correlated subquery.
|
|
2127
|
+
const activityAt = buildRootActivityExpr({
|
|
2128
|
+
lastActivityAt: posts.lastActivityAt,
|
|
2129
|
+
publishedAt: posts.publishedAt,
|
|
2130
|
+
updatedAt: posts.updatedAt,
|
|
2131
|
+
});
|
|
2132
|
+
// A `SUM(CASE …)` and a `MAX(CASE …)` per filter, over one pass of the
|
|
2133
|
+
// rows the base predicate already narrows to. The aliases are positional
|
|
2134
|
+
// and generated here, so nothing a caller supplies reaches the SQL as an
|
|
2135
|
+
// identifier.
|
|
2136
|
+
const columns = Object.fromEntries(
|
|
2137
|
+
filters.flatMap((filter, index) => {
|
|
2138
|
+
const conditions = buildFilterConditions({ ...base, ...filter });
|
|
2139
|
+
const predicate =
|
|
2140
|
+
conditions.length > 0 ? and(...conditions) : sql`1 = 1`;
|
|
2141
|
+
return [
|
|
2142
|
+
[
|
|
2143
|
+
`n${index}`,
|
|
2144
|
+
sql<number>`CAST(SUM(CASE WHEN (${predicate}) THEN 1 ELSE 0 END) AS INTEGER)`,
|
|
2145
|
+
],
|
|
2146
|
+
[
|
|
2147
|
+
`a${index}`,
|
|
2148
|
+
sql<
|
|
2149
|
+
number | null
|
|
2150
|
+
>`MAX(CASE WHEN (${predicate}) THEN ${activityAt} END)`,
|
|
2151
|
+
],
|
|
2152
|
+
];
|
|
2153
|
+
}),
|
|
2154
|
+
);
|
|
2155
|
+
|
|
2156
|
+
const rows = await db
|
|
2157
|
+
.select(columns)
|
|
2158
|
+
.from(posts)
|
|
2159
|
+
.where(baseConditions.length > 0 ? and(...baseConditions) : undefined);
|
|
2160
|
+
|
|
2161
|
+
const row = rows[0] as Record<string, number | null> | undefined;
|
|
2162
|
+
return filters.map((_, index) => {
|
|
2163
|
+
const activity = row?.[`a${index}`];
|
|
2164
|
+
return {
|
|
2165
|
+
count: Number(row?.[`n${index}`] ?? 0),
|
|
2166
|
+
recentActivityAt:
|
|
2167
|
+
activity === null || activity === undefined
|
|
2168
|
+
? null
|
|
2169
|
+
: Number(activity),
|
|
2170
|
+
};
|
|
2171
|
+
});
|
|
2172
|
+
},
|
|
2173
|
+
|
|
1848
2174
|
async countUpTo(filters = {}, limit) {
|
|
1849
2175
|
const normalizedLimit = Math.max(0, Math.trunc(limit));
|
|
1850
2176
|
if (normalizedLimit === 0) {
|
|
@@ -1937,6 +2263,11 @@ export function createPostService(
|
|
|
1937
2263
|
let threadId = id;
|
|
1938
2264
|
let status: Status = requestedStatus ?? "published";
|
|
1939
2265
|
let visibility: Visibility | null = requestedVisibility ?? "public";
|
|
2266
|
+
// Roots carry the requested language; replies overwrite this with the
|
|
2267
|
+
// root's value below so a Thread never mixes languages.
|
|
2268
|
+
let language = normalizePostLanguage(data.language);
|
|
2269
|
+
let translationGroupId: string | null = null;
|
|
2270
|
+
let sourceTranslationPostId: string | null = null;
|
|
1940
2271
|
|
|
1941
2272
|
// Collapse the two-input (`flag` + `flagAt`) pair the DTO exposes
|
|
1942
2273
|
// into the single timestamp form the DB column stores. Explicit
|
|
@@ -2001,6 +2332,10 @@ export function createPostService(
|
|
|
2001
2332
|
status = root.status;
|
|
2002
2333
|
}
|
|
2003
2334
|
visibility = null;
|
|
2335
|
+
// A reply is part of the root's Thread, so it is written in the
|
|
2336
|
+
// Thread's language whatever the caller asked for. This is what keeps
|
|
2337
|
+
// language filters correct on member-grained queries.
|
|
2338
|
+
language = root?.language ?? parent.language ?? null;
|
|
2004
2339
|
|
|
2005
2340
|
if (
|
|
2006
2341
|
(data.collectionIds?.length ?? 0) > 0 ||
|
|
@@ -2012,6 +2347,54 @@ export function createPostService(
|
|
|
2012
2347
|
}
|
|
2013
2348
|
}
|
|
2014
2349
|
|
|
2350
|
+
const { primary, languages, reservedPrefixes } =
|
|
2351
|
+
await readLanguageSettings(db, siteId, databaseSchema);
|
|
2352
|
+
|
|
2353
|
+
// Nobody named a language, so read one out of the text. Every write path
|
|
2354
|
+
// that bypasses compose — the API, the Telegram bot, MCP — lands here, so
|
|
2355
|
+
// a multilingual site never accumulates unlabelled posts. An explicit
|
|
2356
|
+
// choice is the author's and is left alone; replies already carry the
|
|
2357
|
+
// Thread's language.
|
|
2358
|
+
if (!data.replyToId && !language) {
|
|
2359
|
+
language = suggestPostLanguage({
|
|
2360
|
+
text: bodyText ?? title ?? quoteText,
|
|
2361
|
+
languages,
|
|
2362
|
+
primary,
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
// "Add a translation" carries only the source ID until the author
|
|
2367
|
+
// submits; the group is minted here so abandoning the composer leaves
|
|
2368
|
+
// nothing behind (no draft row, no slug, no single-member group).
|
|
2369
|
+
if (data.translationOfId) {
|
|
2370
|
+
if (data.replyToId) {
|
|
2371
|
+
throw new ValidationError(
|
|
2372
|
+
"Translations link whole threads. Add the translation from the thread's first post.",
|
|
2373
|
+
);
|
|
2374
|
+
}
|
|
2375
|
+
const source = await requireTranslatableRoot(data.translationOfId);
|
|
2376
|
+
if (!language) {
|
|
2377
|
+
throw new ValidationError(
|
|
2378
|
+
"Choose a language for this translation before saving it.",
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
if (source.language === language) {
|
|
2382
|
+
throw new ConflictError(
|
|
2383
|
+
"That post is already written in this language. Pick a different language for the translation.",
|
|
2384
|
+
);
|
|
2385
|
+
}
|
|
2386
|
+
if (source.translationGroupId) {
|
|
2387
|
+
await assertTranslationLanguageFree(
|
|
2388
|
+
source.translationGroupId,
|
|
2389
|
+
language,
|
|
2390
|
+
);
|
|
2391
|
+
translationGroupId = source.translationGroupId;
|
|
2392
|
+
} else {
|
|
2393
|
+
translationGroupId = createEntityId("translationGroup");
|
|
2394
|
+
sourceTranslationPostId = source.id;
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2015
2398
|
// Featured surfaces only list published posts, so featuring a draft
|
|
2016
2399
|
// writes a flag that does nothing and shows nowhere. Say so instead.
|
|
2017
2400
|
if (status === "draft" && resolvedFeaturedAt !== null) {
|
|
@@ -2033,10 +2416,17 @@ export function createPostService(
|
|
|
2033
2416
|
// readable slug can still set one explicitly.
|
|
2034
2417
|
const titleForSlug =
|
|
2035
2418
|
format === "quote" ? undefined : (title ?? undefined);
|
|
2419
|
+
// Only a non-primary language earns a readable language suffix: the bare
|
|
2420
|
+
// namespace belongs to the primary language, and a suffix shared by two
|
|
2421
|
+
// same-language posts distinguishes nothing.
|
|
2422
|
+
const languageSuffix =
|
|
2423
|
+
language && language !== primary
|
|
2424
|
+
? toLanguagePrefix(language)
|
|
2425
|
+
: undefined;
|
|
2036
2426
|
|
|
2037
2427
|
if (data.path) {
|
|
2038
2428
|
const normalized = normalizePath(data.path);
|
|
2039
|
-
if (isReservedPath(normalized)) {
|
|
2429
|
+
if (isReservedPath(normalized, reservedPrefixes)) {
|
|
2040
2430
|
throw new ValidationError(
|
|
2041
2431
|
`Path "${normalized}" is reserved and cannot be used`,
|
|
2042
2432
|
);
|
|
@@ -2046,6 +2436,7 @@ export function createPostService(
|
|
|
2046
2436
|
slug = await generatePostSlug({
|
|
2047
2437
|
slug: normalized,
|
|
2048
2438
|
idLength: config.slugIdLength,
|
|
2439
|
+
reservedPrefixes,
|
|
2049
2440
|
isAvailable: isSlugAvailable,
|
|
2050
2441
|
});
|
|
2051
2442
|
} else {
|
|
@@ -2055,6 +2446,8 @@ export function createPostService(
|
|
|
2055
2446
|
slug: slugified || undefined,
|
|
2056
2447
|
title: titleForSlug,
|
|
2057
2448
|
idLength: config.slugIdLength,
|
|
2449
|
+
languageSuffix,
|
|
2450
|
+
reservedPrefixes,
|
|
2058
2451
|
isAvailable: isSlugAvailable,
|
|
2059
2452
|
});
|
|
2060
2453
|
// Verify the alias path is available before proceeding
|
|
@@ -2068,6 +2461,8 @@ export function createPostService(
|
|
|
2068
2461
|
slug: data.slug,
|
|
2069
2462
|
title: titleForSlug,
|
|
2070
2463
|
idLength: config.slugIdLength,
|
|
2464
|
+
languageSuffix,
|
|
2465
|
+
reservedPrefixes,
|
|
2071
2466
|
isAvailable: isSlugAvailable,
|
|
2072
2467
|
});
|
|
2073
2468
|
}
|
|
@@ -2127,6 +2522,23 @@ export function createPostService(
|
|
|
2127
2522
|
if (usesBatchWrites) {
|
|
2128
2523
|
const writeQueries = [];
|
|
2129
2524
|
|
|
2525
|
+
// Mint the group on the source Post in the same write as the
|
|
2526
|
+
// translation itself, so a failure never leaves a one-member group.
|
|
2527
|
+
if (sourceTranslationPostId && translationGroupId) {
|
|
2528
|
+
writeQueries.push(
|
|
2529
|
+
db
|
|
2530
|
+
.update(posts)
|
|
2531
|
+
.set({ translationGroupId, updatedAt: timestamp })
|
|
2532
|
+
.where(
|
|
2533
|
+
and(
|
|
2534
|
+
eq(posts.siteId, siteId),
|
|
2535
|
+
eq(posts.id, sourceTranslationPostId),
|
|
2536
|
+
isNull(posts.translationGroupId),
|
|
2537
|
+
),
|
|
2538
|
+
),
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2130
2542
|
writeQueries.push(
|
|
2131
2543
|
db.insert(posts).values({
|
|
2132
2544
|
id,
|
|
@@ -2147,6 +2559,8 @@ export function createPostService(
|
|
|
2147
2559
|
rating,
|
|
2148
2560
|
replyToId: data.replyToId ?? null,
|
|
2149
2561
|
threadId,
|
|
2562
|
+
language,
|
|
2563
|
+
translationGroupId,
|
|
2150
2564
|
quietReply: isQuietReply,
|
|
2151
2565
|
publishedAt,
|
|
2152
2566
|
lastActivityAt: publishedAt ?? timestamp,
|
|
@@ -2207,6 +2621,19 @@ export function createPostService(
|
|
|
2207
2621
|
);
|
|
2208
2622
|
} else {
|
|
2209
2623
|
await db.transaction(async (tx) => {
|
|
2624
|
+
if (sourceTranslationPostId && translationGroupId) {
|
|
2625
|
+
await tx
|
|
2626
|
+
.update(posts)
|
|
2627
|
+
.set({ translationGroupId, updatedAt: timestamp })
|
|
2628
|
+
.where(
|
|
2629
|
+
and(
|
|
2630
|
+
eq(posts.siteId, siteId),
|
|
2631
|
+
eq(posts.id, sourceTranslationPostId),
|
|
2632
|
+
isNull(posts.translationGroupId),
|
|
2633
|
+
),
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
|
|
2210
2637
|
await tx.insert(posts).values({
|
|
2211
2638
|
id,
|
|
2212
2639
|
siteId,
|
|
@@ -2226,6 +2653,8 @@ export function createPostService(
|
|
|
2226
2653
|
rating,
|
|
2227
2654
|
replyToId: data.replyToId ?? null,
|
|
2228
2655
|
threadId,
|
|
2656
|
+
language,
|
|
2657
|
+
translationGroupId,
|
|
2229
2658
|
quietReply: isQuietReply,
|
|
2230
2659
|
publishedAt,
|
|
2231
2660
|
lastActivityAt: publishedAt ?? timestamp,
|
|
@@ -2365,6 +2794,9 @@ export function createPostService(
|
|
|
2365
2794
|
...data,
|
|
2366
2795
|
// Chain each post as a reply to the previous one (server-side chaining)
|
|
2367
2796
|
replyToId: i === 0 ? data.replyToId : prevPost?.id,
|
|
2797
|
+
// Translation groups and languages are Thread-level: the root carries
|
|
2798
|
+
// them and every reply inherits, so later items must not restate them.
|
|
2799
|
+
translationOfId: i === 0 ? data.translationOfId : undefined,
|
|
2368
2800
|
quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
|
|
2369
2801
|
publishedAt:
|
|
2370
2802
|
i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
|
|
@@ -2397,6 +2829,17 @@ export function createPostService(
|
|
|
2397
2829
|
const existing = await this.getById(id);
|
|
2398
2830
|
if (!existing) return null;
|
|
2399
2831
|
|
|
2832
|
+
// Thread-wide, so it cannot ride along on the single-row write below.
|
|
2833
|
+
// Runs first: if the language is refused — the Thread's translation group
|
|
2834
|
+
// already holds it — nothing else should have been written either.
|
|
2835
|
+
if (
|
|
2836
|
+
data.language &&
|
|
2837
|
+
!existing.replyToId &&
|
|
2838
|
+
data.language !== existing.language
|
|
2839
|
+
) {
|
|
2840
|
+
await this.setThreadLanguage(id, data.language);
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2400
2843
|
const timestamp = now();
|
|
2401
2844
|
const nextFormat =
|
|
2402
2845
|
data.format !== undefined
|
|
@@ -2761,8 +3204,7 @@ export function createPostService(
|
|
|
2761
3204
|
],
|
|
2762
3205
|
);
|
|
2763
3206
|
updateResult = results[updateIdx] as
|
|
2764
|
-
|
|
2765
|
-
| undefined;
|
|
3207
|
+
(typeof posts.$inferSelect)[] | undefined;
|
|
2766
3208
|
} else {
|
|
2767
3209
|
await db.transaction(async (tx) => {
|
|
2768
3210
|
if (needsCascade) {
|
|
@@ -3154,6 +3596,397 @@ export function createPostService(
|
|
|
3154
3596
|
await recalculateThreadActivity(rootId);
|
|
3155
3597
|
},
|
|
3156
3598
|
|
|
3599
|
+
async setThreadLanguage(postId, language) {
|
|
3600
|
+
const normalized = normalizePostLanguage(language);
|
|
3601
|
+
if (!normalized) {
|
|
3602
|
+
throw new ValidationError("Choose a language for this post.");
|
|
3603
|
+
}
|
|
3604
|
+
|
|
3605
|
+
// A language the site does not publish has no view, no feed and no
|
|
3606
|
+
// switcher entry, so the post would simply vanish. Only checked once the
|
|
3607
|
+
// site actually has a language set to belong to.
|
|
3608
|
+
const { languages } = await readLanguageSettings(
|
|
3609
|
+
db,
|
|
3610
|
+
siteId,
|
|
3611
|
+
databaseSchema,
|
|
3612
|
+
);
|
|
3613
|
+
if (
|
|
3614
|
+
languages.length > 0 &&
|
|
3615
|
+
!languages.some(
|
|
3616
|
+
(tag) => toLanguagePrefix(tag) === toLanguagePrefix(normalized),
|
|
3617
|
+
)
|
|
3618
|
+
) {
|
|
3619
|
+
throw new ValidationError(
|
|
3620
|
+
"This site does not publish that language. Add it in Settings → Language first.",
|
|
3621
|
+
);
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
const rows = await db
|
|
3625
|
+
.select({
|
|
3626
|
+
threadId: posts.threadId,
|
|
3627
|
+
id: posts.id,
|
|
3628
|
+
translationGroupId: posts.translationGroupId,
|
|
3629
|
+
})
|
|
3630
|
+
.from(posts)
|
|
3631
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
3632
|
+
.limit(1);
|
|
3633
|
+
const row = rows[0];
|
|
3634
|
+
if (!row) {
|
|
3635
|
+
throw new NotFoundError("Post");
|
|
3636
|
+
}
|
|
3637
|
+
|
|
3638
|
+
// The group lives on the root, so a reply's Thread has to be resolved
|
|
3639
|
+
// before the translation-group check can run.
|
|
3640
|
+
const rootRows =
|
|
3641
|
+
row.id === row.threadId
|
|
3642
|
+
? [row]
|
|
3643
|
+
: await db
|
|
3644
|
+
.select({
|
|
3645
|
+
threadId: posts.threadId,
|
|
3646
|
+
id: posts.id,
|
|
3647
|
+
translationGroupId: posts.translationGroupId,
|
|
3648
|
+
})
|
|
3649
|
+
.from(posts)
|
|
3650
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, row.threadId)))
|
|
3651
|
+
.limit(1);
|
|
3652
|
+
const root = rootRows[0];
|
|
3653
|
+
if (root?.translationGroupId) {
|
|
3654
|
+
await assertTranslationLanguageFree(
|
|
3655
|
+
root.translationGroupId,
|
|
3656
|
+
normalized,
|
|
3657
|
+
root.id,
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
const timestamp = now();
|
|
3662
|
+
await db
|
|
3663
|
+
.update(posts)
|
|
3664
|
+
.set({ language: normalized, updatedAt: timestamp })
|
|
3665
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.threadId, row.threadId)));
|
|
3666
|
+
},
|
|
3667
|
+
|
|
3668
|
+
async materializeMissingLanguage(language) {
|
|
3669
|
+
const normalized = normalizePostLanguage(language);
|
|
3670
|
+
if (!normalized) {
|
|
3671
|
+
throw new ValidationError("Choose a primary language for this site.");
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
// Counted before the write rather than read back from it: the three
|
|
3675
|
+
// supported drivers report affected rows differently, and the caller only
|
|
3676
|
+
// needs the number for its confirmation copy.
|
|
3677
|
+
const pending = await this.countMissingLanguage();
|
|
3678
|
+
if (pending === 0) return 0;
|
|
3679
|
+
|
|
3680
|
+
await db
|
|
3681
|
+
.update(posts)
|
|
3682
|
+
.set({ language: normalized })
|
|
3683
|
+
.where(and(eq(posts.siteId, siteId), isNull(posts.language)));
|
|
3684
|
+
return pending;
|
|
3685
|
+
},
|
|
3686
|
+
|
|
3687
|
+
async countMissingLanguage() {
|
|
3688
|
+
const rows = await db
|
|
3689
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
|
|
3690
|
+
.from(posts)
|
|
3691
|
+
.where(and(eq(posts.siteId, siteId), isNull(posts.language)));
|
|
3692
|
+
return Number(rows[0]?.count ?? 0);
|
|
3693
|
+
},
|
|
3694
|
+
|
|
3695
|
+
async countByLanguage(language) {
|
|
3696
|
+
const normalized = normalizePostLanguage(language);
|
|
3697
|
+
if (!normalized) return 0;
|
|
3698
|
+
|
|
3699
|
+
const rows = await db
|
|
3700
|
+
.select({ count: sql<number>`CAST(count(*) AS INTEGER)` })
|
|
3701
|
+
.from(posts)
|
|
3702
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.language, normalized)));
|
|
3703
|
+
return Number(rows[0]?.count ?? 0);
|
|
3704
|
+
},
|
|
3705
|
+
|
|
3706
|
+
async listLanguagesInUse() {
|
|
3707
|
+
const rows = await db
|
|
3708
|
+
.select({
|
|
3709
|
+
language: posts.language,
|
|
3710
|
+
count: sql<number>`CAST(count(*) AS INTEGER)`,
|
|
3711
|
+
})
|
|
3712
|
+
.from(posts)
|
|
3713
|
+
.where(and(eq(posts.siteId, siteId), isNotNull(posts.language)))
|
|
3714
|
+
.groupBy(posts.language);
|
|
3715
|
+
return rows
|
|
3716
|
+
.filter((row): row is { language: string; count: number } =>
|
|
3717
|
+
Boolean(row.language),
|
|
3718
|
+
)
|
|
3719
|
+
.map((row) => ({ language: row.language, count: Number(row.count) }));
|
|
3720
|
+
},
|
|
3721
|
+
|
|
3722
|
+
async listTranslations(postId) {
|
|
3723
|
+
const map = await this.getTranslationsMap([postId]);
|
|
3724
|
+
return map.get(postId) ?? [];
|
|
3725
|
+
},
|
|
3726
|
+
|
|
3727
|
+
async listTranslationCandidates(postId, options) {
|
|
3728
|
+
const term = options.query.trim();
|
|
3729
|
+
if (!term) return [];
|
|
3730
|
+
const limit = options.limit ?? 8;
|
|
3731
|
+
|
|
3732
|
+
const source = await this.getById(postId);
|
|
3733
|
+
if (!source) return [];
|
|
3734
|
+
const root =
|
|
3735
|
+
source.replyToId === null
|
|
3736
|
+
? source
|
|
3737
|
+
: await this.getById(source.threadId);
|
|
3738
|
+
if (!root?.language) return [];
|
|
3739
|
+
|
|
3740
|
+
// Languages this Thread's group already speaks for, its own included.
|
|
3741
|
+
const taken = new Set([root.language]);
|
|
3742
|
+
if (root.translationGroupId) {
|
|
3743
|
+
for (const sibling of await this.listTranslations(root.id)) {
|
|
3744
|
+
if (sibling.language) taken.add(sibling.language);
|
|
3745
|
+
}
|
|
3746
|
+
}
|
|
3747
|
+
|
|
3748
|
+
// Escaped with an explicit ESCAPE clause: the two dialects disagree on
|
|
3749
|
+
// the default escape character (see `paths.findPathsUnderSegment`).
|
|
3750
|
+
const like = `%${term.replace(/[\\%_]/g, "\\$&")}%`;
|
|
3751
|
+
const conditions: SQL[] = [
|
|
3752
|
+
eq(posts.siteId, siteId),
|
|
3753
|
+
eq(posts.status, "published"),
|
|
3754
|
+
// Thread roots only: a translation links whole Threads.
|
|
3755
|
+
sql`${posts.id} = ${posts.threadId}`,
|
|
3756
|
+
sql`${posts.id} != ${root.id}`,
|
|
3757
|
+
isNotNull(posts.language),
|
|
3758
|
+
notInArray(posts.language, [...taken]),
|
|
3759
|
+
sql`(${posts.title} LIKE ${like} ESCAPE '\\' OR ${posts.bodyText} LIKE ${like} ESCAPE '\\')`,
|
|
3760
|
+
];
|
|
3761
|
+
|
|
3762
|
+
if (root.translationGroupId) {
|
|
3763
|
+
// Both sides having a group would ask for a merge, which is refused.
|
|
3764
|
+
conditions.push(isNull(posts.translationGroupId));
|
|
3765
|
+
} else {
|
|
3766
|
+
// The candidate may carry a group, as long as joining it would not put
|
|
3767
|
+
// two Posts of this language in it.
|
|
3768
|
+
conditions.push(
|
|
3769
|
+
sql`(${posts.translationGroupId} IS NULL OR NOT EXISTS (
|
|
3770
|
+
SELECT 1
|
|
3771
|
+
FROM post AS group_member
|
|
3772
|
+
WHERE group_member.site_id = ${siteId}
|
|
3773
|
+
AND group_member.translation_group_id = "post"."translation_group_id"
|
|
3774
|
+
AND group_member.language = ${root.language}
|
|
3775
|
+
))`,
|
|
3776
|
+
);
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
const rows = await db
|
|
3780
|
+
.select()
|
|
3781
|
+
.from(posts)
|
|
3782
|
+
.where(and(...conditions))
|
|
3783
|
+
.orderBy(desc(posts.publishedAt))
|
|
3784
|
+
.limit(limit);
|
|
3785
|
+
|
|
3786
|
+
return hydratePosts(rows);
|
|
3787
|
+
},
|
|
3788
|
+
|
|
3789
|
+
async resolveTranslationCandidate(postId, path) {
|
|
3790
|
+
const source = await this.getById(postId);
|
|
3791
|
+
if (!source) return { status: "not_found" };
|
|
3792
|
+
const root =
|
|
3793
|
+
source.replyToId === null
|
|
3794
|
+
? source
|
|
3795
|
+
: await this.getById(source.threadId);
|
|
3796
|
+
if (!root?.language) return { status: "not_found" };
|
|
3797
|
+
|
|
3798
|
+
const target = await resolvedPaths.resolveTarget(path);
|
|
3799
|
+
if (!target) return { status: "not_found" };
|
|
3800
|
+
if (target.targetType !== "post" || !target.postId) {
|
|
3801
|
+
return { status: "not_a_post" };
|
|
3802
|
+
}
|
|
3803
|
+
|
|
3804
|
+
const found = await this.getById(target.postId);
|
|
3805
|
+
if (!found) return { status: "not_found" };
|
|
3806
|
+
// Translations link whole Threads, so an address that lands on a reply
|
|
3807
|
+
// is taken as naming the Thread it belongs to.
|
|
3808
|
+
const candidate =
|
|
3809
|
+
found.replyToId === null ? found : await this.getById(found.threadId);
|
|
3810
|
+
if (!candidate) return { status: "not_found" };
|
|
3811
|
+
|
|
3812
|
+
if (candidate.id === root.id) return { status: "same_thread" };
|
|
3813
|
+
if (candidate.status !== "published") return { status: "unpublished" };
|
|
3814
|
+
if (!candidate.language) return { status: "no_language" };
|
|
3815
|
+
if (candidate.language === root.language) {
|
|
3816
|
+
return { status: "same_language" };
|
|
3817
|
+
}
|
|
3818
|
+
|
|
3819
|
+
const siblings = root.translationGroupId
|
|
3820
|
+
? await this.listTranslations(root.id)
|
|
3821
|
+
: [];
|
|
3822
|
+
if (siblings.some((post) => post.language === candidate.language)) {
|
|
3823
|
+
return { status: "language_taken", language: candidate.language };
|
|
3824
|
+
}
|
|
3825
|
+
|
|
3826
|
+
if (candidate.translationGroupId) {
|
|
3827
|
+
// `linkTranslation` refuses to merge two groups, and refuses to join
|
|
3828
|
+
// one that already speaks for this Thread's language.
|
|
3829
|
+
if (root.translationGroupId) return { status: "group_conflict" };
|
|
3830
|
+
|
|
3831
|
+
const theirGroup = await this.listTranslations(candidate.id);
|
|
3832
|
+
if (theirGroup.some((post) => post.language === root.language)) {
|
|
3833
|
+
return { status: "group_language_taken", language: root.language };
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
return { status: "ok", post: candidate };
|
|
3838
|
+
},
|
|
3839
|
+
|
|
3840
|
+
async getTranslationsMap(postIds) {
|
|
3841
|
+
const result = new Map<string, Post[]>();
|
|
3842
|
+
const uniqueIds = [...new Set(postIds)].filter(Boolean);
|
|
3843
|
+
if (uniqueIds.length === 0) return result;
|
|
3844
|
+
|
|
3845
|
+
const seedRows = await db
|
|
3846
|
+
.select({ id: posts.id, translationGroupId: posts.translationGroupId })
|
|
3847
|
+
.from(posts)
|
|
3848
|
+
.where(
|
|
3849
|
+
and(
|
|
3850
|
+
eq(posts.siteId, siteId),
|
|
3851
|
+
inArray(posts.id, uniqueIds),
|
|
3852
|
+
isNotNull(posts.translationGroupId),
|
|
3853
|
+
),
|
|
3854
|
+
);
|
|
3855
|
+
if (seedRows.length === 0) return result;
|
|
3856
|
+
|
|
3857
|
+
const groupIds = [
|
|
3858
|
+
...new Set(
|
|
3859
|
+
seedRows
|
|
3860
|
+
.map((row) => row.translationGroupId)
|
|
3861
|
+
.filter((groupId): groupId is string => groupId !== null),
|
|
3862
|
+
),
|
|
3863
|
+
];
|
|
3864
|
+
const memberRows = await db
|
|
3865
|
+
.select()
|
|
3866
|
+
.from(posts)
|
|
3867
|
+
.where(
|
|
3868
|
+
and(
|
|
3869
|
+
eq(posts.siteId, siteId),
|
|
3870
|
+
inArray(posts.translationGroupId, groupIds),
|
|
3871
|
+
),
|
|
3872
|
+
);
|
|
3873
|
+
const members = await hydratePosts(memberRows);
|
|
3874
|
+
const byGroup = new Map<string, Post[]>();
|
|
3875
|
+
for (const member of members) {
|
|
3876
|
+
const groupId = member.translationGroupId;
|
|
3877
|
+
if (!groupId) continue;
|
|
3878
|
+
const bucket = byGroup.get(groupId);
|
|
3879
|
+
if (bucket) bucket.push(member);
|
|
3880
|
+
else byGroup.set(groupId, [member]);
|
|
3881
|
+
}
|
|
3882
|
+
|
|
3883
|
+
for (const seed of seedRows) {
|
|
3884
|
+
const groupId = seed.translationGroupId;
|
|
3885
|
+
if (!groupId) continue;
|
|
3886
|
+
const siblings = (byGroup.get(groupId) ?? []).filter(
|
|
3887
|
+
(member) => member.id !== seed.id,
|
|
3888
|
+
);
|
|
3889
|
+
if (siblings.length > 0) result.set(seed.id, siblings);
|
|
3890
|
+
}
|
|
3891
|
+
return result;
|
|
3892
|
+
},
|
|
3893
|
+
|
|
3894
|
+
async linkTranslation(postId, otherPostId) {
|
|
3895
|
+
if (postId === otherPostId) {
|
|
3896
|
+
throw new ValidationError("Pick a different post to link.");
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
const [post, other] = await Promise.all([
|
|
3900
|
+
requireTranslatableRoot(postId),
|
|
3901
|
+
requireTranslatableRoot(otherPostId),
|
|
3902
|
+
]);
|
|
3903
|
+
|
|
3904
|
+
if (!post.language || !other.language) {
|
|
3905
|
+
throw new ValidationError(
|
|
3906
|
+
"Both posts need a language before they can be linked as translations.",
|
|
3907
|
+
);
|
|
3908
|
+
}
|
|
3909
|
+
if (post.language === other.language) {
|
|
3910
|
+
throw new ConflictError(
|
|
3911
|
+
"These two posts are written in the same language. Translations need different languages.",
|
|
3912
|
+
);
|
|
3913
|
+
}
|
|
3914
|
+
if (post.translationGroupId && other.translationGroupId) {
|
|
3915
|
+
if (post.translationGroupId === other.translationGroupId) return;
|
|
3916
|
+
throw new ConflictError(
|
|
3917
|
+
"Both posts already belong to translation groups. Unlink one of them first.",
|
|
3918
|
+
);
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
const timestamp = now();
|
|
3922
|
+
if (post.translationGroupId ?? other.translationGroupId) {
|
|
3923
|
+
const groupId = (post.translationGroupId ??
|
|
3924
|
+
other.translationGroupId) as string;
|
|
3925
|
+
const joiningId = post.translationGroupId ? other.id : post.id;
|
|
3926
|
+
const joiningLanguage = post.translationGroupId
|
|
3927
|
+
? other.language
|
|
3928
|
+
: post.language;
|
|
3929
|
+
await assertTranslationLanguageFree(groupId, joiningLanguage);
|
|
3930
|
+
await db
|
|
3931
|
+
.update(posts)
|
|
3932
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
3933
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, joiningId)));
|
|
3934
|
+
return;
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
const groupId = createEntityId("translationGroup");
|
|
3938
|
+
const setGroup = (targetId: string) =>
|
|
3939
|
+
db
|
|
3940
|
+
.update(posts)
|
|
3941
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
3942
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, targetId)));
|
|
3943
|
+
|
|
3944
|
+
if (usesBatchWrites) {
|
|
3945
|
+
await db.batch([setGroup(post.id), setGroup(other.id)]);
|
|
3946
|
+
} else {
|
|
3947
|
+
await db.transaction(async (tx) => {
|
|
3948
|
+
await tx
|
|
3949
|
+
.update(posts)
|
|
3950
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
3951
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
|
|
3952
|
+
await tx
|
|
3953
|
+
.update(posts)
|
|
3954
|
+
.set({ translationGroupId: groupId, updatedAt: timestamp })
|
|
3955
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, other.id)));
|
|
3956
|
+
});
|
|
3957
|
+
}
|
|
3958
|
+
},
|
|
3959
|
+
|
|
3960
|
+
async unlinkTranslation(postId) {
|
|
3961
|
+
const post = await requireTranslatableRoot(postId);
|
|
3962
|
+
const groupId = post.translationGroupId;
|
|
3963
|
+
if (!groupId) return;
|
|
3964
|
+
|
|
3965
|
+
const timestamp = now();
|
|
3966
|
+
await db
|
|
3967
|
+
.update(posts)
|
|
3968
|
+
.set({ translationGroupId: null, updatedAt: timestamp })
|
|
3969
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, post.id)));
|
|
3970
|
+
|
|
3971
|
+
// A group of one is indistinguishable from no group and would linger as
|
|
3972
|
+
// a dangling key, so collapse it as soon as the second-to-last member
|
|
3973
|
+
// leaves.
|
|
3974
|
+
const remaining = await db
|
|
3975
|
+
.select({ id: posts.id })
|
|
3976
|
+
.from(posts)
|
|
3977
|
+
.where(
|
|
3978
|
+
and(eq(posts.siteId, siteId), eq(posts.translationGroupId, groupId)),
|
|
3979
|
+
)
|
|
3980
|
+
.limit(2);
|
|
3981
|
+
const lastMember = remaining.length === 1 ? remaining[0] : undefined;
|
|
3982
|
+
if (lastMember) {
|
|
3983
|
+
await db
|
|
3984
|
+
.update(posts)
|
|
3985
|
+
.set({ translationGroupId: null, updatedAt: timestamp })
|
|
3986
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, lastMember.id)));
|
|
3987
|
+
}
|
|
3988
|
+
},
|
|
3989
|
+
|
|
3157
3990
|
async getReplyCounts(postIds) {
|
|
3158
3991
|
if (postIds.length === 0) return new Map();
|
|
3159
3992
|
|
|
@@ -3278,8 +4111,11 @@ export function createPostService(
|
|
|
3278
4111
|
.from(rankedReplies)
|
|
3279
4112
|
.where(
|
|
3280
4113
|
or(
|
|
3281
|
-
|
|
3282
|
-
|
|
4114
|
+
// One past the leading window: that extra row is never rendered,
|
|
4115
|
+
// it is only where the gap link points. Without it the gap has to
|
|
4116
|
+
// aim at a reply already on screen.
|
|
4117
|
+
lte(rankedReplies.firstReplyRank, THREAD_LEADING_REPLIES + 1),
|
|
4118
|
+
lte(rankedReplies.latestReplyRank, THREAD_TRAILING_REPLIES),
|
|
3283
4119
|
),
|
|
3284
4120
|
);
|
|
3285
4121
|
|
|
@@ -3293,6 +4129,7 @@ export function createPostService(
|
|
|
3293
4129
|
leadingReplyIds: Map<number, string>;
|
|
3294
4130
|
trailingReplyIds: Map<number, string>;
|
|
3295
4131
|
latestReplyId: string | null;
|
|
4132
|
+
firstHiddenReplyId: string | null;
|
|
3296
4133
|
totalReplyCount: number;
|
|
3297
4134
|
}
|
|
3298
4135
|
>();
|
|
@@ -3301,13 +4138,20 @@ export function createPostService(
|
|
|
3301
4138
|
leadingReplyIds: new Map<number, string>(),
|
|
3302
4139
|
trailingReplyIds: new Map<number, string>(),
|
|
3303
4140
|
latestReplyId: null,
|
|
4141
|
+
firstHiddenReplyId: null,
|
|
3304
4142
|
totalReplyCount: row.totalReplyCount,
|
|
3305
4143
|
};
|
|
3306
4144
|
|
|
3307
|
-
if (row.firstReplyRank <=
|
|
4145
|
+
if (row.firstReplyRank <= THREAD_LEADING_REPLIES) {
|
|
3308
4146
|
existing.leadingReplyIds.set(row.firstReplyRank, row.id);
|
|
3309
4147
|
}
|
|
3310
|
-
if (row.
|
|
4148
|
+
if (row.firstReplyRank === THREAD_LEADING_REPLIES + 1) {
|
|
4149
|
+
existing.firstHiddenReplyId = row.id;
|
|
4150
|
+
}
|
|
4151
|
+
if (
|
|
4152
|
+
row.latestReplyRank > 1 &&
|
|
4153
|
+
row.latestReplyRank <= THREAD_TRAILING_REPLIES
|
|
4154
|
+
) {
|
|
3311
4155
|
existing.trailingReplyIds.set(row.latestReplyRank, row.id);
|
|
3312
4156
|
}
|
|
3313
4157
|
if (row.latestReplyRank === 1) {
|
|
@@ -3329,16 +4173,36 @@ export function createPostService(
|
|
|
3329
4173
|
.map((id) => (id ? hydratedPosts.get(id) : undefined))
|
|
3330
4174
|
.filter((post): post is Post => post !== undefined);
|
|
3331
4175
|
|
|
4176
|
+
const leadingReplies = hydrateReplyIds(
|
|
4177
|
+
Array.from({ length: THREAD_LEADING_REPLIES }, (_unused, index) =>
|
|
4178
|
+
context.leadingReplyIds.get(index + 1),
|
|
4179
|
+
),
|
|
4180
|
+
);
|
|
4181
|
+
// Ranked newest-first, so walking down to rank 2 puts them back in
|
|
4182
|
+
// thread order. Rank 1 is the hero and is carried separately.
|
|
4183
|
+
const trailingReplies = hydrateReplyIds(
|
|
4184
|
+
Array.from(
|
|
4185
|
+
{ length: THREAD_TRAILING_REPLIES - 1 },
|
|
4186
|
+
(_unused, index) =>
|
|
4187
|
+
context.trailingReplyIds.get(THREAD_TRAILING_REPLIES - index),
|
|
4188
|
+
),
|
|
4189
|
+
);
|
|
4190
|
+
const firstHiddenReply =
|
|
4191
|
+
(context.firstHiddenReplyId
|
|
4192
|
+
? hydratedPosts.get(context.firstHiddenReplyId)
|
|
4193
|
+
: undefined) ?? null;
|
|
4194
|
+
|
|
3332
4195
|
result.set(threadId, {
|
|
3333
|
-
leadingReplies
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
context.trailingReplyIds.get(2),
|
|
3340
|
-
]),
|
|
4196
|
+
leadingReplies,
|
|
4197
|
+
// Left overlapping on purpose. These are the ranked buckets, not the
|
|
4198
|
+
// rendered fold: on a short thread a reply lands in both windows, and
|
|
4199
|
+
// dropping the duplicate is a presentation rule that `ThreadPreview`
|
|
4200
|
+
// and `getThreadHiddenCount` each already apply.
|
|
4201
|
+
trailingReplies,
|
|
3341
4202
|
latestReply,
|
|
4203
|
+
// Only a gap target: the query fetches it one past the leading
|
|
4204
|
+
// window, and nothing renders it.
|
|
4205
|
+
firstHiddenReply,
|
|
3342
4206
|
totalReplyCount: context.totalReplyCount,
|
|
3343
4207
|
});
|
|
3344
4208
|
}
|
|
@@ -3728,20 +4592,25 @@ export function createPostService(
|
|
|
3728
4592
|
const result = new Map<string, Post[]>();
|
|
3729
4593
|
if (rootIds.length === 0) return result;
|
|
3730
4594
|
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
4595
|
+
// A feed loads every Thread it renders at once, which can be hundreds.
|
|
4596
|
+
// Each Thread lands in one chunk, so its posts keep their order.
|
|
4597
|
+
const rows = await batchQueryRows([...new Set(rootIds)], (chunk) => {
|
|
4598
|
+
const conditions = [
|
|
4599
|
+
eq(posts.siteId, siteId),
|
|
4600
|
+
inArray(posts.threadId, chunk),
|
|
4601
|
+
eq(posts.status, "published"),
|
|
4602
|
+
];
|
|
4603
|
+
if (options.publishedBefore !== undefined) {
|
|
4604
|
+
conditions.push(
|
|
4605
|
+
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
4606
|
+
);
|
|
4607
|
+
}
|
|
4608
|
+
return db
|
|
4609
|
+
.select()
|
|
4610
|
+
.from(posts)
|
|
4611
|
+
.where(and(...conditions))
|
|
4612
|
+
.orderBy(posts.threadId, posts.createdAt, posts.id);
|
|
4613
|
+
});
|
|
3745
4614
|
|
|
3746
4615
|
for (const post of await hydratePosts(rows)) {
|
|
3747
4616
|
const thread = result.get(post.threadId);
|