@jant/core 0.6.14 → 0.6.15
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 +81 -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-BxCOR3Uc.js +6 -0
- package/dist/{app-2i7-WQHg.js → app-C-oi_t8W.js} +7408 -6343
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-B_eGKN5p.css +2 -0
- package/dist/client/_assets/{client-BvID5ucz.js → client-DxY5BFe8.js} +58 -37
- package/dist/client/_assets/client-auth-CakPESv9.js +5533 -0
- package/dist/{export-DWn149b7.js → export-CsFx6F_M.js} +1259 -125
- package/dist/{github-sync-Ckk0sJxK.js → github-sync-Cq1pzzOI.js} +1 -1
- package/dist/{github-sync-C0zyF6XS.js → github-sync-ppWXN3jb.js} +2 -2
- package/dist/index.js +3 -3
- package/dist/node.js +5 -5
- 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__/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 +64 -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 +18 -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__/jant-collection-directory.test.ts +155 -6
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +370 -5
- package/src/client/components/__tests__/jant-compose-editor.test.ts +97 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +104 -0
- 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-types.ts +8 -0
- package/src/client/components/jant-collection-directory.ts +165 -30
- package/src/client/components/jant-command-palette.ts +2 -1
- package/src/client/components/jant-compose-dialog.ts +85 -21
- package/src/client/components/jant-compose-editor.ts +13 -7
- package/src/client/components/jant-compose-fullscreen.ts +24 -4
- 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 +86 -74
- 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 +4 -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/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__/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/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 +8 -6
- package/src/client-auth.ts +1 -0
- package/src/client.ts +1 -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-collection-migration.test.ts +364 -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/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/_journal.json +28 -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/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/_journal.json +28 -0
- package/src/db/pg/schema.ts +28 -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 +28 -20
- package/src/i18n/__tests__/detect.test.ts +8 -2
- package/src/i18n/detect.ts +14 -5
- package/src/i18n/locales/public/en.po +133 -30
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +128 -25
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +128 -25
- 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__/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 +8 -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__/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__/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/footnotes.ts +264 -0
- package/src/lib/github-sync-site-config.ts +2 -0
- package/src/lib/hugo-markdown.ts +10 -3
- package/src/lib/jant-branding.ts +1 -0
- package/src/lib/markdown-manager.ts +188 -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 +7 -1
- 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 +98 -122
- package/src/lib/timezones.ts +54 -14
- package/src/lib/tiptap-render.ts +355 -81
- package/src/lib/view.ts +30 -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__/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 +2 -2
- package/src/routes/api/public/__tests__/archive.test.ts +35 -0
- package/src/routes/api/public/__tests__/posts.test.ts +208 -0
- package/src/routes/api/public/archive.ts +4 -0
- package/src/routes/api/public/posts.ts +41 -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 +2 -1
- package/src/routes/dash/settings.tsx +45 -2
- package/src/routes/feed/__tests__/feed.test.ts +210 -6
- package/src/routes/feed/feed.ts +23 -14
- package/src/routes/pages/__tests__/archive-params.test.ts +92 -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__/preview.test.ts +174 -0
- package/src/routes/pages/__tests__/search.test.ts +69 -0
- package/src/routes/pages/archive.tsx +27 -7
- package/src/routes/pages/collection.tsx +32 -19
- package/src/routes/pages/collections.tsx +6 -1
- package/src/routes/pages/page.tsx +83 -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 +159 -44
- package/src/services/__tests__/navigation.test.ts +229 -21
- package/src/services/__tests__/post-timeline.test.ts +109 -30
- package/src/services/__tests__/post.test.ts +567 -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 +226 -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 +91 -80
- package/src/services/mcp.ts +37 -27
- package/src/services/media.ts +1 -1
- package/src/services/navigation.ts +185 -46
- package/src/services/post.ts +874 -271
- package/src/services/search.ts +8 -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 -0
- package/src/styles/site-media.css +111 -19
- package/src/styles/tokens.css +14 -9
- package/src/styles/ui.css +829 -29
- 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 +9 -8
- package/src/types/operations.ts +18 -10
- package/src/types/props.ts +8 -2
- package/src/types/raw-assets.d.ts +5 -0
- package/src/types/views.ts +16 -4
- package/src/ui/__tests__/font-themes.test.ts +42 -30
- package/src/ui/compose/ComposeDialog.tsx +92 -2
- 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/NoteCard.tsx +10 -20
- 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/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 +7 -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 +33 -0
- package/src/ui/shared/HomePageBranding.tsx +2 -2
- package/src/ui/shared/MediaGallery.tsx +1 -0
- package/src/ui/shared/PostFooter.tsx +3 -1
- 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/navigation-labels.ts +2 -4
- package/src/ui/shared/post-article-attributes.ts +46 -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,
|
|
@@ -33,12 +34,19 @@ import {
|
|
|
33
34
|
} from "../db/schema-bundle.js";
|
|
34
35
|
import { createEntityId } from "../lib/ids.js";
|
|
35
36
|
import { now } from "../lib/time.js";
|
|
36
|
-
import {
|
|
37
|
+
import { trimTiptapBody } from "../lib/tiptap-render.js";
|
|
38
|
+
import {
|
|
39
|
+
POST_BODY_HTML_VERSION,
|
|
40
|
+
renderPostBodyHtml,
|
|
41
|
+
resolvePostBodyHtml,
|
|
42
|
+
tryPreparePostBodyHtml,
|
|
43
|
+
} from "../lib/post-body-html.js";
|
|
37
44
|
import { extractSummary, extractBodyText } from "../lib/summary.js";
|
|
38
45
|
import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
|
|
39
46
|
import { tiptapJsonToMarkdown } from "../lib/tiptap-to-markdown.js";
|
|
40
47
|
import { generatePostSlug } from "../lib/slug.js";
|
|
41
48
|
import { getSlugValidationIssue } from "../lib/slug-format.js";
|
|
49
|
+
import { isReservedPath } from "../lib/constants.js";
|
|
42
50
|
import { normalizePath, slugify } from "../lib/url.js";
|
|
43
51
|
import type { StorageDriver } from "../lib/storage.js";
|
|
44
52
|
import type { MediaService } from "./media.js";
|
|
@@ -73,6 +81,9 @@ import {
|
|
|
73
81
|
} from "../lib/youtube.js";
|
|
74
82
|
import { getPreviewStorageKey } from "../lib/upload.js";
|
|
75
83
|
import { generateRandomId } from "../lib/nanoid.js";
|
|
84
|
+
import { resolveSummaryConfig } from "../lib/resolve-config.js";
|
|
85
|
+
import type { Bindings } from "../types/bindings.js";
|
|
86
|
+
import type { SettingsService } from "./settings.js";
|
|
76
87
|
|
|
77
88
|
/** Dependencies for operations that coordinate with other services */
|
|
78
89
|
export interface PostDeleteDeps {
|
|
@@ -80,6 +91,32 @@ export interface PostDeleteDeps {
|
|
|
80
91
|
storage?: StorageDriver | null;
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
export interface RebuildBodyHtmlOptions {
|
|
95
|
+
/** Batch size (1..100, default 50). */
|
|
96
|
+
limit?: number;
|
|
97
|
+
/** Exclusive post ID cursor. */
|
|
98
|
+
cursor?: string;
|
|
99
|
+
/** Compute and report changes without writing them. */
|
|
100
|
+
dryRun?: boolean;
|
|
101
|
+
/** Summary settings used only when a canonical legacy body is upgraded. */
|
|
102
|
+
summaryConfig?: SummaryConfig;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface RebuildBodyHtmlResult {
|
|
106
|
+
processed: number;
|
|
107
|
+
wouldRebuild: number;
|
|
108
|
+
rebuilt: number;
|
|
109
|
+
wouldUpgradeFootnotes: number;
|
|
110
|
+
upgradedFootnotes: number;
|
|
111
|
+
skipped: number;
|
|
112
|
+
conflicted: number;
|
|
113
|
+
failed: number;
|
|
114
|
+
failures: Array<{ postId: string; error: string }>;
|
|
115
|
+
nextCursor: string | null;
|
|
116
|
+
done: boolean;
|
|
117
|
+
targetVersion: number;
|
|
118
|
+
}
|
|
119
|
+
|
|
83
120
|
export interface PostAttachmentDeps extends PostDeleteDeps {
|
|
84
121
|
storageDriver: string;
|
|
85
122
|
maxFileSizeMB: number;
|
|
@@ -119,7 +156,9 @@ export interface PostFilters {
|
|
|
119
156
|
hasReplies?: boolean;
|
|
120
157
|
/** Explicit result sort order */
|
|
121
158
|
sortOrder?: SortOrder;
|
|
122
|
-
/**
|
|
159
|
+
/** Timestamp used for chronological sorting (defaults to thread activity). */
|
|
160
|
+
sortBy?: "activity" | "published";
|
|
161
|
+
/** Ignore global pinned ordering when results must remain chronological. */
|
|
123
162
|
ignorePinnedSort?: boolean;
|
|
124
163
|
limit?: number;
|
|
125
164
|
cursor?: string;
|
|
@@ -135,6 +174,13 @@ export interface SummaryConfig {
|
|
|
135
174
|
interface ThreadRootPageOptions {
|
|
136
175
|
status?: Status;
|
|
137
176
|
excludePrivate?: boolean;
|
|
177
|
+
excludeLatestHidden?: boolean;
|
|
178
|
+
/** Exclude Posts published at or after this Unix timestamp. */
|
|
179
|
+
publishedBefore?: number;
|
|
180
|
+
/** Restrict by the Thread root's format without excluding Child Posts. */
|
|
181
|
+
rootFormat?: Format;
|
|
182
|
+
/** Restrict to Threads with at least one rated published post. */
|
|
183
|
+
hasRating?: true;
|
|
138
184
|
limit?: number;
|
|
139
185
|
offset?: number;
|
|
140
186
|
}
|
|
@@ -146,6 +192,8 @@ interface CollectionFeedEntryOptions extends ThreadRootPageOptions {
|
|
|
146
192
|
|
|
147
193
|
interface CollectionThreadRootPageOptions extends ThreadRootPageOptions {
|
|
148
194
|
sortOrder?: CollectionSortOrder;
|
|
195
|
+
/** Root Post ID returned by the previous page. */
|
|
196
|
+
cursor?: string;
|
|
149
197
|
}
|
|
150
198
|
|
|
151
199
|
interface CursorSortKey {
|
|
@@ -159,6 +207,24 @@ export interface CollectionFeedEntry {
|
|
|
159
207
|
collectedAt: number;
|
|
160
208
|
}
|
|
161
209
|
|
|
210
|
+
export interface FeaturedThreadTimelinePost {
|
|
211
|
+
post: Post;
|
|
212
|
+
/** Zero-based position among the Thread's published Posts. */
|
|
213
|
+
position: number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Bounded projection for one curated Featured Thread.
|
|
218
|
+
*
|
|
219
|
+
* `posts` contains only the Root, every Featured Post, and the final published
|
|
220
|
+
* Post, in Thread order. Positions preserve the hidden-gap counts without
|
|
221
|
+
* loading the omitted Posts or their related metadata.
|
|
222
|
+
*/
|
|
223
|
+
export interface FeaturedThreadTimelineData {
|
|
224
|
+
posts: FeaturedThreadTimelinePost[];
|
|
225
|
+
featuredPostIds: string[];
|
|
226
|
+
}
|
|
227
|
+
|
|
162
228
|
export interface PostBodyContent {
|
|
163
229
|
id: string;
|
|
164
230
|
type: "post";
|
|
@@ -237,6 +303,8 @@ export interface PostService {
|
|
|
237
303
|
/**
|
|
238
304
|
* Atomically create a thread of posts. The first item is the root; each
|
|
239
305
|
* subsequent item is automatically chained as a reply to the previous one.
|
|
306
|
+
* When the first item quietly extends an existing thread, the quiet behavior
|
|
307
|
+
* applies to every new reply in the batch.
|
|
240
308
|
* On failure, all already-created posts are rolled back.
|
|
241
309
|
*
|
|
242
310
|
* @param items - Ordered list of (data, attachments) pairs; at least 2 required
|
|
@@ -296,8 +364,12 @@ export interface PostService {
|
|
|
296
364
|
): Promise<Map<string, ThreadTimelineContext>>;
|
|
297
365
|
/** Count distinct thread roots that contain featured published posts */
|
|
298
366
|
countFeaturedThreadRoots(options?: ThreadRootPageOptions): Promise<number>;
|
|
299
|
-
/** List
|
|
367
|
+
/** List Featured Thread roots by the latest selected Post publication time. */
|
|
300
368
|
listFeaturedThreadRootIds(options?: ThreadRootPageOptions): Promise<string[]>;
|
|
369
|
+
/** Load only the Posts rendered by the curated Featured Thread timeline. */
|
|
370
|
+
getFeaturedThreadTimelineData(
|
|
371
|
+
rootIds: string[],
|
|
372
|
+
): Promise<Map<string, FeaturedThreadTimelineData>>;
|
|
301
373
|
/** Count distinct thread roots that contain published posts in the given collection */
|
|
302
374
|
countCollectionThreadRoots(
|
|
303
375
|
collectionId: string,
|
|
@@ -308,6 +380,12 @@ export interface PostService {
|
|
|
308
380
|
collectionIds: string[],
|
|
309
381
|
options?: ThreadRootPageOptions,
|
|
310
382
|
): Promise<number>;
|
|
383
|
+
/** Count distinct matching collection Threads up to a fixed limit. */
|
|
384
|
+
countCollectionThreadRootsUpToForCollections(
|
|
385
|
+
collectionIds: string[],
|
|
386
|
+
options: ThreadRootPageOptions | undefined,
|
|
387
|
+
limit: number,
|
|
388
|
+
): Promise<number>;
|
|
311
389
|
/** List collection thread root IDs ordered by collected-at or rating semantics */
|
|
312
390
|
listCollectionThreadRootIds(
|
|
313
391
|
collectionId: string,
|
|
@@ -318,6 +396,11 @@ export interface PostService {
|
|
|
318
396
|
collectionIds: string[],
|
|
319
397
|
options?: CollectionThreadRootPageOptions,
|
|
320
398
|
): Promise<string[]>;
|
|
399
|
+
/** List and hydrate collection Thread roots using Thread-level ordering. */
|
|
400
|
+
listCollectionThreadRootsForCollections(
|
|
401
|
+
collectionIds: string[],
|
|
402
|
+
options?: CollectionThreadRootPageOptions,
|
|
403
|
+
): Promise<Post[]>;
|
|
321
404
|
/** List collection feed entries ordered by latest added-at timestamp */
|
|
322
405
|
listCollectionFeedEntries(
|
|
323
406
|
collectionId: string,
|
|
@@ -328,18 +411,11 @@ export interface PostService {
|
|
|
328
411
|
collectionIds: string[],
|
|
329
412
|
options?: CollectionFeedEntryOptions,
|
|
330
413
|
): Promise<CollectionFeedEntry[]>;
|
|
331
|
-
/** Fetch
|
|
332
|
-
getPublishedThreads(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
threadIds: string[],
|
|
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[]>>;
|
|
414
|
+
/** Fetch published Posts for each requested Thread root. */
|
|
415
|
+
getPublishedThreads(
|
|
416
|
+
rootIds: string[],
|
|
417
|
+
options?: Pick<PostFilters, "publishedBefore">,
|
|
418
|
+
): Promise<Map<string, Post[]>>;
|
|
343
419
|
/** Get distinct years that have published posts */
|
|
344
420
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
345
421
|
/** For each thread ID, return the ID of the last published, non-deleted post */
|
|
@@ -366,6 +442,44 @@ export interface PostService {
|
|
|
366
442
|
nextCursor: string | null;
|
|
367
443
|
done: boolean;
|
|
368
444
|
}>;
|
|
445
|
+
/**
|
|
446
|
+
* Upgrade recognized legacy footnotes and rebuild stored post body HTML for
|
|
447
|
+
* this site.
|
|
448
|
+
*
|
|
449
|
+
* Updates use compare-and-swap guards so concurrent edits win. Editorial
|
|
450
|
+
* timestamps are never touched. The operation is cursor-paginated,
|
|
451
|
+
* idempotent, and supports a read-only dry run.
|
|
452
|
+
*/
|
|
453
|
+
rebuildBodyHtml(
|
|
454
|
+
options?: RebuildBodyHtmlOptions,
|
|
455
|
+
): Promise<RebuildBodyHtmlResult>;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Rebuild body HTML using the site's current runtime summary settings.
|
|
460
|
+
*
|
|
461
|
+
* Internal maintenance routes do not run behind the normal app-config
|
|
462
|
+
* middleware, so this service-level orchestration preserves the same
|
|
463
|
+
* DB → environment → built-in resolution used by regular requests.
|
|
464
|
+
*
|
|
465
|
+
* @param services - Site-scoped post and settings services.
|
|
466
|
+
* @param env - Runtime environment bindings.
|
|
467
|
+
* @param options - Rebuild options supplied by the maintenance caller.
|
|
468
|
+
* @returns Rebuild progress and outcome counts.
|
|
469
|
+
*/
|
|
470
|
+
export async function rebuildPostBodyHtmlWithRuntimeSettings(
|
|
471
|
+
services: Pick<
|
|
472
|
+
{ posts: PostService; settings: SettingsService },
|
|
473
|
+
"posts" | "settings"
|
|
474
|
+
>,
|
|
475
|
+
env: Bindings,
|
|
476
|
+
options: Omit<RebuildBodyHtmlOptions, "summaryConfig"> = {},
|
|
477
|
+
): Promise<RebuildBodyHtmlResult> {
|
|
478
|
+
const allSettings = await services.settings.getAll();
|
|
479
|
+
return services.posts.rebuildBodyHtml({
|
|
480
|
+
...options,
|
|
481
|
+
summaryConfig: resolveSummaryConfig(env, allSettings),
|
|
482
|
+
});
|
|
369
483
|
}
|
|
370
484
|
|
|
371
485
|
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
@@ -521,7 +635,8 @@ export function createPostService(
|
|
|
521
635
|
databaseSchema: DatabaseSchema = sqliteSchemaBundle,
|
|
522
636
|
): PostService {
|
|
523
637
|
const resolvedPaths = paths ?? createPathService(db, siteId, databaseSchema);
|
|
524
|
-
const { pathRegistry, posts,
|
|
638
|
+
const { navItems, pathRegistry, posts, sites, threadCollections } =
|
|
639
|
+
databaseSchema;
|
|
525
640
|
const databaseDialect = config.databaseDialect ?? "sqlite";
|
|
526
641
|
const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
|
|
527
642
|
|
|
@@ -695,11 +810,11 @@ export function createPostService(
|
|
|
695
810
|
}
|
|
696
811
|
|
|
697
812
|
return uniqueCollectionIds.length === 1
|
|
698
|
-
? eq(
|
|
699
|
-
: inArray(
|
|
813
|
+
? eq(threadCollections.collectionId, firstCollectionId)
|
|
814
|
+
: inArray(threadCollections.collectionId, uniqueCollectionIds);
|
|
700
815
|
}
|
|
701
816
|
|
|
702
|
-
function
|
|
817
|
+
function buildThreadCollectionSubqueryCondition(
|
|
703
818
|
collectionIds: readonly string[],
|
|
704
819
|
): SQL<unknown> {
|
|
705
820
|
const uniqueCollectionIds = normalizeCollectionIds(collectionIds);
|
|
@@ -710,9 +825,9 @@ export function createPostService(
|
|
|
710
825
|
const placeholders = uniqueCollectionIds.map(
|
|
711
826
|
(collectionId) => sql`${collectionId}`,
|
|
712
827
|
);
|
|
713
|
-
return sql`${posts.
|
|
714
|
-
SELECT
|
|
715
|
-
FROM
|
|
828
|
+
return sql`${posts.threadId} IN (
|
|
829
|
+
SELECT thread_id
|
|
830
|
+
FROM thread_collection
|
|
716
831
|
WHERE site_id = ${siteId}
|
|
717
832
|
AND collection_id IN (${sql.join(placeholders, sql`, `)})
|
|
718
833
|
)`;
|
|
@@ -753,11 +868,11 @@ export function createPostService(
|
|
|
753
868
|
}
|
|
754
869
|
if (filters.collectionIds !== undefined) {
|
|
755
870
|
conditions.push(
|
|
756
|
-
|
|
871
|
+
buildThreadCollectionSubqueryCondition(filters.collectionIds),
|
|
757
872
|
);
|
|
758
873
|
} else if (filters.collectionId !== undefined) {
|
|
759
874
|
conditions.push(
|
|
760
|
-
|
|
875
|
+
buildThreadCollectionSubqueryCondition([filters.collectionId]),
|
|
761
876
|
);
|
|
762
877
|
}
|
|
763
878
|
if (filters.threadId) {
|
|
@@ -844,7 +959,13 @@ export function createPostService(
|
|
|
844
959
|
return rows[0]?.id ?? null;
|
|
845
960
|
}
|
|
846
961
|
|
|
847
|
-
function getCursorSortTimestamp(
|
|
962
|
+
function getCursorSortTimestamp(
|
|
963
|
+
row: typeof posts.$inferSelect,
|
|
964
|
+
filters: PostFilters,
|
|
965
|
+
): number {
|
|
966
|
+
if (filters.sortBy === "published") {
|
|
967
|
+
return row.publishedAt ?? row.createdAt;
|
|
968
|
+
}
|
|
848
969
|
return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
|
|
849
970
|
}
|
|
850
971
|
|
|
@@ -888,16 +1009,20 @@ export function createPostService(
|
|
|
888
1009
|
}
|
|
889
1010
|
|
|
890
1011
|
const sortTimestampExpr =
|
|
891
|
-
filters.
|
|
892
|
-
? posts.
|
|
893
|
-
: filters.status === "
|
|
894
|
-
? posts.
|
|
895
|
-
:
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
1012
|
+
filters.sortBy === "published"
|
|
1013
|
+
? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
|
|
1014
|
+
: filters.status === "draft"
|
|
1015
|
+
? posts.updatedAt
|
|
1016
|
+
: filters.status === "published"
|
|
1017
|
+
? posts.lastActivityAt
|
|
1018
|
+
: sql<number>`CASE
|
|
1019
|
+
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1020
|
+
ELSE ${posts.lastActivityAt}
|
|
1021
|
+
END`;
|
|
899
1022
|
const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
|
|
900
|
-
const
|
|
1023
|
+
const featuredPublishedSortExpr = sql<number>`coalesce(
|
|
1024
|
+
${posts.publishedAt}, ${posts.createdAt}, -1
|
|
1025
|
+
)`;
|
|
901
1026
|
const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestampExpr}, -1)`;
|
|
902
1027
|
const ratingPresenceExpr = sql<number>`CASE
|
|
903
1028
|
WHEN ${posts.rating} IS NULL THEN 0
|
|
@@ -905,8 +1030,9 @@ export function createPostService(
|
|
|
905
1030
|
END`;
|
|
906
1031
|
const ratingSortExpr = sql<number>`coalesce(${posts.rating}, -1)`;
|
|
907
1032
|
const cursorPinnedAt = cursorPost.pinnedAt ?? -1;
|
|
908
|
-
const
|
|
909
|
-
|
|
1033
|
+
const cursorFeaturedPublishedAt =
|
|
1034
|
+
cursorPost.publishedAt ?? cursorPost.createdAt;
|
|
1035
|
+
const cursorSortTimestamp = getCursorSortTimestamp(cursorPost, filters);
|
|
910
1036
|
const cursorRatingPresence = cursorPost.rating === null ? 0 : 1;
|
|
911
1037
|
const cursorRating = cursorPost.rating ?? -1;
|
|
912
1038
|
const withPinnedSortKey = (
|
|
@@ -925,8 +1051,8 @@ export function createPostService(
|
|
|
925
1051
|
withPinnedSortKey([
|
|
926
1052
|
{
|
|
927
1053
|
direction: "desc",
|
|
928
|
-
expr:
|
|
929
|
-
value:
|
|
1054
|
+
expr: featuredPublishedSortExpr,
|
|
1055
|
+
value: cursorFeaturedPublishedAt,
|
|
930
1056
|
},
|
|
931
1057
|
{ direction: "desc", expr: posts.id, value: cursorPost.id },
|
|
932
1058
|
]),
|
|
@@ -1012,7 +1138,12 @@ export function createPostService(
|
|
|
1012
1138
|
title: row.title,
|
|
1013
1139
|
url: row.url,
|
|
1014
1140
|
body: row.body,
|
|
1015
|
-
bodyHtml:
|
|
1141
|
+
bodyHtml: resolvePostBodyHtml({
|
|
1142
|
+
id: row.id,
|
|
1143
|
+
body: row.body,
|
|
1144
|
+
bodyHtml: row.bodyHtml,
|
|
1145
|
+
bodyHtmlVersion: row.bodyHtmlVersion,
|
|
1146
|
+
}),
|
|
1016
1147
|
bodyText: row.bodyText,
|
|
1017
1148
|
quoteText: row.quoteText,
|
|
1018
1149
|
summary: row.summary,
|
|
@@ -1122,6 +1253,33 @@ export function createPostService(
|
|
|
1122
1253
|
if (options?.excludePrivate) {
|
|
1123
1254
|
conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
|
|
1124
1255
|
}
|
|
1256
|
+
if (options?.excludeLatestHidden) {
|
|
1257
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'latest_hidden'`);
|
|
1258
|
+
}
|
|
1259
|
+
if (options?.publishedBefore !== undefined) {
|
|
1260
|
+
conditions.push(
|
|
1261
|
+
sql`${posts.publishedAt} < ${options.publishedBefore}`,
|
|
1262
|
+
sql`EXISTS (
|
|
1263
|
+
SELECT 1
|
|
1264
|
+
FROM post AS publication_root
|
|
1265
|
+
WHERE publication_root.site_id = ${siteId}
|
|
1266
|
+
AND publication_root.id = ${posts.threadId}
|
|
1267
|
+
AND publication_root.published_at < ${options.publishedBefore}
|
|
1268
|
+
)`,
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
if (options?.rootFormat) {
|
|
1272
|
+
conditions.push(sql`EXISTS (
|
|
1273
|
+
SELECT 1
|
|
1274
|
+
FROM post AS collection_root
|
|
1275
|
+
WHERE collection_root.site_id = ${siteId}
|
|
1276
|
+
AND collection_root.id = ${posts.threadId}
|
|
1277
|
+
AND collection_root.format = ${options.rootFormat}
|
|
1278
|
+
)`);
|
|
1279
|
+
}
|
|
1280
|
+
if (options?.hasRating) {
|
|
1281
|
+
conditions.push(isNotNull(posts.rating));
|
|
1282
|
+
}
|
|
1125
1283
|
|
|
1126
1284
|
return conditions;
|
|
1127
1285
|
}
|
|
@@ -1130,7 +1288,12 @@ export function createPostService(
|
|
|
1130
1288
|
return sql<number>`MAX(
|
|
1131
1289
|
COALESCE(
|
|
1132
1290
|
(
|
|
1133
|
-
SELECT
|
|
1291
|
+
SELECT CASE
|
|
1292
|
+
WHEN root.updated_at > root.created_at
|
|
1293
|
+
AND root.updated_at > COALESCE(root.last_activity_at, -1)
|
|
1294
|
+
THEN root.updated_at
|
|
1295
|
+
ELSE COALESCE(root.last_activity_at, root.updated_at)
|
|
1296
|
+
END
|
|
1134
1297
|
FROM post AS root
|
|
1135
1298
|
WHERE root.site_id = ${siteId}
|
|
1136
1299
|
AND root.id = ${posts.threadId}
|
|
@@ -1141,6 +1304,59 @@ export function createPostService(
|
|
|
1141
1304
|
)`.as(alias);
|
|
1142
1305
|
}
|
|
1143
1306
|
|
|
1307
|
+
function buildCollectionThreadSortQuery(
|
|
1308
|
+
collectionIds: string[],
|
|
1309
|
+
options: CollectionThreadRootPageOptions,
|
|
1310
|
+
) {
|
|
1311
|
+
const conditions = [
|
|
1312
|
+
...buildThreadRootPageConditions(options),
|
|
1313
|
+
buildCollectionMembershipCondition(collectionIds),
|
|
1314
|
+
];
|
|
1315
|
+
const sortOrder = options.sortOrder ?? "newest";
|
|
1316
|
+
const publishedAt =
|
|
1317
|
+
sortOrder === "oldest"
|
|
1318
|
+
? sql<number>`MIN(${posts.publishedAt})`.as("published_at")
|
|
1319
|
+
: sql<number>`MAX(${posts.publishedAt})`.as("published_at");
|
|
1320
|
+
const threadActivityAt =
|
|
1321
|
+
buildCollectionThreadActivityExpr("thread_activity_at");
|
|
1322
|
+
const ratingPresence = sql<number>`MAX(
|
|
1323
|
+
CASE
|
|
1324
|
+
WHEN ${posts.rating} IS NULL THEN 0
|
|
1325
|
+
ELSE 1
|
|
1326
|
+
END
|
|
1327
|
+
)`.as("rating_presence");
|
|
1328
|
+
const ratingValue = sql<number>`MAX(coalesce(${posts.rating}, -1))`.as(
|
|
1329
|
+
"rating_value",
|
|
1330
|
+
);
|
|
1331
|
+
const collectionPinnedAt =
|
|
1332
|
+
sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
|
|
1333
|
+
"collection_pinned_at",
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
const sortedThreads = db
|
|
1337
|
+
.select({
|
|
1338
|
+
threadId: posts.threadId,
|
|
1339
|
+
publishedAt,
|
|
1340
|
+
threadActivityAt,
|
|
1341
|
+
collectionPinnedAt,
|
|
1342
|
+
ratingPresence,
|
|
1343
|
+
ratingValue,
|
|
1344
|
+
})
|
|
1345
|
+
.from(posts)
|
|
1346
|
+
.innerJoin(
|
|
1347
|
+
threadCollections,
|
|
1348
|
+
and(
|
|
1349
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
1350
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
1351
|
+
),
|
|
1352
|
+
)
|
|
1353
|
+
.where(and(...conditions))
|
|
1354
|
+
.groupBy(posts.threadId)
|
|
1355
|
+
.as("collection_thread_sort");
|
|
1356
|
+
|
|
1357
|
+
return { sortOrder, sortedThreads };
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1144
1360
|
function isMediaAttachmentInput(
|
|
1145
1361
|
attachment: PostAttachmentInput,
|
|
1146
1362
|
): attachment is Extract<PostAttachmentInput, { type: "media" }> {
|
|
@@ -1210,36 +1426,50 @@ export function createPostService(
|
|
|
1210
1426
|
await deps.media.deleteByIds(mediaIds, deps.storage).catch(() => undefined);
|
|
1211
1427
|
}
|
|
1212
1428
|
|
|
1213
|
-
async function
|
|
1429
|
+
async function getCollectionIdsForThread(
|
|
1430
|
+
threadId: string,
|
|
1431
|
+
): Promise<string[]> {
|
|
1214
1432
|
const rows = await db
|
|
1215
|
-
.select({ collectionId:
|
|
1216
|
-
.from(
|
|
1433
|
+
.select({ collectionId: threadCollections.collectionId })
|
|
1434
|
+
.from(threadCollections)
|
|
1217
1435
|
.where(
|
|
1218
1436
|
and(
|
|
1219
|
-
eq(
|
|
1220
|
-
eq(
|
|
1437
|
+
eq(threadCollections.siteId, siteId),
|
|
1438
|
+
eq(threadCollections.threadId, threadId),
|
|
1221
1439
|
),
|
|
1222
1440
|
);
|
|
1223
1441
|
|
|
1224
1442
|
return rows.map((row) => row.collectionId);
|
|
1225
1443
|
}
|
|
1226
1444
|
|
|
1227
|
-
async function
|
|
1228
|
-
|
|
1229
|
-
|
|
1445
|
+
async function getThreadCollectionEntries(threadId: string): Promise<
|
|
1446
|
+
Map<
|
|
1447
|
+
string,
|
|
1448
|
+
{
|
|
1449
|
+
createdAt: number;
|
|
1450
|
+
pinnedAt: number | null;
|
|
1451
|
+
}
|
|
1452
|
+
>
|
|
1453
|
+
> {
|
|
1230
1454
|
const rows = await db
|
|
1231
1455
|
.select({
|
|
1232
|
-
collectionId:
|
|
1233
|
-
createdAt:
|
|
1456
|
+
collectionId: threadCollections.collectionId,
|
|
1457
|
+
createdAt: threadCollections.createdAt,
|
|
1458
|
+
pinnedAt: threadCollections.pinnedAt,
|
|
1234
1459
|
})
|
|
1235
|
-
.from(
|
|
1460
|
+
.from(threadCollections)
|
|
1236
1461
|
.where(
|
|
1237
1462
|
and(
|
|
1238
|
-
eq(
|
|
1239
|
-
eq(
|
|
1463
|
+
eq(threadCollections.siteId, siteId),
|
|
1464
|
+
eq(threadCollections.threadId, threadId),
|
|
1240
1465
|
),
|
|
1241
1466
|
);
|
|
1242
|
-
return new Map(
|
|
1467
|
+
return new Map(
|
|
1468
|
+
rows.map((row) => [
|
|
1469
|
+
row.collectionId,
|
|
1470
|
+
{ createdAt: row.createdAt, pinnedAt: row.pinnedAt },
|
|
1471
|
+
]),
|
|
1472
|
+
);
|
|
1243
1473
|
}
|
|
1244
1474
|
|
|
1245
1475
|
function buildRollbackUpdate(
|
|
@@ -1324,14 +1554,16 @@ export function createPostService(
|
|
|
1324
1554
|
return [];
|
|
1325
1555
|
}
|
|
1326
1556
|
const sortTimestamp =
|
|
1327
|
-
filters.
|
|
1328
|
-
? posts.
|
|
1329
|
-
: filters.status === "
|
|
1330
|
-
? posts.
|
|
1331
|
-
:
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1557
|
+
filters.sortBy === "published"
|
|
1558
|
+
? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
|
|
1559
|
+
: filters.status === "draft"
|
|
1560
|
+
? posts.updatedAt
|
|
1561
|
+
: filters.status === "published"
|
|
1562
|
+
? posts.lastActivityAt
|
|
1563
|
+
: sql<number>`CASE
|
|
1564
|
+
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1565
|
+
ELSE ${posts.lastActivityAt}
|
|
1566
|
+
END`;
|
|
1335
1567
|
|
|
1336
1568
|
if (cursorCondition) {
|
|
1337
1569
|
conditions.push(cursorCondition);
|
|
@@ -1344,10 +1576,13 @@ export function createPostService(
|
|
|
1344
1576
|
|
|
1345
1577
|
// NULL-sort order differs between dialects: SQLite puts NULLs last for
|
|
1346
1578
|
// DESC, Postgres puts them first. Wrap nullable sort keys in COALESCE
|
|
1347
|
-
// so pinned
|
|
1579
|
+
// so pinned and nullable timeline values sort identically under both
|
|
1580
|
+
// engines. Mirrors the
|
|
1348
1581
|
// expressions used by `buildListCursorCondition` above.
|
|
1349
1582
|
const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
|
|
1350
|
-
const
|
|
1583
|
+
const featuredPublishedSortExpr = sql<number>`coalesce(
|
|
1584
|
+
${posts.publishedAt}, ${posts.createdAt}, -1
|
|
1585
|
+
)`;
|
|
1351
1586
|
const sortTimestampSortExpr = sql<number>`coalesce(${sortTimestamp}, -1)`;
|
|
1352
1587
|
const pinnedOrder = filters.ignorePinnedSort
|
|
1353
1588
|
? []
|
|
@@ -1364,7 +1599,7 @@ export function createPostService(
|
|
|
1364
1599
|
? baseQuery.orderBy(
|
|
1365
1600
|
...pinnedOrder,
|
|
1366
1601
|
filters.featured
|
|
1367
|
-
? desc(
|
|
1602
|
+
? desc(featuredPublishedSortExpr)
|
|
1368
1603
|
: desc(sortTimestampSortExpr),
|
|
1369
1604
|
desc(posts.id),
|
|
1370
1605
|
)
|
|
@@ -1535,7 +1770,11 @@ export function createPostService(
|
|
|
1535
1770
|
const rawBody = data.bodyMarkdown
|
|
1536
1771
|
? markdownToTiptapJson(data.bodyMarkdown)
|
|
1537
1772
|
: (data.body ?? null);
|
|
1538
|
-
const
|
|
1773
|
+
const trimmedBody = rawBody ? trimTiptapBody(rawBody) : null;
|
|
1774
|
+
const preparedBody = trimmedBody
|
|
1775
|
+
? tryPreparePostBodyHtml(id, trimmedBody)
|
|
1776
|
+
: null;
|
|
1777
|
+
const body = preparedBody?.ok ? preparedBody.body : trimmedBody;
|
|
1539
1778
|
const title = data.title?.trim() || null;
|
|
1540
1779
|
const quoteText = data.quoteText?.trim() || null;
|
|
1541
1780
|
const url = data.url?.trim() || null;
|
|
@@ -1551,7 +1790,11 @@ export function createPostService(
|
|
|
1551
1790
|
: undefined,
|
|
1552
1791
|
});
|
|
1553
1792
|
|
|
1554
|
-
const bodyHtml =
|
|
1793
|
+
const bodyHtml = preparedBody?.ok
|
|
1794
|
+
? preparedBody.html
|
|
1795
|
+
: body
|
|
1796
|
+
? renderPostBodyHtml(id, body)
|
|
1797
|
+
: null;
|
|
1555
1798
|
const bodyText = body
|
|
1556
1799
|
? extractBodyText(body, { includeLinkHrefs: true })
|
|
1557
1800
|
: null;
|
|
@@ -1623,6 +1866,15 @@ export function createPostService(
|
|
|
1623
1866
|
}
|
|
1624
1867
|
}
|
|
1625
1868
|
visibility = null;
|
|
1869
|
+
|
|
1870
|
+
if (
|
|
1871
|
+
(data.collectionIds?.length ?? 0) > 0 ||
|
|
1872
|
+
(data.collectionEntries?.length ?? 0) > 0
|
|
1873
|
+
) {
|
|
1874
|
+
throw new ConflictError(
|
|
1875
|
+
"Cannot set Collections while creating a Thread reply. Set them on the Thread root instead.",
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1626
1878
|
}
|
|
1627
1879
|
|
|
1628
1880
|
assertDraftPublishedAt(status, data.publishedAt);
|
|
@@ -1643,6 +1895,11 @@ export function createPostService(
|
|
|
1643
1895
|
|
|
1644
1896
|
if (data.path) {
|
|
1645
1897
|
const normalized = normalizePath(data.path);
|
|
1898
|
+
if (isReservedPath(normalized)) {
|
|
1899
|
+
throw new ValidationError(
|
|
1900
|
+
`Path "${normalized}" is reserved and cannot be used`,
|
|
1901
|
+
);
|
|
1902
|
+
}
|
|
1646
1903
|
if (isValidSlug(normalized)) {
|
|
1647
1904
|
// Path is a valid slug — use it directly
|
|
1648
1905
|
slug = await generatePostSlug({
|
|
@@ -1674,7 +1931,8 @@ export function createPostService(
|
|
|
1674
1931
|
});
|
|
1675
1932
|
}
|
|
1676
1933
|
|
|
1677
|
-
//
|
|
1934
|
+
// Thread collection membership is stored against the root ID. When
|
|
1935
|
+
// structured `collectionEntries` are provided (Hugo import path),
|
|
1678
1936
|
// they win over the bare `collectionIds` slug list and carry
|
|
1679
1937
|
// `createdAt` / `position` / `pinnedAt` per row. Otherwise fall back to
|
|
1680
1938
|
// the simple list and derive those fields from sensible defaults.
|
|
@@ -1683,7 +1941,7 @@ export function createPostService(
|
|
|
1683
1941
|
data.collectionEntries.length > 0;
|
|
1684
1942
|
const collectionInsertRows: {
|
|
1685
1943
|
siteId: string;
|
|
1686
|
-
|
|
1944
|
+
threadId: string;
|
|
1687
1945
|
collectionId: string;
|
|
1688
1946
|
createdAt: number;
|
|
1689
1947
|
position: number;
|
|
@@ -1693,7 +1951,7 @@ export function createPostService(
|
|
|
1693
1951
|
const seen = new Set<string>();
|
|
1694
1952
|
const rows: {
|
|
1695
1953
|
siteId: string;
|
|
1696
|
-
|
|
1954
|
+
threadId: string;
|
|
1697
1955
|
collectionId: string;
|
|
1698
1956
|
createdAt: number;
|
|
1699
1957
|
position: number;
|
|
@@ -1705,7 +1963,7 @@ export function createPostService(
|
|
|
1705
1963
|
seen.add(entry.collectionId);
|
|
1706
1964
|
rows.push({
|
|
1707
1965
|
siteId,
|
|
1708
|
-
|
|
1966
|
+
threadId,
|
|
1709
1967
|
collectionId: entry.collectionId,
|
|
1710
1968
|
createdAt: entry.createdAt ?? timestamp,
|
|
1711
1969
|
position: entry.position ?? fallbackPosition,
|
|
@@ -1717,7 +1975,7 @@ export function createPostService(
|
|
|
1717
1975
|
})()
|
|
1718
1976
|
: [...new Set(data.collectionIds ?? [])].map((collectionId, index) => ({
|
|
1719
1977
|
siteId,
|
|
1720
|
-
|
|
1978
|
+
threadId,
|
|
1721
1979
|
collectionId,
|
|
1722
1980
|
createdAt: timestamp,
|
|
1723
1981
|
position: index,
|
|
@@ -1741,6 +1999,7 @@ export function createPostService(
|
|
|
1741
1999
|
url,
|
|
1742
2000
|
body,
|
|
1743
2001
|
bodyHtml,
|
|
2002
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
1744
2003
|
bodyText,
|
|
1745
2004
|
quoteText,
|
|
1746
2005
|
summary,
|
|
@@ -1788,7 +2047,12 @@ export function createPostService(
|
|
|
1788
2047
|
|
|
1789
2048
|
if (collectionInsertRows.length > 0) {
|
|
1790
2049
|
writeQueries.push(
|
|
1791
|
-
|
|
2050
|
+
data.replyToId
|
|
2051
|
+
? db
|
|
2052
|
+
.insert(threadCollections)
|
|
2053
|
+
.values(collectionInsertRows)
|
|
2054
|
+
.onConflictDoNothing()
|
|
2055
|
+
: db.insert(threadCollections).values(collectionInsertRows),
|
|
1792
2056
|
);
|
|
1793
2057
|
}
|
|
1794
2058
|
|
|
@@ -1812,6 +2076,7 @@ export function createPostService(
|
|
|
1812
2076
|
url,
|
|
1813
2077
|
body,
|
|
1814
2078
|
bodyHtml,
|
|
2079
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
1815
2080
|
bodyText,
|
|
1816
2081
|
quoteText,
|
|
1817
2082
|
summary,
|
|
@@ -1853,7 +2118,14 @@ export function createPostService(
|
|
|
1853
2118
|
}
|
|
1854
2119
|
|
|
1855
2120
|
if (collectionInsertRows.length > 0) {
|
|
1856
|
-
|
|
2121
|
+
const insertQuery = tx
|
|
2122
|
+
.insert(threadCollections)
|
|
2123
|
+
.values(collectionInsertRows);
|
|
2124
|
+
if (data.replyToId) {
|
|
2125
|
+
await insertQuery.onConflictDoNothing();
|
|
2126
|
+
} else {
|
|
2127
|
+
await insertQuery;
|
|
2128
|
+
}
|
|
1857
2129
|
}
|
|
1858
2130
|
});
|
|
1859
2131
|
}
|
|
@@ -1926,6 +2198,9 @@ export function createPostService(
|
|
|
1926
2198
|
throw new ValidationError("A thread requires at least 2 posts.");
|
|
1927
2199
|
}
|
|
1928
2200
|
|
|
2201
|
+
const extendsExistingThreadQuietly =
|
|
2202
|
+
items[0]?.data.replyToId !== undefined &&
|
|
2203
|
+
items[0].data.quietReply === true;
|
|
1929
2204
|
const created: Post[] = [];
|
|
1930
2205
|
|
|
1931
2206
|
for (let i = 0; i < items.length; i++) {
|
|
@@ -1937,6 +2212,7 @@ export function createPostService(
|
|
|
1937
2212
|
...data,
|
|
1938
2213
|
// Chain each post as a reply to the previous one (server-side chaining)
|
|
1939
2214
|
replyToId: i === 0 ? data.replyToId : prevPost?.id,
|
|
2215
|
+
quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
|
|
1940
2216
|
};
|
|
1941
2217
|
|
|
1942
2218
|
try {
|
|
@@ -1990,6 +2266,19 @@ export function createPostService(
|
|
|
1990
2266
|
|
|
1991
2267
|
const nextTitle =
|
|
1992
2268
|
data.title !== undefined ? data.title?.trim() || null : existing.title;
|
|
2269
|
+
const effectiveNextVisibility = nextVisibility ?? existing.visibility;
|
|
2270
|
+
const wasNavigationPageEligible =
|
|
2271
|
+
existing.format === "note" &&
|
|
2272
|
+
existing.status === "published" &&
|
|
2273
|
+
existing.visibility !== "private" &&
|
|
2274
|
+
!isThreadReply(existing) &&
|
|
2275
|
+
hasNonEmptyText(existing.title);
|
|
2276
|
+
const remainsNavigationPageEligible =
|
|
2277
|
+
nextFormat === "note" &&
|
|
2278
|
+
nextStatus === "published" &&
|
|
2279
|
+
effectiveNextVisibility !== "private" &&
|
|
2280
|
+
!isThreadReply(existing) &&
|
|
2281
|
+
hasNonEmptyText(nextTitle);
|
|
1993
2282
|
|
|
1994
2283
|
assertPostFormatShape({
|
|
1995
2284
|
format: nextFormat,
|
|
@@ -2046,17 +2335,25 @@ export function createPostService(
|
|
|
2046
2335
|
updates.featuredAt = data.featured ? timestamp : null;
|
|
2047
2336
|
}
|
|
2048
2337
|
|
|
2338
|
+
let updatedBody: string | null | undefined;
|
|
2049
2339
|
if (data.body !== undefined || data.bodyMarkdown !== undefined) {
|
|
2050
2340
|
const rawBody = data.bodyMarkdown
|
|
2051
2341
|
? markdownToTiptapJson(data.bodyMarkdown)
|
|
2052
2342
|
: (data.body ?? null);
|
|
2053
2343
|
const normalizedBody = rawBody ? trimTiptapBody(rawBody) : null;
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
? renderTiptapJson(normalizedBody)
|
|
2344
|
+
const preparedBody = normalizedBody
|
|
2345
|
+
? tryPreparePostBodyHtml(existing.id, normalizedBody)
|
|
2057
2346
|
: null;
|
|
2058
|
-
|
|
2059
|
-
|
|
2347
|
+
updatedBody = preparedBody?.ok ? preparedBody.body : normalizedBody;
|
|
2348
|
+
updates.body = updatedBody;
|
|
2349
|
+
updates.bodyHtml = preparedBody?.ok
|
|
2350
|
+
? preparedBody.html
|
|
2351
|
+
: updatedBody
|
|
2352
|
+
? renderPostBodyHtml(existing.id, updatedBody)
|
|
2353
|
+
: null;
|
|
2354
|
+
updates.bodyHtmlVersion = POST_BODY_HTML_VERSION;
|
|
2355
|
+
updates.bodyText = updatedBody
|
|
2356
|
+
? extractBodyText(updatedBody, { includeLinkHrefs: true })
|
|
2060
2357
|
: null;
|
|
2061
2358
|
}
|
|
2062
2359
|
|
|
@@ -2064,14 +2361,7 @@ export function createPostService(
|
|
|
2064
2361
|
if (summaryConfig) {
|
|
2065
2362
|
const format = nextFormat;
|
|
2066
2363
|
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;
|
|
2364
|
+
const body = updatedBody !== undefined ? updatedBody : existing.body;
|
|
2075
2365
|
if (format === "note" && title && body) {
|
|
2076
2366
|
updates.summary = extractSummary(
|
|
2077
2367
|
body,
|
|
@@ -2128,14 +2418,24 @@ export function createPostService(
|
|
|
2128
2418
|
const needsCascade = statusChanged && !isThreadReply(existing);
|
|
2129
2419
|
const needsReplyVisibilityCleanup =
|
|
2130
2420
|
!isThreadReply(existing) && (statusChanged || visibilityChanged);
|
|
2131
|
-
const needsCollectionSync =
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
: [];
|
|
2421
|
+
const needsCollectionSync =
|
|
2422
|
+
data.collectionIds !== undefined ||
|
|
2423
|
+
data.collectionEntries !== undefined;
|
|
2135
2424
|
const needsThreadActivityRecalc =
|
|
2136
2425
|
statusChanged || publishedAtChanged || existing.status === "draft";
|
|
2426
|
+
const needsPageNavDelete =
|
|
2427
|
+
wasNavigationPageEligible && !remainsNavigationPageEligible;
|
|
2428
|
+
const needsPageNavUrlUpdate =
|
|
2429
|
+
wasNavigationPageEligible &&
|
|
2430
|
+
remainsNavigationPageEligible &&
|
|
2431
|
+
slugChanged &&
|
|
2432
|
+
Boolean(data.slug);
|
|
2137
2433
|
const hasExtraWrites =
|
|
2138
|
-
needsCascade ||
|
|
2434
|
+
needsCascade ||
|
|
2435
|
+
needsReplyVisibilityCleanup ||
|
|
2436
|
+
needsCollectionSync ||
|
|
2437
|
+
needsPageNavDelete ||
|
|
2438
|
+
needsPageNavUrlUpdate;
|
|
2139
2439
|
|
|
2140
2440
|
if (!hasExtraWrites) {
|
|
2141
2441
|
// Simple case: only the post update
|
|
@@ -2151,10 +2451,57 @@ export function createPostService(
|
|
|
2151
2451
|
return hydratePost(result[0]);
|
|
2152
2452
|
}
|
|
2153
2453
|
|
|
2154
|
-
// Complex case: cascade + update + collection sync atomically
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2454
|
+
// Complex case: cascade + update + Thread collection sync atomically.
|
|
2455
|
+
// Retained entries keep their collected/pinned timestamps when a normal
|
|
2456
|
+
// collectionIds update only changes selection or ordering.
|
|
2457
|
+
const existingThreadCollections = needsCollectionSync
|
|
2458
|
+
? await getThreadCollectionEntries(existing.threadId)
|
|
2459
|
+
: new Map<string, { createdAt: number; pinnedAt: number | null }>();
|
|
2460
|
+
const collectionTimestamp = now();
|
|
2461
|
+
const nextThreadCollectionRows = (() => {
|
|
2462
|
+
if (!needsCollectionSync) return [];
|
|
2463
|
+
|
|
2464
|
+
if (data.collectionEntries !== undefined) {
|
|
2465
|
+
const seen = new Set<string>();
|
|
2466
|
+
return data.collectionEntries.flatMap((entry, index) => {
|
|
2467
|
+
if (seen.has(entry.collectionId)) return [];
|
|
2468
|
+
seen.add(entry.collectionId);
|
|
2469
|
+
const existingEntry = existingThreadCollections.get(
|
|
2470
|
+
entry.collectionId,
|
|
2471
|
+
);
|
|
2472
|
+
return [
|
|
2473
|
+
{
|
|
2474
|
+
siteId,
|
|
2475
|
+
threadId: existing.threadId,
|
|
2476
|
+
collectionId: entry.collectionId,
|
|
2477
|
+
createdAt:
|
|
2478
|
+
entry.createdAt ??
|
|
2479
|
+
existingEntry?.createdAt ??
|
|
2480
|
+
collectionTimestamp,
|
|
2481
|
+
position: entry.position ?? index,
|
|
2482
|
+
pinnedAt:
|
|
2483
|
+
entry.pinnedAt !== undefined
|
|
2484
|
+
? entry.pinnedAt
|
|
2485
|
+
: (existingEntry?.pinnedAt ?? null),
|
|
2486
|
+
},
|
|
2487
|
+
];
|
|
2488
|
+
});
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
return [...new Set(data.collectionIds ?? [])].map(
|
|
2492
|
+
(collectionId, index) => {
|
|
2493
|
+
const existingEntry = existingThreadCollections.get(collectionId);
|
|
2494
|
+
return {
|
|
2495
|
+
siteId,
|
|
2496
|
+
threadId: existing.threadId,
|
|
2497
|
+
collectionId,
|
|
2498
|
+
createdAt: existingEntry?.createdAt ?? collectionTimestamp,
|
|
2499
|
+
position: index,
|
|
2500
|
+
pinnedAt: existingEntry?.pinnedAt ?? null,
|
|
2501
|
+
};
|
|
2502
|
+
},
|
|
2503
|
+
);
|
|
2504
|
+
})();
|
|
2158
2505
|
let updateResult: (typeof posts.$inferSelect)[] | undefined;
|
|
2159
2506
|
|
|
2160
2507
|
if (usesBatchWrites) {
|
|
@@ -2208,33 +2555,40 @@ export function createPostService(
|
|
|
2208
2555
|
.returning(),
|
|
2209
2556
|
);
|
|
2210
2557
|
|
|
2558
|
+
if (needsPageNavDelete) {
|
|
2559
|
+
writeQueries.push(
|
|
2560
|
+
db
|
|
2561
|
+
.delete(navItems)
|
|
2562
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
|
|
2563
|
+
);
|
|
2564
|
+
} else if (needsPageNavUrlUpdate && data.slug) {
|
|
2565
|
+
writeQueries.push(
|
|
2566
|
+
db
|
|
2567
|
+
.update(navItems)
|
|
2568
|
+
.set({
|
|
2569
|
+
url: `/${normalizePath(data.slug)}`,
|
|
2570
|
+
updatedAt: timestamp,
|
|
2571
|
+
})
|
|
2572
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id))),
|
|
2573
|
+
);
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2211
2576
|
if (needsCollectionSync) {
|
|
2212
|
-
// Delete all and re-insert
|
|
2577
|
+
// Delete all and re-insert the one shared Thread membership set.
|
|
2213
2578
|
writeQueries.push(
|
|
2214
2579
|
db
|
|
2215
|
-
.delete(
|
|
2580
|
+
.delete(threadCollections)
|
|
2216
2581
|
.where(
|
|
2217
2582
|
and(
|
|
2218
|
-
eq(
|
|
2219
|
-
eq(
|
|
2583
|
+
eq(threadCollections.siteId, siteId),
|
|
2584
|
+
eq(threadCollections.threadId, existing.threadId),
|
|
2220
2585
|
),
|
|
2221
2586
|
),
|
|
2222
2587
|
);
|
|
2223
2588
|
|
|
2224
|
-
if (
|
|
2225
|
-
const collectionTimestamp = now();
|
|
2589
|
+
if (nextThreadCollectionRows.length > 0) {
|
|
2226
2590
|
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
|
-
),
|
|
2591
|
+
db.insert(threadCollections).values(nextThreadCollectionRows),
|
|
2238
2592
|
);
|
|
2239
2593
|
}
|
|
2240
2594
|
}
|
|
@@ -2291,30 +2645,35 @@ export function createPostService(
|
|
|
2291
2645
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
|
|
2292
2646
|
.returning();
|
|
2293
2647
|
|
|
2648
|
+
if (needsPageNavDelete) {
|
|
2649
|
+
await tx
|
|
2650
|
+
.delete(navItems)
|
|
2651
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
|
|
2652
|
+
} else if (needsPageNavUrlUpdate && data.slug) {
|
|
2653
|
+
await tx
|
|
2654
|
+
.update(navItems)
|
|
2655
|
+
.set({
|
|
2656
|
+
url: `/${normalizePath(data.slug)}`,
|
|
2657
|
+
updatedAt: timestamp,
|
|
2658
|
+
})
|
|
2659
|
+
.where(and(eq(navItems.siteId, siteId), eq(navItems.postId, id)));
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2294
2662
|
if (needsCollectionSync) {
|
|
2295
|
-
// Delete all and re-insert
|
|
2663
|
+
// Delete all and re-insert the one shared Thread membership set.
|
|
2296
2664
|
await tx
|
|
2297
|
-
.delete(
|
|
2665
|
+
.delete(threadCollections)
|
|
2298
2666
|
.where(
|
|
2299
2667
|
and(
|
|
2300
|
-
eq(
|
|
2301
|
-
eq(
|
|
2668
|
+
eq(threadCollections.siteId, siteId),
|
|
2669
|
+
eq(threadCollections.threadId, existing.threadId),
|
|
2302
2670
|
),
|
|
2303
2671
|
);
|
|
2304
2672
|
|
|
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
|
-
);
|
|
2673
|
+
if (nextThreadCollectionRows.length > 0) {
|
|
2674
|
+
await tx
|
|
2675
|
+
.insert(threadCollections)
|
|
2676
|
+
.values(nextThreadCollectionRows);
|
|
2318
2677
|
}
|
|
2319
2678
|
}
|
|
2320
2679
|
});
|
|
@@ -2360,7 +2719,9 @@ export function createPostService(
|
|
|
2360
2719
|
const existingPost = await this.getById(id);
|
|
2361
2720
|
if (!existingPost) return null;
|
|
2362
2721
|
|
|
2363
|
-
const existingCollectionIds = await
|
|
2722
|
+
const existingCollectionIds = await getCollectionIdsForThread(
|
|
2723
|
+
existingPost.threadId,
|
|
2724
|
+
);
|
|
2364
2725
|
const rollbackData = buildRollbackUpdate(
|
|
2365
2726
|
existingPost,
|
|
2366
2727
|
existingCollectionIds,
|
|
@@ -2718,8 +3079,8 @@ export function createPostService(
|
|
|
2718
3079
|
.from(rankedReplies)
|
|
2719
3080
|
.where(
|
|
2720
3081
|
or(
|
|
2721
|
-
|
|
2722
|
-
lte(rankedReplies.latestReplyRank,
|
|
3082
|
+
lte(rankedReplies.firstReplyRank, 2),
|
|
3083
|
+
lte(rankedReplies.latestReplyRank, 3),
|
|
2723
3084
|
),
|
|
2724
3085
|
);
|
|
2725
3086
|
|
|
@@ -2730,25 +3091,25 @@ export function createPostService(
|
|
|
2730
3091
|
const contextByThreadId = new Map<
|
|
2731
3092
|
string,
|
|
2732
3093
|
{
|
|
2733
|
-
|
|
2734
|
-
|
|
3094
|
+
leadingReplyIds: Map<number, string>;
|
|
3095
|
+
trailingReplyIds: Map<number, string>;
|
|
2735
3096
|
latestReplyId: string | null;
|
|
2736
3097
|
totalReplyCount: number;
|
|
2737
3098
|
}
|
|
2738
3099
|
>();
|
|
2739
3100
|
for (const row of contextRows) {
|
|
2740
3101
|
const existing = contextByThreadId.get(row.threadId) ?? {
|
|
2741
|
-
|
|
2742
|
-
|
|
3102
|
+
leadingReplyIds: new Map<number, string>(),
|
|
3103
|
+
trailingReplyIds: new Map<number, string>(),
|
|
2743
3104
|
latestReplyId: null,
|
|
2744
3105
|
totalReplyCount: row.totalReplyCount,
|
|
2745
3106
|
};
|
|
2746
3107
|
|
|
2747
|
-
if (row.firstReplyRank
|
|
2748
|
-
existing.
|
|
3108
|
+
if (row.firstReplyRank <= 2) {
|
|
3109
|
+
existing.leadingReplyIds.set(row.firstReplyRank, row.id);
|
|
2749
3110
|
}
|
|
2750
|
-
if (row.latestReplyRank === 2) {
|
|
2751
|
-
existing.
|
|
3111
|
+
if (row.latestReplyRank === 2 || row.latestReplyRank === 3) {
|
|
3112
|
+
existing.trailingReplyIds.set(row.latestReplyRank, row.id);
|
|
2752
3113
|
}
|
|
2753
3114
|
if (row.latestReplyRank === 1) {
|
|
2754
3115
|
existing.latestReplyId = row.id;
|
|
@@ -2764,13 +3125,20 @@ export function createPostService(
|
|
|
2764
3125
|
const latestReply = hydratedPosts.get(context.latestReplyId);
|
|
2765
3126
|
if (!latestReply) continue;
|
|
2766
3127
|
|
|
3128
|
+
const hydrateReplyIds = (ids: Array<string | undefined>) =>
|
|
3129
|
+
ids
|
|
3130
|
+
.map((id) => (id ? hydratedPosts.get(id) : undefined))
|
|
3131
|
+
.filter((post): post is Post => post !== undefined);
|
|
3132
|
+
|
|
2767
3133
|
result.set(threadId, {
|
|
2768
|
-
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3134
|
+
leadingReplies: hydrateReplyIds([
|
|
3135
|
+
context.leadingReplyIds.get(1),
|
|
3136
|
+
context.leadingReplyIds.get(2),
|
|
3137
|
+
]),
|
|
3138
|
+
trailingReplies: hydrateReplyIds([
|
|
3139
|
+
context.trailingReplyIds.get(3),
|
|
3140
|
+
context.trailingReplyIds.get(2),
|
|
3141
|
+
]),
|
|
2774
3142
|
latestReply,
|
|
2775
3143
|
totalReplyCount: context.totalReplyCount,
|
|
2776
3144
|
});
|
|
@@ -2803,19 +3171,20 @@ export function createPostService(
|
|
|
2803
3171
|
...buildThreadRootPageConditions(options),
|
|
2804
3172
|
isNotNull(posts.featuredAt),
|
|
2805
3173
|
];
|
|
2806
|
-
const
|
|
2807
|
-
|
|
2808
|
-
|
|
3174
|
+
const latestFeaturedPublishedAt =
|
|
3175
|
+
sql<number>`MAX(${posts.publishedAt})`.as(
|
|
3176
|
+
"latest_featured_published_at",
|
|
3177
|
+
);
|
|
2809
3178
|
|
|
2810
3179
|
let query = db
|
|
2811
3180
|
.select({
|
|
2812
3181
|
threadId: posts.threadId,
|
|
2813
|
-
|
|
3182
|
+
latestFeaturedPublishedAt,
|
|
2814
3183
|
})
|
|
2815
3184
|
.from(posts)
|
|
2816
3185
|
.where(and(...conditions))
|
|
2817
3186
|
.groupBy(posts.threadId)
|
|
2818
|
-
.orderBy(desc(
|
|
3187
|
+
.orderBy(desc(latestFeaturedPublishedAt), desc(posts.threadId));
|
|
2819
3188
|
|
|
2820
3189
|
if (options.limit !== undefined) {
|
|
2821
3190
|
query = query.limit(options.limit) as typeof query;
|
|
@@ -2828,6 +3197,77 @@ export function createPostService(
|
|
|
2828
3197
|
return rows.map((row) => row.threadId);
|
|
2829
3198
|
},
|
|
2830
3199
|
|
|
3200
|
+
async getFeaturedThreadTimelineData(rootIds) {
|
|
3201
|
+
const result = new Map<string, FeaturedThreadTimelineData>();
|
|
3202
|
+
if (rootIds.length === 0) return result;
|
|
3203
|
+
|
|
3204
|
+
const uniqueRootIds = [...new Set(rootIds)];
|
|
3205
|
+
const threadPosition = sql<number>`CAST(
|
|
3206
|
+
row_number() OVER (
|
|
3207
|
+
PARTITION BY ${posts.threadId}
|
|
3208
|
+
ORDER BY ${posts.createdAt}, ${posts.id}
|
|
3209
|
+
) - 1 AS INTEGER
|
|
3210
|
+
)`.as("thread_position");
|
|
3211
|
+
const threadPostCount = sql<number>`CAST(
|
|
3212
|
+
count(*) OVER (PARTITION BY ${posts.threadId}) AS INTEGER
|
|
3213
|
+
)`.as("thread_post_count");
|
|
3214
|
+
|
|
3215
|
+
const rankedPosts = db
|
|
3216
|
+
.select({
|
|
3217
|
+
...getTableColumns(posts),
|
|
3218
|
+
threadPosition,
|
|
3219
|
+
threadPostCount,
|
|
3220
|
+
})
|
|
3221
|
+
.from(posts)
|
|
3222
|
+
.where(
|
|
3223
|
+
and(
|
|
3224
|
+
eq(posts.siteId, siteId),
|
|
3225
|
+
inArray(posts.threadId, uniqueRootIds),
|
|
3226
|
+
eq(posts.status, "published"),
|
|
3227
|
+
),
|
|
3228
|
+
)
|
|
3229
|
+
.as("ranked_featured_thread_post");
|
|
3230
|
+
|
|
3231
|
+
const rows = await db
|
|
3232
|
+
.select()
|
|
3233
|
+
.from(rankedPosts)
|
|
3234
|
+
.where(
|
|
3235
|
+
or(
|
|
3236
|
+
eq(rankedPosts.id, rankedPosts.threadId),
|
|
3237
|
+
isNotNull(rankedPosts.featuredAt),
|
|
3238
|
+
sql`${rankedPosts.threadPosition} = ${rankedPosts.threadPostCount} - 1`,
|
|
3239
|
+
),
|
|
3240
|
+
)
|
|
3241
|
+
.orderBy(rankedPosts.threadId, sql`${rankedPosts.threadPosition}`);
|
|
3242
|
+
|
|
3243
|
+
const hydratedPosts = await hydratePosts(
|
|
3244
|
+
rows.map(
|
|
3245
|
+
({ threadPosition: _position, threadPostCount: _count, ...row }) =>
|
|
3246
|
+
row,
|
|
3247
|
+
),
|
|
3248
|
+
);
|
|
3249
|
+
const hydratedById = new Map(
|
|
3250
|
+
hydratedPosts.map((post) => [post.id, post]),
|
|
3251
|
+
);
|
|
3252
|
+
|
|
3253
|
+
for (const row of rows) {
|
|
3254
|
+
const post = hydratedById.get(row.id);
|
|
3255
|
+
if (!post) continue;
|
|
3256
|
+
|
|
3257
|
+
const thread = result.get(row.threadId) ?? {
|
|
3258
|
+
posts: [],
|
|
3259
|
+
featuredPostIds: [],
|
|
3260
|
+
};
|
|
3261
|
+
thread.posts.push({ post, position: row.threadPosition });
|
|
3262
|
+
if (row.featuredAt !== null) {
|
|
3263
|
+
thread.featuredPostIds.push(row.id);
|
|
3264
|
+
}
|
|
3265
|
+
result.set(row.threadId, thread);
|
|
3266
|
+
}
|
|
3267
|
+
|
|
3268
|
+
return result;
|
|
3269
|
+
},
|
|
3270
|
+
|
|
2831
3271
|
async countCollectionThreadRoots(collectionId, options = {}) {
|
|
2832
3272
|
return this.countCollectionThreadRootsForCollections(
|
|
2833
3273
|
[collectionId],
|
|
@@ -2853,10 +3293,10 @@ export function createPostService(
|
|
|
2853
3293
|
})
|
|
2854
3294
|
.from(posts)
|
|
2855
3295
|
.innerJoin(
|
|
2856
|
-
|
|
3296
|
+
threadCollections,
|
|
2857
3297
|
and(
|
|
2858
|
-
eq(
|
|
2859
|
-
eq(
|
|
3298
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3299
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
2860
3300
|
),
|
|
2861
3301
|
)
|
|
2862
3302
|
.where(and(...conditions));
|
|
@@ -2864,6 +3304,35 @@ export function createPostService(
|
|
|
2864
3304
|
return rows[0]?.count ?? 0;
|
|
2865
3305
|
},
|
|
2866
3306
|
|
|
3307
|
+
async countCollectionThreadRootsUpToForCollections(
|
|
3308
|
+
collectionIds,
|
|
3309
|
+
options = {},
|
|
3310
|
+
limit,
|
|
3311
|
+
) {
|
|
3312
|
+
const normalizedLimit = Math.max(0, Math.trunc(limit));
|
|
3313
|
+
if (normalizedLimit === 0) return 0;
|
|
3314
|
+
|
|
3315
|
+
const conditions = [
|
|
3316
|
+
...buildThreadRootPageConditions(options),
|
|
3317
|
+
buildCollectionMembershipCondition(collectionIds),
|
|
3318
|
+
];
|
|
3319
|
+
const rows = await db
|
|
3320
|
+
.select({ threadId: posts.threadId })
|
|
3321
|
+
.from(posts)
|
|
3322
|
+
.innerJoin(
|
|
3323
|
+
threadCollections,
|
|
3324
|
+
and(
|
|
3325
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3326
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
3327
|
+
),
|
|
3328
|
+
)
|
|
3329
|
+
.where(and(...conditions))
|
|
3330
|
+
.groupBy(posts.threadId)
|
|
3331
|
+
.limit(normalizedLimit);
|
|
3332
|
+
|
|
3333
|
+
return rows.length;
|
|
3334
|
+
},
|
|
3335
|
+
|
|
2867
3336
|
async listCollectionThreadRootIds(collectionId, options = {}) {
|
|
2868
3337
|
return this.listCollectionThreadRootIdsForCollections(
|
|
2869
3338
|
[collectionId],
|
|
@@ -2875,71 +3344,102 @@ export function createPostService(
|
|
|
2875
3344
|
collectionIds,
|
|
2876
3345
|
options = {},
|
|
2877
3346
|
) {
|
|
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",
|
|
3347
|
+
const { sortOrder, sortedThreads } = buildCollectionThreadSortQuery(
|
|
3348
|
+
collectionIds,
|
|
3349
|
+
options,
|
|
2897
3350
|
);
|
|
3351
|
+
let cursorCondition: SQL<unknown> | undefined;
|
|
3352
|
+
|
|
3353
|
+
if (options.cursor) {
|
|
3354
|
+
const cursorRows = await db
|
|
3355
|
+
.select()
|
|
3356
|
+
.from(sortedThreads)
|
|
3357
|
+
.where(eq(sortedThreads.threadId, options.cursor))
|
|
3358
|
+
.limit(1);
|
|
3359
|
+
const cursorRow = cursorRows[0];
|
|
3360
|
+
if (!cursorRow) return [];
|
|
3361
|
+
|
|
3362
|
+
const pinnedKey: CursorSortKey = {
|
|
3363
|
+
direction: "desc",
|
|
3364
|
+
expr: sortedThreads.collectionPinnedAt,
|
|
3365
|
+
value: cursorRow.collectionPinnedAt,
|
|
3366
|
+
};
|
|
2898
3367
|
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
3368
|
+
cursorCondition =
|
|
3369
|
+
sortOrder === "oldest"
|
|
3370
|
+
? buildLexicographicCursorCondition([
|
|
3371
|
+
pinnedKey,
|
|
3372
|
+
{
|
|
3373
|
+
direction: "asc",
|
|
3374
|
+
expr: sortedThreads.publishedAt,
|
|
3375
|
+
value: cursorRow.publishedAt,
|
|
3376
|
+
},
|
|
3377
|
+
{
|
|
3378
|
+
direction: "asc",
|
|
3379
|
+
expr: sortedThreads.threadId,
|
|
3380
|
+
value: cursorRow.threadId,
|
|
3381
|
+
},
|
|
3382
|
+
])
|
|
3383
|
+
: sortOrder === "rating_desc"
|
|
3384
|
+
? buildLexicographicCursorCondition([
|
|
3385
|
+
pinnedKey,
|
|
3386
|
+
{
|
|
3387
|
+
direction: "desc",
|
|
3388
|
+
expr: sortedThreads.ratingPresence,
|
|
3389
|
+
value: cursorRow.ratingPresence,
|
|
3390
|
+
},
|
|
3391
|
+
{
|
|
3392
|
+
direction: "desc",
|
|
3393
|
+
expr: sortedThreads.ratingValue,
|
|
3394
|
+
value: cursorRow.ratingValue,
|
|
3395
|
+
},
|
|
3396
|
+
{
|
|
3397
|
+
direction: "desc",
|
|
3398
|
+
expr: sortedThreads.threadActivityAt,
|
|
3399
|
+
value: cursorRow.threadActivityAt,
|
|
3400
|
+
},
|
|
3401
|
+
{
|
|
3402
|
+
direction: "desc",
|
|
3403
|
+
expr: sortedThreads.threadId,
|
|
3404
|
+
value: cursorRow.threadId,
|
|
3405
|
+
},
|
|
3406
|
+
])
|
|
3407
|
+
: buildLexicographicCursorCondition([
|
|
3408
|
+
pinnedKey,
|
|
3409
|
+
{
|
|
3410
|
+
direction: "desc",
|
|
3411
|
+
expr: sortedThreads.threadActivityAt,
|
|
3412
|
+
value: cursorRow.threadActivityAt,
|
|
3413
|
+
},
|
|
3414
|
+
{
|
|
3415
|
+
direction: "desc",
|
|
3416
|
+
expr: sortedThreads.threadId,
|
|
3417
|
+
value: cursorRow.threadId,
|
|
3418
|
+
},
|
|
3419
|
+
]);
|
|
3420
|
+
}
|
|
2903
3421
|
|
|
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);
|
|
3422
|
+
const baseQuery = db.select().from(sortedThreads).where(cursorCondition);
|
|
2923
3423
|
|
|
2924
3424
|
let query =
|
|
2925
3425
|
sortOrder === "oldest"
|
|
2926
3426
|
? baseQuery.orderBy(
|
|
2927
|
-
desc(collectionPinnedAt),
|
|
2928
|
-
asc(publishedAt),
|
|
2929
|
-
asc(
|
|
3427
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3428
|
+
asc(sortedThreads.publishedAt),
|
|
3429
|
+
asc(sortedThreads.threadId),
|
|
2930
3430
|
)
|
|
2931
3431
|
: sortOrder === "rating_desc"
|
|
2932
3432
|
? baseQuery.orderBy(
|
|
2933
|
-
desc(collectionPinnedAt),
|
|
2934
|
-
desc(ratingPresence),
|
|
2935
|
-
desc(ratingValue),
|
|
2936
|
-
desc(threadActivityAt),
|
|
2937
|
-
desc(
|
|
3433
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3434
|
+
desc(sortedThreads.ratingPresence),
|
|
3435
|
+
desc(sortedThreads.ratingValue),
|
|
3436
|
+
desc(sortedThreads.threadActivityAt),
|
|
3437
|
+
desc(sortedThreads.threadId),
|
|
2938
3438
|
)
|
|
2939
3439
|
: baseQuery.orderBy(
|
|
2940
|
-
desc(collectionPinnedAt),
|
|
2941
|
-
desc(threadActivityAt),
|
|
2942
|
-
desc(
|
|
3440
|
+
desc(sortedThreads.collectionPinnedAt),
|
|
3441
|
+
desc(sortedThreads.threadActivityAt),
|
|
3442
|
+
desc(sortedThreads.threadId),
|
|
2943
3443
|
);
|
|
2944
3444
|
|
|
2945
3445
|
if (options.limit !== undefined) {
|
|
@@ -2953,6 +3453,18 @@ export function createPostService(
|
|
|
2953
3453
|
return rows.map((row) => row.threadId);
|
|
2954
3454
|
},
|
|
2955
3455
|
|
|
3456
|
+
async listCollectionThreadRootsForCollections(collectionIds, options = {}) {
|
|
3457
|
+
const rootIds = await this.listCollectionThreadRootIdsForCollections(
|
|
3458
|
+
collectionIds,
|
|
3459
|
+
options,
|
|
3460
|
+
);
|
|
3461
|
+
const rootsById = await hydratePostsById(rootIds);
|
|
3462
|
+
return rootIds.flatMap((rootId) => {
|
|
3463
|
+
const root = rootsById.get(rootId);
|
|
3464
|
+
return root ? [root] : [];
|
|
3465
|
+
});
|
|
3466
|
+
},
|
|
3467
|
+
|
|
2956
3468
|
async listCollectionFeedEntries(collectionId, options = {}) {
|
|
2957
3469
|
return this.listCollectionFeedEntriesForCollections(
|
|
2958
3470
|
[collectionId],
|
|
@@ -2967,11 +3479,11 @@ export function createPostService(
|
|
|
2967
3479
|
];
|
|
2968
3480
|
const threadActivityAt =
|
|
2969
3481
|
buildCollectionThreadActivityExpr("thread_activity_at");
|
|
2970
|
-
const collectedAt = sql<number>`MAX(${
|
|
3482
|
+
const collectedAt = sql<number>`MAX(${threadCollections.createdAt})`.as(
|
|
2971
3483
|
"collected_at",
|
|
2972
3484
|
);
|
|
2973
3485
|
const collectionPinnedAt =
|
|
2974
|
-
sql<number>`MAX(coalesce(${
|
|
3486
|
+
sql<number>`MAX(coalesce(${threadCollections.pinnedAt}, -1))`.as(
|
|
2975
3487
|
"collection_pinned_at",
|
|
2976
3488
|
);
|
|
2977
3489
|
const pinnedOrder = options.ignoreCollectionPinnedSort
|
|
@@ -2987,10 +3499,10 @@ export function createPostService(
|
|
|
2987
3499
|
})
|
|
2988
3500
|
.from(posts)
|
|
2989
3501
|
.innerJoin(
|
|
2990
|
-
|
|
3502
|
+
threadCollections,
|
|
2991
3503
|
and(
|
|
2992
|
-
eq(
|
|
2993
|
-
eq(
|
|
3504
|
+
eq(threadCollections.siteId, posts.siteId),
|
|
3505
|
+
eq(threadCollections.threadId, posts.threadId),
|
|
2994
3506
|
),
|
|
2995
3507
|
)
|
|
2996
3508
|
.where(and(...conditions))
|
|
@@ -3013,21 +3525,23 @@ export function createPostService(
|
|
|
3013
3525
|
});
|
|
3014
3526
|
},
|
|
3015
3527
|
|
|
3016
|
-
async getPublishedThreads(rootIds) {
|
|
3528
|
+
async getPublishedThreads(rootIds, options = {}) {
|
|
3017
3529
|
const result = new Map<string, Post[]>();
|
|
3018
3530
|
if (rootIds.length === 0) return result;
|
|
3019
3531
|
|
|
3020
3532
|
const unique = [...new Set(rootIds)];
|
|
3533
|
+
const conditions = [
|
|
3534
|
+
eq(posts.siteId, siteId),
|
|
3535
|
+
inArray(posts.threadId, unique),
|
|
3536
|
+
eq(posts.status, "published"),
|
|
3537
|
+
];
|
|
3538
|
+
if (options.publishedBefore !== undefined) {
|
|
3539
|
+
conditions.push(sql`${posts.publishedAt} < ${options.publishedBefore}`);
|
|
3540
|
+
}
|
|
3021
3541
|
const rows = await db
|
|
3022
3542
|
.select()
|
|
3023
3543
|
.from(posts)
|
|
3024
|
-
.where(
|
|
3025
|
-
and(
|
|
3026
|
-
eq(posts.siteId, siteId),
|
|
3027
|
-
inArray(posts.threadId, unique),
|
|
3028
|
-
eq(posts.status, "published"),
|
|
3029
|
-
),
|
|
3030
|
-
)
|
|
3544
|
+
.where(and(...conditions))
|
|
3031
3545
|
.orderBy(posts.threadId, posts.createdAt, posts.id);
|
|
3032
3546
|
|
|
3033
3547
|
for (const post of await hydratePosts(rows)) {
|
|
@@ -3042,56 +3556,6 @@ export function createPostService(
|
|
|
3042
3556
|
return result;
|
|
3043
3557
|
},
|
|
3044
3558
|
|
|
3045
|
-
async getCollectionPostIdsByThread(collectionId, threadIds) {
|
|
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
3559
|
async getLastPostIdsByThread(threadIds) {
|
|
3096
3560
|
const result = new Map<string, string>();
|
|
3097
3561
|
if (threadIds.length === 0) return result;
|
|
@@ -3191,5 +3655,144 @@ export function createPostService(
|
|
|
3191
3655
|
done: !hasMore,
|
|
3192
3656
|
};
|
|
3193
3657
|
},
|
|
3658
|
+
|
|
3659
|
+
async rebuildBodyHtml(options = {}) {
|
|
3660
|
+
const requested = options.limit ?? 50;
|
|
3661
|
+
const limit = Math.min(Math.max(Math.trunc(requested), 1), 100);
|
|
3662
|
+
const cursor = options.cursor;
|
|
3663
|
+
const dryRun = options.dryRun ?? false;
|
|
3664
|
+
|
|
3665
|
+
const siteRows = await db
|
|
3666
|
+
.select({ id: sites.id })
|
|
3667
|
+
.from(sites)
|
|
3668
|
+
.where(eq(sites.id, siteId))
|
|
3669
|
+
.limit(1);
|
|
3670
|
+
if (!siteRows[0]) {
|
|
3671
|
+
throw new NotFoundError("Site");
|
|
3672
|
+
}
|
|
3673
|
+
|
|
3674
|
+
const whereConditions = [eq(posts.siteId, siteId)];
|
|
3675
|
+
if (cursor) whereConditions.push(gt(posts.id, cursor));
|
|
3676
|
+
|
|
3677
|
+
const rows = await db
|
|
3678
|
+
.select({
|
|
3679
|
+
id: posts.id,
|
|
3680
|
+
format: posts.format,
|
|
3681
|
+
title: posts.title,
|
|
3682
|
+
body: posts.body,
|
|
3683
|
+
bodyHtml: posts.bodyHtml,
|
|
3684
|
+
bodyHtmlVersion: posts.bodyHtmlVersion,
|
|
3685
|
+
})
|
|
3686
|
+
.from(posts)
|
|
3687
|
+
.where(and(...whereConditions))
|
|
3688
|
+
.orderBy(asc(posts.id))
|
|
3689
|
+
.limit(limit + 1);
|
|
3690
|
+
|
|
3691
|
+
const hasMore = rows.length > limit;
|
|
3692
|
+
const batch = hasMore ? rows.slice(0, limit) : rows;
|
|
3693
|
+
const failures: Array<{ postId: string; error: string }> = [];
|
|
3694
|
+
let wouldRebuild = 0;
|
|
3695
|
+
let rebuilt = 0;
|
|
3696
|
+
let wouldUpgradeFootnotes = 0;
|
|
3697
|
+
let upgradedFootnotes = 0;
|
|
3698
|
+
let skipped = 0;
|
|
3699
|
+
let conflicted = 0;
|
|
3700
|
+
|
|
3701
|
+
for (const row of batch) {
|
|
3702
|
+
if (row.bodyHtmlVersion > POST_BODY_HTML_VERSION) {
|
|
3703
|
+
skipped++;
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
let nextBody = row.body;
|
|
3708
|
+
let nextBodyHtml: string | null = null;
|
|
3709
|
+
let upgradesLegacyFootnotes = false;
|
|
3710
|
+
if (row.body !== null) {
|
|
3711
|
+
const prepared = tryPreparePostBodyHtml(row.id, row.body);
|
|
3712
|
+
if (!prepared.ok) {
|
|
3713
|
+
failures.push({ postId: row.id, error: prepared.error });
|
|
3714
|
+
continue;
|
|
3715
|
+
}
|
|
3716
|
+
nextBody = prepared.body;
|
|
3717
|
+
nextBodyHtml = prepared.html;
|
|
3718
|
+
upgradesLegacyFootnotes = prepared.upgradedLegacyFootnotes;
|
|
3719
|
+
}
|
|
3720
|
+
|
|
3721
|
+
if (
|
|
3722
|
+
row.bodyHtmlVersion === POST_BODY_HTML_VERSION &&
|
|
3723
|
+
row.body === nextBody &&
|
|
3724
|
+
row.bodyHtml === nextBodyHtml
|
|
3725
|
+
) {
|
|
3726
|
+
skipped++;
|
|
3727
|
+
continue;
|
|
3728
|
+
}
|
|
3729
|
+
|
|
3730
|
+
wouldRebuild++;
|
|
3731
|
+
if (upgradesLegacyFootnotes) wouldUpgradeFootnotes++;
|
|
3732
|
+
if (dryRun) continue;
|
|
3733
|
+
|
|
3734
|
+
const bodyCondition =
|
|
3735
|
+
row.body === null ? isNull(posts.body) : eq(posts.body, row.body);
|
|
3736
|
+
const canonicalBodyChanged = row.body !== nextBody;
|
|
3737
|
+
const updates: Partial<typeof posts.$inferInsert> = {
|
|
3738
|
+
bodyHtml: nextBodyHtml,
|
|
3739
|
+
bodyHtmlVersion: POST_BODY_HTML_VERSION,
|
|
3740
|
+
};
|
|
3741
|
+
if (canonicalBodyChanged) {
|
|
3742
|
+
updates.body = nextBody;
|
|
3743
|
+
updates.bodyText = nextBody
|
|
3744
|
+
? extractBodyText(nextBody, { includeLinkHrefs: true })
|
|
3745
|
+
: null;
|
|
3746
|
+
|
|
3747
|
+
if (options.summaryConfig) {
|
|
3748
|
+
updates.summary =
|
|
3749
|
+
row.format === "note" && row.title && nextBody
|
|
3750
|
+
? extractSummary(
|
|
3751
|
+
nextBody,
|
|
3752
|
+
options.summaryConfig.maxParagraphs,
|
|
3753
|
+
options.summaryConfig.maxChars,
|
|
3754
|
+
)
|
|
3755
|
+
: null;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
const updatedRows = await db
|
|
3759
|
+
.update(posts)
|
|
3760
|
+
.set(updates)
|
|
3761
|
+
.where(
|
|
3762
|
+
and(
|
|
3763
|
+
eq(posts.siteId, siteId),
|
|
3764
|
+
eq(posts.id, row.id),
|
|
3765
|
+
bodyCondition,
|
|
3766
|
+
eq(posts.bodyHtmlVersion, row.bodyHtmlVersion),
|
|
3767
|
+
),
|
|
3768
|
+
)
|
|
3769
|
+
.returning({ id: posts.id });
|
|
3770
|
+
|
|
3771
|
+
if (updatedRows.length === 0) {
|
|
3772
|
+
conflicted++;
|
|
3773
|
+
} else {
|
|
3774
|
+
rebuilt++;
|
|
3775
|
+
if (upgradesLegacyFootnotes) upgradedFootnotes++;
|
|
3776
|
+
}
|
|
3777
|
+
}
|
|
3778
|
+
|
|
3779
|
+
const lastRow = batch.at(-1);
|
|
3780
|
+
const lastId = lastRow ? lastRow.id : null;
|
|
3781
|
+
|
|
3782
|
+
return {
|
|
3783
|
+
processed: batch.length,
|
|
3784
|
+
wouldRebuild,
|
|
3785
|
+
rebuilt,
|
|
3786
|
+
wouldUpgradeFootnotes,
|
|
3787
|
+
upgradedFootnotes,
|
|
3788
|
+
skipped,
|
|
3789
|
+
conflicted,
|
|
3790
|
+
failed: failures.length,
|
|
3791
|
+
failures,
|
|
3792
|
+
nextCursor: hasMore ? lastId : null,
|
|
3793
|
+
done: !hasMore,
|
|
3794
|
+
targetVersion: POST_BODY_HTML_VERSION,
|
|
3795
|
+
};
|
|
3796
|
+
},
|
|
3194
3797
|
};
|
|
3195
3798
|
}
|