@jant/core 0.6.15 → 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 +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- 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-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- 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-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
package/src/services/post.ts
CHANGED
|
@@ -32,6 +32,10 @@ import {
|
|
|
32
32
|
sqliteSchemaBundle,
|
|
33
33
|
type DatabaseSchema,
|
|
34
34
|
} from "../db/schema-bundle.js";
|
|
35
|
+
import {
|
|
36
|
+
buildRootActivityExpr,
|
|
37
|
+
rootActivityColumns,
|
|
38
|
+
} from "../db/thread-activity.js";
|
|
35
39
|
import { createEntityId } from "../lib/ids.js";
|
|
36
40
|
import { now } from "../lib/time.js";
|
|
37
41
|
import { trimTiptapBody } from "../lib/tiptap-render.js";
|
|
@@ -141,6 +145,14 @@ export interface PostFilters {
|
|
|
141
145
|
publishedAfter?: number;
|
|
142
146
|
/** Unix timestamp (exclusive) — only posts published before this time */
|
|
143
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;
|
|
144
156
|
/** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
|
|
145
157
|
mediaKinds?: MediaKind[];
|
|
146
158
|
/** Filter by media presence */
|
|
@@ -156,8 +168,20 @@ export interface PostFilters {
|
|
|
156
168
|
hasReplies?: boolean;
|
|
157
169
|
/** Explicit result sort order */
|
|
158
170
|
sortOrder?: SortOrder;
|
|
159
|
-
/**
|
|
160
|
-
|
|
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";
|
|
161
185
|
/** Ignore global pinned ordering when results must remain chronological. */
|
|
162
186
|
ignorePinnedSort?: boolean;
|
|
163
187
|
limit?: number;
|
|
@@ -289,7 +313,7 @@ export interface PostService {
|
|
|
289
313
|
count(filters?: PostFilters): Promise<number>;
|
|
290
314
|
/** Count posts matching filters up to a fixed limit (ignores cursor, offset, limit) */
|
|
291
315
|
countUpTo(filters: PostFilters | undefined, limit: number): Promise<number>;
|
|
292
|
-
/** Count posts grouped by
|
|
316
|
+
/** Count posts grouped by year-month (YYYY-MM) on the `sortBy` time axis */
|
|
293
317
|
countByYearMonth(
|
|
294
318
|
filters?: PostFilters,
|
|
295
319
|
): Promise<{ yearMonth: string; count: number }[]>;
|
|
@@ -346,6 +370,21 @@ export interface PostService {
|
|
|
346
370
|
*/
|
|
347
371
|
deleteThreadDraft(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
348
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>;
|
|
349
388
|
updateThreadStatusAndVisibility(
|
|
350
389
|
rootId: string,
|
|
351
390
|
status: Status,
|
|
@@ -416,10 +455,31 @@ export interface PostService {
|
|
|
416
455
|
rootIds: string[],
|
|
417
456
|
options?: Pick<PostFilters, "publishedBefore">,
|
|
418
457
|
): Promise<Map<string, Post[]>>;
|
|
419
|
-
/** Get distinct years
|
|
458
|
+
/** Get distinct years with posts, bucketed on the `sortBy` time axis */
|
|
420
459
|
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
421
|
-
/**
|
|
422
|
-
|
|
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>>;
|
|
423
483
|
/**
|
|
424
484
|
* Rebuild `post.body_text` for a batch of non-deleted posts, cursor-paginated
|
|
425
485
|
* by post id. For each row, recomputes the plain-text extraction via
|
|
@@ -640,16 +700,27 @@ export function createPostService(
|
|
|
640
700
|
const databaseDialect = config.databaseDialect ?? "sqlite";
|
|
641
701
|
const usesBatchWrites = !supportsDrizzleTransaction(db, databaseDialect);
|
|
642
702
|
|
|
643
|
-
|
|
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> {
|
|
644
715
|
return databaseDialect === "pg"
|
|
645
|
-
? sql<string>`to_char(timezone('UTC', to_timestamp(${
|
|
646
|
-
: 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')`;
|
|
647
718
|
}
|
|
648
719
|
|
|
649
|
-
function
|
|
720
|
+
function buildYearExpr(column: SQLWrapper): SQL<string> {
|
|
650
721
|
return databaseDialect === "pg"
|
|
651
|
-
? sql<string>`to_char(timezone('UTC', to_timestamp(${
|
|
652
|
-
: sql<string>`strftime('%Y', ${
|
|
722
|
+
? sql<string>`to_char(timezone('UTC', to_timestamp(${column})), 'YYYY')`
|
|
723
|
+
: sql<string>`strftime('%Y', ${column}, 'unixepoch')`;
|
|
653
724
|
}
|
|
654
725
|
|
|
655
726
|
const effectiveVisibilityExpr = sql<string>`coalesce(
|
|
@@ -690,28 +761,58 @@ export function createPostService(
|
|
|
690
761
|
return rows.length > 0;
|
|
691
762
|
}
|
|
692
763
|
|
|
693
|
-
|
|
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> {
|
|
694
779
|
const rootRows = await db
|
|
695
780
|
.select({
|
|
696
|
-
|
|
697
|
-
|
|
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",
|
|
698
789
|
),
|
|
699
790
|
})
|
|
700
791
|
.from(posts)
|
|
701
|
-
.where(
|
|
792
|
+
.where(
|
|
793
|
+
and(
|
|
794
|
+
eq(posts.siteId, siteId),
|
|
795
|
+
eq(posts.threadId, rootId),
|
|
796
|
+
eq(posts.status, "published"),
|
|
797
|
+
),
|
|
798
|
+
);
|
|
702
799
|
|
|
703
|
-
const
|
|
800
|
+
const announcedAt = rootRows[0]?.announcedAt ?? null;
|
|
801
|
+
const threadUpdatedAt = rootRows[0]?.updatedAt ?? null;
|
|
704
802
|
const root = await db
|
|
705
803
|
.select({ updatedAt: posts.updatedAt })
|
|
706
804
|
.from(posts)
|
|
707
805
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)))
|
|
708
806
|
.limit(1);
|
|
709
807
|
|
|
710
|
-
const
|
|
808
|
+
const fallback = root[0]?.updatedAt ?? now();
|
|
711
809
|
|
|
712
810
|
await db
|
|
713
811
|
.update(posts)
|
|
714
|
-
.set({
|
|
812
|
+
.set({
|
|
813
|
+
lastActivityAt: announcedAt ?? fallback,
|
|
814
|
+
threadUpdatedAt: threadUpdatedAt ?? fallback,
|
|
815
|
+
})
|
|
715
816
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, rootId)));
|
|
716
817
|
}
|
|
717
818
|
|
|
@@ -887,6 +988,15 @@ export function createPostService(
|
|
|
887
988
|
if (filters.publishedBefore !== undefined) {
|
|
888
989
|
conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
|
|
889
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
|
+
}
|
|
890
1000
|
if (filters.mediaKinds && filters.mediaKinds.length > 0) {
|
|
891
1001
|
const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
|
|
892
1002
|
conditions.push(
|
|
@@ -899,19 +1009,13 @@ export function createPostService(
|
|
|
899
1009
|
);
|
|
900
1010
|
}
|
|
901
1011
|
if (filters.hasMedia !== undefined) {
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
conditions.push(
|
|
910
|
-
sql`${posts.id} NOT IN (
|
|
911
|
-
SELECT post_id FROM media WHERE site_id = ${siteId}
|
|
912
|
-
)`,
|
|
913
|
-
);
|
|
914
|
-
}
|
|
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}`);
|
|
915
1019
|
}
|
|
916
1020
|
if (filters.hasTitle !== undefined) {
|
|
917
1021
|
if (filters.hasTitle) {
|
|
@@ -946,19 +1050,6 @@ export function createPostService(
|
|
|
946
1050
|
return conditions;
|
|
947
1051
|
}
|
|
948
1052
|
|
|
949
|
-
async function getLastLivePostIdInThread(
|
|
950
|
-
threadId: string,
|
|
951
|
-
): Promise<string | null> {
|
|
952
|
-
const rows = await db
|
|
953
|
-
.select({ id: posts.id })
|
|
954
|
-
.from(posts)
|
|
955
|
-
.where(and(eq(posts.siteId, siteId), eq(posts.threadId, threadId)))
|
|
956
|
-
.orderBy(desc(posts.createdAt), desc(posts.id))
|
|
957
|
-
.limit(1);
|
|
958
|
-
|
|
959
|
-
return rows[0]?.id ?? null;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
1053
|
function getCursorSortTimestamp(
|
|
963
1054
|
row: typeof posts.$inferSelect,
|
|
964
1055
|
filters: PostFilters,
|
|
@@ -966,9 +1057,33 @@ export function createPostService(
|
|
|
966
1057
|
if (filters.sortBy === "published") {
|
|
967
1058
|
return row.publishedAt ?? row.createdAt;
|
|
968
1059
|
}
|
|
1060
|
+
if (filters.sortBy === "thread_updated") {
|
|
1061
|
+
return row.threadUpdatedAt ?? -1;
|
|
1062
|
+
}
|
|
969
1063
|
return row.status === "draft" ? row.updatedAt : (row.lastActivityAt ?? -1);
|
|
970
1064
|
}
|
|
971
1065
|
|
|
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`;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
972
1087
|
function buildLexicographicCursorCondition(
|
|
973
1088
|
keys: [CursorSortKey, ...CursorSortKey[]],
|
|
974
1089
|
): SQL<unknown> {
|
|
@@ -1008,17 +1123,7 @@ export function createPostService(
|
|
|
1008
1123
|
return null;
|
|
1009
1124
|
}
|
|
1010
1125
|
|
|
1011
|
-
const sortTimestampExpr =
|
|
1012
|
-
filters.sortBy === "published"
|
|
1013
|
-
? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
|
|
1014
|
-
: filters.status === "draft"
|
|
1015
|
-
? posts.updatedAt
|
|
1016
|
-
: filters.status === "published"
|
|
1017
|
-
? posts.lastActivityAt
|
|
1018
|
-
: sql<number>`CASE
|
|
1019
|
-
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1020
|
-
ELSE ${posts.lastActivityAt}
|
|
1021
|
-
END`;
|
|
1126
|
+
const sortTimestampExpr = buildSortTimestampExpr(filters);
|
|
1022
1127
|
const pinnedSortExpr = sql<number>`coalesce(${posts.pinnedAt}, -1)`;
|
|
1023
1128
|
const featuredPublishedSortExpr = sql<number>`coalesce(
|
|
1024
1129
|
${posts.publishedAt}, ${posts.createdAt}, -1
|
|
@@ -1153,8 +1258,14 @@ export function createPostService(
|
|
|
1153
1258
|
previewProvider: row.previewProvider,
|
|
1154
1259
|
replyToId: row.replyToId,
|
|
1155
1260
|
threadId: row.threadId,
|
|
1261
|
+
quietReply: row.quietReply,
|
|
1156
1262
|
publishedAt: row.publishedAt,
|
|
1157
1263
|
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
1264
|
+
threadUpdatedAt:
|
|
1265
|
+
row.threadUpdatedAt ??
|
|
1266
|
+
row.lastActivityAt ??
|
|
1267
|
+
row.publishedAt ??
|
|
1268
|
+
row.updatedAt,
|
|
1158
1269
|
createdAt: row.createdAt,
|
|
1159
1270
|
updatedAt: row.updatedAt,
|
|
1160
1271
|
};
|
|
@@ -1284,16 +1395,19 @@ export function createPostService(
|
|
|
1284
1395
|
return conditions;
|
|
1285
1396
|
}
|
|
1286
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
|
+
*/
|
|
1287
1406
|
function buildCollectionThreadActivityExpr(alias: string) {
|
|
1288
1407
|
return sql<number>`MAX(
|
|
1289
1408
|
COALESCE(
|
|
1290
1409
|
(
|
|
1291
|
-
SELECT
|
|
1292
|
-
WHEN root.updated_at > root.created_at
|
|
1293
|
-
AND root.updated_at > COALESCE(root.last_activity_at, -1)
|
|
1294
|
-
THEN root.updated_at
|
|
1295
|
-
ELSE COALESCE(root.last_activity_at, root.updated_at)
|
|
1296
|
-
END
|
|
1410
|
+
SELECT ${buildRootActivityExpr(rootActivityColumns("root"))}
|
|
1297
1411
|
FROM post AS root
|
|
1298
1412
|
WHERE root.site_id = ${siteId}
|
|
1299
1413
|
AND root.id = ${posts.threadId}
|
|
@@ -1553,17 +1667,7 @@ export function createPostService(
|
|
|
1553
1667
|
if (filters.cursor && !cursorCondition) {
|
|
1554
1668
|
return [];
|
|
1555
1669
|
}
|
|
1556
|
-
const sortTimestamp =
|
|
1557
|
-
filters.sortBy === "published"
|
|
1558
|
-
? sql<number>`coalesce(${posts.publishedAt}, ${posts.createdAt})`
|
|
1559
|
-
: filters.status === "draft"
|
|
1560
|
-
? posts.updatedAt
|
|
1561
|
-
: filters.status === "published"
|
|
1562
|
-
? posts.lastActivityAt
|
|
1563
|
-
: sql<number>`CASE
|
|
1564
|
-
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
1565
|
-
ELSE ${posts.lastActivityAt}
|
|
1566
|
-
END`;
|
|
1670
|
+
const sortTimestamp = buildSortTimestampExpr(filters);
|
|
1567
1671
|
|
|
1568
1672
|
if (cursorCondition) {
|
|
1569
1673
|
conditions.push(cursorCondition);
|
|
@@ -1645,10 +1749,30 @@ export function createPostService(
|
|
|
1645
1749
|
conditions.push(sql`${posts.id} > ${afterId}`);
|
|
1646
1750
|
}
|
|
1647
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
|
+
|
|
1648
1772
|
const rows = await db
|
|
1649
1773
|
.select({
|
|
1650
1774
|
id: posts.id,
|
|
1651
|
-
updatedAt:
|
|
1775
|
+
updatedAt: threadModifiedAt,
|
|
1652
1776
|
featuredAt: posts.featuredAt,
|
|
1653
1777
|
})
|
|
1654
1778
|
.from(posts)
|
|
@@ -1738,11 +1862,9 @@ export function createPostService(
|
|
|
1738
1862
|
},
|
|
1739
1863
|
|
|
1740
1864
|
async countByYearMonth(filters = {}) {
|
|
1741
|
-
const
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
];
|
|
1745
|
-
const publishedYearMonthExpr = buildPublishedYearMonthExpr();
|
|
1865
|
+
const axis = timeAxisColumn(filters);
|
|
1866
|
+
const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
|
|
1867
|
+
const publishedYearMonthExpr = buildYearMonthExpr(axis);
|
|
1746
1868
|
|
|
1747
1869
|
return db
|
|
1748
1870
|
.select({
|
|
@@ -1766,6 +1888,8 @@ export function createPostService(
|
|
|
1766
1888
|
? ensurePostVisibility(data.visibility)
|
|
1767
1889
|
: undefined;
|
|
1768
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;
|
|
1769
1893
|
|
|
1770
1894
|
const rawBody = data.bodyMarkdown
|
|
1771
1895
|
? markdownToTiptapJson(data.bodyMarkdown)
|
|
@@ -1846,10 +1970,19 @@ export function createPostService(
|
|
|
1846
1970
|
);
|
|
1847
1971
|
}
|
|
1848
1972
|
|
|
1849
|
-
|
|
1850
|
-
|
|
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);
|
|
1851
1982
|
throw new ConflictError(
|
|
1852
|
-
|
|
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.",
|
|
1853
1986
|
);
|
|
1854
1987
|
}
|
|
1855
1988
|
|
|
@@ -1860,10 +1993,12 @@ export function createPostService(
|
|
|
1860
1993
|
parent.threadId === parent.id
|
|
1861
1994
|
? parent
|
|
1862
1995
|
: await this.getById(parent.threadId);
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
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;
|
|
1867
2002
|
}
|
|
1868
2003
|
visibility = null;
|
|
1869
2004
|
|
|
@@ -1877,6 +2012,12 @@ export function createPostService(
|
|
|
1877
2012
|
}
|
|
1878
2013
|
}
|
|
1879
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.");
|
|
2019
|
+
}
|
|
2020
|
+
|
|
1880
2021
|
assertDraftPublishedAt(status, data.publishedAt);
|
|
1881
2022
|
const publishedAt =
|
|
1882
2023
|
status === "published" ? (data.publishedAt ?? timestamp) : null;
|
|
@@ -2006,8 +2147,10 @@ export function createPostService(
|
|
|
2006
2147
|
rating,
|
|
2007
2148
|
replyToId: data.replyToId ?? null,
|
|
2008
2149
|
threadId,
|
|
2150
|
+
quietReply: isQuietReply,
|
|
2009
2151
|
publishedAt,
|
|
2010
2152
|
lastActivityAt: publishedAt ?? timestamp,
|
|
2153
|
+
threadUpdatedAt: publishedAt ?? timestamp,
|
|
2011
2154
|
createdAt: timestamp,
|
|
2012
2155
|
updatedAt: timestamp,
|
|
2013
2156
|
}),
|
|
@@ -2083,8 +2226,10 @@ export function createPostService(
|
|
|
2083
2226
|
rating,
|
|
2084
2227
|
replyToId: data.replyToId ?? null,
|
|
2085
2228
|
threadId,
|
|
2229
|
+
quietReply: isQuietReply,
|
|
2086
2230
|
publishedAt,
|
|
2087
2231
|
lastActivityAt: publishedAt ?? timestamp,
|
|
2232
|
+
threadUpdatedAt: publishedAt ?? timestamp,
|
|
2088
2233
|
createdAt: timestamp,
|
|
2089
2234
|
updatedAt: timestamp,
|
|
2090
2235
|
});
|
|
@@ -2144,9 +2289,11 @@ export function createPostService(
|
|
|
2144
2289
|
throw new ConflictError(`Slug "${slug}" could not be resolved`);
|
|
2145
2290
|
}
|
|
2146
2291
|
|
|
2147
|
-
//
|
|
2148
|
-
|
|
2149
|
-
|
|
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);
|
|
2150
2297
|
}
|
|
2151
2298
|
|
|
2152
2299
|
return post;
|
|
@@ -2208,11 +2355,19 @@ export function createPostService(
|
|
|
2208
2355
|
if (!item) continue;
|
|
2209
2356
|
const { data, attachments } = item;
|
|
2210
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;
|
|
2211
2364
|
const postData: CreatePost = {
|
|
2212
2365
|
...data,
|
|
2213
2366
|
// Chain each post as a reply to the previous one (server-side chaining)
|
|
2214
2367
|
replyToId: i === 0 ? data.replyToId : prevPost?.id,
|
|
2215
2368
|
quietReply: extendsExistingThreadQuietly ? true : data.quietReply,
|
|
2369
|
+
publishedAt:
|
|
2370
|
+
i === 0 ? data.publishedAt : (data.publishedAt ?? rootPublishedAt),
|
|
2216
2371
|
};
|
|
2217
2372
|
|
|
2218
2373
|
try {
|
|
@@ -2334,6 +2489,12 @@ export function createPostService(
|
|
|
2334
2489
|
} else if (data.featured !== undefined) {
|
|
2335
2490
|
updates.featuredAt = data.featured ? timestamp : null;
|
|
2336
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
|
+
}
|
|
2337
2498
|
|
|
2338
2499
|
let updatedBody: string | null | undefined;
|
|
2339
2500
|
if (data.body !== undefined || data.bodyMarkdown !== undefined) {
|
|
@@ -2445,7 +2606,7 @@ export function createPostService(
|
|
|
2445
2606
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)))
|
|
2446
2607
|
.returning();
|
|
2447
2608
|
if (needsThreadActivityRecalc) {
|
|
2448
|
-
await
|
|
2609
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2449
2610
|
return this.getById(id);
|
|
2450
2611
|
}
|
|
2451
2612
|
return hydratePost(result[0]);
|
|
@@ -2680,7 +2841,7 @@ export function createPostService(
|
|
|
2680
2841
|
}
|
|
2681
2842
|
|
|
2682
2843
|
if (needsThreadActivityRecalc) {
|
|
2683
|
-
await
|
|
2844
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2684
2845
|
return this.getById(id);
|
|
2685
2846
|
}
|
|
2686
2847
|
return hydratePost(updateResult?.[0]);
|
|
@@ -2868,7 +3029,7 @@ export function createPostService(
|
|
|
2868
3029
|
await db
|
|
2869
3030
|
.delete(posts)
|
|
2870
3031
|
.where(and(eq(posts.siteId, siteId), eq(posts.id, id)));
|
|
2871
|
-
await
|
|
3032
|
+
await recalculateThreadActivity(existing.threadId);
|
|
2872
3033
|
}
|
|
2873
3034
|
|
|
2874
3035
|
return true;
|
|
@@ -2888,6 +3049,44 @@ export function createPostService(
|
|
|
2888
3049
|
return hydratePosts(rows);
|
|
2889
3050
|
},
|
|
2890
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
|
+
|
|
2891
3090
|
async updateThreadStatusAndVisibility(rootId, status, visibility) {
|
|
2892
3091
|
const nextStatus = ensurePostStatus(status);
|
|
2893
3092
|
const nextVisibility = ensurePostVisibility(visibility);
|
|
@@ -2952,7 +3151,7 @@ export function createPostService(
|
|
|
2952
3151
|
);
|
|
2953
3152
|
});
|
|
2954
3153
|
}
|
|
2955
|
-
await
|
|
3154
|
+
await recalculateThreadActivity(rootId);
|
|
2956
3155
|
},
|
|
2957
3156
|
|
|
2958
3157
|
async getReplyCounts(postIds) {
|
|
@@ -3556,7 +3755,7 @@ export function createPostService(
|
|
|
3556
3755
|
return result;
|
|
3557
3756
|
},
|
|
3558
3757
|
|
|
3559
|
-
async
|
|
3758
|
+
async getThreadTailIds(threadIds, options) {
|
|
3560
3759
|
const result = new Map<string, string>();
|
|
3561
3760
|
if (threadIds.length === 0) return result;
|
|
3562
3761
|
|
|
@@ -3571,7 +3770,7 @@ export function createPostService(
|
|
|
3571
3770
|
and(
|
|
3572
3771
|
eq(posts.siteId, siteId),
|
|
3573
3772
|
inArray(posts.threadId, unique),
|
|
3574
|
-
eq(posts.status, "published"),
|
|
3773
|
+
...(options?.includeDrafts ? [] : [eq(posts.status, "published")]),
|
|
3575
3774
|
),
|
|
3576
3775
|
)
|
|
3577
3776
|
.orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
|
|
@@ -3585,11 +3784,9 @@ export function createPostService(
|
|
|
3585
3784
|
},
|
|
3586
3785
|
|
|
3587
3786
|
async getDistinctYears(filters = {}) {
|
|
3588
|
-
const
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
];
|
|
3592
|
-
const publishedYearExpr = buildPublishedYearExpr();
|
|
3787
|
+
const axis = timeAxisColumn(filters);
|
|
3788
|
+
const conditions = [...buildFilterConditions(filters), isNotNull(axis)];
|
|
3789
|
+
const publishedYearExpr = buildYearExpr(axis);
|
|
3593
3790
|
|
|
3594
3791
|
const rows = await db
|
|
3595
3792
|
.select({
|