@jant/core 0.6.14 → 0.6.16
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/bin/commands/import-site.js +88 -13
- package/bin/commands/migrate.js +55 -0
- package/bin/commands/posts/rebuild-html.js +184 -0
- package/bin/commands/site/snapshot/import.js +14 -10
- package/bin/lib/d1-query.js +23 -5
- package/bin/lib/migration-runner.js +29 -7
- package/bin/lib/node-database.js +32 -0
- package/bin/lib/site-snapshot.js +347 -46
- package/bin/lib/sql-export.js +1 -1
- package/bin/lib/thread-collection-migration.js +326 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C7_4oab9.js} +7774 -6425
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-BvID5ucz.js → client-Bq0vre8Y.js} +59 -38
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/client-auth-F2rb9WfF.js +5571 -0
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-DWn149b7.js → export-Dl5KCiEs.js} +1348 -128
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +7 -7
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +11 -10
- package/src/__tests__/bin/posts-rebuild-html.test.ts +137 -0
- package/src/__tests__/dev-navigation-defaults.test.ts +22 -0
- package/src/__tests__/export-hugo-build.test.ts +63 -4
- package/src/__tests__/export-import-roundtrip.test.ts +6 -4
- package/src/__tests__/export-service.test.ts +142 -9
- package/src/__tests__/helpers/app.ts +3 -0
- package/src/__tests__/helpers/export-fixtures.ts +2 -0
- package/src/__tests__/import-site-command.test.ts +65 -0
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/__tests__/vite-config.test.ts +45 -0
- package/src/app.tsx +16 -3
- package/src/client/__tests__/collection-created-notice.test.ts +36 -0
- package/src/client/__tests__/collection-form-bridge.test.ts +10 -1
- package/src/client/__tests__/collection-page-actions.test.ts +71 -2
- package/src/client/__tests__/compose-bridge.test.ts +316 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/feed-video-player.test.ts +16 -1
- package/src/client/__tests__/footnote-rail.test.ts +472 -0
- package/src/client/__tests__/hydrate-partial.test.ts +3 -0
- package/src/client/__tests__/toast.test.ts +82 -1
- package/src/client/collection-created-notice.ts +66 -0
- package/src/client/collection-form-bridge.ts +17 -0
- package/src/client/collection-navigation.ts +44 -0
- package/src/client/collection-page-actions.ts +57 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2268 -206
- package/src/client/components/__tests__/jant-compose-editor.test.ts +147 -59
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +114 -64
- package/src/client/components/__tests__/jant-config-editor.test.ts +405 -0
- package/src/client/components/__tests__/jant-media-lightbox.test.ts +121 -0
- package/src/client/components/__tests__/jant-nav-manager.test.ts +538 -2
- package/src/client/components/__tests__/jant-post-form.test.ts +2 -2
- package/src/client/components/__tests__/jant-post-menu.test.ts +47 -5
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-manager-types.ts +9 -3
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +55 -3
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +14 -7
- package/src/client/components/jant-compose-dialog.ts +1542 -694
- package/src/client/components/jant-compose-editor.ts +237 -99
- package/src/client/components/jant-compose-fullscreen.ts +27 -9
- package/src/client/components/jant-config-editor.ts +722 -0
- package/src/client/components/jant-media-lightbox.ts +100 -18
- package/src/client/components/jant-nav-manager.ts +1283 -54
- package/src/client/components/jant-post-form.ts +3 -3
- package/src/client/components/jant-post-menu.ts +177 -95
- package/src/client/components/nav-manager-types.ts +51 -2
- package/src/client/components/post-form-types.ts +1 -1
- package/src/client/compose-bridge.ts +147 -123
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/feed-video-player.ts +62 -2
- package/src/client/footnote-rail.ts +478 -0
- package/src/client/hydrate-partial.ts +2 -0
- package/src/client/media-lightbox-events.ts +63 -0
- package/src/client/post-refresh.ts +135 -0
- package/src/client/site-header-fragment.ts +38 -0
- package/src/client/tiptap/__tests__/blockquote-editing.test.ts +201 -0
- package/src/client/tiptap/__tests__/clear-formatting.test.ts +168 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/__tests__/list-editing.test.ts +447 -0
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +727 -4
- package/src/client/tiptap/__tests__/table-controls.test.ts +263 -0
- package/src/client/tiptap/__tests__/table-size-picker.test.ts +179 -0
- package/src/client/tiptap/bubble-menu.ts +88 -10
- package/src/client/tiptap/continuous-lists.ts +102 -0
- package/src/client/tiptap/create-editor.ts +3 -0
- package/src/client/tiptap/extensions.ts +14 -5
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/tiptap/markdown-clipboard.ts +608 -11
- package/src/client/tiptap/slash-commands.ts +8 -1
- package/src/client/tiptap/structural-keymap.ts +211 -0
- package/src/client/tiptap/tab-indent.ts +8 -22
- package/src/client/tiptap/table-control-labels.ts +30 -0
- package/src/client/tiptap/table-controls.ts +537 -0
- package/src/client/tiptap/table-size-picker.ts +254 -0
- package/src/client/toast.ts +133 -39
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -0
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/d1-query.test.ts +43 -1
- package/src/db/__tests__/migration-runner.test.ts +63 -0
- package/src/db/__tests__/migrations.test.ts +469 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/__tests__/thread-collection-migration.test.ts +364 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0027_old_blue_blade.sql +39 -0
- package/src/db/migrations/0028_superb_post.sql +2 -0
- package/src/db/migrations/0029_boring_magma.sql +1 -0
- package/src/db/migrations/0030_typical_vivisector.sql +50 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0027_snapshot.json +2511 -0
- package/src/db/migrations/meta/0028_snapshot.json +2511 -0
- package/src/db/migrations/meta/0029_snapshot.json +2519 -0
- package/src/db/migrations/meta/0030_snapshot.json +2541 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +35 -0
- package/src/db/migrations/pg/0025_moaning_epoch.sql +39 -0
- package/src/db/migrations/pg/0026_talented_killer_shrike.sql +2 -0
- package/src/db/migrations/pg/0027_short_cannonball.sql +1 -0
- package/src/db/migrations/pg/0028_perfect_sharon_carter.sql +27 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0025_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0026_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/0027_snapshot.json +3211 -0
- package/src/db/migrations/pg/meta/0028_snapshot.json +3248 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +35 -0
- package/src/db/pg/schema.ts +46 -20
- package/src/db/rehearsal-fixtures/demo-current.json +39 -4
- package/src/db/rehearsal-fixtures/demo-current.sql +121 -6
- package/src/db/schema.ts +48 -20
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +222 -51
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +216 -45
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +216 -45
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +513 -10
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +515 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +515 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/api-settings.test.ts +299 -0
- package/src/lib/__tests__/constants.test.ts +5 -0
- package/src/lib/__tests__/feed-policy.test.ts +25 -0
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/footnotes-upgrade.test.ts +181 -0
- package/src/lib/__tests__/markdown-roundtrip-embed.test.ts +61 -0
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +32 -4
- package/src/lib/__tests__/markdown.test.ts +24 -6
- package/src/lib/__tests__/navigation.test.ts +59 -2
- package/src/lib/__tests__/post-body-html.test.ts +176 -0
- package/src/lib/__tests__/post-display.test.ts +64 -0
- package/src/lib/__tests__/resolve-config.test.ts +113 -1
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/summary.test.ts +82 -0
- package/src/lib/__tests__/timeline.test.ts +109 -70
- package/src/lib/__tests__/timezones.test.ts +31 -3
- package/src/lib/__tests__/tiptap-render.test.ts +268 -9
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +6 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/__tests__/view.test.ts +53 -0
- package/src/lib/__tests__/worker-response-cache.test.ts +18 -1
- package/src/lib/api-settings.ts +239 -12
- package/src/lib/collection-sort.ts +7 -5
- package/src/lib/constants.ts +2 -0
- package/src/lib/feed-path.ts +25 -0
- package/src/lib/feed-policy.ts +64 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +15 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +193 -5
- package/src/lib/markdown.ts +10 -3
- package/src/lib/navigation.ts +16 -3
- package/src/lib/post-body-html.ts +131 -0
- package/src/lib/post-display.ts +47 -8
- package/src/lib/render.tsx +9 -2
- package/src/lib/resolve-config.ts +100 -16
- package/src/lib/schemas.ts +133 -1
- package/src/lib/settings-paths.ts +5 -0
- package/src/lib/site-skill.ts +78 -0
- package/src/lib/summary.ts +16 -5
- package/src/lib/timeline.ts +137 -131
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +359 -83
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +58 -20
- package/src/lib/worker-response-cache.ts +11 -18
- package/src/middleware/__tests__/public-content-access.test.ts +91 -0
- package/src/middleware/config.ts +2 -2
- package/src/middleware/public-content-access.ts +87 -0
- package/src/node/__tests__/cli-export.test.ts +14 -0
- package/src/node/__tests__/cli-migrate.test.ts +6 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +205 -2
- package/src/node/__tests__/cli-snapshot-meta.test.ts +100 -0
- package/src/node/index.ts +2 -1
- package/src/preset.css +16 -2
- package/src/routes/__tests__/compose.test.ts +51 -0
- package/src/routes/__tests__/site-skill.test.ts +112 -0
- package/src/routes/api/__tests__/collections.test.ts +91 -19
- package/src/routes/api/__tests__/mcp.test.ts +65 -1
- package/src/routes/api/__tests__/nav-items.test.ts +91 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/__tests__/public-access.test.ts +81 -0
- package/src/routes/api/__tests__/settings.test.ts +217 -0
- package/src/routes/api/collections.ts +22 -26
- package/src/routes/api/export.ts +2 -0
- package/src/routes/api/internal/__tests__/post-body-html.test.ts +163 -0
- package/src/routes/api/internal/__tests__/sites.test.ts +35 -2
- package/src/routes/api/internal/post-body-html.ts +43 -0
- package/src/routes/api/internal/sites.ts +24 -0
- package/src/routes/api/nav-items.ts +24 -1
- package/src/routes/api/posts.ts +14 -10
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +246 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +48 -24
- package/src/routes/api/search.ts +3 -0
- package/src/routes/api/settings.ts +33 -0
- package/src/routes/auth/__tests__/setup.test.ts +7 -4
- package/src/routes/compose.tsx +8 -3
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +384 -3
- package/src/routes/pages/__tests__/collection-routing.test.ts +63 -5
- package/src/routes/pages/__tests__/collections.test.ts +9 -9
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +176 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +90 -22
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +86 -11
- package/src/routes/site-skill.ts +18 -0
- package/src/services/__tests__/auth.test.ts +7 -5
- package/src/services/__tests__/collection.test.ts +190 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +249 -39
- package/src/services/__tests__/post.test.ts +844 -5
- package/src/services/__tests__/search.test.ts +48 -1
- package/src/services/__tests__/settings.test.ts +8 -0
- package/src/services/__tests__/site-admin.test.ts +121 -1
- package/src/services/auth.ts +2 -2
- package/src/services/bootstrap.ts +3 -2
- package/src/services/collection.ts +224 -167
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export-theme/assets/client-site.js +46 -25
- package/src/services/export-theme/layouts/_default/list.html +18 -4
- package/src/services/export-theme/layouts/_default/rss.xml +8 -12
- package/src/services/export-theme/layouts/featured/list.html +5 -4
- package/src/services/export-theme/layouts/partials/featured-thread.html +56 -0
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/media-gallery.html +1 -0
- package/src/services/export-theme/styles/main.css +30 -2
- package/src/services/export.ts +94 -85
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +1142 -342
- package/src/services/search.ts +17 -2
- package/src/services/settings.ts +2 -10
- package/src/services/site-admin.ts +63 -43
- package/src/style-cjk-jp.css +1 -1
- package/src/style-cjk-kr.css +1 -1
- package/src/style-cjk-tc.css +1 -1
- package/src/style-cjk.css +1 -1
- package/src/styles/components.css +540 -14
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +76 -10
- package/src/styles/ui.css +2227 -1347
- package/src/types/bindings.ts +3 -0
- package/src/types/config.ts +243 -12
- package/src/types/constants.ts +22 -1
- package/src/types/entities.ts +14 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +20 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +22 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +109 -19
- package/src/ui/dash/appearance/NavigationContent.tsx +332 -4
- package/src/ui/dash/appearance/__tests__/NavigationContent.test.tsx +23 -0
- package/src/ui/dash/settings/ConfigEditorContent.tsx +578 -0
- package/src/ui/dash/settings/GeneralContent.tsx +15 -5
- package/src/ui/dash/settings/SettingsRootContent.tsx +21 -1
- package/src/ui/dash/settings/__tests__/ConfigEditorContent.test.tsx +98 -0
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +15 -0
- package/src/ui/feed/CuratedThreadPreview.tsx +16 -4
- package/src/ui/feed/LinkCard.tsx +2 -12
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/NoteCard.tsx +10 -20
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/feed/QuoteCard.tsx +2 -12
- package/src/ui/feed/ThreadPreview.tsx +26 -34
- package/src/ui/feed/TimelineFeed.tsx +2 -2
- package/src/ui/feed/__tests__/thread-preview.test.ts +197 -56
- package/src/ui/feed/__tests__/timeline-cards.test.ts +178 -3
- package/src/ui/feed/thread-preview-state.ts +5 -7
- package/src/ui/font-themes.ts +74 -26
- package/src/ui/layouts/BaseLayout.tsx +22 -8
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +64 -1
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/CollectionPage.tsx +101 -22
- package/src/ui/pages/CollectionsPage.tsx +3 -1
- package/src/ui/pages/PostPage.tsx +39 -12
- package/src/ui/pages/SearchPage.tsx +6 -14
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +70 -0
- package/src/ui/pages/__tests__/PostPage.test.tsx +88 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -9
- package/src/ui/shared/CollectionsManager.tsx +17 -7
- package/src/ui/shared/DraftPreviewBar.tsx +49 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +38 -13
- package/src/ui/shared/__tests__/home-page-branding.test.tsx +1 -1
- package/src/ui/shared/__tests__/media-gallery.test.ts +1 -0
- package/src/ui/shared/__tests__/navigation-labels.test.ts +2 -2
- package/src/ui/shared/__tests__/post-footer.test.ts +15 -0
- package/src/ui/shared/collection-management-labels.ts +33 -2
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +52 -0
- package/dist/app-DZDlmh7C.js +0 -6
- package/dist/client/_assets/client-Cevfpm8H.css +0 -2
- package/dist/client/_assets/client-auth-v3yvhbgZ.js +0 -4736
- package/src/client/tiptap/wrapping-input-rules.ts +0 -102
package/src/services/post.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
asc,
|
|
21
21
|
lte,
|
|
22
22
|
gt,
|
|
23
|
+
getTableColumns,
|
|
23
24
|
} from "drizzle-orm";
|
|
24
25
|
import {
|
|
25
26
|
type Database,
|
|
@@ -31,14 +32,25 @@ import {
|
|
|
31
32
|
sqliteSchemaBundle,
|
|
32
33
|
type DatabaseSchema,
|
|
33
34
|
} from "../db/schema-bundle.js";
|
|
35
|
+
import {
|
|
36
|
+
buildRootActivityExpr,
|
|
37
|
+
rootActivityColumns,
|
|
38
|
+
} from "../db/thread-activity.js";
|
|
34
39
|
import { createEntityId } from "../lib/ids.js";
|
|
35
40
|
import { now } from "../lib/time.js";
|
|
36
|
-
import {
|
|
41
|
+
import { trimTiptapBody } from "../lib/tiptap-render.js";
|
|
42
|
+
import {
|
|
43
|
+
POST_BODY_HTML_VERSION,
|
|
44
|
+
renderPostBodyHtml,
|
|
45
|
+
resolvePostBodyHtml,
|
|
46
|
+
tryPreparePostBodyHtml,
|
|
47
|
+
} from "../lib/post-body-html.js";
|
|
37
48
|
import { extractSummary, extractBodyText } from "../lib/summary.js";
|
|
38
49
|
import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
|
|
39
50
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
40
51
|
import { generatePostSlug } from "../lib/slug.js";
|
|
41
52
|
import { getSlugValidationIssue } from "../lib/slug-format.js";
|
|
53
|
+
import { isReservedPath } from "../lib/constants.js";
|
|
42
54
|
import { normalizePath, slugify } from "../lib/url.js";
|
|
43
55
|
import type { StorageDriver } from "../lib/storage.js";
|
|
44
56
|
import type { MediaService } from "./media.js";
|
|
@@ -73,6 +85,9 @@ import {
|
|
|
73
85
|
} from "../lib/youtube.js";
|
|
74
86
|
import { getPreviewStorageKey } from "../lib/upload.js";
|
|
75
87
|
import { generateRandomId } from "../lib/nanoid.js";
|
|
88
|
+
import { resolveSummaryConfig } from "../lib/resolve-config.js";
|
|
89
|
+
import type { Bindings } from "../types/bindings.js";
|
|
90
|
+
import type { SettingsService } from "./settings.js";
|
|
76
91
|
|
|
77
92
|
/** Dependencies for operations that coordinate with other services */
|
|
78
93
|
export interface PostDeleteDeps {
|
|
@@ -80,6 +95,32 @@ export interface PostDeleteDeps {
|
|
|
80
95
|
storage?: StorageDriver | null;
|
|
81
96
|
}
|
|
82
97
|
|
|
98
|
+
export interface RebuildBodyHtmlOptions {
|
|
99
|
+
/** Batch size (1..100, default 50). */
|
|
100
|
+
limit?: number;
|
|
101
|
+
/** Exclusive post ID cursor. */
|
|
102
|
+
cursor?: string;
|
|
103
|
+
/** Compute and report changes without writing them. */
|
|
104
|
+
dryRun?: boolean;
|
|
105
|
+
/** Summary settings used only when a canonical legacy body is upgraded. */
|
|
106
|
+
summaryConfig?: SummaryConfig;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface RebuildBodyHtmlResult {
|
|
110
|
+
processed: number;
|
|
111
|
+
wouldRebuild: number;
|
|
112
|
+
rebuilt: number;
|
|
113
|
+
wouldUpgradeFootnotes: number;
|
|
114
|
+
upgradedFootnotes: number;
|
|
115
|
+
skipped: number;
|
|
116
|
+
conflicted: number;
|
|
117
|
+
failed: number;
|
|
118
|
+
failures: Array<{ postId: string; error: string }>;
|
|
119
|
+
nextCursor: string | null;
|
|
120
|
+
done: boolean;
|
|
121
|
+
targetVersion: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
83
124
|
export interface PostAttachmentDeps extends PostDeleteDeps {
|
|
84
125
|
storageDriver: string;
|
|
85
126
|
maxFileSizeMB: number;
|
|
@@ -104,6 +145,14 @@ export interface PostFilters {
|
|
|
104
145
|
publishedAfter?: number;
|
|
105
146
|
/** Unix timestamp (exclusive) — only posts published before this time */
|
|
106
147
|
publishedBefore?: number;
|
|
148
|
+
/**
|
|
149
|
+
* Unix timestamp (inclusive) — range filter on whichever column `sortBy`
|
|
150
|
+
* selects. Kept separate from `publishedAfter` so a caller can pin a
|
|
151
|
+
* publication window (the RSS delay) and still filter on the sort axis.
|
|
152
|
+
*/
|
|
153
|
+
axisAfter?: number;
|
|
154
|
+
/** Unix timestamp (exclusive) — upper bound on the `sortBy` column */
|
|
155
|
+
axisBefore?: number;
|
|
107
156
|
/** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
|
|
108
157
|
mediaKinds?: MediaKind[];
|
|
109
158
|
/** Filter by media presence */
|
|
@@ -119,7 +168,21 @@ export interface PostFilters {
|
|
|
119
168
|
hasReplies?: boolean;
|
|
120
169
|
/** Explicit result sort order */
|
|
121
170
|
sortOrder?: SortOrder;
|
|
122
|
-
/**
|
|
171
|
+
/**
|
|
172
|
+
* Time axis for chronological queries (defaults to announced activity).
|
|
173
|
+
*
|
|
174
|
+
* - `activity` — `lastActivityAt`: last announced post. Quiet replies do not
|
|
175
|
+
* move it. Latest and the feeds use this.
|
|
176
|
+
* - `thread_updated` — `threadUpdatedAt`: last post of any kind, quiet
|
|
177
|
+
* included. The archive's updated sort uses this.
|
|
178
|
+
* - `published` — when the Thread root itself was published.
|
|
179
|
+
*
|
|
180
|
+
* Selects the column used for sorting **and** for the year/month bucketing
|
|
181
|
+
* done by `countByYearMonth` and `getDistinctYears`, so a caller that groups
|
|
182
|
+
* its results cannot end up with buckets that disagree with the order.
|
|
183
|
+
*/
|
|
184
|
+
sortBy?: "activity" | "thread_updated" | "published";
|
|
185
|
+
/** Ignore global pinned ordering when results must remain chronological. */
|
|
123
186
|
ignorePinnedSort?: boolean;
|
|
124
187
|
limit?: number;
|
|
125
188
|
cursor?: string;
|
|
@@ -135,6 +198,13 @@ export interface SummaryConfig {
|
|
|
135
198
|
interface ThreadRootPageOptions {
|
|
136
199
|
status?: Status;
|
|
137
200
|
excludePrivate?: boolean;
|
|
201
|
+
excludeLatestHidden?: boolean;
|
|
202
|
+
/** Exclude Posts published at or after this Unix timestamp. */
|
|
203
|
+
publishedBefore?: number;
|
|
204
|
+
/** Restrict by the Thread root's format without excluding Child Posts. */
|
|
205
|
+
rootFormat?: Format;
|
|
206
|
+
/** Restrict to Threads with at least one rated published post. */
|
|
207
|
+
hasRating?: true;
|
|
138
208
|
limit?: number;
|
|
139
209
|
offset?: number;
|
|
140
210
|
}
|
|
@@ -146,6 +216,8 @@ interface CollectionFeedEntryOptions extends ThreadRootPageOptions {
|
|
|
146
216
|
|
|
147
217
|
interface CollectionThreadRootPageOptions extends ThreadRootPageOptions {
|
|
148
218
|
sortOrder?: CollectionSortOrder;
|
|
219
|
+
/** Root Post ID returned by the previous page. */
|
|
220
|
+
cursor?: string;
|
|
149
221
|
}
|
|
150
222
|
|
|
151
223
|
interface CursorSortKey {
|
|
@@ -159,6 +231,24 @@ export interface CollectionFeedEntry {
|
|
|
159
231
|
collectedAt: number;
|
|
160
232
|
}
|
|
161
233
|
|
|
234
|
+
export interface FeaturedThreadTimelinePost {
|
|
235
|
+
post: Post;
|
|
236
|
+
/** Zero-based position among the Thread's published Posts. */
|
|
237
|
+
position: number;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Bounded projection for one curated Featured Thread.
|
|
242
|
+
*
|
|
243
|
+
* `posts` contains only the Root, every Featured Post, and the final published
|
|
244
|
+
* Post, in Thread order. Positions preserve the hidden-gap counts without
|
|
245
|
+
* loading the omitted Posts or their related metadata.
|
|
246
|
+
*/
|
|
247
|
+
export interface FeaturedThreadTimelineData {
|
|
248
|
+
posts: FeaturedThreadTimelinePost[];
|
|
249
|
+
featuredPostIds: string[];
|
|
250
|
+
}
|
|
251
|
+
|
|
162
252
|
export interface PostBodyContent {
|
|
163
253
|
id: string;
|
|
164
254
|
type: "post";
|
|
@@ -223,7 +313,7 @@ export interface PostService {
|
|
|
223
313
|
count(filters?: PostFilters): Promise<number>;
|
|
224
314
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
225
315
|
countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
|
|
226
|
-
/** Count posts grouped by
|
|
316
|
+
/** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
|
|
227
317
|
countByYearMonth(
|
|
228
318
|
filters?: PostFilters,
|
|
229
319
|
): Promise<{ yearMonth: string; count: number }[]>;
|
|
@@ -237,6 +327,8 @@ export interface PostService {
|
|
|
237
327
|
/**
|
|
238
328
|
* Atomically create a thread of posts. The first item is the root; each
|
|
239
329
|
* subsequent item is automatically chained as a reply to the previous one.
|
|
330
|
+
* When the first item quietly extends an existing thread, the quiet behavior
|
|
331
|
+
* applies to every new reply in the batch.
|
|
240
332
|
* On failure, all already-created posts are rolled back.
|
|
241
333
|
*
|
|
242
334
|
* @param items - Ordered list of (data, attachments) pairs; at least 2 required
|
|
@@ -278,6 +370,21 @@ export interface PostService {
|
|
|
278
370
|
*/
|
|
279
371
|
deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
280
372
|
getThread(rootId: string): Promise<Post[]>;
|
|
373
|
+
/**
|
|
374
|
+
* 1-based position of a Post in the reply chain running from its Thread root
|
|
375
|
+
* down to it: a root is 1, a reply to it is 2, a reply to that is 3.
|
|
376
|
+
*
|
|
377
|
+
* Threads are stored as a tree (`replyToId`) flattened under one `threadId`,
|
|
378
|
+
* so this is the Post's depth, not how many Posts the Thread holds — two
|
|
379
|
+
* replies to the same parent are both at the same position.
|
|
380
|
+
*
|
|
381
|
+
* @param postId - TypeID of the Post to locate
|
|
382
|
+
* @returns The 1-based position, or 0 when no such Post exists
|
|
383
|
+
* @example
|
|
384
|
+
* // Root -> reply -> reply, asked about the last one
|
|
385
|
+
* await posts.getThreadPosition(lastId); // => 3
|
|
386
|
+
*/
|
|
387
|
+
getThreadPosition(postId: string): Promise<number>;
|
|
281
388
|
updateThreadStatusAndVisibility(
|
|
282
389
|
rootId: string,
|
|
283
390
|
status: Status,
|
|
@@ -296,8 +403,12 @@ export interface PostService {
|
|
|
296
403
|
): Promise<Map<string, ThreadTimelineContext>>;
|
|
297
404
|
/** Count distinct thread roots that contain featured published posts */
|
|
298
405
|
countFeaturedThreadRoots(options?: ThreadRootPageOptions): Promise<number>;
|
|
299
|
-
/** List
|
|
406
|
+
/** List Featured Thread roots by the latest selected Post publication time. */
|
|
300
407
|
listFeaturedThreadRootIds(options?: ThreadRootPageOptions): Promise<string[]>;
|
|
408
|
+
/** Load only the Posts rendered by the curated Featured Thread timeline. */
|
|
409
|
+
getFeaturedThreadTimelineData(
|
|
410
|
+
rootIds: string[],
|
|
411
|
+
): Promise<Map<string, FeaturedThreadTimelineData>>;
|
|
301
412
|
/** Count distinct thread roots that contain published posts in the given collection */
|
|
302
413
|
countCollectionThreadRoots(
|
|
303
414
|
collectionId: string,
|
|
@@ -308,6 +419,12 @@ export interface PostService {
|
|
|
308
419
|
collectionIds: string[],
|
|
309
420
|
options?: ThreadRootPageOptions,
|
|
310
421
|
): Promise<number>;
|
|
422
|
+
/** Count distinct matching collection Threads up to a fixed limit. */
|
|
423
|
+
countCollectionThreadRootsUpToForCollections(
|
|
424
|
+
collectionIds: string[],
|
|
425
|
+
options: ThreadRootPageOptions | undefined,
|
|
426
|
+
limit: number,
|
|
427
|
+
): Promise<number>;
|
|
311
428
|
/** List collection thread root IDs ordered by collected-at or rating semantics */
|
|
312
429
|
listCollectionThreadRootIds(
|
|
313
430
|
collectionId: string,
|
|
@@ -318,6 +435,11 @@ export interface PostService {
|
|
|
318
435
|
collectionIds: string[],
|
|
319
436
|
options?: CollectionThreadRootPageOptions,
|
|
320
437
|
): Promise<string[]>;
|
|
438
|
+
/** List and hydrate collection Thread roots using Thread-level ordering. */
|
|
439
|
+
listCollectionThreadRootsForCollections(
|
|
440
|
+
collectionIds: string[],
|
|
441
|
+
options?: CollectionThreadRootPageOptions,
|
|
442
|
+
): Promise<Post[]>;
|
|
321
443
|
/** List collection feed entries ordered by latest added-at timestamp */
|
|
322
444
|
listCollectionFeedEntries(
|
|
323
445
|
collectionId: string,
|
|
@@ -328,22 +450,36 @@ export interface PostService {
|
|
|
328
450
|
collectionIds: string[],
|
|
329
451
|
options?: CollectionFeedEntryOptions,
|
|
330
452
|
): Promise<CollectionFeedEntry[]>;
|
|
331
|
-
/** Fetch
|
|
332
|
-
getPublishedThreads(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
): Promise<Map<string, string[]>>;
|
|
338
|
-
/** For each thread, return post IDs that belong to any of the given collections */
|
|
339
|
-
getCollectionPostIdsByThreadForCollections(
|
|
340
|
-
collectionIds: string[],
|
|
341
|
-
threadIds: string[],
|
|
342
|
-
): Promise<Map<string, string[]>>;
|
|
343
|
-
/** Get distinct years that have published posts */
|
|
453
|
+
/** Fetch published Posts for each requested Thread root. */
|
|
454
|
+
getPublishedThreads(
|
|
455
|
+
rootIds: string[],
|
|
456
|
+
options?: Pick<PostFilters, "publishedBefore">,
|
|
457
|
+
): Promise<Map<string, Post[]>>;
|
|
458
|
+
/** Get distinct years with posts, bucketed on the `sortBy` time axis */
|
|
344
459
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
345
|
-
/**
|
|
346
|
-
|
|
460
|
+
/**
|
|
461
|
+
* For each Thread ID, resolve the Post that currently ends the chain.
|
|
462
|
+
*
|
|
463
|
+
* Two callers need two different answers. Readers ask what the audience can
|
|
464
|
+
* see, so drafts must not count — the Reply affordance belongs on the last
|
|
465
|
+
* published Post. The reply guard asks what the chain physically ends with,
|
|
466
|
+
* and there an unpublished draft still owns the slot: attaching a second
|
|
467
|
+
* reply to the same parent would fork a structure that is meant to be linear.
|
|
468
|
+
*
|
|
469
|
+
* @param threadIds - Thread root IDs to resolve (duplicates are fine)
|
|
470
|
+
* @param options - `includeDrafts` counts unpublished members as the tail
|
|
471
|
+
* @returns Map of Thread root ID to its tail Post ID; threads with no
|
|
472
|
+
* matching member are absent from the map
|
|
473
|
+
* @example
|
|
474
|
+
* ```ts
|
|
475
|
+
* const tails = await posts.getThreadTailIds([root.id], { includeDrafts: true });
|
|
476
|
+
* const tailId = tails.get(root.id);
|
|
477
|
+
* ```
|
|
478
|
+
*/
|
|
479
|
+
getThreadTailIds(
|
|
480
|
+
threadIds: string[],
|
|
481
|
+
options?: { includeDrafts?: boolean },
|
|
482
|
+
): Promise<Map<string, string>>;
|
|
347
483
|
/**
|
|
348
484
|
* Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
|
|
349
485
|
* by post id. For each row, recomputes the plain-text extraction via
|
|
@@ -366,6 +502,44 @@ export interface PostService {
|
|
|
366
502
|
nextCursor: string | null;
|
|
367
503
|
done: boolean;
|
|
368
504
|
}>;
|
|
505
|
+
/**
|
|
506
|
+
* Upgrade recognized legacy footnotes and rebuild stored post body HTML for
|
|
507
|
+
* this site.
|
|
508
|
+
*
|
|
509
|
+
* Updates use compare-and-swap guards so concurrent edits win. Editorial
|
|
510
|
+
* timestamps are never touched. The operation is cursor-paginated,
|
|
511
|
+
* idempotent, and supports a read-only dry run.
|
|
512
|
+
*/
|
|
513
|
+
rebuildBodyHtml(
|
|
514
|
+
options?: RebuildBodyHtmlOptions,
|
|
515
|
+
): Promise<RebuildBodyHtmlResult>;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Rebuild body HTML using the site's current runtime summary settings.
|
|
520
|
+
*
|
|
521
|
+
* Internal maintenance routes do not run behind the normal app-config
|
|
522
|
+
* middleware, so this service-level orchestration preserves the same
|
|
523
|
+
* DB → environment → built-in resolution used by regular requests.
|
|
524
|
+
*
|
|
525
|
+
* @param services - Site-scoped post and settings services.
|
|
526
|
+
* @param env - Runtime environment bindings.
|
|
527
|
+
* @param options - Rebuild options supplied by the maintenance caller.
|
|
528
|
+
* @returns Rebuild progress and outcome counts.
|
|
529
|
+
*/
|
|
530
|
+
export async function rebuildPostBodyHtmlWithRuntimeSettings(
|
|
531
|
+
services: Pick<
|
|
532
|
+
{ posts: PostService; settings: SettingsService },
|
|
533
|
+
"posts" | "settings"
|
|
534
|
+
>,
|
|
535
|
+
env: Bindings,
|
|
536
|
+
options: Omit<RebuildBodyHtmlOptions, "summaryConfig"> = {},
|
|
537
|
+
): Promise<RebuildBodyHtmlResult> {
|
|
538
|
+
const allSettings = await services.settings.getAll();
|
|
539
|
+
return services.posts.rebuildBodyHtml({
|
|
540
|
+
...options,
|
|
541
|
+
summaryConfig: resolveSummaryConfig(env, allSettings),
|
|
542
|
+
});
|
|
369
543
|
}
|
|
370
544
|
|
|
371
545
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
@@ -521,20 +695,32 @@ export function createPostService(
|
|
|
521
695
|
databaseSchema: DatabaseSchema = sqliteSchemaBundle,
|
|
522
696
|
): PostService {
|
|
523
697
|
const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
|
|
524
|
-
const { pathRegistry, posts,
|
|
698
|
+
const { navItems, pathRegistry, posts, sites, threadCollections } =
|
|
699
|
+
databaseSchema;
|
|
525
700
|
const databaseDialect = config.databaseDialect ?? "sqlite";
|
|
526
701
|
const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
|
|
527
702
|
|
|
528
|
-
|
|
703
|
+
/**
|
|
704
|
+
* Column that carries the time axis selected by `PostFilters.sortBy`.
|
|
705
|
+
* Sorting, year/month bucketing, and date-range filters all read it through
|
|
706
|
+
* here so they can never drift onto different columns.
|
|
707
|
+
*/
|
|
708
|
+
function timeAxisColumn(filters: Pick<PostFilters, "sortBy">) {
|
|
709
|
+
if (filters.sortBy === "activity") return posts.lastActivityAt;
|
|
710
|
+
if (filters.sortBy === "thread_updated") return posts.threadUpdatedAt;
|
|
711
|
+
return posts.publishedAt;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function buildYearMonthExpr(column: SQLWrapper): SQL<string> {
|
|
529
715
|
return databaseDialect === "pg"
|
|
530
|
-
? sql<string>`to_char(timezone('UTC', to_timestamp(${
|
|
531
|
-
: sql<string>`strftime('%Y-%m', ${
|
|
716
|
+
? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY-MM')`
|
|
717
|
+
: sql<string>`strftime('%Y-%m', ${column}, 'unixepoch')`;
|
|
532
718
|
}
|
|
533
719
|
|
|
534
|
-
function
|
|
720
|
+
function buildYearExpr(column: SQLWrapper): SQL<string> {
|
|
535
721
|
return databaseDialect === "pg"
|
|
536
|
-
? sql<string>`to_char(timezone('UTC', to_timestamp(${
|
|
537
|
-
: sql<string>`strftime('%Y', ${
|
|
722
|
+
? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')`
|
|
723
|
+
: sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
|
|
538
724
|
}
|
|
539
725
|
|
|
540
726
|
const effectiveVisibilityExpr = sql<string>`coalesce(
|
|
@@ -575,28 +761,58 @@ export function createPostService(
|
|
|
575
761
|
return rows.length > 0;
|
|
576
762
|
}
|
|
577
763
|
|
|
578
|
-
|
|
764
|
+
/**
|
|
765
|
+
* Recompute both Thread activity timestamps on the root from the Thread's
|
|
766
|
+
* rows.
|
|
767
|
+
*
|
|
768
|
+
* - `lastActivityAt` — newest published post excluding quiet replies. This
|
|
769
|
+
* is "last announced" and drives Latest and the feeds.
|
|
770
|
+
* - `threadUpdatedAt` — newest published post including quiet replies. This
|
|
771
|
+
* is "last changed" and drives the archive's updated sort.
|
|
772
|
+
*
|
|
773
|
+
* Both are derived, never accumulated, so a quiet reply stays quiet no
|
|
774
|
+
* matter what later edits or deletions trigger a recalculation.
|
|
775
|
+
*
|
|
776
|
+
* @param rootId - Thread root post ID
|
|
777
|
+
*/
|
|
778
|
+
async function recalculateThreadActivity(rootId: string): Promise<void> {
|
|
579
779
|
const rootRows = await db
|
|
580
780
|
.select({
|
|
581
|
-
|
|
582
|
-
|
|
781
|
+
announcedAt: sql<number | null>`MAX(
|
|
782
|
+
CASE
|
|
783
|
+
WHEN ${posts.quietReply} THEN NULL
|
|
784
|
+
ELSE ${posts.publishedAt}
|
|
785
|
+
END
|
|
786
|
+
)`.as("announced_at"),
|
|
787
|
+
updatedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
|
|
788
|
+
"thread_updated_at",
|
|
583
789
|
),
|
|
584
790
|
})
|
|
585
791
|
.from(posts)
|
|
586
|
-
.where(
|
|
792
|
+
.where(
|
|
793
|
+
and(
|
|
794
|
+
eq(posts.siteId, siteId),
|
|
795
|
+
eq(posts.threadId, rootId),
|
|
796
|
+
eq(posts.status, "published"),
|
|
797
|
+
),
|
|
798
|
+
);
|
|
587
799
|
|
|
588
|
-
const
|
|
800
|
+
const announcedAt = rootRows[0]?.announcedAt ?? null;
|
|
801
|
+
const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
|
|
589
802
|
const root = await db
|
|
590
803
|
.select({ updatedAt: posts.updatedAt })
|
|
591
804
|
.from(posts)
|
|
592
805
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)))
|
|
593
806
|
.limit(1);
|
|
594
807
|
|
|
595
|
-
const
|
|
808
|
+
const fallback = root[0]?.updatedAt ?? now();
|
|
596
809
|
|
|
597
810
|
await db
|
|
598
811
|
.update(posts)
|
|
599
|
-
.set({
|
|
812
|
+
.set({
|
|
813
|
+
lastActivityAt: announcedAt ?? fallback,
|
|
814
|
+
threadUpdatedAt: threadUpdatedAt ?? fallback,
|
|
815
|
+
})
|
|
600
816
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
|
|
601
817
|
}
|
|
602
818
|
|
|
@@ -695,11 +911,11 @@ export function createPostService(
|
|
|
695
911
|
}
|
|
696
912
|
|
|
697
913
|
return uniqueCollectionIds.length === 1
|
|
698
|
-
? eq(
|
|
699
|
-
: inArray(
|
|
914
|
+
? eq(threadCollections.collectionId, firstCollectionId)
|
|
915
|
+
: inArray(threadCollections.collectionId, uniqueCollectionIds);
|
|
700
916
|
}
|
|
701
917
|
|
|
702
|
-
function
|
|
918
|
+
function buildThreadCollectionSubqueryCondition(
|
|
703
919
|
collectionIds: readonly string[],
|
|
704
920
|
): SQL<unknown> {
|
|
705
921
|
const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
|
|
@@ -710,9 +926,9 @@ export function createPostService(
|
|
|
710
926
|
const placeholders = uniqueCollectionIds.map(
|
|
711
927
|
(collectionId) => sql`${collectionId}`,
|
|
712
928
|
);
|
|
713
|
-
return sql`${posts.
|
|
714
|
-
SELECT
|
|
715
|
-
FROM
|
|
929
|
+
return sql`${posts.threadId} IN (
|
|
930
|
+
SELECT thread_id
|
|
931
|
+
FROM thread_collection
|
|
716
932
|
WHERE site_id = ${siteId}
|
|
717
933
|
AND collection_id IN (${sql.join(placeholders, sql`, `)})
|
|
718
934
|
)`;
|
|
@@ -753,11 +969,11 @@ export function createPostService(
|
|
|
753
969
|
}
|
|
754
970
|
if (filters.collectionIds !== undefined) {
|
|
755
971
|
conditions.push(
|
|
756
|
-
|
|
972
|
+
buildThreadCollectionSubqueryCondition(filters.collectionIds),
|
|
757
973
|
);
|
|
758
974
|
} else if (filters.collectionId !== undefined) {
|
|
759
975
|
conditions.push(
|
|
760
|
-
|
|
976
|
+
buildThreadCollectionSubqueryCondition([filters.collectionId]),
|
|
761
977
|
);
|
|
762
978
|
}
|
|
763
979
|
if (filters.threadId) {
|
|
@@ -772,6 +988,15 @@ export function createPostService(
|
|
|
772
988
|
if (filters.publishedBefore !== undefined) {
|
|
773
989
|
conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
|
|
774
990
|
}
|
|
991
|
+
if (filters.axisAfter !== undefined || filters.axisBefore !== undefined) {
|
|
992
|
+
const axis = timeAxisColumn(filters);
|
|
993
|
+
if (filters.axisAfter !== undefined) {
|
|
994
|
+
conditions.push(sql`${axis} >= ${filters.axisAfter}`);
|
|
995
|
+
}
|
|
996
|
+
if (filters.axisBefore !== undefined) {
|
|
997
|
+
conditions.push(sql`${axis} < ${filters.axisBefore}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
775
1000
|
if (filters.mediaKinds && filters.mediaKinds.length > 0) {
|
|
776
1001
|
const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
|
|
777
1002
|
conditions.push(
|
|
@@ -784,19 +1009,13 @@ export function createPostService(
|
|
|
784
1009
|
);
|
|
785
1010
|
}
|
|
786
1011
|
if (filters.hasMedia !== undefined) {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
conditions.push(
|
|
795
|
-
sql`${posts.id} NOT IN (
|
|
796
|
-
SELECT post_id FROM media WHERE site_id = ${siteId}
|
|
797
|
-
)`,
|
|
798
|
-
);
|
|
799
|
-
}
|
|
1012
|
+
const mediaExists = sql`EXISTS (
|
|
1013
|
+
SELECT 1
|
|
1014
|
+
FROM media
|
|
1015
|
+
WHERE site_id = ${siteId}
|
|
1016
|
+
AND post_id = ${posts.id}
|
|
1017
|
+
)`;
|
|
1018
|
+
conditions.push(filters.hasMedia ? mediaExists : sql`NOT ${mediaExists}`);
|
|
800
1019
|
}
|
|
801
1020
|
if (filters.hasTitle !== undefined) {
|
|
802
1021
|
if (filters.hasTitle) {
|
|
@@ -831,21 +1050,38 @@ export function createPostService(
|
|
|
831
1050
|
return conditions;
|
|
832
1051
|
}
|
|
833
1052
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
.
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
.
|
|
843
|
-
|
|
844
|
-
return
|
|
1053
|
+
function getCursorSortTimestamp(
|
|
1054
|
+
row: typeof posts.$inferSelect,
|
|
1055
|
+
filters: PostFilters,
|
|
1056
|
+
): number {
|
|
1057
|
+
if (filters.sortBy === "published") {
|
|
1058
|
+
return row.publishedAt ?? row.createdAt;
|
|
1059
|
+
}
|
|
1060
|
+
if (filters.sortBy === "thread_updated") {
|
|
1061
|
+
return row.threadUpdatedAt ?? -1;
|
|
1062
|
+
}
|
|
1063
|
+
return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
|
|
845
1064
|
}
|
|
846
1065
|
|
|
847
|
-
|
|
848
|
-
|
|
1066
|
+
/**
|
|
1067
|
+
* Chronological sort key for `list()`.
|
|
1068
|
+
*
|
|
1069
|
+
* Shared by the ORDER BY and the keyset cursor comparison — they must read
|
|
1070
|
+
* the same expression or pagination silently skips or repeats rows.
|
|
1071
|
+
*/
|
|
1072
|
+
function buildSortTimestampExpr(filters: PostFilters): SQLWrapper {
|
|
1073
|
+
if (filters.sortBy === "published") {
|
|
1074
|
+
return sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`;
|
|
1075
|
+
}
|
|
1076
|
+
if (filters.sortBy === "thread_updated") {
|
|
1077
|
+
return posts.threadUpdatedAt;
|
|
1078
|
+
}
|
|
1079
|
+
if (filters.status === "draft") return posts.updatedAt;
|
|
1080
|
+
if (filters.status === "published") return posts.lastActivityAt;
|
|
1081
|
+
return sql<number>`CASE
|
|
1082
|
+
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1083
|
+
ELSE ${posts.lastActivityAt}
|
|
1084
|
+
END`;
|
|
849
1085
|
}
|
|
850
1086
|
|
|
851
1087
|
function buildLexicographicCursorCondition(
|
|
@@ -887,17 +1123,11 @@ export function createPostService(
|
|
|
887
1123
|
return null;
|
|
888
1124
|
}
|
|
889
1125
|
|
|
890
|
-
const sortTimestampExpr =
|
|
891
|
-
filters.status === "draft"
|
|
892
|
-
? posts.updatedAt
|
|
893
|
-
: filters.status === "published"
|
|
894
|
-
? posts.lastActivityAt
|
|
895
|
-
: sql<number>`CASE
|
|
896
|
-
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
897
|
-
ELSE ${posts.lastActivityAt}
|
|
898
|
-
END`;
|
|
1126
|
+
const sortTimestampExpr = buildSortTimestampExpr(filters);
|
|
899
1127
|
const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
|
|
900
|
-
const
|
|
1128
|
+
const featuredPublishedSortExpr = sql<number>`coalesce(
|
|
1129
|
+
${posts.publishedAt}, ${posts.createdAt}, -1
|
|
1130
|
+
)`;
|
|
901
1131
|
const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestampExpr}, -1)`;
|
|
902
1132
|
const ratingPresenceExpr = sql<number>`CASE
|
|
903
1133
|
WHEN ${posts.rating} IS NULL THEN 0
|
|
@@ -905,8 +1135,9 @@ export function createPostService(
|
|
|
905
1135
|
END`;
|
|
906
1136
|
const ratingSortExpr = sql<number>`coalesce(${posts.rating}, -1)`;
|
|
907
1137
|
const cursorPinnedAt = cursorPost.pinnedAt ?? -1;
|
|
908
|
-
const
|
|
909
|
-
|
|
1138
|
+
const cursorFeaturedPublishedAt =
|
|
1139
|
+
cursorPost.publishedAt ?? cursorPost.createdAt;
|
|
1140
|
+
const cursorSortTimestamp = getCursorSortTimestamp(cursorPost, filters);
|
|
910
1141
|
const cursorRatingPresence = cursorPost.rating === null ? 0 : 1;
|
|
911
1142
|
const cursorRating = cursorPost.rating ?? -1;
|
|
912
1143
|
const withPinnedSortKey = (
|
|
@@ -925,8 +1156,8 @@ export function createPostService(
|
|
|
925
1156
|
withPinnedSortKey([
|
|
926
1157
|
{
|
|
927
1158
|
direction: "desc",
|
|
928
|
-
expr:
|
|
929
|
-
value:
|
|
1159
|
+
expr: featuredPublishedSortExpr,
|
|
1160
|
+
value: cursorFeaturedPublishedAt,
|
|
930
1161
|
},
|
|
931
1162
|
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
932
1163
|
]),
|
|
@@ -1012,7 +1243,12 @@ export function createPostService(
|
|
|
1012
1243
|
title: row.title,
|
|
1013
1244
|
url: row.url,
|
|
1014
1245
|
body: row.body,
|
|
1015
|
-
bodyHtml:
|
|
1246
|
+
bodyHtml: resolvePostBodyHtml({
|
|
1247
|
+
id: row.id,
|
|
1248
|
+
body: row.body,
|
|
1249
|
+
bodyHtml: row.bodyHtml,
|
|
1250
|
+
bodyHtmlVersion: row.bodyHtmlVersion,
|
|
1251
|
+
}),
|
|
1016
1252
|
bodyText: row.bodyText,
|
|
1017
1253
|
quoteText: row.quoteText,
|
|
1018
1254
|
summary: row.summary,
|
|
@@ -1022,8 +1258,14 @@ export function createPostService(
|
|
|
1022
1258
|
previewProvider: row.previewProvider,
|
|
1023
1259
|
replyToId: row.replyToId,
|
|
1024
1260
|
threadId: row.threadId,
|
|
1261
|
+
quietReply: row.quietReply,
|
|
1025
1262
|
publishedAt: row.publishedAt,
|
|
1026
1263
|
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
1264
|
+
threadUpdatedAt:
|
|
1265
|
+
row.threadUpdatedAt ??
|
|
1266
|
+
row.lastActivityAt ??
|
|
1267
|
+
row.publishedAt ??
|
|
1268
|
+
row.updatedAt,
|
|
1027
1269
|
createdAt: row.createdAt,
|
|
1028
1270
|
updatedAt: row.updatedAt,
|
|
1029
1271
|
};
|
|
@@ -1122,15 +1364,50 @@ export function createPostService(
|
|
|
1122
1364
|
if (options?.excludePrivate) {
|
|
1123
1365
|
conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
|
|
1124
1366
|
}
|
|
1367
|
+
if (options?.excludeLatestHidden) {
|
|
1368
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
|
|
1369
|
+
}
|
|
1370
|
+
if (options?.publishedBefore !== undefined) {
|
|
1371
|
+
conditions.push(
|
|
1372
|
+
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
1373
|
+
sql`EXISTS (
|
|
1374
|
+
SELECT 1
|
|
1375
|
+
FROM post AS publication_root
|
|
1376
|
+
WHERE publication_root.site_id = ${siteId}
|
|
1377
|
+
AND publication_root.id = ${posts.threadId}
|
|
1378
|
+
AND publication_root.published_at < ${options.publishedBefore}
|
|
1379
|
+
)`,
|
|
1380
|
+
);
|
|
1381
|
+
}
|
|
1382
|
+
if (options?.rootFormat) {
|
|
1383
|
+
conditions.push(sql`EXISTS (
|
|
1384
|
+
SELECT 1
|
|
1385
|
+
FROM post AS collection_root
|
|
1386
|
+
WHERE collection_root.site_id = ${siteId}
|
|
1387
|
+
AND collection_root.id = ${posts.threadId}
|
|
1388
|
+
AND collection_root.format = ${options.rootFormat}
|
|
1389
|
+
)`);
|
|
1390
|
+
}
|
|
1391
|
+
if (options?.hasRating) {
|
|
1392
|
+
conditions.push(isNotNull(posts.rating));
|
|
1393
|
+
}
|
|
1125
1394
|
|
|
1126
1395
|
return conditions;
|
|
1127
1396
|
}
|
|
1128
1397
|
|
|
1398
|
+
/**
|
|
1399
|
+
* Thread activity timestamp used to order collection threads.
|
|
1400
|
+
*
|
|
1401
|
+
* Rows here are Thread *members* grouped by thread_id, so the definition is
|
|
1402
|
+
* reached through a subquery to the root. The outer MAX only collapses the
|
|
1403
|
+
* group — the subquery returns one value per thread. The outer COALESCE
|
|
1404
|
+
* covers a member whose root row is missing.
|
|
1405
|
+
*/
|
|
1129
1406
|
function buildCollectionThreadActivityExpr(alias: string) {
|
|
1130
1407
|
return sql<number>`MAX(
|
|
1131
1408
|
COALESCE(
|
|
1132
1409
|
(
|
|
1133
|
-
SELECT root
|
|
1410
|
+
SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
|
|
1134
1411
|
FROM post AS root
|
|
1135
1412
|
WHERE root.site_id = ${siteId}
|
|
1136
1413
|
AND root.id = ${posts.threadId}
|
|
@@ -1141,6 +1418,59 @@ export function createPostService(
|
|
|
1141
1418
|
)`.as(alias);
|
|
1142
1419
|
}
|
|
1143
1420
|
|
|
1421
|
+
function buildCollectionThreadSortQuery(
|
|
1422
|
+
collectionIds: string[],
|
|
1423
|
+
options: CollectionThreadRootPageOptions,
|
|
1424
|
+
) {
|
|
1425
|
+
const conditions = [
|
|
1426
|
+
...buildThreadRootPageConditions(options),
|
|
1427
|
+
buildCollectionMembershipCondition(collectionIds),
|
|
1428
|
+
];
|
|
1429
|
+
const sortOrder = options.sortOrder ?? "newest";
|
|
1430
|
+
const publishedAt =
|
|
1431
|
+
sortOrder === "oldest"
|
|
1432
|
+
? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
|
|
1433
|
+
: sql<number>`MAX(${posts.publishedAt})`.as("published_at");
|
|
1434
|
+
const threadActivityAt =
|
|
1435
|
+
buildCollectionThreadActivityExpr("thread_activity_at");
|
|
1436
|
+
const ratingPresence = sql<number>`MAX(
|
|
1437
|
+
CASE
|
|
1438
|
+
WHEN ${posts.rating} IS NULL THEN 0
|
|
1439
|
+
ELSE 1
|
|
1440
|
+
END
|
|
1441
|
+
)`.as("rating_presence");
|
|
1442
|
+
const ratingValue = sql<number>`MAX(coalesce(${posts.rating}, -1))`.as(
|
|
1443
|
+
"rating_value",
|
|
1444
|
+
);
|
|
1445
|
+
const collectionPinnedAt =
|
|
1446
|
+
sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
|
|
1447
|
+
"collection_pinned_at",
|
|
1448
|
+
);
|
|
1449
|
+
|
|
1450
|
+
const sortedThreads = db
|
|
1451
|
+
.select({
|
|
1452
|
+
threadId: posts.threadId,
|
|
1453
|
+
publishedAt,
|
|
1454
|
+
threadActivityAt,
|
|
1455
|
+
collectionPinnedAt,
|
|
1456
|
+
ratingPresence,
|
|
1457
|
+
ratingValue,
|
|
1458
|
+
})
|
|
1459
|
+
.from(posts)
|
|
1460
|
+
.innerJoin(
|
|
1461
|
+
threadCollections,
|
|
1462
|
+
and(
|
|
1463
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
1464
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
1465
|
+
),
|
|
1466
|
+
)
|
|
1467
|
+
.where(and(...conditions))
|
|
1468
|
+
.groupBy(posts.threadId)
|
|
1469
|
+
.as("collection_thread_sort");
|
|
1470
|
+
|
|
1471
|
+
return { sortOrder, sortedThreads };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1144
1474
|
function isMediaAttachmentInput(
|
|
1145
1475
|
attachment: PostAttachmentInput,
|
|
1146
1476
|
): attachment is Extract<PostAttachmentInput, { type: "media" }> {
|
|
@@ -1210,36 +1540,50 @@ export function createPostService(
|
|
|
1210
1540
|
await deps.media.deleteByIds(mediaIds, deps.storage).catch(() => undefined);
|
|
1211
1541
|
}
|
|
1212
1542
|
|
|
1213
|
-
async function
|
|
1543
|
+
async function getCollectionIdsForThread(
|
|
1544
|
+
threadId: string,
|
|
1545
|
+
): Promise<string[]> {
|
|
1214
1546
|
const rows = await db
|
|
1215
|
-
.select({ collectionId:
|
|
1216
|
-
.from(
|
|
1547
|
+
.select({ collectionId: threadCollections.collectionId })
|
|
1548
|
+
.from(threadCollections)
|
|
1217
1549
|
.where(
|
|
1218
1550
|
and(
|
|
1219
|
-
eq(
|
|
1220
|
-
eq(
|
|
1551
|
+
eq(threadCollections.siteId, siteId),
|
|
1552
|
+
eq(threadCollections.threadId, threadId),
|
|
1221
1553
|
),
|
|
1222
1554
|
);
|
|
1223
1555
|
|
|
1224
1556
|
return rows.map((row) => row.collectionId);
|
|
1225
1557
|
}
|
|
1226
1558
|
|
|
1227
|
-
async function
|
|
1228
|
-
|
|
1229
|
-
|
|
1559
|
+
async function getThreadCollectionEntries(threadId: string): Promise<
|
|
1560
|
+
Map<
|
|
1561
|
+
string,
|
|
1562
|
+
{
|
|
1563
|
+
createdAt: number;
|
|
1564
|
+
pinnedAt: number | null;
|
|
1565
|
+
}
|
|
1566
|
+
>
|
|
1567
|
+
> {
|
|
1230
1568
|
const rows = await db
|
|
1231
1569
|
.select({
|
|
1232
|
-
collectionId:
|
|
1233
|
-
createdAt:
|
|
1570
|
+
collectionId: threadCollections.collectionId,
|
|
1571
|
+
createdAt: threadCollections.createdAt,
|
|
1572
|
+
pinnedAt: threadCollections.pinnedAt,
|
|
1234
1573
|
})
|
|
1235
|
-
.from(
|
|
1574
|
+
.from(threadCollections)
|
|
1236
1575
|
.where(
|
|
1237
1576
|
and(
|
|
1238
|
-
eq(
|
|
1239
|
-
eq(
|
|
1577
|
+
eq(threadCollections.siteId, siteId),
|
|
1578
|
+
eq(threadCollections.threadId, threadId),
|
|
1240
1579
|
),
|
|
1241
1580
|
);
|
|
1242
|
-
return new Map(
|
|
1581
|
+
return new Map(
|
|
1582
|
+
rows.map((row) => [
|
|
1583
|
+
row.collectionId,
|
|
1584
|
+
{ createdAt: row.createdAt, pinnedAt: row.pinnedAt },
|
|
1585
|
+
]),
|
|
1586
|
+
);
|
|
1243
1587
|
}
|
|
1244
1588
|
|
|
1245
1589
|
function buildRollbackUpdate(
|
|
@@ -1323,15 +1667,7 @@ export function createPostService(
|
|
|
1323
1667
|
if (filters.cursor && !cursorCondition) {
|
|
1324
1668
|
return [];
|
|
1325
1669
|
}
|
|
1326
|
-
const sortTimestamp =
|
|
1327
|
-
filters.status === "draft"
|
|
1328
|
-
? posts.updatedAt
|
|
1329
|
-
: filters.status === "published"
|
|
1330
|
-
? posts.lastActivityAt
|
|
1331
|
-
: sql<number>`CASE
|
|
1332
|
-
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1333
|
-
ELSE ${posts.lastActivityAt}
|
|
1334
|
-
END`;
|
|
1670
|
+
const sortTimestamp = buildSortTimestampExpr(filters);
|
|
1335
1671
|
|
|
1336
1672
|
if (cursorCondition) {
|
|
1337
1673
|
conditions.push(cursorCondition);
|
|
@@ -1344,10 +1680,13 @@ export function createPostService(
|
|
|
1344
1680
|
|
|
1345
1681
|
// NULL-sort order differs between dialects: SQLite puts NULLs last for
|
|
1346
1682
|
// DESC, Postgres puts them first. Wrap nullable sort keys in COALESCE
|
|
1347
|
-
// so pinned
|
|
1683
|
+
// so pinned and nullable timeline values sort identically under both
|
|
1684
|
+
// engines. Mirrors the
|
|
1348
1685
|
// expressions used by `buildListCursorCondition` above.
|
|
1349
1686
|
const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
|
|
1350
|
-
const
|
|
1687
|
+
const featuredPublishedSortExpr = sql<number>`coalesce(
|
|
1688
|
+
${posts.publishedAt}, ${posts.createdAt}, -1
|
|
1689
|
+
)`;
|
|
1351
1690
|
const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestamp}, -1)`;
|
|
1352
1691
|
const pinnedOrder = filters.ignorePinnedSort
|
|
1353
1692
|
? []
|
|
@@ -1364,7 +1703,7 @@ export function createPostService(
|
|
|
1364
1703
|
? baseQuery.orderBy(
|
|
1365
1704
|
...pinnedOrder,
|
|
1366
1705
|
filters.featured
|
|
1367
|
-
? desc(
|
|
1706
|
+
? desc(featuredPublishedSortExpr)
|
|
1368
1707
|
: desc(sortTimestampSortExpr),
|
|
1369
1708
|
desc(posts.id),
|
|
1370
1709
|
)
|
|
@@ -1410,10 +1749,30 @@ export function createPostService(
|
|
|
1410
1749
|
conditions.push(sql`${posts.id} > ${afterId}`);
|
|
1411
1750
|
}
|
|
1412
1751
|
|
|
1752
|
+
// `lastmod` describes the page, and a Thread root's page renders its
|
|
1753
|
+
// replies too — so an added or edited reply changes it even though the
|
|
1754
|
+
// root row is untouched. Unlike ordering, edits do count here: the page
|
|
1755
|
+
// really did change. Mirrors `articleModifiedTime` in lib/post-display.
|
|
1756
|
+
//
|
|
1757
|
+
// The outer columns are written out rather than interpolated: Drizzle
|
|
1758
|
+
// only qualifies projection columns when the query has a join, so
|
|
1759
|
+
// `${posts.id}` would render as a bare "id" here and bind to the
|
|
1760
|
+
// subquery's own alias instead of correlating to the outer row.
|
|
1761
|
+
const threadModifiedAt = sql<number>`COALESCE(
|
|
1762
|
+
(
|
|
1763
|
+
SELECT MAX(member.updated_at)
|
|
1764
|
+
FROM post AS member
|
|
1765
|
+
WHERE member.site_id = ${siteId}
|
|
1766
|
+
AND member.thread_id = "post"."id"
|
|
1767
|
+
AND member.status = 'published'
|
|
1768
|
+
),
|
|
1769
|
+
"post"."updated_at"
|
|
1770
|
+
)`;
|
|
1771
|
+
|
|
1413
1772
|
const rows = await db
|
|
1414
1773
|
.select({
|
|
1415
1774
|
id: posts.id,
|
|
1416
|
-
updatedAt:
|
|
1775
|
+
updatedAt: threadModifiedAt,
|
|
1417
1776
|
featuredAt: posts.featuredAt,
|
|
1418
1777
|
})
|
|
1419
1778
|
.from(posts)
|
|
@@ -1503,11 +1862,9 @@ export function createPostService(
|
|
|
1503
1862
|
},
|
|
1504
1863
|
|
|
1505
1864
|
async countByYearMonth(filters = {}) {
|
|
1506
|
-
const
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
];
|
|
1510
|
-
const publishedYearMonthExpr = buildPublishedYearMonthExpr();
|
|
1865
|
+
const axis = timeAxisColumn(filters);
|
|
1866
|
+
const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
|
|
1867
|
+
const publishedYearMonthExpr = buildYearMonthExpr(axis);
|
|
1511
1868
|
|
|
1512
1869
|
return db
|
|
1513
1870
|
.select({
|
|
@@ -1531,11 +1888,17 @@ export function createPostService(
|
|
|
1531
1888
|
? ensurePostVisibility(data.visibility)
|
|
1532
1889
|
: undefined;
|
|
1533
1890
|
const rating = ensurePostRating(data.rating);
|
|
1891
|
+
// Only replies can be quiet — a root has nothing to stay quiet about.
|
|
1892
|
+
const isQuietReply = Boolean(data.replyToId) && data.quietReply === true;
|
|
1534
1893
|
|
|
1535
1894
|
const rawBody = data.bodyMarkdown
|
|
1536
1895
|
? markdownToTiptapJson(data.bodyMarkdown)
|
|
1537
1896
|
: (data.body ?? null);
|
|
1538
|
-
const
|
|
1897
|
+
const trimmedBody = rawBody ? trimTiptapBody(rawBody) : null;
|
|
1898
|
+
const preparedBody = trimmedBody
|
|
1899
|
+
? tryPreparePostBodyHtml(id, trimmedBody)
|
|
1900
|
+
: null;
|
|
1901
|
+
const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
|
|
1539
1902
|
const title = data.title?.trim() || null;
|
|
1540
1903
|
const quoteText = data.quoteText?.trim() || null;
|
|
1541
1904
|
const url = data.url?.trim() || null;
|
|
@@ -1551,7 +1914,11 @@ export function createPostService(
|
|
|
1551
1914
|
: undefined,
|
|
1552
1915
|
});
|
|
1553
1916
|
|
|
1554
|
-
const bodyHtml =
|
|
1917
|
+
const bodyHtml = preparedBody?.ok
|
|
1918
|
+
? preparedBody.html
|
|
1919
|
+
: body
|
|
1920
|
+
? renderPostBodyHtml(id, body)
|
|
1921
|
+
: null;
|
|
1555
1922
|
const bodyText = body
|
|
1556
1923
|
? extractBodyText(body, { includeLinkHrefs: true })
|
|
1557
1924
|
: null;
|
|
@@ -1603,10 +1970,19 @@ export function createPostService(
|
|
|
1603
1970
|
);
|
|
1604
1971
|
}
|
|
1605
1972
|
|
|
1606
|
-
|
|
1607
|
-
|
|
1973
|
+
// Drafts count here: an unpublished reply already owns the tail slot,
|
|
1974
|
+
// and a sibling attached to the same parent would fork the chain.
|
|
1975
|
+
const tailId = (
|
|
1976
|
+
await this.getThreadTailIds([parent.threadId], {
|
|
1977
|
+
includeDrafts: true,
|
|
1978
|
+
})
|
|
1979
|
+
).get(parent.threadId);
|
|
1980
|
+
if (tailId && tailId !== parent.id) {
|
|
1981
|
+
const tail = await this.getById(tailId);
|
|
1608
1982
|
throw new ConflictError(
|
|
1609
|
-
|
|
1983
|
+
tail?.status === "draft"
|
|
1984
|
+
? "This thread ends with an unpublished draft. Finish that draft or discard it, then reply."
|
|
1985
|
+
: "This post is no longer the end of the thread. Reply to the latest post instead.",
|
|
1610
1986
|
);
|
|
1611
1987
|
}
|
|
1612
1988
|
|
|
@@ -1617,12 +1993,29 @@ export function createPostService(
|
|
|
1617
1993
|
parent.threadId === parent.id
|
|
1618
1994
|
? parent
|
|
1619
1995
|
: await this.getById(parent.threadId);
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1996
|
+
// A reply takes the root's status. A draft earlier in the chain does
|
|
1997
|
+
// not hold it back: readers never saw that post, so publishing past it
|
|
1998
|
+
// reads as continuous, and the draft stays parked until it is dealt
|
|
1999
|
+
// with on its own.
|
|
2000
|
+
if (root && data.status !== "draft") {
|
|
2001
|
+
status = root.status;
|
|
1624
2002
|
}
|
|
1625
2003
|
visibility = null;
|
|
2004
|
+
|
|
2005
|
+
if (
|
|
2006
|
+
(data.collectionIds?.length ?? 0) > 0 ||
|
|
2007
|
+
(data.collectionEntries?.length ?? 0) > 0
|
|
2008
|
+
) {
|
|
2009
|
+
throw new ConflictError(
|
|
2010
|
+
"Cannot set Collections while creating a Thread reply. Set them on the Thread root instead.",
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
// Featured surfaces only list published posts, so featuring a draft
|
|
2016
|
+
// writes a flag that does nothing and shows nowhere. Say so instead.
|
|
2017
|
+
if (status === "draft" && resolvedFeaturedAt !== null) {
|
|
2018
|
+
throw new ConflictError("Publish this post before featuring it.");
|
|
1626
2019
|
}
|
|
1627
2020
|
|
|
1628
2021
|
assertDraftPublishedAt(status, data.publishedAt);
|
|
@@ -1643,6 +2036,11 @@ export function createPostService(
|
|
|
1643
2036
|
|
|
1644
2037
|
if (data.path) {
|
|
1645
2038
|
const normalized = normalizePath(data.path);
|
|
2039
|
+
if (isReservedPath(normalized)) {
|
|
2040
|
+
throw new ValidationError(
|
|
2041
|
+
`Path "${normalized}" is reserved and cannot be used`,
|
|
2042
|
+
);
|
|
2043
|
+
}
|
|
1646
2044
|
if (isValidSlug(normalized)) {
|
|
1647
2045
|
// Path is a valid slug — use it directly
|
|
1648
2046
|
slug = await generatePostSlug({
|
|
@@ -1674,7 +2072,8 @@ export function createPostService(
|
|
|
1674
2072
|
});
|
|
1675
2073
|
}
|
|
1676
2074
|
|
|
1677
|
-
//
|
|
2075
|
+
// Thread collection membership is stored against the root ID. When
|
|
2076
|
+
// structured `collectionEntries` are provided (Hugo import path),
|
|
1678
2077
|
// they win over the bare `collectionIds` slug list and carry
|
|
1679
2078
|
// `createdAt` / `position` / `pinnedAt` per row. Otherwise fall back to
|
|
1680
2079
|
// the simple list and derive those fields from sensible defaults.
|
|
@@ -1683,7 +2082,7 @@ export function createPostService(
|
|
|
1683
2082
|
data.collectionEntries.length > 0;
|
|
1684
2083
|
const collectionInsertRows: {
|
|
1685
2084
|
siteId: string;
|
|
1686
|
-
|
|
2085
|
+
threadId: string;
|
|
1687
2086
|
collectionId: string;
|
|
1688
2087
|
createdAt: number;
|
|
1689
2088
|
position: number;
|
|
@@ -1693,7 +2092,7 @@ export function createPostService(
|
|
|
1693
2092
|
const seen = new Set<string>();
|
|
1694
2093
|
const rows: {
|
|
1695
2094
|
siteId: string;
|
|
1696
|
-
|
|
2095
|
+
threadId: string;
|
|
1697
2096
|
collectionId: string;
|
|
1698
2097
|
createdAt: number;
|
|
1699
2098
|
position: number;
|
|
@@ -1705,7 +2104,7 @@ export function createPostService(
|
|
|
1705
2104
|
seen.add(entry.collectionId);
|
|
1706
2105
|
rows.push({
|
|
1707
2106
|
siteId,
|
|
1708
|
-
|
|
2107
|
+
threadId,
|
|
1709
2108
|
collectionId: entry.collectionId,
|
|
1710
2109
|
createdAt: entry.createdAt ?? timestamp,
|
|
1711
2110
|
position: entry.position ?? fallbackPosition,
|
|
@@ -1717,7 +2116,7 @@ export function createPostService(
|
|
|
1717
2116
|
})()
|
|
1718
2117
|
: [...new Set(data.collectionIds ?? [])].map((collectionId, index) => ({
|
|
1719
2118
|
siteId,
|
|
1720
|
-
|
|
2119
|
+
threadId,
|
|
1721
2120
|
collectionId,
|
|
1722
2121
|
createdAt: timestamp,
|
|
1723
2122
|
position: index,
|
|
@@ -1741,14 +2140,17 @@ export function createPostService(
|
|
|
1741
2140
|
url,
|
|
1742
2141
|
body,
|
|
1743
2142
|
bodyHtml,
|
|
2143
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
1744
2144
|
bodyText,
|
|
1745
2145
|
quoteText,
|
|
1746
2146
|
summary,
|
|
1747
2147
|
rating,
|
|
1748
2148
|
replyToId: data.replyToId ?? null,
|
|
1749
2149
|
threadId,
|
|
2150
|
+
quietReply: isQuietReply,
|
|
1750
2151
|
publishedAt,
|
|
1751
2152
|
lastActivityAt: publishedAt ?? timestamp,
|
|
2153
|
+
threadUpdatedAt: publishedAt ?? timestamp,
|
|
1752
2154
|
createdAt: timestamp,
|
|
1753
2155
|
updatedAt: timestamp,
|
|
1754
2156
|
}),
|
|
@@ -1788,7 +2190,12 @@ export function createPostService(
|
|
|
1788
2190
|
|
|
1789
2191
|
if (collectionInsertRows.length > 0) {
|
|
1790
2192
|
writeQueries.push(
|
|
1791
|
-
|
|
2193
|
+
data.replyToId
|
|
2194
|
+
? db
|
|
2195
|
+
.insert(threadCollections)
|
|
2196
|
+
.values(collectionInsertRows)
|
|
2197
|
+
.onConflictDoNothing()
|
|
2198
|
+
: db.insert(threadCollections).values(collectionInsertRows),
|
|
1792
2199
|
);
|
|
1793
2200
|
}
|
|
1794
2201
|
|
|
@@ -1812,14 +2219,17 @@ export function createPostService(
|
|
|
1812
2219
|
url,
|
|
1813
2220
|
body,
|
|
1814
2221
|
bodyHtml,
|
|
2222
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
1815
2223
|
bodyText,
|
|
1816
2224
|
quoteText,
|
|
1817
2225
|
summary,
|
|
1818
2226
|
rating,
|
|
1819
2227
|
replyToId: data.replyToId ?? null,
|
|
1820
2228
|
threadId,
|
|
2229
|
+
quietReply: isQuietReply,
|
|
1821
2230
|
publishedAt,
|
|
1822
2231
|
lastActivityAt: publishedAt ?? timestamp,
|
|
2232
|
+
threadUpdatedAt: publishedAt ?? timestamp,
|
|
1823
2233
|
createdAt: timestamp,
|
|
1824
2234
|
updatedAt: timestamp,
|
|
1825
2235
|
});
|
|
@@ -1853,7 +2263,14 @@ export function createPostService(
|
|
|
1853
2263
|
}
|
|
1854
2264
|
|
|
1855
2265
|
if (collectionInsertRows.length > 0) {
|
|
1856
|
-
|
|
2266
|
+
const insertQuery = tx
|
|
2267
|
+
.insert(threadCollections)
|
|
2268
|
+
.values(collectionInsertRows);
|
|
2269
|
+
if (data.replyToId) {
|
|
2270
|
+
await insertQuery.onConflictDoNothing();
|
|
2271
|
+
} else {
|
|
2272
|
+
await insertQuery;
|
|
2273
|
+
}
|
|
1857
2274
|
}
|
|
1858
2275
|
});
|
|
1859
2276
|
}
|
|
@@ -1872,9 +2289,11 @@ export function createPostService(
|
|
|
1872
2289
|
throw new ConflictError(`Slug "${slug}" could not be resolved`);
|
|
1873
2290
|
}
|
|
1874
2291
|
|
|
1875
|
-
//
|
|
1876
|
-
|
|
1877
|
-
|
|
2292
|
+
// A quiet reply still changes the Thread, it just isn't announced —
|
|
2293
|
+
// the recalculation reads the persisted quiet_reply flag and keeps
|
|
2294
|
+
// lastActivityAt where it was while moving threadUpdatedAt forward.
|
|
2295
|
+
if (data.replyToId && status === "published") {
|
|
2296
|
+
await recalculateThreadActivity(threadId);
|
|
1878
2297
|
}
|
|
1879
2298
|
|
|
1880
2299
|
return post;
|
|
@@ -1926,6 +2345,9 @@ export function createPostService(
|
|
|
1926
2345
|
throw new ValidationError("A thread requires at least 2 posts.");
|
|
1927
2346
|
}
|
|
1928
2347
|
|
|
2348
|
+
const extendsExistingThreadQuietly =
|
|
2349
|
+
items[0]?.data.replyToId !== undefined &&
|
|
2350
|
+
items[0].data.quietReply === true;
|
|
1929
2351
|
const created: Post[] = [];
|
|
1930
2352
|
|
|
1931
2353
|
for (let i = 0; i < items.length; i++) {
|
|
@@ -1933,10 +2355,19 @@ export function createPostService(
|
|
|
1933
2355
|
if (!item) continue;
|
|
1934
2356
|
const { data, attachments } = item;
|
|
1935
2357
|
const prevPost = created[i - 1];
|
|
2358
|
+
// A reply with no date of its own belongs to the root's moment, not to
|
|
2359
|
+
// whenever the request happened to run. Without this, backdating the
|
|
2360
|
+
// root leaves its replies stamped today and the thread reads as if it
|
|
2361
|
+
// spanned years. Thread order is by `createdAt`/`id` (see the thread
|
|
2362
|
+
// queries), so sharing one `publishedAt` cannot reorder anything.
|
|
2363
|
+
const rootPublishedAt = created[0]?.publishedAt ?? undefined;
|
|
1936
2364
|
const postData: CreatePost = {
|
|
1937
2365
|
...data,
|
|
1938
2366
|
// Chain each post as a reply to the previous one (server-side chaining)
|
|
1939
2367
|
replyToId: i === 0 ? data.replyToId : prevPost?.id,
|
|
2368
|
+
quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
|
|
2369
|
+
publishedAt:
|
|
2370
|
+
i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
|
|
1940
2371
|
};
|
|
1941
2372
|
|
|
1942
2373
|
try {
|
|
@@ -1990,6 +2421,19 @@ export function createPostService(
|
|
|
1990
2421
|
|
|
1991
2422
|
const nextTitle =
|
|
1992
2423
|
data.title !== undefined ? data.title?.trim() || null : existing.title;
|
|
2424
|
+
const effectiveNextVisibility = nextVisibility ?? existing.visibility;
|
|
2425
|
+
const wasNavigationPageEligible =
|
|
2426
|
+
existing.format === "note" &&
|
|
2427
|
+
existing.status === "published" &&
|
|
2428
|
+
existing.visibility !== "private" &&
|
|
2429
|
+
!isThreadReply(existing) &&
|
|
2430
|
+
hasNonEmptyText(existing.title);
|
|
2431
|
+
const remainsNavigationPageEligible =
|
|
2432
|
+
nextFormat === "note" &&
|
|
2433
|
+
nextStatus === "published" &&
|
|
2434
|
+
effectiveNextVisibility !== "private" &&
|
|
2435
|
+
!isThreadReply(existing) &&
|
|
2436
|
+
hasNonEmptyText(nextTitle);
|
|
1993
2437
|
|
|
1994
2438
|
assertPostFormatShape({
|
|
1995
2439
|
format: nextFormat,
|
|
@@ -2045,18 +2489,32 @@ export function createPostService(
|
|
|
2045
2489
|
} else if (data.featured !== undefined) {
|
|
2046
2490
|
updates.featuredAt = data.featured ? timestamp : null;
|
|
2047
2491
|
}
|
|
2492
|
+
// Same rule `create` enforces: nothing unpublished can be featured.
|
|
2493
|
+
// Only guards turning it on — an existing flag survives a trip back to
|
|
2494
|
+
// draft and takes effect again when the post is republished.
|
|
2495
|
+
if (nextStatus === "draft" && updates.featuredAt) {
|
|
2496
|
+
throw new ConflictError("Publish this post before featuring it.");
|
|
2497
|
+
}
|
|
2048
2498
|
|
|
2499
|
+
let updatedBody: string | null | undefined;
|
|
2049
2500
|
if (data.body !== undefined || data.bodyMarkdown !== undefined) {
|
|
2050
2501
|
const rawBody = data.bodyMarkdown
|
|
2051
2502
|
? markdownToTiptapJson(data.bodyMarkdown)
|
|
2052
2503
|
: (data.body ?? null);
|
|
2053
2504
|
const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
? renderTiptapJson(normalizedBody)
|
|
2505
|
+
const preparedBody = normalizedBody
|
|
2506
|
+
? tryPreparePostBodyHtml(existing.id, normalizedBody)
|
|
2057
2507
|
: null;
|
|
2058
|
-
|
|
2059
|
-
|
|
2508
|
+
updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
|
|
2509
|
+
updates.body = updatedBody;
|
|
2510
|
+
updates.bodyHtml = preparedBody?.ok
|
|
2511
|
+
? preparedBody.html
|
|
2512
|
+
: updatedBody
|
|
2513
|
+
? renderPostBodyHtml(existing.id, updatedBody)
|
|
2514
|
+
: null;
|
|
2515
|
+
updates.bodyHtmlVersion = POST_BODY_HTML_VERSION;
|
|
2516
|
+
updates.bodyText = updatedBody
|
|
2517
|
+
? extractBodyText(updatedBody, { includeLinkHrefs: true })
|
|
2060
2518
|
: null;
|
|
2061
2519
|
}
|
|
2062
2520
|
|
|
@@ -2064,14 +2522,7 @@ export function createPostService(
|
|
|
2064
2522
|
if (summaryConfig) {
|
|
2065
2523
|
const format = nextFormat;
|
|
2066
2524
|
const title = nextTitle;
|
|
2067
|
-
const body =
|
|
2068
|
-
data.bodyMarkdown !== undefined
|
|
2069
|
-
? data.bodyMarkdown
|
|
2070
|
-
? markdownToTiptapJson(data.bodyMarkdown)
|
|
2071
|
-
: null
|
|
2072
|
-
: data.body !== undefined
|
|
2073
|
-
? data.body
|
|
2074
|
-
: existing.body;
|
|
2525
|
+
const body = updatedBody !== undefined ? updatedBody : existing.body;
|
|
2075
2526
|
if (format === "note" && title && body) {
|
|
2076
2527
|
updates.summary = extractSummary(
|
|
2077
2528
|
body,
|
|
@@ -2128,14 +2579,24 @@ export function createPostService(
|
|
|
2128
2579
|
const needsCascade = statusChanged && !isThreadReply(existing);
|
|
2129
2580
|
const needsReplyVisibilityCleanup =
|
|
2130
2581
|
!isThreadReply(existing) && (statusChanged || visibilityChanged);
|
|
2131
|
-
const needsCollectionSync =
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
: [];
|
|
2582
|
+
const needsCollectionSync =
|
|
2583
|
+
data.collectionIds !== undefined ||
|
|
2584
|
+
data.collectionEntries !== undefined;
|
|
2135
2585
|
const needsThreadActivityRecalc =
|
|
2136
2586
|
statusChanged || publishedAtChanged || existing.status === "draft";
|
|
2587
|
+
const needsPageNavDelete =
|
|
2588
|
+
wasNavigationPageEligible && !remainsNavigationPageEligible;
|
|
2589
|
+
const needsPageNavUrlUpdate =
|
|
2590
|
+
wasNavigationPageEligible &&
|
|
2591
|
+
remainsNavigationPageEligible &&
|
|
2592
|
+
slugChanged &&
|
|
2593
|
+
Boolean(data.slug);
|
|
2137
2594
|
const hasExtraWrites =
|
|
2138
|
-
needsCascade ||
|
|
2595
|
+
needsCascade ||
|
|
2596
|
+
needsReplyVisibilityCleanup ||
|
|
2597
|
+
needsCollectionSync ||
|
|
2598
|
+
needsPageNavDelete ||
|
|
2599
|
+
needsPageNavUrlUpdate;
|
|
2139
2600
|
|
|
2140
2601
|
if (!hasExtraWrites) {
|
|
2141
2602
|
// Simple case: only the post update
|
|
@@ -2145,16 +2606,63 @@ export function createPostService(
|
|
|
2145
2606
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
|
|
2146
2607
|
.returning();
|
|
2147
2608
|
if (needsThreadActivityRecalc) {
|
|
2148
|
-
await
|
|
2609
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2149
2610
|
return this.getById(id);
|
|
2150
2611
|
}
|
|
2151
2612
|
return hydratePost(result[0]);
|
|
2152
2613
|
}
|
|
2153
2614
|
|
|
2154
|
-
// Complex case: cascade + update + collection sync atomically
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2615
|
+
// Complex case: cascade + update + Thread collection sync atomically.
|
|
2616
|
+
// Retained entries keep their collected/pinned timestamps when a normal
|
|
2617
|
+
// collectionIds update only changes selection or ordering.
|
|
2618
|
+
const existingThreadCollections = needsCollectionSync
|
|
2619
|
+
? await getThreadCollectionEntries(existing.threadId)
|
|
2620
|
+
: new Map<string, { createdAt: number; pinnedAt: number | null }>();
|
|
2621
|
+
const collectionTimestamp = now();
|
|
2622
|
+
const nextThreadCollectionRows = (() => {
|
|
2623
|
+
if (!needsCollectionSync) return [];
|
|
2624
|
+
|
|
2625
|
+
if (data.collectionEntries !== undefined) {
|
|
2626
|
+
const seen = new Set<string>();
|
|
2627
|
+
return data.collectionEntries.flatMap((entry, index) => {
|
|
2628
|
+
if (seen.has(entry.collectionId)) return [];
|
|
2629
|
+
seen.add(entry.collectionId);
|
|
2630
|
+
const existingEntry = existingThreadCollections.get(
|
|
2631
|
+
entry.collectionId,
|
|
2632
|
+
);
|
|
2633
|
+
return [
|
|
2634
|
+
{
|
|
2635
|
+
siteId,
|
|
2636
|
+
threadId: existing.threadId,
|
|
2637
|
+
collectionId: entry.collectionId,
|
|
2638
|
+
createdAt:
|
|
2639
|
+
entry.createdAt ??
|
|
2640
|
+
existingEntry?.createdAt ??
|
|
2641
|
+
collectionTimestamp,
|
|
2642
|
+
position: entry.position ?? index,
|
|
2643
|
+
pinnedAt:
|
|
2644
|
+
entry.pinnedAt !== undefined
|
|
2645
|
+
? entry.pinnedAt
|
|
2646
|
+
: (existingEntry?.pinnedAt ?? null),
|
|
2647
|
+
},
|
|
2648
|
+
];
|
|
2649
|
+
});
|
|
2650
|
+
}
|
|
2651
|
+
|
|
2652
|
+
return [...new Set(data.collectionIds ?? [])].map(
|
|
2653
|
+
(collectionId, index) => {
|
|
2654
|
+
const existingEntry = existingThreadCollections.get(collectionId);
|
|
2655
|
+
return {
|
|
2656
|
+
siteId,
|
|
2657
|
+
threadId: existing.threadId,
|
|
2658
|
+
collectionId,
|
|
2659
|
+
createdAt: existingEntry?.createdAt ?? collectionTimestamp,
|
|
2660
|
+
position: index,
|
|
2661
|
+
pinnedAt: existingEntry?.pinnedAt ?? null,
|
|
2662
|
+
};
|
|
2663
|
+
},
|
|
2664
|
+
);
|
|
2665
|
+
})();
|
|
2158
2666
|
let updateResult: (typeof posts.$inferSelect)[] | undefined;
|
|
2159
2667
|
|
|
2160
2668
|
if (usesBatchWrites) {
|
|
@@ -2208,33 +2716,40 @@ export function createPostService(
|
|
|
2208
2716
|
.returning(),
|
|
2209
2717
|
);
|
|
2210
2718
|
|
|
2719
|
+
if (needsPageNavDelete) {
|
|
2720
|
+
writeQueries.push(
|
|
2721
|
+
db
|
|
2722
|
+
.delete(navItems)
|
|
2723
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
|
|
2724
|
+
);
|
|
2725
|
+
} else if (needsPageNavUrlUpdate && data.slug) {
|
|
2726
|
+
writeQueries.push(
|
|
2727
|
+
db
|
|
2728
|
+
.update(navItems)
|
|
2729
|
+
.set({
|
|
2730
|
+
url: `/${normalizePath(data.slug)}`,
|
|
2731
|
+
updatedAt: timestamp,
|
|
2732
|
+
})
|
|
2733
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
|
|
2734
|
+
);
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2211
2737
|
if (needsCollectionSync) {
|
|
2212
|
-
// Delete all and re-insert
|
|
2738
|
+
// Delete all and re-insert the one shared Thread membership set.
|
|
2213
2739
|
writeQueries.push(
|
|
2214
2740
|
db
|
|
2215
|
-
.delete(
|
|
2741
|
+
.delete(threadCollections)
|
|
2216
2742
|
.where(
|
|
2217
2743
|
and(
|
|
2218
|
-
eq(
|
|
2219
|
-
eq(
|
|
2744
|
+
eq(threadCollections.siteId, siteId),
|
|
2745
|
+
eq(threadCollections.threadId, existing.threadId),
|
|
2220
2746
|
),
|
|
2221
2747
|
),
|
|
2222
2748
|
);
|
|
2223
2749
|
|
|
2224
|
-
if (
|
|
2225
|
-
const collectionTimestamp = now();
|
|
2750
|
+
if (nextThreadCollectionRows.length > 0) {
|
|
2226
2751
|
writeQueries.push(
|
|
2227
|
-
db.insert(
|
|
2228
|
-
nextCollectionIds.map((collectionId, index) => ({
|
|
2229
|
-
siteId,
|
|
2230
|
-
postId: id,
|
|
2231
|
-
collectionId,
|
|
2232
|
-
createdAt:
|
|
2233
|
-
existingCollectionTimestamps.get(collectionId) ??
|
|
2234
|
-
collectionTimestamp,
|
|
2235
|
-
position: index,
|
|
2236
|
-
})),
|
|
2237
|
-
),
|
|
2752
|
+
db.insert(threadCollections).values(nextThreadCollectionRows),
|
|
2238
2753
|
);
|
|
2239
2754
|
}
|
|
2240
2755
|
}
|
|
@@ -2291,37 +2806,42 @@ export function createPostService(
|
|
|
2291
2806
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
|
|
2292
2807
|
.returning();
|
|
2293
2808
|
|
|
2809
|
+
if (needsPageNavDelete) {
|
|
2810
|
+
await tx
|
|
2811
|
+
.delete(navItems)
|
|
2812
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
|
|
2813
|
+
} else if (needsPageNavUrlUpdate && data.slug) {
|
|
2814
|
+
await tx
|
|
2815
|
+
.update(navItems)
|
|
2816
|
+
.set({
|
|
2817
|
+
url: `/${normalizePath(data.slug)}`,
|
|
2818
|
+
updatedAt: timestamp,
|
|
2819
|
+
})
|
|
2820
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2294
2823
|
if (needsCollectionSync) {
|
|
2295
|
-
// Delete all and re-insert
|
|
2824
|
+
// Delete all and re-insert the one shared Thread membership set.
|
|
2296
2825
|
await tx
|
|
2297
|
-
.delete(
|
|
2826
|
+
.delete(threadCollections)
|
|
2298
2827
|
.where(
|
|
2299
2828
|
and(
|
|
2300
|
-
eq(
|
|
2301
|
-
eq(
|
|
2829
|
+
eq(threadCollections.siteId, siteId),
|
|
2830
|
+
eq(threadCollections.threadId, existing.threadId),
|
|
2302
2831
|
),
|
|
2303
2832
|
);
|
|
2304
2833
|
|
|
2305
|
-
if (
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
siteId,
|
|
2310
|
-
postId: id,
|
|
2311
|
-
collectionId,
|
|
2312
|
-
createdAt:
|
|
2313
|
-
existingCollectionTimestamps.get(collectionId) ??
|
|
2314
|
-
collectionTimestamp,
|
|
2315
|
-
position: index,
|
|
2316
|
-
})),
|
|
2317
|
-
);
|
|
2834
|
+
if (nextThreadCollectionRows.length > 0) {
|
|
2835
|
+
await tx
|
|
2836
|
+
.insert(threadCollections)
|
|
2837
|
+
.values(nextThreadCollectionRows);
|
|
2318
2838
|
}
|
|
2319
2839
|
}
|
|
2320
2840
|
});
|
|
2321
2841
|
}
|
|
2322
2842
|
|
|
2323
2843
|
if (needsThreadActivityRecalc) {
|
|
2324
|
-
await
|
|
2844
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2325
2845
|
return this.getById(id);
|
|
2326
2846
|
}
|
|
2327
2847
|
return hydratePost(updateResult?.[0]);
|
|
@@ -2360,7 +2880,9 @@ export function createPostService(
|
|
|
2360
2880
|
const existingPost = await this.getById(id);
|
|
2361
2881
|
if (!existingPost) return null;
|
|
2362
2882
|
|
|
2363
|
-
const existingCollectionIds = await
|
|
2883
|
+
const existingCollectionIds = await getCollectionIdsForThread(
|
|
2884
|
+
existingPost.threadId,
|
|
2885
|
+
);
|
|
2364
2886
|
const rollbackData = buildRollbackUpdate(
|
|
2365
2887
|
existingPost,
|
|
2366
2888
|
existingCollectionIds,
|
|
@@ -2507,7 +3029,7 @@ export function createPostService(
|
|
|
2507
3029
|
await db
|
|
2508
3030
|
.delete(posts)
|
|
2509
3031
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
|
|
2510
|
-
await
|
|
3032
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2511
3033
|
}
|
|
2512
3034
|
|
|
2513
3035
|
return true;
|
|
@@ -2527,6 +3049,44 @@ export function createPostService(
|
|
|
2527
3049
|
return hydratePosts(rows);
|
|
2528
3050
|
},
|
|
2529
3051
|
|
|
3052
|
+
async getThreadPosition(postId) {
|
|
3053
|
+
const targetRows = await db
|
|
3054
|
+
.select({ replyToId: posts.replyToId, threadId: posts.threadId })
|
|
3055
|
+
.from(posts)
|
|
3056
|
+
.where(and(eq(posts.siteId, siteId), eq(posts.id, postId)))
|
|
3057
|
+
.limit(1);
|
|
3058
|
+
|
|
3059
|
+
const target = targetRows[0];
|
|
3060
|
+
if (!target) return 0;
|
|
3061
|
+
if (!target.replyToId) return 1;
|
|
3062
|
+
|
|
3063
|
+
// One read for the whole Thread, then walk up in memory: a chain of N
|
|
3064
|
+
// Posts would otherwise be N round trips.
|
|
3065
|
+
const threadRows = await db
|
|
3066
|
+
.select({ id: posts.id, replyToId: posts.replyToId })
|
|
3067
|
+
.from(posts)
|
|
3068
|
+
.where(
|
|
3069
|
+
and(eq(posts.siteId, siteId), eq(posts.threadId, target.threadId)),
|
|
3070
|
+
);
|
|
3071
|
+
|
|
3072
|
+
const parentOf = new Map(
|
|
3073
|
+
threadRows.map((row) => [row.id, row.replyToId] as const),
|
|
3074
|
+
);
|
|
3075
|
+
|
|
3076
|
+
let position = 1;
|
|
3077
|
+
let cursor: string | null = target.replyToId;
|
|
3078
|
+
// A cycle is impossible per the schema's CHECK constraints, but walking a
|
|
3079
|
+
// parent chain unguarded turns a bad row into a hung request.
|
|
3080
|
+
const visited = new Set<string>([postId]);
|
|
3081
|
+
while (cursor && !visited.has(cursor)) {
|
|
3082
|
+
visited.add(cursor);
|
|
3083
|
+
position += 1;
|
|
3084
|
+
cursor = parentOf.get(cursor) ?? null;
|
|
3085
|
+
}
|
|
3086
|
+
|
|
3087
|
+
return position;
|
|
3088
|
+
},
|
|
3089
|
+
|
|
2530
3090
|
async updateThreadStatusAndVisibility(rootId, status, visibility) {
|
|
2531
3091
|
const nextStatus = ensurePostStatus(status);
|
|
2532
3092
|
const nextVisibility = ensurePostVisibility(visibility);
|
|
@@ -2591,7 +3151,7 @@ export function createPostService(
|
|
|
2591
3151
|
);
|
|
2592
3152
|
});
|
|
2593
3153
|
}
|
|
2594
|
-
await
|
|
3154
|
+
await recalculateThreadActivity(rootId);
|
|
2595
3155
|
},
|
|
2596
3156
|
|
|
2597
3157
|
async getReplyCounts(postIds) {
|
|
@@ -2718,8 +3278,8 @@ export function createPostService(
|
|
|
2718
3278
|
.from(rankedReplies)
|
|
2719
3279
|
.where(
|
|
2720
3280
|
or(
|
|
2721
|
-
|
|
2722
|
-
lte(rankedReplies.latestReplyRank,
|
|
3281
|
+
lte(rankedReplies.firstReplyRank, 2),
|
|
3282
|
+
lte(rankedReplies.latestReplyRank, 3),
|
|
2723
3283
|
),
|
|
2724
3284
|
);
|
|
2725
3285
|
|
|
@@ -2730,25 +3290,25 @@ export function createPostService(
|
|
|
2730
3290
|
const contextByThreadId = new Map<
|
|
2731
3291
|
string,
|
|
2732
3292
|
{
|
|
2733
|
-
|
|
2734
|
-
|
|
3293
|
+
leadingReplyIds: Map<number, string>;
|
|
3294
|
+
trailingReplyIds: Map<number, string>;
|
|
2735
3295
|
latestReplyId: string | null;
|
|
2736
3296
|
totalReplyCount: number;
|
|
2737
3297
|
}
|
|
2738
3298
|
>();
|
|
2739
3299
|
for (const row of contextRows) {
|
|
2740
3300
|
const existing = contextByThreadId.get(row.threadId) ?? {
|
|
2741
|
-
|
|
2742
|
-
|
|
3301
|
+
leadingReplyIds: new Map<number, string>(),
|
|
3302
|
+
trailingReplyIds: new Map<number, string>(),
|
|
2743
3303
|
latestReplyId: null,
|
|
2744
3304
|
totalReplyCount: row.totalReplyCount,
|
|
2745
3305
|
};
|
|
2746
3306
|
|
|
2747
|
-
if (row.firstReplyRank
|
|
2748
|
-
existing.
|
|
3307
|
+
if (row.firstReplyRank <= 2) {
|
|
3308
|
+
existing.leadingReplyIds.set(row.firstReplyRank, row.id);
|
|
2749
3309
|
}
|
|
2750
|
-
if (row.latestReplyRank === 2) {
|
|
2751
|
-
existing.
|
|
3310
|
+
if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
|
|
3311
|
+
existing.trailingReplyIds.set(row.latestReplyRank, row.id);
|
|
2752
3312
|
}
|
|
2753
3313
|
if (row.latestReplyRank === 1) {
|
|
2754
3314
|
existing.latestReplyId = row.id;
|
|
@@ -2764,13 +3324,20 @@ export function createPostService(
|
|
|
2764
3324
|
const latestReply = hydratedPosts.get(context.latestReplyId);
|
|
2765
3325
|
if (!latestReply) continue;
|
|
2766
3326
|
|
|
3327
|
+
const hydrateReplyIds = (ids: Array<string | undefined>) =>
|
|
3328
|
+
ids
|
|
3329
|
+
.map((id) => (id ? hydratedPosts.get(id) : undefined))
|
|
3330
|
+
.filter((post): post is Post => post !== undefined);
|
|
3331
|
+
|
|
2767
3332
|
result.set(threadId, {
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3333
|
+
leadingReplies: hydrateReplyIds([
|
|
3334
|
+
context.leadingReplyIds.get(1),
|
|
3335
|
+
context.leadingReplyIds.get(2),
|
|
3336
|
+
]),
|
|
3337
|
+
trailingReplies: hydrateReplyIds([
|
|
3338
|
+
context.trailingReplyIds.get(3),
|
|
3339
|
+
context.trailingReplyIds.get(2),
|
|
3340
|
+
]),
|
|
2774
3341
|
latestReply,
|
|
2775
3342
|
totalReplyCount: context.totalReplyCount,
|
|
2776
3343
|
});
|
|
@@ -2803,19 +3370,20 @@ export function createPostService(
|
|
|
2803
3370
|
...buildThreadRootPageConditions(options),
|
|
2804
3371
|
isNotNull(posts.featuredAt),
|
|
2805
3372
|
];
|
|
2806
|
-
const
|
|
2807
|
-
|
|
2808
|
-
|
|
3373
|
+
const latestFeaturedPublishedAt =
|
|
3374
|
+
sql<number>`MAX(${posts.publishedAt})`.as(
|
|
3375
|
+
"latest_featured_published_at",
|
|
3376
|
+
);
|
|
2809
3377
|
|
|
2810
3378
|
let query = db
|
|
2811
3379
|
.select({
|
|
2812
3380
|
threadId: posts.threadId,
|
|
2813
|
-
|
|
3381
|
+
latestFeaturedPublishedAt,
|
|
2814
3382
|
})
|
|
2815
3383
|
.from(posts)
|
|
2816
3384
|
.where(and(...conditions))
|
|
2817
3385
|
.groupBy(posts.threadId)
|
|
2818
|
-
.orderBy(desc(
|
|
3386
|
+
.orderBy(desc(latestFeaturedPublishedAt), desc(posts.threadId));
|
|
2819
3387
|
|
|
2820
3388
|
if (options.limit !== undefined) {
|
|
2821
3389
|
query = query.limit(options.limit) as typeof query;
|
|
@@ -2828,6 +3396,77 @@ export function createPostService(
|
|
|
2828
3396
|
return rows.map((row) => row.threadId);
|
|
2829
3397
|
},
|
|
2830
3398
|
|
|
3399
|
+
async getFeaturedThreadTimelineData(rootIds) {
|
|
3400
|
+
const result = new Map<string, FeaturedThreadTimelineData>();
|
|
3401
|
+
if (rootIds.length === 0) return result;
|
|
3402
|
+
|
|
3403
|
+
const uniqueRootIds = [...new Set(rootIds)];
|
|
3404
|
+
const threadPosition = sql<number>`CAST(
|
|
3405
|
+
row_number() OVER (
|
|
3406
|
+
PARTITION BY ${posts.threadId}
|
|
3407
|
+
ORDER BY ${posts.createdAt}, ${posts.id}
|
|
3408
|
+
) - 1 AS INTEGER
|
|
3409
|
+
)`.as("thread_position");
|
|
3410
|
+
const threadPostCount = sql<number>`CAST(
|
|
3411
|
+
count(*) OVER (PARTITION BY ${posts.threadId}) AS INTEGER
|
|
3412
|
+
)`.as("thread_post_count");
|
|
3413
|
+
|
|
3414
|
+
const rankedPosts = db
|
|
3415
|
+
.select({
|
|
3416
|
+
...getTableColumns(posts),
|
|
3417
|
+
threadPosition,
|
|
3418
|
+
threadPostCount,
|
|
3419
|
+
})
|
|
3420
|
+
.from(posts)
|
|
3421
|
+
.where(
|
|
3422
|
+
and(
|
|
3423
|
+
eq(posts.siteId, siteId),
|
|
3424
|
+
inArray(posts.threadId, uniqueRootIds),
|
|
3425
|
+
eq(posts.status, "published"),
|
|
3426
|
+
),
|
|
3427
|
+
)
|
|
3428
|
+
.as("ranked_featured_thread_post");
|
|
3429
|
+
|
|
3430
|
+
const rows = await db
|
|
3431
|
+
.select()
|
|
3432
|
+
.from(rankedPosts)
|
|
3433
|
+
.where(
|
|
3434
|
+
or(
|
|
3435
|
+
eq(rankedPosts.id, rankedPosts.threadId),
|
|
3436
|
+
isNotNull(rankedPosts.featuredAt),
|
|
3437
|
+
sql`${rankedPosts.threadPosition} = ${rankedPosts.threadPostCount} - 1`,
|
|
3438
|
+
),
|
|
3439
|
+
)
|
|
3440
|
+
.orderBy(rankedPosts.threadId, sql`${rankedPosts.threadPosition}`);
|
|
3441
|
+
|
|
3442
|
+
const hydratedPosts = await hydratePosts(
|
|
3443
|
+
rows.map(
|
|
3444
|
+
({ threadPosition: _position, threadPostCount: _count, ...row }) =>
|
|
3445
|
+
row,
|
|
3446
|
+
),
|
|
3447
|
+
);
|
|
3448
|
+
const hydratedById = new Map(
|
|
3449
|
+
hydratedPosts.map((post) => [post.id, post]),
|
|
3450
|
+
);
|
|
3451
|
+
|
|
3452
|
+
for (const row of rows) {
|
|
3453
|
+
const post = hydratedById.get(row.id);
|
|
3454
|
+
if (!post) continue;
|
|
3455
|
+
|
|
3456
|
+
const thread = result.get(row.threadId) ?? {
|
|
3457
|
+
posts: [],
|
|
3458
|
+
featuredPostIds: [],
|
|
3459
|
+
};
|
|
3460
|
+
thread.posts.push({ post, position: row.threadPosition });
|
|
3461
|
+
if (row.featuredAt !== null) {
|
|
3462
|
+
thread.featuredPostIds.push(row.id);
|
|
3463
|
+
}
|
|
3464
|
+
result.set(row.threadId, thread);
|
|
3465
|
+
}
|
|
3466
|
+
|
|
3467
|
+
return result;
|
|
3468
|
+
},
|
|
3469
|
+
|
|
2831
3470
|
async countCollectionThreadRoots(collectionId, options = {}) {
|
|
2832
3471
|
return this.countCollectionThreadRootsForCollections(
|
|
2833
3472
|
[collectionId],
|
|
@@ -2853,10 +3492,10 @@ export function createPostService(
|
|
|
2853
3492
|
})
|
|
2854
3493
|
.from(posts)
|
|
2855
3494
|
.innerJoin(
|
|
2856
|
-
|
|
3495
|
+
threadCollections,
|
|
2857
3496
|
and(
|
|
2858
|
-
eq(
|
|
2859
|
-
eq(
|
|
3497
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3498
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
2860
3499
|
),
|
|
2861
3500
|
)
|
|
2862
3501
|
.where(and(...conditions));
|
|
@@ -2864,6 +3503,35 @@ export function createPostService(
|
|
|
2864
3503
|
return rows[0]?.count ?? 0;
|
|
2865
3504
|
},
|
|
2866
3505
|
|
|
3506
|
+
async countCollectionThreadRootsUpToForCollections(
|
|
3507
|
+
collectionIds,
|
|
3508
|
+
options = {},
|
|
3509
|
+
limit,
|
|
3510
|
+
) {
|
|
3511
|
+
const normalizedLimit = Math.max(0, Math.trunc(limit));
|
|
3512
|
+
if (normalizedLimit === 0) return 0;
|
|
3513
|
+
|
|
3514
|
+
const conditions = [
|
|
3515
|
+
...buildThreadRootPageConditions(options),
|
|
3516
|
+
buildCollectionMembershipCondition(collectionIds),
|
|
3517
|
+
];
|
|
3518
|
+
const rows = await db
|
|
3519
|
+
.select({ threadId: posts.threadId })
|
|
3520
|
+
.from(posts)
|
|
3521
|
+
.innerJoin(
|
|
3522
|
+
threadCollections,
|
|
3523
|
+
and(
|
|
3524
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3525
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
3526
|
+
),
|
|
3527
|
+
)
|
|
3528
|
+
.where(and(...conditions))
|
|
3529
|
+
.groupBy(posts.threadId)
|
|
3530
|
+
.limit(normalizedLimit);
|
|
3531
|
+
|
|
3532
|
+
return rows.length;
|
|
3533
|
+
},
|
|
3534
|
+
|
|
2867
3535
|
async listCollectionThreadRootIds(collectionId, options = {}) {
|
|
2868
3536
|
return this.listCollectionThreadRootIdsForCollections(
|
|
2869
3537
|
[collectionId],
|
|
@@ -2875,71 +3543,102 @@ export function createPostService(
|
|
|
2875
3543
|
collectionIds,
|
|
2876
3544
|
options = {},
|
|
2877
3545
|
) {
|
|
2878
|
-
const
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
];
|
|
2882
|
-
const sortOrder = options.sortOrder ?? "newest";
|
|
2883
|
-
const publishedAt =
|
|
2884
|
-
sortOrder === "oldest"
|
|
2885
|
-
? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
|
|
2886
|
-
: sql<number>`MAX(${posts.publishedAt})`.as("published_at");
|
|
2887
|
-
const threadActivityAt =
|
|
2888
|
-
buildCollectionThreadActivityExpr("thread_activity_at");
|
|
2889
|
-
const ratingPresence = sql<number>`MAX(
|
|
2890
|
-
CASE
|
|
2891
|
-
WHEN ${posts.rating} IS NULL THEN 0
|
|
2892
|
-
ELSE 1
|
|
2893
|
-
END
|
|
2894
|
-
)`.as("rating_presence");
|
|
2895
|
-
const ratingValue = sql<number | null>`MAX(${posts.rating})`.as(
|
|
2896
|
-
"rating_value",
|
|
3546
|
+
const { sortOrder, sortedThreads } = buildCollectionThreadSortQuery(
|
|
3547
|
+
collectionIds,
|
|
3548
|
+
options,
|
|
2897
3549
|
);
|
|
3550
|
+
let cursorCondition: SQL<unknown> | undefined;
|
|
3551
|
+
|
|
3552
|
+
if (options.cursor) {
|
|
3553
|
+
const cursorRows = await db
|
|
3554
|
+
.select()
|
|
3555
|
+
.from(sortedThreads)
|
|
3556
|
+
.where(eq(sortedThreads.threadId, options.cursor))
|
|
3557
|
+
.limit(1);
|
|
3558
|
+
const cursorRow = cursorRows[0];
|
|
3559
|
+
if (!cursorRow) return [];
|
|
3560
|
+
|
|
3561
|
+
const pinnedKey: CursorSortKey = {
|
|
3562
|
+
direction: "desc",
|
|
3563
|
+
expr: sortedThreads.collectionPinnedAt,
|
|
3564
|
+
value: cursorRow.collectionPinnedAt,
|
|
3565
|
+
};
|
|
2898
3566
|
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3567
|
+
cursorCondition =
|
|
3568
|
+
sortOrder === "oldest"
|
|
3569
|
+
? buildLexicographicCursorCondition([
|
|
3570
|
+
pinnedKey,
|
|
3571
|
+
{
|
|
3572
|
+
direction: "asc",
|
|
3573
|
+
expr: sortedThreads.publishedAt,
|
|
3574
|
+
value: cursorRow.publishedAt,
|
|
3575
|
+
},
|
|
3576
|
+
{
|
|
3577
|
+
direction: "asc",
|
|
3578
|
+
expr: sortedThreads.threadId,
|
|
3579
|
+
value: cursorRow.threadId,
|
|
3580
|
+
},
|
|
3581
|
+
])
|
|
3582
|
+
: sortOrder === "rating_desc"
|
|
3583
|
+
? buildLexicographicCursorCondition([
|
|
3584
|
+
pinnedKey,
|
|
3585
|
+
{
|
|
3586
|
+
direction: "desc",
|
|
3587
|
+
expr: sortedThreads.ratingPresence,
|
|
3588
|
+
value: cursorRow.ratingPresence,
|
|
3589
|
+
},
|
|
3590
|
+
{
|
|
3591
|
+
direction: "desc",
|
|
3592
|
+
expr: sortedThreads.ratingValue,
|
|
3593
|
+
value: cursorRow.ratingValue,
|
|
3594
|
+
},
|
|
3595
|
+
{
|
|
3596
|
+
direction: "desc",
|
|
3597
|
+
expr: sortedThreads.threadActivityAt,
|
|
3598
|
+
value: cursorRow.threadActivityAt,
|
|
3599
|
+
},
|
|
3600
|
+
{
|
|
3601
|
+
direction: "desc",
|
|
3602
|
+
expr: sortedThreads.threadId,
|
|
3603
|
+
value: cursorRow.threadId,
|
|
3604
|
+
},
|
|
3605
|
+
])
|
|
3606
|
+
: buildLexicographicCursorCondition([
|
|
3607
|
+
pinnedKey,
|
|
3608
|
+
{
|
|
3609
|
+
direction: "desc",
|
|
3610
|
+
expr: sortedThreads.threadActivityAt,
|
|
3611
|
+
value: cursorRow.threadActivityAt,
|
|
3612
|
+
},
|
|
3613
|
+
{
|
|
3614
|
+
direction: "desc",
|
|
3615
|
+
expr: sortedThreads.threadId,
|
|
3616
|
+
value: cursorRow.threadId,
|
|
3617
|
+
},
|
|
3618
|
+
]);
|
|
3619
|
+
}
|
|
2903
3620
|
|
|
2904
|
-
const baseQuery = db
|
|
2905
|
-
.select({
|
|
2906
|
-
threadId: posts.threadId,
|
|
2907
|
-
publishedAt,
|
|
2908
|
-
threadActivityAt,
|
|
2909
|
-
collectionPinnedAt,
|
|
2910
|
-
ratingPresence,
|
|
2911
|
-
ratingValue,
|
|
2912
|
-
})
|
|
2913
|
-
.from(posts)
|
|
2914
|
-
.innerJoin(
|
|
2915
|
-
postCollections,
|
|
2916
|
-
and(
|
|
2917
|
-
eq(postCollections.siteId, siteId),
|
|
2918
|
-
eq(postCollections.postId, posts.id),
|
|
2919
|
-
),
|
|
2920
|
-
)
|
|
2921
|
-
.where(and(...conditions))
|
|
2922
|
-
.groupBy(posts.threadId);
|
|
3621
|
+
const baseQuery = db.select().from(sortedThreads).where(cursorCondition);
|
|
2923
3622
|
|
|
2924
3623
|
let query =
|
|
2925
3624
|
sortOrder === "oldest"
|
|
2926
3625
|
? baseQuery.orderBy(
|
|
2927
|
-
desc(collectionPinnedAt),
|
|
2928
|
-
asc(publishedAt),
|
|
2929
|
-
asc(
|
|
3626
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3627
|
+
asc(sortedThreads.publishedAt),
|
|
3628
|
+
asc(sortedThreads.threadId),
|
|
2930
3629
|
)
|
|
2931
3630
|
: sortOrder === "rating_desc"
|
|
2932
3631
|
? baseQuery.orderBy(
|
|
2933
|
-
desc(collectionPinnedAt),
|
|
2934
|
-
desc(ratingPresence),
|
|
2935
|
-
desc(ratingValue),
|
|
2936
|
-
desc(threadActivityAt),
|
|
2937
|
-
desc(
|
|
3632
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3633
|
+
desc(sortedThreads.ratingPresence),
|
|
3634
|
+
desc(sortedThreads.ratingValue),
|
|
3635
|
+
desc(sortedThreads.threadActivityAt),
|
|
3636
|
+
desc(sortedThreads.threadId),
|
|
2938
3637
|
)
|
|
2939
3638
|
: baseQuery.orderBy(
|
|
2940
|
-
desc(collectionPinnedAt),
|
|
2941
|
-
desc(threadActivityAt),
|
|
2942
|
-
desc(
|
|
3639
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3640
|
+
desc(sortedThreads.threadActivityAt),
|
|
3641
|
+
desc(sortedThreads.threadId),
|
|
2943
3642
|
);
|
|
2944
3643
|
|
|
2945
3644
|
if (options.limit !== undefined) {
|
|
@@ -2953,6 +3652,18 @@ export function createPostService(
|
|
|
2953
3652
|
return rows.map((row) => row.threadId);
|
|
2954
3653
|
},
|
|
2955
3654
|
|
|
3655
|
+
async listCollectionThreadRootsForCollections(collectionIds, options = {}) {
|
|
3656
|
+
const rootIds = await this.listCollectionThreadRootIdsForCollections(
|
|
3657
|
+
collectionIds,
|
|
3658
|
+
options,
|
|
3659
|
+
);
|
|
3660
|
+
const rootsById = await hydratePostsById(rootIds);
|
|
3661
|
+
return rootIds.flatMap((rootId) => {
|
|
3662
|
+
const root = rootsById.get(rootId);
|
|
3663
|
+
return root ? [root] : [];
|
|
3664
|
+
});
|
|
3665
|
+
},
|
|
3666
|
+
|
|
2956
3667
|
async listCollectionFeedEntries(collectionId, options = {}) {
|
|
2957
3668
|
return this.listCollectionFeedEntriesForCollections(
|
|
2958
3669
|
[collectionId],
|
|
@@ -2967,11 +3678,11 @@ export function createPostService(
|
|
|
2967
3678
|
];
|
|
2968
3679
|
const threadActivityAt =
|
|
2969
3680
|
buildCollectionThreadActivityExpr("thread_activity_at");
|
|
2970
|
-
const collectedAt = sql<number>`MAX(${
|
|
3681
|
+
const collectedAt = sql<number>`MAX(${threadCollections.createdAt})`.as(
|
|
2971
3682
|
"collected_at",
|
|
2972
3683
|
);
|
|
2973
3684
|
const collectionPinnedAt =
|
|
2974
|
-
sql<number>`MAX(coalesce(${
|
|
3685
|
+
sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
|
|
2975
3686
|
"collection_pinned_at",
|
|
2976
3687
|
);
|
|
2977
3688
|
const pinnedOrder = options.ignoreCollectionPinnedSort
|
|
@@ -2987,10 +3698,10 @@ export function createPostService(
|
|
|
2987
3698
|
})
|
|
2988
3699
|
.from(posts)
|
|
2989
3700
|
.innerJoin(
|
|
2990
|
-
|
|
3701
|
+
threadCollections,
|
|
2991
3702
|
and(
|
|
2992
|
-
eq(
|
|
2993
|
-
eq(
|
|
3703
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3704
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
2994
3705
|
),
|
|
2995
3706
|
)
|
|
2996
3707
|
.where(and(...conditions))
|
|
@@ -3013,21 +3724,23 @@ export function createPostService(
|
|
|
3013
3724
|
});
|
|
3014
3725
|
},
|
|
3015
3726
|
|
|
3016
|
-
async getPublishedThreads(rootIds) {
|
|
3727
|
+
async getPublishedThreads(rootIds, options = {}) {
|
|
3017
3728
|
const result = new Map<string, Post[]>();
|
|
3018
3729
|
if (rootIds.length === 0) return result;
|
|
3019
3730
|
|
|
3020
3731
|
const unique = [...new Set(rootIds)];
|
|
3732
|
+
const conditions = [
|
|
3733
|
+
eq(posts.siteId, siteId),
|
|
3734
|
+
inArray(posts.threadId, unique),
|
|
3735
|
+
eq(posts.status, "published"),
|
|
3736
|
+
];
|
|
3737
|
+
if (options.publishedBefore !== undefined) {
|
|
3738
|
+
conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
|
|
3739
|
+
}
|
|
3021
3740
|
const rows = await db
|
|
3022
3741
|
.select()
|
|
3023
3742
|
.from(posts)
|
|
3024
|
-
.where(
|
|
3025
|
-
and(
|
|
3026
|
-
eq(posts.siteId, siteId),
|
|
3027
|
-
inArray(posts.threadId, unique),
|
|
3028
|
-
eq(posts.status, "published"),
|
|
3029
|
-
),
|
|
3030
|
-
)
|
|
3743
|
+
.where(and(...conditions))
|
|
3031
3744
|
.orderBy(posts.threadId, posts.createdAt, posts.id);
|
|
3032
3745
|
|
|
3033
3746
|
for (const post of await hydratePosts(rows)) {
|
|
@@ -3042,57 +3755,7 @@ export function createPostService(
|
|
|
3042
3755
|
return result;
|
|
3043
3756
|
},
|
|
3044
3757
|
|
|
3045
|
-
async
|
|
3046
|
-
return this.getCollectionPostIdsByThreadForCollections(
|
|
3047
|
-
[collectionId],
|
|
3048
|
-
threadIds,
|
|
3049
|
-
);
|
|
3050
|
-
},
|
|
3051
|
-
|
|
3052
|
-
async getCollectionPostIdsByThreadForCollections(collectionIds, threadIds) {
|
|
3053
|
-
const result = new Map<string, string[]>();
|
|
3054
|
-
if (threadIds.length === 0) return result;
|
|
3055
|
-
|
|
3056
|
-
const unique = [...new Set(threadIds)];
|
|
3057
|
-
const rows = await batchQueryRows(unique, (chunk) =>
|
|
3058
|
-
db
|
|
3059
|
-
.select({
|
|
3060
|
-
threadId: posts.threadId,
|
|
3061
|
-
postId: posts.id,
|
|
3062
|
-
})
|
|
3063
|
-
.from(posts)
|
|
3064
|
-
.innerJoin(
|
|
3065
|
-
postCollections,
|
|
3066
|
-
and(
|
|
3067
|
-
eq(postCollections.siteId, siteId),
|
|
3068
|
-
eq(postCollections.postId, posts.id),
|
|
3069
|
-
),
|
|
3070
|
-
)
|
|
3071
|
-
.where(
|
|
3072
|
-
and(
|
|
3073
|
-
eq(posts.siteId, siteId),
|
|
3074
|
-
buildCollectionMembershipCondition(collectionIds),
|
|
3075
|
-
inArray(posts.threadId, chunk),
|
|
3076
|
-
eq(posts.status, "published"),
|
|
3077
|
-
),
|
|
3078
|
-
)
|
|
3079
|
-
.groupBy(posts.threadId, posts.id)
|
|
3080
|
-
.orderBy(posts.threadId, posts.createdAt, posts.id),
|
|
3081
|
-
);
|
|
3082
|
-
|
|
3083
|
-
for (const row of rows) {
|
|
3084
|
-
const list = result.get(row.threadId);
|
|
3085
|
-
if (list) {
|
|
3086
|
-
list.push(row.postId);
|
|
3087
|
-
} else {
|
|
3088
|
-
result.set(row.threadId, [row.postId]);
|
|
3089
|
-
}
|
|
3090
|
-
}
|
|
3091
|
-
|
|
3092
|
-
return result;
|
|
3093
|
-
},
|
|
3094
|
-
|
|
3095
|
-
async getLastPostIdsByThread(threadIds) {
|
|
3758
|
+
async getThreadTailIds(threadIds, options) {
|
|
3096
3759
|
const result = new Map<string, string>();
|
|
3097
3760
|
if (threadIds.length === 0) return result;
|
|
3098
3761
|
|
|
@@ -3107,7 +3770,7 @@ export function createPostService(
|
|
|
3107
3770
|
and(
|
|
3108
3771
|
eq(posts.siteId, siteId),
|
|
3109
3772
|
inArray(posts.threadId, unique),
|
|
3110
|
-
eq(posts.status, "published"),
|
|
3773
|
+
...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
|
|
3111
3774
|
),
|
|
3112
3775
|
)
|
|
3113
3776
|
.orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
|
|
@@ -3121,11 +3784,9 @@ export function createPostService(
|
|
|
3121
3784
|
},
|
|
3122
3785
|
|
|
3123
3786
|
async getDistinctYears(filters = {}) {
|
|
3124
|
-
const
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
];
|
|
3128
|
-
const publishedYearExpr = buildPublishedYearExpr();
|
|
3787
|
+
const axis = timeAxisColumn(filters);
|
|
3788
|
+
const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
|
|
3789
|
+
const publishedYearExpr = buildYearExpr(axis);
|
|
3129
3790
|
|
|
3130
3791
|
const rows = await db
|
|
3131
3792
|
.select({
|
|
@@ -3191,5 +3852,144 @@ export function createPostService(
|
|
|
3191
3852
|
done: !hasMore,
|
|
3192
3853
|
};
|
|
3193
3854
|
},
|
|
3855
|
+
|
|
3856
|
+
async rebuildBodyHtml(options = {}) {
|
|
3857
|
+
const requested = options.limit ?? 50;
|
|
3858
|
+
const limit = Math.min(Math.max(Math.trunc(requested), 1), 100);
|
|
3859
|
+
const cursor = options.cursor;
|
|
3860
|
+
const dryRun = options.dryRun ?? false;
|
|
3861
|
+
|
|
3862
|
+
const siteRows = await db
|
|
3863
|
+
.select({ id: sites.id })
|
|
3864
|
+
.from(sites)
|
|
3865
|
+
.where(eq(sites.id, siteId))
|
|
3866
|
+
.limit(1);
|
|
3867
|
+
if (!siteRows[0]) {
|
|
3868
|
+
throw new NotFoundError("Site");
|
|
3869
|
+
}
|
|
3870
|
+
|
|
3871
|
+
const whereConditions = [eq(posts.siteId, siteId)];
|
|
3872
|
+
if (cursor) whereConditions.push(gt(posts.id, cursor));
|
|
3873
|
+
|
|
3874
|
+
const rows = await db
|
|
3875
|
+
.select({
|
|
3876
|
+
id: posts.id,
|
|
3877
|
+
format: posts.format,
|
|
3878
|
+
title: posts.title,
|
|
3879
|
+
body: posts.body,
|
|
3880
|
+
bodyHtml: posts.bodyHtml,
|
|
3881
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
3882
|
+
})
|
|
3883
|
+
.from(posts)
|
|
3884
|
+
.where(and(...whereConditions))
|
|
3885
|
+
.orderBy(asc(posts.id))
|
|
3886
|
+
.limit(limit + 1);
|
|
3887
|
+
|
|
3888
|
+
const hasMore = rows.length > limit;
|
|
3889
|
+
const batch = hasMore ? rows.slice(0, limit) : rows;
|
|
3890
|
+
const failures: Array<{ postId: string; error: string }> = [];
|
|
3891
|
+
let wouldRebuild = 0;
|
|
3892
|
+
let rebuilt = 0;
|
|
3893
|
+
let wouldUpgradeFootnotes = 0;
|
|
3894
|
+
let upgradedFootnotes = 0;
|
|
3895
|
+
let skipped = 0;
|
|
3896
|
+
let conflicted = 0;
|
|
3897
|
+
|
|
3898
|
+
for (const row of batch) {
|
|
3899
|
+
if (row.bodyHtmlVersion > POST_BODY_HTML_VERSION) {
|
|
3900
|
+
skipped++;
|
|
3901
|
+
continue;
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
let nextBody = row.body;
|
|
3905
|
+
let nextBodyHtml: string | null = null;
|
|
3906
|
+
let upgradesLegacyFootnotes = false;
|
|
3907
|
+
if (row.body !== null) {
|
|
3908
|
+
const prepared = tryPreparePostBodyHtml(row.id, row.body);
|
|
3909
|
+
if (!prepared.ok) {
|
|
3910
|
+
failures.push({ postId: row.id, error: prepared.error });
|
|
3911
|
+
continue;
|
|
3912
|
+
}
|
|
3913
|
+
nextBody = prepared.body;
|
|
3914
|
+
nextBodyHtml = prepared.html;
|
|
3915
|
+
upgradesLegacyFootnotes = prepared.upgradedLegacyFootnotes;
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
if (
|
|
3919
|
+
row.bodyHtmlVersion === POST_BODY_HTML_VERSION &&
|
|
3920
|
+
row.body === nextBody &&
|
|
3921
|
+
row.bodyHtml === nextBodyHtml
|
|
3922
|
+
) {
|
|
3923
|
+
skipped++;
|
|
3924
|
+
continue;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3927
|
+
wouldRebuild++;
|
|
3928
|
+
if (upgradesLegacyFootnotes) wouldUpgradeFootnotes++;
|
|
3929
|
+
if (dryRun) continue;
|
|
3930
|
+
|
|
3931
|
+
const bodyCondition =
|
|
3932
|
+
row.body === null ? isNull(posts.body) : eq(posts.body, row.body);
|
|
3933
|
+
const canonicalBodyChanged = row.body !== nextBody;
|
|
3934
|
+
const updates: Partial<typeof posts.$inferInsert> = {
|
|
3935
|
+
bodyHtml: nextBodyHtml,
|
|
3936
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
3937
|
+
};
|
|
3938
|
+
if (canonicalBodyChanged) {
|
|
3939
|
+
updates.body = nextBody;
|
|
3940
|
+
updates.bodyText = nextBody
|
|
3941
|
+
? extractBodyText(nextBody, { includeLinkHrefs: true })
|
|
3942
|
+
: null;
|
|
3943
|
+
|
|
3944
|
+
if (options.summaryConfig) {
|
|
3945
|
+
updates.summary =
|
|
3946
|
+
row.format === "note" && row.title && nextBody
|
|
3947
|
+
? extractSummary(
|
|
3948
|
+
nextBody,
|
|
3949
|
+
options.summaryConfig.maxParagraphs,
|
|
3950
|
+
options.summaryConfig.maxChars,
|
|
3951
|
+
)
|
|
3952
|
+
: null;
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
const updatedRows = await db
|
|
3956
|
+
.update(posts)
|
|
3957
|
+
.set(updates)
|
|
3958
|
+
.where(
|
|
3959
|
+
and(
|
|
3960
|
+
eq(posts.siteId, siteId),
|
|
3961
|
+
eq(posts.id, row.id),
|
|
3962
|
+
bodyCondition,
|
|
3963
|
+
eq(posts.bodyHtmlVersion, row.bodyHtmlVersion),
|
|
3964
|
+
),
|
|
3965
|
+
)
|
|
3966
|
+
.returning({ id: posts.id });
|
|
3967
|
+
|
|
3968
|
+
if (updatedRows.length === 0) {
|
|
3969
|
+
conflicted++;
|
|
3970
|
+
} else {
|
|
3971
|
+
rebuilt++;
|
|
3972
|
+
if (upgradesLegacyFootnotes) upgradedFootnotes++;
|
|
3973
|
+
}
|
|
3974
|
+
}
|
|
3975
|
+
|
|
3976
|
+
const lastRow = batch.at(-1);
|
|
3977
|
+
const lastId = lastRow ? lastRow.id : null;
|
|
3978
|
+
|
|
3979
|
+
return {
|
|
3980
|
+
processed: batch.length,
|
|
3981
|
+
wouldRebuild,
|
|
3982
|
+
rebuilt,
|
|
3983
|
+
wouldUpgradeFootnotes,
|
|
3984
|
+
upgradedFootnotes,
|
|
3985
|
+
skipped,
|
|
3986
|
+
conflicted,
|
|
3987
|
+
failed: failures.length,
|
|
3988
|
+
failures,
|
|
3989
|
+
nextCursor: hasMore ? lastId : null,
|
|
3990
|
+
done: !hasMore,
|
|
3991
|
+
targetVersion: POST_BODY_HTML_VERSION,
|
|
3992
|
+
};
|
|
3993
|
+
},
|
|
3194
3994
|
};
|
|
3195
3995
|
}
|