@jant/core 0.3.36 → 0.3.37
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
|
@@ -2,80 +2,211 @@
|
|
|
2
2
|
* Collection Service (v2)
|
|
3
3
|
*
|
|
4
4
|
* Manages collections. Posts belong to collections via post_collections junction table (M:N).
|
|
5
|
+
* Sidebar ordering is managed through the sidebar_items table with fractional indexing.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import { eq, asc, sql,
|
|
8
|
-
import type {
|
|
8
|
+
import { eq, asc, sql, and, inArray, desc } from "drizzle-orm";
|
|
9
|
+
import type { BatchItem } from "drizzle-orm/batch";
|
|
10
|
+
import { generateKeyBetween } from "fractional-indexing";
|
|
11
|
+
import { uuidv7 } from "uuidv7";
|
|
12
|
+
import { type Database, batchQueryRows } from "../db/index.js";
|
|
9
13
|
import {
|
|
10
14
|
collections,
|
|
11
|
-
|
|
15
|
+
pathRegistry,
|
|
16
|
+
sidebarItems,
|
|
12
17
|
postCollections,
|
|
13
18
|
} from "../db/schema.js";
|
|
14
19
|
import { now } from "../lib/time.js";
|
|
15
20
|
import type {
|
|
16
21
|
Collection,
|
|
17
|
-
|
|
22
|
+
SidebarItem,
|
|
23
|
+
SidebarItemType,
|
|
18
24
|
CreateCollection,
|
|
19
25
|
UpdateCollection,
|
|
20
26
|
SortOrder,
|
|
21
27
|
} from "../types.js";
|
|
28
|
+
import { ConflictError } from "../lib/errors.js";
|
|
29
|
+
import {
|
|
30
|
+
createPathService,
|
|
31
|
+
toCollectionPath,
|
|
32
|
+
type PathService,
|
|
33
|
+
} from "./path.js";
|
|
34
|
+
|
|
35
|
+
const POSITION_RETRY_ATTEMPTS = 5;
|
|
36
|
+
|
|
37
|
+
function isUniqueConstraintError(err: unknown): boolean {
|
|
38
|
+
let current: unknown = err;
|
|
39
|
+
while (current) {
|
|
40
|
+
const msg = String(current);
|
|
41
|
+
if (
|
|
42
|
+
msg.includes("UNIQUE constraint") ||
|
|
43
|
+
msg.includes("SQLITE_CONSTRAINT")
|
|
44
|
+
) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
current =
|
|
48
|
+
current instanceof Error && current.cause !== current
|
|
49
|
+
? current.cause
|
|
50
|
+
: undefined;
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
22
54
|
|
|
23
55
|
export interface CollectionService {
|
|
24
|
-
getById(id:
|
|
56
|
+
getById(id: string): Promise<Collection | null>;
|
|
25
57
|
getBySlug(slug: string): Promise<Collection | null>;
|
|
26
58
|
list(): Promise<Collection[]>;
|
|
59
|
+
/** List collections sorted by most recent post addition (for compose dialog) */
|
|
60
|
+
listByRecentActivity(): Promise<Collection[]>;
|
|
27
61
|
create(data: CreateCollection): Promise<Collection>;
|
|
28
|
-
update(id:
|
|
29
|
-
delete(id:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
|
|
62
|
+
update(id: string, data: UpdateCollection): Promise<Collection | null>;
|
|
63
|
+
delete(id: string): Promise<boolean>;
|
|
64
|
+
/** List all sidebar items ordered by position */
|
|
65
|
+
listSidebarItems(): Promise<SidebarItem[]>;
|
|
66
|
+
/** Create a sidebar item (collection or divider) */
|
|
67
|
+
createSidebarItem(
|
|
68
|
+
type: SidebarItemType,
|
|
69
|
+
collectionId?: string,
|
|
70
|
+
): Promise<SidebarItem>;
|
|
71
|
+
/** Delete a sidebar item by ID */
|
|
72
|
+
deleteSidebarItem(id: string): Promise<boolean>;
|
|
73
|
+
/** Move a sidebar item between two neighbors */
|
|
74
|
+
moveSidebarItem(
|
|
75
|
+
id: string,
|
|
76
|
+
after: string | null,
|
|
77
|
+
before: string | null,
|
|
78
|
+
): Promise<SidebarItem | null>;
|
|
39
79
|
/** Get post count per collection */
|
|
40
|
-
getPostCounts(): Promise<Map<
|
|
80
|
+
getPostCounts(): Promise<Map<string, number>>;
|
|
41
81
|
/** Add a post to a collection */
|
|
42
|
-
addPost(collectionId:
|
|
82
|
+
addPost(collectionId: string, postId: string): Promise<void>;
|
|
43
83
|
/** Remove a post from a collection */
|
|
44
|
-
removePost(collectionId:
|
|
84
|
+
removePost(collectionId: string, postId: string): Promise<void>;
|
|
45
85
|
/** Get all collections a post belongs to */
|
|
46
|
-
getCollectionsByPostId(postId:
|
|
86
|
+
getCollectionsByPostId(postId: string): Promise<Collection[]>;
|
|
87
|
+
/** Batch get collections for multiple posts */
|
|
88
|
+
getCollectionsByPostIds(
|
|
89
|
+
postIds: string[],
|
|
90
|
+
): Promise<Map<string, Collection[]>>;
|
|
47
91
|
/** Get all post IDs in a collection */
|
|
48
|
-
getPostIds(collectionId:
|
|
92
|
+
getPostIds(collectionId: string): Promise<string[]>;
|
|
49
93
|
/** Sync a post's collection memberships (replace all with given IDs) */
|
|
50
|
-
syncPostCollections(postId:
|
|
94
|
+
syncPostCollections(postId: string, collectionIds: string[]): Promise<void>;
|
|
51
95
|
}
|
|
52
96
|
|
|
53
|
-
export function createCollectionService(
|
|
54
|
-
|
|
97
|
+
export function createCollectionService(
|
|
98
|
+
db: Database,
|
|
99
|
+
paths: PathService = createPathService(db),
|
|
100
|
+
): CollectionService {
|
|
101
|
+
function toCollection(
|
|
102
|
+
row: typeof collections.$inferSelect,
|
|
103
|
+
slug: string,
|
|
104
|
+
): Collection {
|
|
55
105
|
return {
|
|
56
106
|
id: row.id,
|
|
57
|
-
slug
|
|
107
|
+
slug,
|
|
58
108
|
title: row.title,
|
|
59
109
|
description: row.description,
|
|
60
110
|
icon: row.icon,
|
|
61
111
|
sortOrder: row.sortOrder as SortOrder,
|
|
62
|
-
position: row.position,
|
|
63
112
|
createdAt: row.createdAt,
|
|
64
113
|
updatedAt: row.updatedAt,
|
|
65
114
|
};
|
|
66
115
|
}
|
|
67
116
|
|
|
68
|
-
function
|
|
69
|
-
row: typeof collectionDividers.$inferSelect,
|
|
70
|
-
): CollectionDivider {
|
|
117
|
+
function toSidebarItem(row: typeof sidebarItems.$inferSelect): SidebarItem {
|
|
71
118
|
return {
|
|
72
119
|
id: row.id,
|
|
120
|
+
type: row.type as SidebarItemType,
|
|
121
|
+
collectionId: row.collectionId,
|
|
73
122
|
position: row.position,
|
|
74
123
|
createdAt: row.createdAt,
|
|
75
124
|
updatedAt: row.updatedAt,
|
|
76
125
|
};
|
|
77
126
|
}
|
|
78
127
|
|
|
128
|
+
async function getLastSidebarPosition(): Promise<string | null> {
|
|
129
|
+
const rows = await db
|
|
130
|
+
.select({ position: sidebarItems.position })
|
|
131
|
+
.from(sidebarItems)
|
|
132
|
+
.orderBy(sql`${sidebarItems.position} DESC`)
|
|
133
|
+
.limit(1);
|
|
134
|
+
return rows[0]?.position ?? null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function listOrderedSidebarPositions(excludeId?: string) {
|
|
138
|
+
const rows = await db
|
|
139
|
+
.select({ id: sidebarItems.id, position: sidebarItems.position })
|
|
140
|
+
.from(sidebarItems)
|
|
141
|
+
.orderBy(asc(sidebarItems.position));
|
|
142
|
+
return excludeId ? rows.filter((row) => row.id !== excludeId) : rows;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function getAppendSidebarPosition(): Promise<string> {
|
|
146
|
+
const lastPos = await getLastSidebarPosition();
|
|
147
|
+
return generateKeyBetween(lastPos, null);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function pathExists(path: string): Promise<boolean> {
|
|
151
|
+
const rows = await db
|
|
152
|
+
.select({ id: pathRegistry.id })
|
|
153
|
+
.from(pathRegistry)
|
|
154
|
+
.where(eq(pathRegistry.path, path))
|
|
155
|
+
.limit(1);
|
|
156
|
+
return rows.length > 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function getSidebarMovePosition(
|
|
160
|
+
id: string,
|
|
161
|
+
afterId: string | null,
|
|
162
|
+
beforeId: string | null,
|
|
163
|
+
): Promise<string> {
|
|
164
|
+
const rows = await listOrderedSidebarPositions(id);
|
|
165
|
+
const afterIndex = afterId
|
|
166
|
+
? rows.findIndex((row) => row.id === afterId)
|
|
167
|
+
: -1;
|
|
168
|
+
if (afterIndex >= 0) {
|
|
169
|
+
return generateKeyBetween(
|
|
170
|
+
rows[afterIndex]?.position ?? null,
|
|
171
|
+
rows[afterIndex + 1]?.position ?? null,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const beforeIndex = beforeId
|
|
176
|
+
? rows.findIndex((row) => row.id === beforeId)
|
|
177
|
+
: -1;
|
|
178
|
+
if (beforeIndex >= 0) {
|
|
179
|
+
return generateKeyBetween(
|
|
180
|
+
rows[beforeIndex - 1]?.position ?? null,
|
|
181
|
+
rows[beforeIndex]?.position ?? null,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return generateKeyBetween(rows.at(-1)?.position ?? null, null);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function hydrateCollection(
|
|
189
|
+
row: typeof collections.$inferSelect | undefined,
|
|
190
|
+
): Promise<Collection | null> {
|
|
191
|
+
if (!row) return null;
|
|
192
|
+
const slug = await paths.getCollectionSlug(row.id);
|
|
193
|
+
if (!slug) return null;
|
|
194
|
+
return toCollection(row, slug);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function hydrateCollections(
|
|
198
|
+
rows: (typeof collections.$inferSelect)[],
|
|
199
|
+
): Promise<Collection[]> {
|
|
200
|
+
if (rows.length === 0) return [];
|
|
201
|
+
const slugMap = await paths.getCollectionSlugMap(rows.map((row) => row.id));
|
|
202
|
+
return rows
|
|
203
|
+
.map((row) => {
|
|
204
|
+
const slug = slugMap.get(row.id);
|
|
205
|
+
return slug ? toCollection(row, slug) : null;
|
|
206
|
+
})
|
|
207
|
+
.filter((row): row is Collection => row !== null);
|
|
208
|
+
}
|
|
209
|
+
|
|
79
210
|
return {
|
|
80
211
|
async getById(id) {
|
|
81
212
|
const result = await db
|
|
@@ -83,72 +214,136 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
83
214
|
.from(collections)
|
|
84
215
|
.where(eq(collections.id, id))
|
|
85
216
|
.limit(1);
|
|
86
|
-
return
|
|
217
|
+
return hydrateCollection(result[0]);
|
|
87
218
|
},
|
|
88
219
|
|
|
89
220
|
async getBySlug(slug) {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return result[0] ? toCollection(result[0]) : null;
|
|
221
|
+
const resolved = await paths.resolve(toCollectionPath(slug));
|
|
222
|
+
if (!resolved || resolved.kind !== "slug" || !resolved.collectionId) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
return this.getById(resolved.collectionId);
|
|
96
226
|
},
|
|
97
227
|
|
|
98
228
|
async list() {
|
|
99
229
|
const rows = await db
|
|
100
230
|
.select()
|
|
101
231
|
.from(collections)
|
|
102
|
-
.orderBy(asc(collections.
|
|
103
|
-
return rows
|
|
232
|
+
.orderBy(asc(collections.createdAt));
|
|
233
|
+
return hydrateCollections(rows);
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
async listByRecentActivity() {
|
|
237
|
+
const lastAddedAt = sql<
|
|
238
|
+
number | null
|
|
239
|
+
>`MAX(${postCollections.createdAt})`.as("last_added_at");
|
|
240
|
+
const rows = await db
|
|
241
|
+
.select({ collection: collections, lastAddedAt })
|
|
242
|
+
.from(collections)
|
|
243
|
+
.leftJoin(
|
|
244
|
+
postCollections,
|
|
245
|
+
eq(collections.id, postCollections.collectionId),
|
|
246
|
+
)
|
|
247
|
+
.groupBy(collections.id)
|
|
248
|
+
.orderBy(desc(sql`last_added_at`), asc(collections.createdAt));
|
|
249
|
+
return hydrateCollections(rows.map((row) => row.collection));
|
|
104
250
|
},
|
|
105
251
|
|
|
106
252
|
async create(data) {
|
|
253
|
+
const id = uuidv7();
|
|
107
254
|
const timestamp = now();
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
255
|
+
const slugPath = toCollectionPath(data.slug);
|
|
256
|
+
|
|
257
|
+
for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
|
|
258
|
+
try {
|
|
259
|
+
const position = await getAppendSidebarPosition();
|
|
260
|
+
const writeQueries: BatchItem<"sqlite">[] = [
|
|
261
|
+
db.insert(collections).values({
|
|
262
|
+
id,
|
|
263
|
+
title: data.title,
|
|
264
|
+
description: data.description ?? null,
|
|
265
|
+
icon: data.icon ?? null,
|
|
266
|
+
sortOrder: data.sortOrder ?? "newest",
|
|
267
|
+
createdAt: timestamp,
|
|
268
|
+
updatedAt: timestamp,
|
|
269
|
+
}),
|
|
270
|
+
db.insert(pathRegistry).values({
|
|
271
|
+
id: uuidv7(),
|
|
272
|
+
path: slugPath,
|
|
273
|
+
kind: "slug",
|
|
274
|
+
postId: null,
|
|
275
|
+
collectionId: id,
|
|
276
|
+
redirectToPath: null,
|
|
277
|
+
redirectType: null,
|
|
278
|
+
createdAt: timestamp,
|
|
279
|
+
updatedAt: timestamp,
|
|
280
|
+
}),
|
|
281
|
+
db.insert(sidebarItems).values({
|
|
282
|
+
id: uuidv7(),
|
|
283
|
+
type: "collection",
|
|
284
|
+
collectionId: id,
|
|
285
|
+
position,
|
|
286
|
+
createdAt: timestamp,
|
|
287
|
+
updatedAt: timestamp,
|
|
288
|
+
}),
|
|
289
|
+
];
|
|
290
|
+
|
|
291
|
+
await db.batch(
|
|
292
|
+
writeQueries as [
|
|
293
|
+
(typeof writeQueries)[number],
|
|
294
|
+
...(typeof writeQueries)[number][],
|
|
295
|
+
],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
const collection = await this.getById(id);
|
|
299
|
+
if (!collection) {
|
|
300
|
+
throw new ConflictError(
|
|
301
|
+
`Slug "${data.slug}" could not be resolved`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
return collection;
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err instanceof ConflictError) {
|
|
307
|
+
throw err;
|
|
308
|
+
}
|
|
309
|
+
if (isUniqueConstraintError(err) && (await pathExists(slugPath))) {
|
|
310
|
+
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
311
|
+
}
|
|
312
|
+
if (attempt === POSITION_RETRY_ATTEMPTS - 1) {
|
|
313
|
+
throw err;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
116
316
|
}
|
|
117
317
|
|
|
118
|
-
|
|
119
|
-
.insert(collections)
|
|
120
|
-
.values({
|
|
121
|
-
slug: data.slug,
|
|
122
|
-
title: data.title,
|
|
123
|
-
description: data.description ?? null,
|
|
124
|
-
icon: data.icon ?? null,
|
|
125
|
-
sortOrder: data.sortOrder ?? "newest",
|
|
126
|
-
position,
|
|
127
|
-
createdAt: timestamp,
|
|
128
|
-
updatedAt: timestamp,
|
|
129
|
-
})
|
|
130
|
-
.returning();
|
|
131
|
-
|
|
132
|
-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
133
|
-
return toCollection(result[0]!);
|
|
318
|
+
throw new Error("Failed to assign a unique sidebar item position");
|
|
134
319
|
},
|
|
135
320
|
|
|
136
321
|
async update(id, data) {
|
|
137
322
|
const existing = await this.getById(id);
|
|
138
323
|
if (!existing) return null;
|
|
139
324
|
|
|
325
|
+
if (data.slug !== undefined && data.slug !== existing.slug) {
|
|
326
|
+
try {
|
|
327
|
+
await paths.updateCollectionSlug(id, data.slug);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err instanceof ConflictError) {
|
|
330
|
+
throw new ConflictError(`Slug "${data.slug}" is already in use`);
|
|
331
|
+
}
|
|
332
|
+
throw err;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
140
336
|
const timestamp = now();
|
|
141
337
|
const updates: Partial<typeof collections.$inferInsert> = {
|
|
142
338
|
updatedAt: timestamp,
|
|
143
339
|
};
|
|
144
340
|
|
|
145
341
|
if (data.title !== undefined) updates.title = data.title;
|
|
146
|
-
if (data.
|
|
147
|
-
if (data.description !== undefined)
|
|
342
|
+
if (data.description !== undefined) {
|
|
148
343
|
updates.description = data.description;
|
|
344
|
+
}
|
|
149
345
|
if (data.icon !== undefined) updates.icon = data.icon;
|
|
150
346
|
if (data.sortOrder !== undefined) updates.sortOrder = data.sortOrder;
|
|
151
|
-
if (data.position !== undefined) updates.position = data.position;
|
|
152
347
|
|
|
153
348
|
const result = await db
|
|
154
349
|
.update(collections)
|
|
@@ -156,11 +351,14 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
156
351
|
.where(eq(collections.id, id))
|
|
157
352
|
.returning();
|
|
158
353
|
|
|
159
|
-
return
|
|
354
|
+
return hydrateCollection(result[0]);
|
|
160
355
|
},
|
|
161
356
|
|
|
162
357
|
async delete(id) {
|
|
163
|
-
|
|
358
|
+
await db
|
|
359
|
+
.delete(postCollections)
|
|
360
|
+
.where(eq(postCollections.collectionId, id));
|
|
361
|
+
await db.delete(sidebarItems).where(eq(sidebarItems.collectionId, id));
|
|
164
362
|
const result = await db
|
|
165
363
|
.delete(collections)
|
|
166
364
|
.where(eq(collections.id, id))
|
|
@@ -168,69 +366,101 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
168
366
|
return result.length > 0;
|
|
169
367
|
},
|
|
170
368
|
|
|
171
|
-
async
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
if (items.length === 0) return;
|
|
178
|
-
const timestamp = now();
|
|
179
|
-
const queries = items.map((item, i) => {
|
|
180
|
-
const [prefix, idStr] = item.split("-");
|
|
181
|
-
const id = Number(idStr);
|
|
182
|
-
if (prefix === "d") {
|
|
183
|
-
return db
|
|
184
|
-
.update(collectionDividers)
|
|
185
|
-
.set({ position: i, updatedAt: timestamp })
|
|
186
|
-
.where(eq(collectionDividers.id, id));
|
|
187
|
-
}
|
|
188
|
-
return db
|
|
189
|
-
.update(collections)
|
|
190
|
-
.set({ position: i, updatedAt: timestamp })
|
|
191
|
-
.where(eq(collections.id, id));
|
|
192
|
-
});
|
|
193
|
-
await db.batch(
|
|
194
|
-
queries as [(typeof queries)[number], ...(typeof queries)[number][]],
|
|
195
|
-
);
|
|
369
|
+
async listSidebarItems() {
|
|
370
|
+
const rows = await db
|
|
371
|
+
.select()
|
|
372
|
+
.from(sidebarItems)
|
|
373
|
+
.orderBy(asc(sidebarItems.position));
|
|
374
|
+
return rows.map(toSidebarItem);
|
|
196
375
|
},
|
|
197
376
|
|
|
198
|
-
async
|
|
377
|
+
async createSidebarItem(type, collectionId) {
|
|
378
|
+
const id = uuidv7();
|
|
199
379
|
const timestamp = now();
|
|
200
380
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
381
|
+
for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
|
|
382
|
+
try {
|
|
383
|
+
const result = await db
|
|
384
|
+
.insert(sidebarItems)
|
|
385
|
+
.values({
|
|
386
|
+
id,
|
|
387
|
+
type,
|
|
388
|
+
collectionId: collectionId ?? null,
|
|
389
|
+
position: await getAppendSidebarPosition(),
|
|
390
|
+
createdAt: timestamp,
|
|
391
|
+
updatedAt: timestamp,
|
|
392
|
+
})
|
|
393
|
+
.returning();
|
|
394
|
+
|
|
395
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- DB insert with .returning() always returns inserted row
|
|
396
|
+
return toSidebarItem(result[0]!);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (
|
|
399
|
+
type === "collection" &&
|
|
400
|
+
collectionId &&
|
|
401
|
+
isUniqueConstraintError(err)
|
|
402
|
+
) {
|
|
403
|
+
const existing = await db
|
|
404
|
+
.select({ id: sidebarItems.id })
|
|
405
|
+
.from(sidebarItems)
|
|
406
|
+
.where(eq(sidebarItems.collectionId, collectionId))
|
|
407
|
+
.limit(1);
|
|
408
|
+
if (existing.length > 0) {
|
|
409
|
+
throw new ConflictError("Collection is already in the sidebar.");
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (
|
|
413
|
+
!isUniqueConstraintError(err) ||
|
|
414
|
+
attempt === POSITION_RETRY_ATTEMPTS - 1
|
|
415
|
+
) {
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
215
420
|
|
|
216
|
-
|
|
217
|
-
return toDivider(result[0]!);
|
|
421
|
+
throw new Error("Failed to assign a unique sidebar item position");
|
|
218
422
|
},
|
|
219
423
|
|
|
220
|
-
async
|
|
424
|
+
async deleteSidebarItem(id) {
|
|
221
425
|
const result = await db
|
|
222
|
-
.delete(
|
|
223
|
-
.where(eq(
|
|
426
|
+
.delete(sidebarItems)
|
|
427
|
+
.where(eq(sidebarItems.id, id))
|
|
224
428
|
.returning();
|
|
225
429
|
return result.length > 0;
|
|
226
430
|
},
|
|
227
431
|
|
|
228
|
-
async
|
|
229
|
-
const
|
|
432
|
+
async moveSidebarItem(id, afterId, beforeId) {
|
|
433
|
+
const items = await db
|
|
230
434
|
.select()
|
|
231
|
-
.from(
|
|
232
|
-
.
|
|
233
|
-
|
|
435
|
+
.from(sidebarItems)
|
|
436
|
+
.where(eq(sidebarItems.id, id))
|
|
437
|
+
.limit(1);
|
|
438
|
+
if (!items[0]) return null;
|
|
439
|
+
|
|
440
|
+
const timestamp = now();
|
|
441
|
+
for (let attempt = 0; attempt < POSITION_RETRY_ATTEMPTS; attempt += 1) {
|
|
442
|
+
try {
|
|
443
|
+
const result = await db
|
|
444
|
+
.update(sidebarItems)
|
|
445
|
+
.set({
|
|
446
|
+
position: await getSidebarMovePosition(id, afterId, beforeId),
|
|
447
|
+
updatedAt: timestamp,
|
|
448
|
+
})
|
|
449
|
+
.where(eq(sidebarItems.id, id))
|
|
450
|
+
.returning();
|
|
451
|
+
|
|
452
|
+
return result[0] ? toSidebarItem(result[0]) : null;
|
|
453
|
+
} catch (err) {
|
|
454
|
+
if (
|
|
455
|
+
!isUniqueConstraintError(err) ||
|
|
456
|
+
attempt === POSITION_RETRY_ATTEMPTS - 1
|
|
457
|
+
) {
|
|
458
|
+
throw err;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
throw new Error("Failed to assign a unique sidebar item position");
|
|
234
464
|
},
|
|
235
465
|
|
|
236
466
|
async getPostCounts() {
|
|
@@ -241,12 +471,12 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
241
471
|
})
|
|
242
472
|
.from(postCollections)
|
|
243
473
|
.innerJoin(
|
|
244
|
-
sql`
|
|
245
|
-
sql`
|
|
474
|
+
sql`post`,
|
|
475
|
+
sql`post.id = ${postCollections.postId} AND post.deleted_at IS NULL`,
|
|
246
476
|
)
|
|
247
477
|
.groupBy(postCollections.collectionId);
|
|
248
478
|
|
|
249
|
-
const counts = new Map<
|
|
479
|
+
const counts = new Map<string, number>();
|
|
250
480
|
for (const row of rows) {
|
|
251
481
|
counts.set(row.collectionId, row.count);
|
|
252
482
|
}
|
|
@@ -256,7 +486,7 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
256
486
|
async addPost(collectionId, postId) {
|
|
257
487
|
await db
|
|
258
488
|
.insert(postCollections)
|
|
259
|
-
.values({ postId, collectionId })
|
|
489
|
+
.values({ postId, collectionId, createdAt: now() })
|
|
260
490
|
.onConflictDoNothing();
|
|
261
491
|
},
|
|
262
492
|
|
|
@@ -280,9 +510,44 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
280
510
|
eq(postCollections.collectionId, collections.id),
|
|
281
511
|
)
|
|
282
512
|
.where(eq(postCollections.postId, postId))
|
|
283
|
-
.orderBy(asc(collections.
|
|
513
|
+
.orderBy(asc(collections.createdAt));
|
|
284
514
|
|
|
285
|
-
return rows.map((
|
|
515
|
+
return hydrateCollections(rows.map((row) => row.collection));
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
async getCollectionsByPostIds(postIds) {
|
|
519
|
+
const result = new Map<string, Collection[]>();
|
|
520
|
+
if (postIds.length === 0) return result;
|
|
521
|
+
|
|
522
|
+
const rows = await batchQueryRows(postIds, (chunk) =>
|
|
523
|
+
db
|
|
524
|
+
.select({
|
|
525
|
+
postId: postCollections.postId,
|
|
526
|
+
collection: collections,
|
|
527
|
+
})
|
|
528
|
+
.from(postCollections)
|
|
529
|
+
.innerJoin(
|
|
530
|
+
collections,
|
|
531
|
+
eq(postCollections.collectionId, collections.id),
|
|
532
|
+
)
|
|
533
|
+
.where(inArray(postCollections.postId, chunk))
|
|
534
|
+
.orderBy(asc(collections.createdAt)),
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
const collectionRows = rows.map((row) => row.collection);
|
|
538
|
+
const slugMap = await paths.getCollectionSlugMap(
|
|
539
|
+
collectionRows.map((row) => row.id),
|
|
540
|
+
);
|
|
541
|
+
|
|
542
|
+
for (const row of rows) {
|
|
543
|
+
const slug = slugMap.get(row.collection.id);
|
|
544
|
+
if (!slug) continue;
|
|
545
|
+
const existing = result.get(row.postId) ?? [];
|
|
546
|
+
existing.push(toCollection(row.collection, slug));
|
|
547
|
+
result.set(row.postId, existing);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return result;
|
|
286
551
|
},
|
|
287
552
|
|
|
288
553
|
async getPostIds(collectionId) {
|
|
@@ -291,26 +556,27 @@ export function createCollectionService(db: Database): CollectionService {
|
|
|
291
556
|
.from(postCollections)
|
|
292
557
|
.where(eq(postCollections.collectionId, collectionId));
|
|
293
558
|
|
|
294
|
-
return rows.map((
|
|
559
|
+
return rows.map((row) => row.postId);
|
|
295
560
|
},
|
|
296
561
|
|
|
297
562
|
async syncPostCollections(postId, collectionIds) {
|
|
298
563
|
if (collectionIds.length === 0) {
|
|
299
|
-
// Only delete — single statement, no batch needed
|
|
300
564
|
await db
|
|
301
565
|
.delete(postCollections)
|
|
302
566
|
.where(eq(postCollections.postId, postId));
|
|
303
567
|
return;
|
|
304
568
|
}
|
|
305
|
-
|
|
569
|
+
|
|
306
570
|
const deleteQuery = db
|
|
307
571
|
.delete(postCollections)
|
|
308
572
|
.where(eq(postCollections.postId, postId));
|
|
309
|
-
const insertQuery = db
|
|
310
|
-
.
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
573
|
+
const insertQuery = db.insert(postCollections).values(
|
|
574
|
+
collectionIds.map((collectionId) => ({
|
|
575
|
+
postId,
|
|
576
|
+
collectionId,
|
|
577
|
+
createdAt: now(),
|
|
578
|
+
})),
|
|
579
|
+
);
|
|
314
580
|
await db.batch([deleteQuery, insertQuery]);
|
|
315
581
|
},
|
|
316
582
|
};
|