@jant/core 0.3.36 → 0.3.38
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/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- 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-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
package/src/services/post.ts
CHANGED
|
@@ -3,28 +3,38 @@
|
|
|
3
3
|
*
|
|
4
4
|
* CRUD operations for posts with Thread support.
|
|
5
5
|
* Posts have format (note/link/quote), status (draft/published),
|
|
6
|
-
* visibility (
|
|
6
|
+
* visibility (public/unlisted/private), featuredAt, and pinnedAt timestamp.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { eq, and, isNull, desc,
|
|
9
|
+
import { eq, and, isNull, desc, inArray, sql, isNotNull } from "drizzle-orm";
|
|
10
10
|
import type { BatchItem } from "drizzle-orm/batch";
|
|
11
|
-
import
|
|
12
|
-
import {
|
|
11
|
+
import { uuidv7 } from "uuidv7";
|
|
12
|
+
import { type Database, batchQueryRows } from "../db/index.js";
|
|
13
|
+
import { pathRegistry, posts, postCollections } from "../db/schema.js";
|
|
13
14
|
import { now } from "../lib/time.js";
|
|
14
15
|
import { renderTiptapJson } from "../lib/tiptap-render.js";
|
|
15
|
-
import { extractSummary } from "../lib/summary.js";
|
|
16
|
+
import { extractSummary, extractBodyText } from "../lib/summary.js";
|
|
17
|
+
import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
|
|
18
|
+
import { generatePostSlug } from "../lib/slug.js";
|
|
19
|
+
import { normalizePath, slugify } from "../lib/url.js";
|
|
16
20
|
import type { StorageDriver } from "../lib/storage.js";
|
|
17
21
|
import type { MediaService } from "./media.js";
|
|
18
22
|
import type {
|
|
19
23
|
Format,
|
|
20
24
|
Status,
|
|
21
25
|
Visibility,
|
|
26
|
+
MediaKind,
|
|
22
27
|
Post,
|
|
23
28
|
CreatePost,
|
|
24
29
|
UpdatePost,
|
|
30
|
+
ThreadTimelineContext,
|
|
25
31
|
} from "../types.js";
|
|
26
|
-
import
|
|
27
|
-
|
|
32
|
+
import {
|
|
33
|
+
ConflictError,
|
|
34
|
+
ValidationError,
|
|
35
|
+
NotFoundError,
|
|
36
|
+
} from "../lib/errors.js";
|
|
37
|
+
import { createPathService, type PathService } from "./path.js";
|
|
28
38
|
|
|
29
39
|
/** Dependencies for operations that coordinate with other services */
|
|
30
40
|
export interface PostDeleteDeps {
|
|
@@ -37,15 +47,28 @@ export interface PostFilters {
|
|
|
37
47
|
status?: Status;
|
|
38
48
|
visibility?: Visibility;
|
|
39
49
|
pinned?: boolean;
|
|
40
|
-
|
|
41
|
-
|
|
50
|
+
featured?: boolean;
|
|
51
|
+
collectionId?: string;
|
|
52
|
+
/** Exclude posts that are replies (have replyToId set) */
|
|
42
53
|
excludeReplies?: boolean;
|
|
43
54
|
/** Exclude unlisted posts from results */
|
|
44
55
|
excludeUnlisted?: boolean;
|
|
56
|
+
/** Exclude private posts from results */
|
|
57
|
+
excludePrivate?: boolean;
|
|
45
58
|
includeDeleted?: boolean;
|
|
46
|
-
threadId?:
|
|
59
|
+
threadId?: string;
|
|
60
|
+
/** Unix timestamp (inclusive) — only posts published at or after this time */
|
|
61
|
+
publishedAfter?: number;
|
|
62
|
+
/** Unix timestamp (exclusive) — only posts published before this time */
|
|
63
|
+
publishedBefore?: number;
|
|
64
|
+
/** Media kinds to filter by (OR logic: post has media of ANY selected kind). */
|
|
65
|
+
mediaKinds?: MediaKind[];
|
|
66
|
+
/** Filter by media presence */
|
|
67
|
+
hasMedia?: boolean;
|
|
68
|
+
/** Filter by title presence */
|
|
69
|
+
hasTitle?: boolean;
|
|
47
70
|
limit?: number;
|
|
48
|
-
cursor?:
|
|
71
|
+
cursor?: string; // post id for cursor pagination (UUIDv7 sorts chronologically)
|
|
49
72
|
offset?: number; // offset for page-based pagination
|
|
50
73
|
}
|
|
51
74
|
|
|
@@ -56,14 +79,14 @@ export interface SummaryConfig {
|
|
|
56
79
|
}
|
|
57
80
|
|
|
58
81
|
export interface PostService {
|
|
59
|
-
getById(id:
|
|
60
|
-
|
|
82
|
+
getById(id: string): Promise<Post | null>;
|
|
83
|
+
getBySlug(slug: string): Promise<Post | null>;
|
|
61
84
|
list(filters?: PostFilters): Promise<Post[]>;
|
|
62
85
|
/** Count posts matching filters (ignores cursor, offset, limit) */
|
|
63
86
|
count(filters?: PostFilters): Promise<number>;
|
|
64
87
|
create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
|
|
65
88
|
update(
|
|
66
|
-
id:
|
|
89
|
+
id: string,
|
|
67
90
|
data: UpdatePost,
|
|
68
91
|
summaryConfig?: SummaryConfig,
|
|
69
92
|
): Promise<Post | null>;
|
|
@@ -74,20 +97,34 @@ export interface PostService {
|
|
|
74
97
|
* @param id - Post ID
|
|
75
98
|
* @param deps - Media service and optional storage driver for file cleanup
|
|
76
99
|
*/
|
|
77
|
-
delete(id:
|
|
78
|
-
getThread(rootId:
|
|
100
|
+
delete(id: string, deps?: PostDeleteDeps): Promise<boolean>;
|
|
101
|
+
getThread(rootId: string): Promise<Post[]>;
|
|
79
102
|
updateThreadStatusAndVisibility(
|
|
80
|
-
rootId:
|
|
103
|
+
rootId: string,
|
|
81
104
|
status: Status,
|
|
82
105
|
visibility: Visibility,
|
|
83
106
|
): Promise<void>;
|
|
84
107
|
/** Get reply counts for multiple posts */
|
|
85
|
-
getReplyCounts(postIds:
|
|
108
|
+
getReplyCounts(postIds: string[]): Promise<Map<string, number>>;
|
|
86
109
|
/** Get preview replies for multiple thread roots */
|
|
87
110
|
getThreadPreviews(
|
|
88
|
-
rootIds:
|
|
111
|
+
rootIds: string[],
|
|
89
112
|
previewCount?: number,
|
|
90
|
-
): Promise<Map<
|
|
113
|
+
): Promise<Map<string, Post[]>>;
|
|
114
|
+
/** Get latest-reply context for multiple thread roots (for timeline display) */
|
|
115
|
+
getThreadTimelineContext(
|
|
116
|
+
rootIds: string[],
|
|
117
|
+
): Promise<Map<string, ThreadTimelineContext>>;
|
|
118
|
+
/** Get distinct years that have published posts */
|
|
119
|
+
getDistinctYears(filters?: PostFilters): Promise<number[]>;
|
|
120
|
+
/** For each thread ID, return the ID of the last published, non-deleted post */
|
|
121
|
+
getLastPostIdsByThread(threadIds: string[]): Promise<Map<string, string>>;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const SLUG_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
|
125
|
+
|
|
126
|
+
function isValidSlug(value: string): boolean {
|
|
127
|
+
return SLUG_RE.test(value);
|
|
91
128
|
}
|
|
92
129
|
|
|
93
130
|
/** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
|
|
@@ -109,10 +146,102 @@ function isUniqueConstraintError(err: unknown): boolean {
|
|
|
109
146
|
return false;
|
|
110
147
|
}
|
|
111
148
|
|
|
149
|
+
function hasNonEmptyText(value: string | null | undefined): boolean {
|
|
150
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assertPostFormatShape(data: {
|
|
154
|
+
format: Format;
|
|
155
|
+
url?: string | null;
|
|
156
|
+
quoteText?: string | null;
|
|
157
|
+
}): void {
|
|
158
|
+
const hasUrl = hasNonEmptyText(data.url);
|
|
159
|
+
const hasQuoteText = hasNonEmptyText(data.quoteText);
|
|
160
|
+
|
|
161
|
+
if (data.format === "note") {
|
|
162
|
+
if (hasUrl) {
|
|
163
|
+
throw new ValidationError("Notes can't include a URL.");
|
|
164
|
+
}
|
|
165
|
+
if (hasQuoteText) {
|
|
166
|
+
throw new ValidationError("Notes can't include quoted text.");
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (data.format === "link") {
|
|
172
|
+
if (!hasUrl) {
|
|
173
|
+
throw new ValidationError("Link posts need a URL.");
|
|
174
|
+
}
|
|
175
|
+
if (hasQuoteText) {
|
|
176
|
+
throw new ValidationError("Link posts can't include quoted text.");
|
|
177
|
+
}
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!hasQuoteText) {
|
|
182
|
+
throw new ValidationError("Quote posts need quoted text.");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isThreadReply(post: Pick<Post, "replyToId">): boolean {
|
|
187
|
+
return post.replyToId !== null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function assertDraftPublishedAt(
|
|
191
|
+
status: Status,
|
|
192
|
+
publishedAt: number | undefined,
|
|
193
|
+
): void {
|
|
194
|
+
if (status === "draft" && publishedAt !== undefined) {
|
|
195
|
+
throw new ValidationError("Drafts can't set a publish time.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
112
199
|
export function createPostService(
|
|
113
200
|
db: Database,
|
|
114
|
-
|
|
201
|
+
config: { slugIdLength: number },
|
|
202
|
+
paths: PathService = createPathService(db),
|
|
115
203
|
): PostService {
|
|
204
|
+
const effectiveVisibilityExpr = sql<string>`coalesce(
|
|
205
|
+
${posts.visibility},
|
|
206
|
+
(SELECT root.visibility FROM post AS root WHERE root.id = ${posts.threadId})
|
|
207
|
+
)`;
|
|
208
|
+
|
|
209
|
+
/** Check if a slug is available (not used by posts or custom_urls) */
|
|
210
|
+
async function isSlugAvailable(slug: string): Promise<boolean> {
|
|
211
|
+
return paths.isPathAvailable(slug);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
215
|
+
const rows = await db
|
|
216
|
+
.select({ id: pathRegistry.id })
|
|
217
|
+
.from(pathRegistry)
|
|
218
|
+
.where(eq(pathRegistry.path, normalizePath(path)))
|
|
219
|
+
.limit(1);
|
|
220
|
+
return rows.length > 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function recalculateThreadLastActivity(rootId: string): Promise<void> {
|
|
224
|
+
const rootRows = await db
|
|
225
|
+
.select({
|
|
226
|
+
latestPublishedAt: sql<number | null>`MAX(${posts.publishedAt})`.as(
|
|
227
|
+
"latest_published_at",
|
|
228
|
+
),
|
|
229
|
+
})
|
|
230
|
+
.from(posts)
|
|
231
|
+
.where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)));
|
|
232
|
+
|
|
233
|
+
const latestPublishedAt = rootRows[0]?.latestPublishedAt ?? null;
|
|
234
|
+
const root = await db
|
|
235
|
+
.select({ updatedAt: posts.updatedAt })
|
|
236
|
+
.from(posts)
|
|
237
|
+
.where(eq(posts.id, rootId))
|
|
238
|
+
.limit(1);
|
|
239
|
+
|
|
240
|
+
const lastActivityAt = latestPublishedAt ?? root[0]?.updatedAt ?? now();
|
|
241
|
+
|
|
242
|
+
await db.update(posts).set({ lastActivityAt }).where(eq(posts.id, rootId));
|
|
243
|
+
}
|
|
244
|
+
|
|
116
245
|
/** Build WHERE conditions from filters (shared by list and count) */
|
|
117
246
|
function buildFilterConditions(filters: PostFilters) {
|
|
118
247
|
const conditions = [];
|
|
@@ -121,13 +250,27 @@ export function createPostService(
|
|
|
121
250
|
conditions.push(eq(posts.status, filters.status));
|
|
122
251
|
}
|
|
123
252
|
if (filters.visibility !== undefined) {
|
|
124
|
-
conditions.push(
|
|
253
|
+
conditions.push(sql`${effectiveVisibilityExpr} = ${filters.visibility}`);
|
|
125
254
|
}
|
|
126
255
|
if (filters.excludeUnlisted) {
|
|
127
|
-
conditions.push(sql`${
|
|
256
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'unlisted'`);
|
|
257
|
+
}
|
|
258
|
+
if (filters.excludePrivate) {
|
|
259
|
+
conditions.push(sql`${effectiveVisibilityExpr} != 'private'`);
|
|
128
260
|
}
|
|
129
261
|
if (filters.pinned !== undefined) {
|
|
130
|
-
conditions.push(
|
|
262
|
+
conditions.push(
|
|
263
|
+
filters.pinned
|
|
264
|
+
? sql`${posts.pinnedAt} IS NOT NULL`
|
|
265
|
+
: isNull(posts.pinnedAt),
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
if (filters.featured !== undefined) {
|
|
269
|
+
conditions.push(
|
|
270
|
+
filters.featured
|
|
271
|
+
? sql`${posts.featuredAt} IS NOT NULL`
|
|
272
|
+
: isNull(posts.featuredAt),
|
|
273
|
+
);
|
|
131
274
|
}
|
|
132
275
|
if (filters.format) {
|
|
133
276
|
conditions.push(eq(posts.format, filters.format));
|
|
@@ -135,34 +278,68 @@ export function createPostService(
|
|
|
135
278
|
if (filters.collectionId !== undefined) {
|
|
136
279
|
// Filter by collection via junction table
|
|
137
280
|
conditions.push(
|
|
138
|
-
sql`${posts.id} IN (SELECT post_id FROM
|
|
281
|
+
sql`${posts.id} IN (SELECT post_id FROM post_collection WHERE collection_id = ${filters.collectionId})`,
|
|
139
282
|
);
|
|
140
283
|
}
|
|
141
284
|
if (filters.threadId) {
|
|
142
285
|
conditions.push(eq(posts.threadId, filters.threadId));
|
|
143
286
|
}
|
|
144
287
|
if (filters.excludeReplies) {
|
|
145
|
-
conditions.push(isNull(posts.
|
|
288
|
+
conditions.push(isNull(posts.replyToId));
|
|
146
289
|
}
|
|
147
290
|
if (!filters.includeDeleted) {
|
|
148
291
|
conditions.push(isNull(posts.deletedAt));
|
|
149
292
|
}
|
|
293
|
+
if (filters.publishedAfter !== undefined) {
|
|
294
|
+
conditions.push(sql`${posts.publishedAt} >= ${filters.publishedAfter}`);
|
|
295
|
+
}
|
|
296
|
+
if (filters.publishedBefore !== undefined) {
|
|
297
|
+
conditions.push(sql`${posts.publishedAt} < ${filters.publishedBefore}`);
|
|
298
|
+
}
|
|
299
|
+
if (filters.mediaKinds && filters.mediaKinds.length > 0) {
|
|
300
|
+
const placeholders = filters.mediaKinds.map((k) => sql`${k}`);
|
|
301
|
+
conditions.push(
|
|
302
|
+
sql`${posts.id} IN (SELECT post_id FROM media WHERE media_kind IN (${sql.join(placeholders, sql`, `)}))`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (filters.hasMedia !== undefined) {
|
|
306
|
+
if (filters.hasMedia) {
|
|
307
|
+
conditions.push(sql`${posts.id} IN (SELECT post_id FROM media)`);
|
|
308
|
+
} else {
|
|
309
|
+
conditions.push(sql`${posts.id} NOT IN (SELECT post_id FROM media)`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (filters.hasTitle !== undefined) {
|
|
313
|
+
if (filters.hasTitle) {
|
|
314
|
+
conditions.push(
|
|
315
|
+
sql`${posts.title} IS NOT NULL AND ${posts.title} != ''`,
|
|
316
|
+
);
|
|
317
|
+
} else {
|
|
318
|
+
conditions.push(sql`(${posts.title} IS NULL OR ${posts.title} = '')`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
150
321
|
|
|
151
322
|
return conditions;
|
|
152
323
|
}
|
|
153
324
|
|
|
154
|
-
function toPost(
|
|
325
|
+
function toPost(
|
|
326
|
+
row: typeof posts.$inferSelect,
|
|
327
|
+
slug: string,
|
|
328
|
+
visibility: Visibility,
|
|
329
|
+
): Post {
|
|
155
330
|
return {
|
|
156
331
|
id: row.id,
|
|
157
332
|
format: row.format as Format,
|
|
158
333
|
status: row.status as Status,
|
|
159
|
-
visibility
|
|
160
|
-
|
|
161
|
-
|
|
334
|
+
visibility,
|
|
335
|
+
pinnedAt: row.pinnedAt,
|
|
336
|
+
featuredAt: row.featuredAt,
|
|
337
|
+
slug,
|
|
162
338
|
title: row.title,
|
|
163
339
|
url: row.url,
|
|
164
340
|
body: row.body,
|
|
165
341
|
bodyHtml: row.bodyHtml,
|
|
342
|
+
bodyText: row.bodyText,
|
|
166
343
|
quoteText: row.quoteText,
|
|
167
344
|
summary: row.summary,
|
|
168
345
|
rating: row.rating,
|
|
@@ -170,11 +347,67 @@ export function createPostService(
|
|
|
170
347
|
threadId: row.threadId,
|
|
171
348
|
deletedAt: row.deletedAt,
|
|
172
349
|
publishedAt: row.publishedAt,
|
|
350
|
+
lastActivityAt: row.lastActivityAt ?? row.publishedAt ?? row.updatedAt,
|
|
173
351
|
createdAt: row.createdAt,
|
|
174
352
|
updatedAt: row.updatedAt,
|
|
175
353
|
};
|
|
176
354
|
}
|
|
177
355
|
|
|
356
|
+
async function hydratePost(
|
|
357
|
+
row: typeof posts.$inferSelect | undefined,
|
|
358
|
+
): Promise<Post | null> {
|
|
359
|
+
if (!row) return null;
|
|
360
|
+
const slug = await paths.getPostSlug(row.id);
|
|
361
|
+
if (!slug) return null;
|
|
362
|
+
const rootVisibilityMap = await getThreadVisibilityMap([row.threadId]);
|
|
363
|
+
const visibility = rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
364
|
+
if (!visibility) return null;
|
|
365
|
+
return toPost(row, slug, visibility as Visibility);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function hydratePosts(
|
|
369
|
+
rows: (typeof posts.$inferSelect)[],
|
|
370
|
+
): Promise<Post[]> {
|
|
371
|
+
if (rows.length === 0) return [];
|
|
372
|
+
const slugMap = await paths.getPostSlugMap(rows.map((row) => row.id));
|
|
373
|
+
const rootVisibilityMap = await getThreadVisibilityMap(
|
|
374
|
+
rows.map((row) => row.threadId),
|
|
375
|
+
);
|
|
376
|
+
return rows
|
|
377
|
+
.map((row) => {
|
|
378
|
+
const slug = slugMap.get(row.id);
|
|
379
|
+
const visibility =
|
|
380
|
+
rootVisibilityMap.get(row.threadId) ?? row.visibility;
|
|
381
|
+
return slug && visibility
|
|
382
|
+
? toPost(row, slug, visibility as Visibility)
|
|
383
|
+
: null;
|
|
384
|
+
})
|
|
385
|
+
.filter((row): row is Post => row !== null);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function getThreadVisibilityMap(
|
|
389
|
+
threadIds: string[],
|
|
390
|
+
): Promise<Map<string, Visibility>> {
|
|
391
|
+
const uniqueThreadIds = [...new Set(threadIds)];
|
|
392
|
+
const result = new Map<string, Visibility>();
|
|
393
|
+
if (uniqueThreadIds.length === 0) return result;
|
|
394
|
+
|
|
395
|
+
const rows = await batchQueryRows(uniqueThreadIds, (chunk) =>
|
|
396
|
+
db
|
|
397
|
+
.select({ id: posts.id, visibility: posts.visibility })
|
|
398
|
+
.from(posts)
|
|
399
|
+
.where(inArray(posts.id, chunk)),
|
|
400
|
+
);
|
|
401
|
+
|
|
402
|
+
for (const row of rows) {
|
|
403
|
+
if (row.visibility) {
|
|
404
|
+
result.set(row.id, row.visibility as Visibility);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
178
411
|
return {
|
|
179
412
|
async getById(id) {
|
|
180
413
|
const result = await db
|
|
@@ -182,20 +415,28 @@ export function createPostService(
|
|
|
182
415
|
.from(posts)
|
|
183
416
|
.where(and(eq(posts.id, id), isNull(posts.deletedAt)))
|
|
184
417
|
.limit(1);
|
|
185
|
-
return
|
|
418
|
+
return hydratePost(result[0]);
|
|
186
419
|
},
|
|
187
420
|
|
|
188
|
-
async
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
return result[0] ? toPost(result[0]) : null;
|
|
421
|
+
async getBySlug(slug) {
|
|
422
|
+
const resolved = await paths.resolve(slug);
|
|
423
|
+
if (!resolved || resolved.kind !== "slug" || !resolved.postId) {
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
return this.getById(resolved.postId);
|
|
195
427
|
},
|
|
196
428
|
|
|
197
429
|
async list(filters = {}) {
|
|
198
430
|
const conditions = buildFilterConditions(filters);
|
|
431
|
+
const sortTimestamp =
|
|
432
|
+
filters.status === "draft"
|
|
433
|
+
? posts.updatedAt
|
|
434
|
+
: filters.status === "published"
|
|
435
|
+
? posts.lastActivityAt
|
|
436
|
+
: sql<number>`CASE
|
|
437
|
+
WHEN ${posts.status} = 'draft' THEN ${posts.updatedAt}
|
|
438
|
+
ELSE ${posts.lastActivityAt}
|
|
439
|
+
END`;
|
|
199
440
|
|
|
200
441
|
if (filters.cursor) {
|
|
201
442
|
conditions.push(sql`${posts.id} < ${filters.cursor}`);
|
|
@@ -205,7 +446,11 @@ export function createPostService(
|
|
|
205
446
|
.select()
|
|
206
447
|
.from(posts)
|
|
207
448
|
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
208
|
-
.orderBy(
|
|
449
|
+
.orderBy(
|
|
450
|
+
desc(posts.pinnedAt),
|
|
451
|
+
filters.featured ? desc(posts.featuredAt) : desc(sortTimestamp),
|
|
452
|
+
desc(posts.id),
|
|
453
|
+
)
|
|
209
454
|
.limit(filters.limit ?? 100);
|
|
210
455
|
|
|
211
456
|
if (filters.offset !== undefined) {
|
|
@@ -213,7 +458,7 @@ export function createPostService(
|
|
|
213
458
|
}
|
|
214
459
|
|
|
215
460
|
const rows = await query;
|
|
216
|
-
return rows
|
|
461
|
+
return hydratePosts(rows);
|
|
217
462
|
},
|
|
218
463
|
|
|
219
464
|
async count(filters = {}) {
|
|
@@ -228,96 +473,195 @@ export function createPostService(
|
|
|
228
473
|
},
|
|
229
474
|
|
|
230
475
|
async create(data, summaryConfig) {
|
|
476
|
+
const id = uuidv7();
|
|
231
477
|
const timestamp = now();
|
|
232
478
|
|
|
233
|
-
|
|
479
|
+
assertPostFormatShape({
|
|
480
|
+
format: data.format,
|
|
481
|
+
url: data.url,
|
|
482
|
+
quoteText: data.quoteText,
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
const body = data.bodyMarkdown
|
|
486
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
487
|
+
: (data.body ?? null);
|
|
488
|
+
const bodyHtml = body ? renderTiptapJson(body) : null;
|
|
489
|
+
const bodyText = body ? extractBodyText(body) : null;
|
|
234
490
|
|
|
235
491
|
// Generate summary for titled notes with body content
|
|
236
492
|
let summary: string | null = null;
|
|
237
|
-
if (data.format === "note" && data.title &&
|
|
493
|
+
if (data.format === "note" && data.title && body && summaryConfig) {
|
|
238
494
|
summary = extractSummary(
|
|
239
|
-
|
|
495
|
+
body,
|
|
240
496
|
summaryConfig.maxParagraphs,
|
|
241
497
|
summaryConfig.maxChars,
|
|
242
498
|
);
|
|
243
499
|
}
|
|
244
500
|
|
|
245
501
|
// Handle thread relationship
|
|
246
|
-
let threadId
|
|
502
|
+
let threadId = id;
|
|
247
503
|
let status: Status = data.status ?? "published";
|
|
248
|
-
let visibility: Visibility = data.visibility ?? "
|
|
504
|
+
let visibility: Visibility | null = data.visibility ?? "public";
|
|
249
505
|
|
|
250
506
|
if (data.replyToId) {
|
|
251
507
|
const parent = await this.getById(data.replyToId);
|
|
252
|
-
if (parent) {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
508
|
+
if (!parent) {
|
|
509
|
+
throw new NotFoundError("Parent post");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (data.pinned) {
|
|
513
|
+
throw new ConflictError(
|
|
514
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
threadId = parent.threadId;
|
|
519
|
+
|
|
520
|
+
// Replies inherit visibility from the root at read time.
|
|
521
|
+
const root =
|
|
522
|
+
parent.threadId === parent.id
|
|
523
|
+
? parent
|
|
524
|
+
: await this.getById(parent.threadId);
|
|
525
|
+
if (root) {
|
|
526
|
+
if (data.status !== "draft") {
|
|
259
527
|
status = root.status as Status;
|
|
260
|
-
visibility = root.visibility as Visibility;
|
|
261
528
|
}
|
|
262
529
|
}
|
|
530
|
+
visibility = null;
|
|
263
531
|
}
|
|
264
532
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
533
|
+
assertDraftPublishedAt(status, data.publishedAt);
|
|
534
|
+
const publishedAt =
|
|
535
|
+
status === "published" ? (data.publishedAt ?? timestamp) : null;
|
|
536
|
+
|
|
537
|
+
// Resolve slug from slug, path, or title
|
|
538
|
+
let slug: string;
|
|
539
|
+
let aliasPath: string | null = null;
|
|
540
|
+
|
|
268
541
|
if (data.path) {
|
|
269
|
-
|
|
542
|
+
const normalized = normalizePath(data.path);
|
|
543
|
+
if (isValidSlug(normalized)) {
|
|
544
|
+
// Path is a valid slug — use it directly
|
|
545
|
+
slug = await generatePostSlug({
|
|
546
|
+
slug: normalized,
|
|
547
|
+
idLength: config.slugIdLength,
|
|
548
|
+
isAvailable: isSlugAvailable,
|
|
549
|
+
});
|
|
550
|
+
} else {
|
|
551
|
+
// Path is not a valid slug — slugify it for the slug, keep original as alias
|
|
552
|
+
const slugified = slugify(normalized);
|
|
553
|
+
slug = await generatePostSlug({
|
|
554
|
+
slug: slugified || undefined,
|
|
555
|
+
title: data.title,
|
|
556
|
+
idLength: config.slugIdLength,
|
|
557
|
+
isAvailable: isSlugAvailable,
|
|
558
|
+
});
|
|
559
|
+
// Verify the alias path is available before proceeding
|
|
560
|
+
if (!(await paths.isPathAvailable(normalized))) {
|
|
561
|
+
throw new ConflictError(`Path "${normalized}" is already in use`);
|
|
562
|
+
}
|
|
563
|
+
aliasPath = normalized;
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
slug = await generatePostSlug({
|
|
567
|
+
slug: data.slug,
|
|
568
|
+
title: data.title,
|
|
569
|
+
idLength: config.slugIdLength,
|
|
570
|
+
isAvailable: isSlugAvailable,
|
|
571
|
+
});
|
|
270
572
|
}
|
|
271
573
|
|
|
272
|
-
|
|
574
|
+
const collectionIds = [...new Set(data.collectionIds ?? [])];
|
|
575
|
+
|
|
273
576
|
try {
|
|
274
|
-
|
|
275
|
-
.insert(posts)
|
|
276
|
-
|
|
577
|
+
const writeQueries: BatchItem<"sqlite">[] = [
|
|
578
|
+
db.insert(posts).values({
|
|
579
|
+
id,
|
|
277
580
|
format: data.format,
|
|
278
581
|
status,
|
|
279
582
|
visibility,
|
|
280
|
-
|
|
281
|
-
|
|
583
|
+
pinnedAt: data.pinned ? timestamp : null,
|
|
584
|
+
featuredAt: data.featured ? timestamp : null,
|
|
282
585
|
title: data.title ?? null,
|
|
283
586
|
url: data.url ?? null,
|
|
284
|
-
body:
|
|
587
|
+
body: body ?? null,
|
|
285
588
|
bodyHtml,
|
|
589
|
+
bodyText,
|
|
286
590
|
quoteText: data.quoteText ?? null,
|
|
287
591
|
summary,
|
|
288
592
|
rating: data.rating ?? null,
|
|
289
593
|
replyToId: data.replyToId ?? null,
|
|
290
594
|
threadId,
|
|
291
|
-
publishedAt
|
|
595
|
+
publishedAt,
|
|
596
|
+
lastActivityAt: publishedAt ?? timestamp,
|
|
292
597
|
createdAt: timestamp,
|
|
293
598
|
updatedAt: timestamp,
|
|
294
|
-
})
|
|
295
|
-
.
|
|
599
|
+
}),
|
|
600
|
+
db.insert(pathRegistry).values({
|
|
601
|
+
id: uuidv7(),
|
|
602
|
+
path: normalizePath(slug),
|
|
603
|
+
kind: "slug",
|
|
604
|
+
postId: id,
|
|
605
|
+
collectionId: null,
|
|
606
|
+
redirectToPath: null,
|
|
607
|
+
redirectType: null,
|
|
608
|
+
createdAt: timestamp,
|
|
609
|
+
updatedAt: timestamp,
|
|
610
|
+
}),
|
|
611
|
+
];
|
|
612
|
+
|
|
613
|
+
if (aliasPath) {
|
|
614
|
+
writeQueries.push(
|
|
615
|
+
db.insert(pathRegistry).values({
|
|
616
|
+
id: uuidv7(),
|
|
617
|
+
path: normalizePath(aliasPath),
|
|
618
|
+
kind: "alias",
|
|
619
|
+
postId: id,
|
|
620
|
+
collectionId: null,
|
|
621
|
+
redirectToPath: null,
|
|
622
|
+
redirectType: null,
|
|
623
|
+
createdAt: timestamp,
|
|
624
|
+
updatedAt: timestamp,
|
|
625
|
+
}),
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (collectionIds.length > 0) {
|
|
630
|
+
writeQueries.push(
|
|
631
|
+
db.insert(postCollections).values(
|
|
632
|
+
collectionIds.map((collectionId) => ({
|
|
633
|
+
postId: id,
|
|
634
|
+
collectionId,
|
|
635
|
+
createdAt: timestamp,
|
|
636
|
+
})),
|
|
637
|
+
),
|
|
638
|
+
);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
await db.batch(
|
|
642
|
+
writeQueries as [
|
|
643
|
+
(typeof writeQueries)[number],
|
|
644
|
+
...(typeof writeQueries)[number][],
|
|
645
|
+
],
|
|
646
|
+
);
|
|
296
647
|
} catch (err) {
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
648
|
+
if (err instanceof ConflictError) {
|
|
649
|
+
throw new ConflictError(`Slug "${slug}" is already in use`);
|
|
650
|
+
}
|
|
651
|
+
if (isUniqueConstraintError(err) && (await pathExists(slug))) {
|
|
652
|
+
throw new ConflictError(`Slug "${slug}" is already in use`);
|
|
300
653
|
}
|
|
301
654
|
throw err;
|
|
302
655
|
}
|
|
303
656
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
// Update registry with actual post ID
|
|
308
|
-
if (post.path) {
|
|
309
|
-
await pathRegistry.release(post.path);
|
|
310
|
-
await pathRegistry.claim(post.path, "post", post.id);
|
|
657
|
+
const post = await this.getById(id);
|
|
658
|
+
if (!post) {
|
|
659
|
+
throw new ConflictError(`Slug "${slug}" could not be resolved`);
|
|
311
660
|
}
|
|
312
661
|
|
|
313
|
-
//
|
|
314
|
-
if (data.
|
|
315
|
-
await
|
|
316
|
-
data.collectionIds.map((collectionId) => ({
|
|
317
|
-
postId: post.id,
|
|
318
|
-
collectionId,
|
|
319
|
-
})),
|
|
320
|
-
);
|
|
662
|
+
// Bump thread root's lastActivityAt when creating a published reply
|
|
663
|
+
if (data.replyToId && status === "published") {
|
|
664
|
+
await recalculateThreadLastActivity(threadId);
|
|
321
665
|
}
|
|
322
666
|
|
|
323
667
|
return post;
|
|
@@ -327,45 +671,73 @@ export function createPostService(
|
|
|
327
671
|
const existing = await this.getById(id);
|
|
328
672
|
if (!existing) return null;
|
|
329
673
|
|
|
330
|
-
// Handle path changes in the registry before modifying the post
|
|
331
|
-
const pathChanging =
|
|
332
|
-
data.path !== undefined && data.path !== existing.path;
|
|
333
|
-
if (pathChanging) {
|
|
334
|
-
// Claim new path (if non-null) before releasing old
|
|
335
|
-
if (data.path) {
|
|
336
|
-
await pathRegistry.claim(data.path, "post", id);
|
|
337
|
-
}
|
|
338
|
-
// Release old path (if it existed)
|
|
339
|
-
if (existing.path) {
|
|
340
|
-
await pathRegistry.release(existing.path);
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
|
|
344
674
|
const timestamp = now();
|
|
675
|
+
const nextFormat = data.format ?? existing.format;
|
|
676
|
+
const nextUrl = data.url !== undefined ? data.url : existing.url;
|
|
677
|
+
const nextQuoteText =
|
|
678
|
+
data.quoteText !== undefined ? data.quoteText : existing.quoteText;
|
|
679
|
+
const nextStatus = data.status ?? existing.status;
|
|
680
|
+
|
|
681
|
+
assertPostFormatShape({
|
|
682
|
+
format: nextFormat,
|
|
683
|
+
url: nextUrl,
|
|
684
|
+
quoteText: nextQuoteText,
|
|
685
|
+
});
|
|
686
|
+
assertDraftPublishedAt(nextStatus, data.publishedAt);
|
|
687
|
+
|
|
345
688
|
const updates: Partial<typeof posts.$inferInsert> = {
|
|
346
689
|
updatedAt: timestamp,
|
|
347
690
|
};
|
|
348
691
|
|
|
692
|
+
// Handle slug change
|
|
693
|
+
const slugChanged =
|
|
694
|
+
data.slug !== undefined && data.slug !== existing.slug;
|
|
695
|
+
if (slugChanged && data.slug) {
|
|
696
|
+
try {
|
|
697
|
+
await paths.updatePostSlug(id, data.slug);
|
|
698
|
+
} catch (err) {
|
|
699
|
+
if (err instanceof ConflictError) {
|
|
700
|
+
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
701
|
+
}
|
|
702
|
+
throw err;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
349
706
|
if (data.format !== undefined) updates.format = data.format;
|
|
350
|
-
if (data.path !== undefined) updates.path = data.path;
|
|
351
707
|
if (data.title !== undefined) updates.title = data.title;
|
|
352
708
|
if (data.url !== undefined) updates.url = data.url;
|
|
353
709
|
if (data.quoteText !== undefined) updates.quoteText = data.quoteText;
|
|
354
710
|
if (data.rating !== undefined) updates.rating = data.rating;
|
|
355
|
-
if (data.
|
|
356
|
-
updates.
|
|
357
|
-
if (data.
|
|
711
|
+
if (data.pinned !== undefined)
|
|
712
|
+
updates.pinnedAt = data.pinned ? now() : null;
|
|
713
|
+
if (data.featured !== undefined)
|
|
714
|
+
updates.featuredAt = data.featured ? now() : null;
|
|
358
715
|
|
|
359
|
-
if (data.body !== undefined) {
|
|
360
|
-
|
|
361
|
-
|
|
716
|
+
if (data.body !== undefined || data.bodyMarkdown !== undefined) {
|
|
717
|
+
const normalizedBody = data.bodyMarkdown
|
|
718
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
719
|
+
: (data.body ?? null);
|
|
720
|
+
updates.body = normalizedBody;
|
|
721
|
+
updates.bodyHtml = normalizedBody
|
|
722
|
+
? renderTiptapJson(normalizedBody)
|
|
723
|
+
: null;
|
|
724
|
+
updates.bodyText = normalizedBody
|
|
725
|
+
? extractBodyText(normalizedBody)
|
|
726
|
+
: null;
|
|
362
727
|
}
|
|
363
728
|
|
|
364
729
|
// Recompute summary when body, title, or format change
|
|
365
730
|
if (summaryConfig) {
|
|
366
731
|
const format = data.format ?? (existing.format as Format);
|
|
367
732
|
const title = data.title !== undefined ? data.title : existing.title;
|
|
368
|
-
const body =
|
|
733
|
+
const body =
|
|
734
|
+
data.bodyMarkdown !== undefined
|
|
735
|
+
? data.bodyMarkdown
|
|
736
|
+
? markdownToTiptapJson(data.bodyMarkdown)
|
|
737
|
+
: null
|
|
738
|
+
: data.body !== undefined
|
|
739
|
+
? data.body
|
|
740
|
+
: existing.body;
|
|
369
741
|
if (format === "note" && title && body) {
|
|
370
742
|
updates.summary = extractSummary(
|
|
371
743
|
body,
|
|
@@ -377,21 +749,54 @@ export function createPostService(
|
|
|
377
749
|
}
|
|
378
750
|
}
|
|
379
751
|
|
|
752
|
+
// Thread replies inherit visibility/pinned from root — reject direct changes
|
|
753
|
+
if (isThreadReply(existing)) {
|
|
754
|
+
if (data.visibility !== undefined) {
|
|
755
|
+
throw new ConflictError(
|
|
756
|
+
"Cannot change visibility of a thread reply. Update the root post instead.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
if (data.pinned !== undefined) {
|
|
760
|
+
throw new ConflictError(
|
|
761
|
+
"Cannot pin a thread reply. Pin the root post instead.",
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
380
766
|
// Handle status/visibility change - cascade to thread if this is root
|
|
381
767
|
const statusChanged =
|
|
382
768
|
data.status !== undefined && data.status !== existing.status;
|
|
383
769
|
const visibilityChanged =
|
|
384
770
|
data.visibility !== undefined &&
|
|
385
771
|
data.visibility !== existing.visibility;
|
|
772
|
+
const publishedAtChanged = data.publishedAt !== undefined;
|
|
773
|
+
const nextPublishedAt =
|
|
774
|
+
nextStatus === "draft"
|
|
775
|
+
? null
|
|
776
|
+
: publishedAtChanged
|
|
777
|
+
? (data.publishedAt ?? timestamp)
|
|
778
|
+
: existing.status === "draft"
|
|
779
|
+
? timestamp
|
|
780
|
+
: (existing.publishedAt ?? timestamp);
|
|
386
781
|
|
|
387
782
|
if (statusChanged) updates.status = data.status;
|
|
388
|
-
if (visibilityChanged
|
|
783
|
+
if (visibilityChanged && !isThreadReply(existing)) {
|
|
784
|
+
updates.visibility = data.visibility;
|
|
785
|
+
}
|
|
786
|
+
if (statusChanged || publishedAtChanged || existing.status === "draft") {
|
|
787
|
+
updates.publishedAt = nextPublishedAt;
|
|
788
|
+
updates.lastActivityAt = nextPublishedAt ?? timestamp;
|
|
789
|
+
}
|
|
389
790
|
|
|
390
791
|
// Build all write queries for atomic execution via D1 batch
|
|
391
|
-
const needsCascade =
|
|
392
|
-
|
|
792
|
+
const needsCascade = statusChanged && !isThreadReply(existing);
|
|
793
|
+
const needsReplyVisibilityCleanup =
|
|
794
|
+
!isThreadReply(existing) && (statusChanged || visibilityChanged);
|
|
393
795
|
const needsCollectionSync = data.collectionIds !== undefined;
|
|
394
|
-
const
|
|
796
|
+
const needsThreadActivityRecalc =
|
|
797
|
+
statusChanged || publishedAtChanged || existing.status === "draft";
|
|
798
|
+
const hasExtraWrites =
|
|
799
|
+
needsCascade || needsReplyVisibilityCleanup || needsCollectionSync;
|
|
395
800
|
|
|
396
801
|
if (!hasExtraWrites) {
|
|
397
802
|
// Simple case: only the post update
|
|
@@ -400,7 +805,11 @@ export function createPostService(
|
|
|
400
805
|
.set(updates)
|
|
401
806
|
.where(eq(posts.id, id))
|
|
402
807
|
.returning();
|
|
403
|
-
|
|
808
|
+
if (needsThreadActivityRecalc) {
|
|
809
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
810
|
+
return this.getById(id);
|
|
811
|
+
}
|
|
812
|
+
return hydratePost(result[0]);
|
|
404
813
|
}
|
|
405
814
|
|
|
406
815
|
// Complex case: batch cascade + update + collection sync atomically
|
|
@@ -412,11 +821,23 @@ export function createPostService(
|
|
|
412
821
|
.update(posts)
|
|
413
822
|
.set({
|
|
414
823
|
status: data.status ?? (existing.status as Status),
|
|
415
|
-
|
|
416
|
-
|
|
824
|
+
publishedAt: nextStatus === "published" ? nextPublishedAt : null,
|
|
825
|
+
lastActivityAt:
|
|
826
|
+
nextStatus === "published"
|
|
827
|
+
? (nextPublishedAt ?? timestamp)
|
|
828
|
+
: timestamp,
|
|
417
829
|
updatedAt: timestamp,
|
|
418
830
|
})
|
|
419
|
-
.where(eq(posts.threadId, id)),
|
|
831
|
+
.where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
|
|
832
|
+
);
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (needsReplyVisibilityCleanup) {
|
|
836
|
+
writeQueries.push(
|
|
837
|
+
db
|
|
838
|
+
.update(posts)
|
|
839
|
+
.set({ visibility: null, updatedAt: timestamp })
|
|
840
|
+
.where(and(eq(posts.threadId, id), isNotNull(posts.replyToId))),
|
|
420
841
|
);
|
|
421
842
|
}
|
|
422
843
|
|
|
@@ -438,6 +859,7 @@ export function createPostService(
|
|
|
438
859
|
data.collectionIds!.map((collectionId) => ({
|
|
439
860
|
postId: id,
|
|
440
861
|
collectionId,
|
|
862
|
+
createdAt: now(),
|
|
441
863
|
})),
|
|
442
864
|
),
|
|
443
865
|
);
|
|
@@ -453,7 +875,11 @@ export function createPostService(
|
|
|
453
875
|
const updateResult = results[updateIdx] as
|
|
454
876
|
| (typeof posts.$inferSelect)[]
|
|
455
877
|
| undefined;
|
|
456
|
-
|
|
878
|
+
if (needsThreadActivityRecalc) {
|
|
879
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
880
|
+
return this.getById(id);
|
|
881
|
+
}
|
|
882
|
+
return hydratePost(updateResult?.[0]);
|
|
457
883
|
},
|
|
458
884
|
|
|
459
885
|
async delete(id, deps) {
|
|
@@ -462,8 +888,8 @@ export function createPostService(
|
|
|
462
888
|
|
|
463
889
|
// Clean up media for all affected posts
|
|
464
890
|
if (deps?.media) {
|
|
465
|
-
let postIds:
|
|
466
|
-
if (!existing
|
|
891
|
+
let postIds: string[];
|
|
892
|
+
if (!isThreadReply(existing)) {
|
|
467
893
|
const thread = await this.getThread(id);
|
|
468
894
|
postIds = thread.map((p) => p.id);
|
|
469
895
|
} else {
|
|
@@ -480,32 +906,21 @@ export function createPostService(
|
|
|
480
906
|
}
|
|
481
907
|
}
|
|
482
908
|
|
|
483
|
-
// Release paths from registry
|
|
484
|
-
if (!existing.threadId) {
|
|
485
|
-
// Thread root: release paths for all posts in thread
|
|
486
|
-
const thread = await this.getThread(id);
|
|
487
|
-
for (const post of thread) {
|
|
488
|
-
if (post.path) {
|
|
489
|
-
await pathRegistry.release(post.path);
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
} else if (existing.path) {
|
|
493
|
-
await pathRegistry.release(existing.path);
|
|
494
|
-
}
|
|
495
|
-
|
|
496
909
|
const timestamp = now();
|
|
497
910
|
|
|
498
911
|
// If this is a thread root, soft delete all posts in the thread
|
|
499
|
-
if (!existing
|
|
912
|
+
if (!isThreadReply(existing)) {
|
|
500
913
|
await db
|
|
501
914
|
.update(posts)
|
|
502
915
|
.set({ deletedAt: timestamp, updatedAt: timestamp })
|
|
503
|
-
.where(
|
|
916
|
+
.where(eq(posts.threadId, id));
|
|
504
917
|
} else {
|
|
918
|
+
// Soft-delete the single reply
|
|
505
919
|
await db
|
|
506
920
|
.update(posts)
|
|
507
921
|
.set({ deletedAt: timestamp, updatedAt: timestamp })
|
|
508
922
|
.where(eq(posts.id, id));
|
|
923
|
+
await recalculateThreadLastActivity(existing.threadId);
|
|
509
924
|
}
|
|
510
925
|
|
|
511
926
|
return true;
|
|
@@ -515,23 +930,37 @@ export function createPostService(
|
|
|
515
930
|
const rows = await db
|
|
516
931
|
.select()
|
|
517
932
|
.from(posts)
|
|
518
|
-
.where(
|
|
519
|
-
and(
|
|
520
|
-
or(eq(posts.id, rootId), eq(posts.threadId, rootId)),
|
|
521
|
-
isNull(posts.deletedAt),
|
|
522
|
-
),
|
|
523
|
-
)
|
|
933
|
+
.where(and(eq(posts.threadId, rootId), isNull(posts.deletedAt)))
|
|
524
934
|
.orderBy(posts.createdAt);
|
|
525
935
|
|
|
526
|
-
return rows
|
|
936
|
+
return hydratePosts(rows);
|
|
527
937
|
},
|
|
528
938
|
|
|
529
939
|
async updateThreadStatusAndVisibility(rootId, status, visibility) {
|
|
530
940
|
const timestamp = now();
|
|
531
|
-
await db
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
941
|
+
await db.batch([
|
|
942
|
+
db
|
|
943
|
+
.update(posts)
|
|
944
|
+
.set({
|
|
945
|
+
status,
|
|
946
|
+
visibility,
|
|
947
|
+
publishedAt: status === "published" ? timestamp : null,
|
|
948
|
+
lastActivityAt: timestamp,
|
|
949
|
+
updatedAt: timestamp,
|
|
950
|
+
})
|
|
951
|
+
.where(eq(posts.id, rootId)),
|
|
952
|
+
db
|
|
953
|
+
.update(posts)
|
|
954
|
+
.set({
|
|
955
|
+
status,
|
|
956
|
+
visibility: null,
|
|
957
|
+
publishedAt: status === "published" ? timestamp : null,
|
|
958
|
+
lastActivityAt: timestamp,
|
|
959
|
+
updatedAt: timestamp,
|
|
960
|
+
})
|
|
961
|
+
.where(and(eq(posts.threadId, rootId), isNotNull(posts.replyToId))),
|
|
962
|
+
]);
|
|
963
|
+
await recalculateThreadLastActivity(rootId);
|
|
535
964
|
},
|
|
536
965
|
|
|
537
966
|
async getReplyCounts(postIds) {
|
|
@@ -543,14 +972,19 @@ export function createPostService(
|
|
|
543
972
|
count: sql<number>`count(*)`.as("count"),
|
|
544
973
|
})
|
|
545
974
|
.from(posts)
|
|
546
|
-
.where(
|
|
975
|
+
.where(
|
|
976
|
+
and(
|
|
977
|
+
inArray(posts.threadId, postIds),
|
|
978
|
+
eq(posts.status, "published"),
|
|
979
|
+
isNotNull(posts.replyToId),
|
|
980
|
+
isNull(posts.deletedAt),
|
|
981
|
+
),
|
|
982
|
+
)
|
|
547
983
|
.groupBy(posts.threadId);
|
|
548
984
|
|
|
549
|
-
const counts = new Map<
|
|
985
|
+
const counts = new Map<string, number>();
|
|
550
986
|
for (const row of rows) {
|
|
551
|
-
|
|
552
|
-
counts.set(row.threadId, row.count);
|
|
553
|
-
}
|
|
987
|
+
counts.set(row.threadId, row.count);
|
|
554
988
|
}
|
|
555
989
|
return counts;
|
|
556
990
|
},
|
|
@@ -561,13 +995,18 @@ export function createPostService(
|
|
|
561
995
|
const rows = await db
|
|
562
996
|
.select()
|
|
563
997
|
.from(posts)
|
|
564
|
-
.where(
|
|
998
|
+
.where(
|
|
999
|
+
and(
|
|
1000
|
+
inArray(posts.threadId, rootIds),
|
|
1001
|
+
eq(posts.status, "published"),
|
|
1002
|
+
isNotNull(posts.replyToId),
|
|
1003
|
+
isNull(posts.deletedAt),
|
|
1004
|
+
),
|
|
1005
|
+
)
|
|
565
1006
|
.orderBy(posts.threadId, posts.createdAt);
|
|
566
1007
|
|
|
567
|
-
const result = new Map<
|
|
568
|
-
for (const
|
|
569
|
-
const post = toPost(row);
|
|
570
|
-
if (post.threadId === null) continue;
|
|
1008
|
+
const result = new Map<string, Post[]>();
|
|
1009
|
+
for (const post of await hydratePosts(rows)) {
|
|
571
1010
|
const list = result.get(post.threadId);
|
|
572
1011
|
if (list) {
|
|
573
1012
|
if (list.length < previewCount) {
|
|
@@ -579,5 +1018,99 @@ export function createPostService(
|
|
|
579
1018
|
}
|
|
580
1019
|
return result;
|
|
581
1020
|
},
|
|
1021
|
+
|
|
1022
|
+
async getThreadTimelineContext(rootIds) {
|
|
1023
|
+
if (rootIds.length === 0) return new Map();
|
|
1024
|
+
|
|
1025
|
+
// Fetch all non-deleted replies ordered by thread, newest first
|
|
1026
|
+
const rows = await db
|
|
1027
|
+
.select()
|
|
1028
|
+
.from(posts)
|
|
1029
|
+
.where(
|
|
1030
|
+
and(
|
|
1031
|
+
inArray(posts.threadId, rootIds),
|
|
1032
|
+
eq(posts.status, "published"),
|
|
1033
|
+
isNotNull(posts.replyToId),
|
|
1034
|
+
isNull(posts.deletedAt),
|
|
1035
|
+
),
|
|
1036
|
+
)
|
|
1037
|
+
.orderBy(posts.threadId, desc(posts.createdAt), desc(posts.id));
|
|
1038
|
+
|
|
1039
|
+
// Group by threadId, extract latest reply + its parent + count
|
|
1040
|
+
const grouped = new Map<string, Post[]>();
|
|
1041
|
+
for (const post of await hydratePosts(rows)) {
|
|
1042
|
+
const list = grouped.get(post.threadId);
|
|
1043
|
+
if (list) {
|
|
1044
|
+
list.push(post);
|
|
1045
|
+
} else {
|
|
1046
|
+
grouped.set(post.threadId, [post]);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
const result = new Map<string, ThreadTimelineContext>();
|
|
1051
|
+
for (const [threadId, replies] of grouped) {
|
|
1052
|
+
// replies are ordered newest-first; first element is the latest
|
|
1053
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- grouped only contains non-empty arrays
|
|
1054
|
+
const latestReply = replies[0]!;
|
|
1055
|
+
const totalReplyCount = replies.length;
|
|
1056
|
+
|
|
1057
|
+
// Find parent of latestReply if it's not the root
|
|
1058
|
+
let parentReply: Post | null = null;
|
|
1059
|
+
if (latestReply.replyToId && latestReply.replyToId !== threadId) {
|
|
1060
|
+
parentReply =
|
|
1061
|
+
replies.find((r) => r.id === latestReply.replyToId) ?? null;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
result.set(threadId, { latestReply, parentReply, totalReplyCount });
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return result;
|
|
1068
|
+
},
|
|
1069
|
+
|
|
1070
|
+
async getLastPostIdsByThread(threadIds) {
|
|
1071
|
+
const result = new Map<string, string>();
|
|
1072
|
+
if (threadIds.length === 0) return result;
|
|
1073
|
+
|
|
1074
|
+
const unique = [...new Set(threadIds)];
|
|
1075
|
+
const rows = await db
|
|
1076
|
+
.select({
|
|
1077
|
+
threadId: posts.threadId,
|
|
1078
|
+
id: sql<string>`(
|
|
1079
|
+
SELECT p2.id FROM post AS p2
|
|
1080
|
+
WHERE p2.thread_id = ${posts.threadId}
|
|
1081
|
+
AND p2.deleted_at IS NULL
|
|
1082
|
+
AND p2.status = 'published'
|
|
1083
|
+
ORDER BY p2.created_at DESC, p2.id DESC
|
|
1084
|
+
LIMIT 1
|
|
1085
|
+
)`.as("last_id"),
|
|
1086
|
+
})
|
|
1087
|
+
.from(posts)
|
|
1088
|
+
.where(inArray(posts.id, unique));
|
|
1089
|
+
|
|
1090
|
+
for (const row of rows) {
|
|
1091
|
+
if (row.id) result.set(row.threadId, row.id);
|
|
1092
|
+
}
|
|
1093
|
+
return result;
|
|
1094
|
+
},
|
|
1095
|
+
|
|
1096
|
+
async getDistinctYears(filters = {}) {
|
|
1097
|
+
const conditions = [
|
|
1098
|
+
...buildFilterConditions(filters),
|
|
1099
|
+
isNotNull(posts.publishedAt),
|
|
1100
|
+
];
|
|
1101
|
+
|
|
1102
|
+
const rows = await db
|
|
1103
|
+
.select({
|
|
1104
|
+
year: sql<string>`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`.as(
|
|
1105
|
+
"year",
|
|
1106
|
+
),
|
|
1107
|
+
})
|
|
1108
|
+
.from(posts)
|
|
1109
|
+
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
|
1110
|
+
.groupBy(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`)
|
|
1111
|
+
.orderBy(desc(sql`strftime('%Y', ${posts.publishedAt}, 'unixepoch')`));
|
|
1112
|
+
|
|
1113
|
+
return rows.map((r) => parseInt(r.year, 10));
|
|
1114
|
+
},
|
|
582
1115
|
};
|
|
583
1116
|
}
|