@jant/core 0.3.34 → 0.3.36
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/dist/client/assets/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-8Dj-5CLW.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +3109 -2294
- package/dist/index.js +3327 -3031
- package/package.json +13 -4
- package/src/__tests__/helpers/app.ts +1 -1
- package/src/__tests__/helpers/db.ts +6 -0
- package/src/app.tsx +1 -5
- package/src/{lib → client}/avatar-upload.ts +1 -1
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
- package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +45 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/{ui → client}/components/compose-types.ts +3 -1
- package/src/{ui → client}/components/jant-collection-form.ts +301 -182
- package/src/client/components/jant-collection-sidebar.ts +801 -0
- package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
- package/src/client/components/jant-compose-editor.ts +1249 -0
- package/src/client/components/jant-compose-fullscreen.ts +338 -0
- package/src/client/components/jant-media-lightbox.ts +257 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
- package/src/{ui → client}/components/jant-post-form.ts +57 -8
- package/src/{ui → client}/components/jant-settings-general.ts +2 -2
- package/src/{ui → client}/components/nav-manager-types.ts +3 -0
- package/src/{ui → client}/components/post-form-template.ts +35 -31
- package/src/{ui → client}/components/post-form-types.ts +7 -3
- package/src/{lib → client}/compose-bridge.ts +9 -7
- package/src/client/lazy-slugify.ts +51 -0
- package/src/{lib → client}/media-upload.ts +16 -3
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/client/page-slug-bridge.ts +42 -0
- package/src/{lib → client}/post-form-bridge.ts +2 -2
- package/src/{lib → client}/settings-bridge.ts +3 -3
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +40 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +60 -0
- package/src/client/tiptap/image-node.ts +488 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +140 -0
- package/src/client/tiptap/slash-commands.ts +328 -0
- package/src/{types → client/types}/sortablejs.d.ts +1 -1
- package/src/client.ts +24 -17
- package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
- package/src/db/schema.ts +6 -1
- package/src/i18n/locales/en.po +641 -215
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +642 -204
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +642 -204
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/lib/__tests__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +9 -6
- package/src/lib/__tests__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +9 -9
- package/src/lib/emoji-catalog.ts +146 -0
- package/src/lib/feed.ts +1 -1
- package/src/lib/media-helpers.ts +10 -9
- package/src/lib/render.tsx +4 -3
- package/src/lib/resolve-config.ts +8 -1
- package/src/lib/schemas.ts +2 -3
- package/src/lib/summary.ts +92 -0
- package/src/lib/timeline.ts +2 -0
- package/src/lib/tiptap-render.ts +196 -0
- package/src/lib/upload.ts +97 -9
- package/src/lib/url.ts +7 -23
- package/src/lib/view.ts +33 -19
- package/src/middleware/error-handler.ts +3 -3
- package/src/preset.css +38 -0
- package/src/routes/api/collections.ts +20 -3
- package/src/routes/api/posts.ts +48 -33
- package/src/routes/api/upload.ts +7 -5
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +26 -11
- package/src/routes/auth/signin.tsx +10 -7
- package/src/routes/compose.tsx +20 -11
- package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
- package/src/routes/dash/index.tsx +7 -1
- package/src/routes/dash/media.tsx +3 -0
- package/src/routes/dash/pages.tsx +8 -2
- package/src/routes/dash/posts.tsx +6 -2
- package/src/routes/dash/redirects.tsx +15 -9
- package/src/routes/dash/settings.tsx +336 -32
- package/src/routes/feed/__tests__/rss.test.ts +245 -6
- package/src/routes/feed/rss.ts +70 -6
- package/src/routes/pages/__tests__/featured.test.ts +6 -7
- package/src/routes/pages/archive.tsx +11 -7
- package/src/routes/pages/collection.tsx +32 -15
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +1 -1
- package/src/routes/pages/home.tsx +1 -1
- package/src/services/__tests__/post.test.ts +124 -33
- package/src/services/__tests__/settings.test.ts +3 -3
- package/src/services/page.ts +16 -3
- package/src/services/post.ts +96 -37
- package/src/services/search.ts +4 -2
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +240 -60
- package/src/styles/tokens.css +10 -0
- package/src/styles/ui.css +1157 -81
- package/src/types/bindings.ts +5 -0
- package/src/types/config.ts +23 -1
- package/src/types/constants.ts +3 -0
- package/src/types/entities.ts +9 -2
- package/src/types/operations.ts +9 -3
- package/src/types/props.ts +3 -3
- package/src/types/views.ts +3 -2
- package/src/ui/compose/ComposeDialog.tsx +24 -7
- package/src/ui/dash/PageForm.tsx +2 -0
- package/src/ui/dash/PostList.tsx +5 -5
- package/src/ui/dash/StatusBadge.tsx +13 -5
- package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
- package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
- package/src/ui/dash/media/MediaListContent.tsx +9 -4
- package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
- package/src/ui/dash/pages/PagesContent.tsx +2 -1
- package/src/ui/dash/posts/PostForm.tsx +19 -7
- package/src/ui/dash/settings/AccountContent.tsx +133 -138
- package/src/ui/dash/settings/AvatarContent.tsx +70 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
- package/src/ui/layouts/DashLayout.tsx +157 -75
- package/src/ui/layouts/SiteLayout.tsx +13 -13
- package/src/ui/pages/ArchivePage.tsx +10 -7
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/SearchPage.tsx +1 -1
- package/src/ui/shared/CollectionsSidebar.tsx +228 -3
- package/src/ui/shared/MediaGallery.tsx +179 -41
- package/src/lib/collections-reorder.ts +0 -28
- package/src/routes/dash/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
- /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
- /package/src/{ui → client}/components/settings-types.ts +0 -0
- /package/src/{lib → client}/image-processor.ts +0 -0
- /package/src/{lib → client}/toast.ts +0 -0
|
@@ -22,28 +22,51 @@ describe("PostService", () => {
|
|
|
22
22
|
|
|
23
23
|
describe("create", () => {
|
|
24
24
|
it("creates a note post with required fields", async () => {
|
|
25
|
+
const body = JSON.stringify({
|
|
26
|
+
type: "doc",
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "paragraph",
|
|
30
|
+
content: [{ type: "text", text: "Hello world" }],
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
});
|
|
25
34
|
const post = await postService.create({
|
|
26
35
|
format: "note",
|
|
27
|
-
body
|
|
36
|
+
body,
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
expect(post.id).toBe(1);
|
|
31
40
|
expect(post.format).toBe("note");
|
|
32
|
-
expect(post.body).toBe(
|
|
41
|
+
expect(post.body).toBe(body);
|
|
33
42
|
expect(post.status).toBe("published"); // default
|
|
34
|
-
expect(post.
|
|
43
|
+
expect(post.visibility).toBe("listed");
|
|
35
44
|
expect(post.pinned).toBe(0);
|
|
36
45
|
expect(post.bodyHtml).toContain("<p>Hello world</p>");
|
|
37
46
|
expect(post.deletedAt).toBeNull();
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
it("creates a post with all fields", async () => {
|
|
50
|
+
const body = JSON.stringify({
|
|
51
|
+
type: "doc",
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: "heading",
|
|
55
|
+
attrs: { level: 1 },
|
|
56
|
+
content: [{ type: "text", text: "Introduction" }],
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: "paragraph",
|
|
60
|
+
content: [{ type: "text", text: "Some content." }],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
41
64
|
const post = await postService.create({
|
|
42
65
|
format: "link",
|
|
43
66
|
title: "My Link",
|
|
44
|
-
body
|
|
67
|
+
body,
|
|
45
68
|
status: "published",
|
|
46
|
-
|
|
69
|
+
visibility: "featured",
|
|
47
70
|
pinned: true,
|
|
48
71
|
path: "my-link",
|
|
49
72
|
url: "https://example.com/source",
|
|
@@ -54,7 +77,7 @@ describe("PostService", () => {
|
|
|
54
77
|
expect(post.format).toBe("link");
|
|
55
78
|
expect(post.title).toBe("My Link");
|
|
56
79
|
expect(post.status).toBe("published");
|
|
57
|
-
expect(post.
|
|
80
|
+
expect(post.visibility).toBe("featured");
|
|
58
81
|
expect(post.pinned).toBe(1);
|
|
59
82
|
expect(post.path).toBe("my-link");
|
|
60
83
|
expect(post.url).toBe("https://example.com/source");
|
|
@@ -63,10 +86,27 @@ describe("PostService", () => {
|
|
|
63
86
|
expect(post.bodyHtml).toContain("<h1>");
|
|
64
87
|
});
|
|
65
88
|
|
|
66
|
-
it("renders
|
|
89
|
+
it("renders Tiptap JSON body to HTML", async () => {
|
|
90
|
+
const body = JSON.stringify({
|
|
91
|
+
type: "doc",
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "paragraph",
|
|
95
|
+
content: [
|
|
96
|
+
{ type: "text", text: "This is " },
|
|
97
|
+
{
|
|
98
|
+
type: "text",
|
|
99
|
+
marks: [{ type: "bold" }],
|
|
100
|
+
text: "bold",
|
|
101
|
+
},
|
|
102
|
+
{ type: "text", text: " text" },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
],
|
|
106
|
+
});
|
|
67
107
|
const post = await postService.create({
|
|
68
108
|
format: "note",
|
|
69
|
-
body
|
|
109
|
+
body,
|
|
70
110
|
});
|
|
71
111
|
|
|
72
112
|
expect(post.bodyHtml).toContain("<strong>bold</strong>");
|
|
@@ -267,26 +307,60 @@ describe("PostService", () => {
|
|
|
267
307
|
expect(published[0]?.status).toBe("published");
|
|
268
308
|
});
|
|
269
309
|
|
|
270
|
-
it("filters by
|
|
310
|
+
it("filters by visibility", async () => {
|
|
271
311
|
await postService.create({
|
|
272
312
|
format: "note",
|
|
273
313
|
body: "featured post",
|
|
274
|
-
|
|
314
|
+
visibility: "featured",
|
|
275
315
|
});
|
|
276
316
|
await postService.create({
|
|
277
317
|
format: "note",
|
|
278
318
|
body: "normal post",
|
|
279
319
|
});
|
|
320
|
+
await postService.create({
|
|
321
|
+
format: "note",
|
|
322
|
+
body: "unlisted post",
|
|
323
|
+
visibility: "unlisted",
|
|
324
|
+
});
|
|
280
325
|
|
|
281
|
-
const featured = await postService.list({
|
|
326
|
+
const featured = await postService.list({ visibility: "featured" });
|
|
282
327
|
expect(featured).toHaveLength(1);
|
|
283
|
-
expect(featured[0]?.
|
|
328
|
+
expect(featured[0]?.visibility).toBe("featured");
|
|
284
329
|
expect(featured[0]?.body).toBe("featured post");
|
|
285
330
|
|
|
286
|
-
const
|
|
287
|
-
expect(
|
|
288
|
-
expect(
|
|
289
|
-
expect(
|
|
331
|
+
const listed = await postService.list({ visibility: "listed" });
|
|
332
|
+
expect(listed).toHaveLength(1);
|
|
333
|
+
expect(listed[0]?.visibility).toBe("listed");
|
|
334
|
+
expect(listed[0]?.body).toBe("normal post");
|
|
335
|
+
|
|
336
|
+
const unlisted = await postService.list({ visibility: "unlisted" });
|
|
337
|
+
expect(unlisted).toHaveLength(1);
|
|
338
|
+
expect(unlisted[0]?.visibility).toBe("unlisted");
|
|
339
|
+
expect(unlisted[0]?.body).toBe("unlisted post");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("excludes unlisted posts when requested", async () => {
|
|
343
|
+
await postService.create({
|
|
344
|
+
format: "note",
|
|
345
|
+
body: "listed post",
|
|
346
|
+
});
|
|
347
|
+
await postService.create({
|
|
348
|
+
format: "note",
|
|
349
|
+
body: "unlisted post",
|
|
350
|
+
visibility: "unlisted",
|
|
351
|
+
});
|
|
352
|
+
await postService.create({
|
|
353
|
+
format: "note",
|
|
354
|
+
body: "featured post",
|
|
355
|
+
visibility: "featured",
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const posts = await postService.list({ excludeUnlisted: true });
|
|
359
|
+
expect(posts).toHaveLength(2);
|
|
360
|
+
expect(posts.map((p) => p.body).sort()).toEqual([
|
|
361
|
+
"featured post",
|
|
362
|
+
"listed post",
|
|
363
|
+
]);
|
|
290
364
|
});
|
|
291
365
|
|
|
292
366
|
it("filters by pinned", async () => {
|
|
@@ -426,15 +500,15 @@ describe("PostService", () => {
|
|
|
426
500
|
expect(count).toBe(1);
|
|
427
501
|
});
|
|
428
502
|
|
|
429
|
-
it("filters by
|
|
503
|
+
it("filters by visibility", async () => {
|
|
430
504
|
await postService.create({
|
|
431
505
|
format: "note",
|
|
432
506
|
body: "featured",
|
|
433
|
-
|
|
507
|
+
visibility: "featured",
|
|
434
508
|
});
|
|
435
509
|
await postService.create({ format: "note", body: "normal" });
|
|
436
510
|
|
|
437
|
-
const count = await postService.count({
|
|
511
|
+
const count = await postService.count({ visibility: "featured" });
|
|
438
512
|
expect(count).toBe(1);
|
|
439
513
|
});
|
|
440
514
|
|
|
@@ -470,15 +544,32 @@ describe("PostService", () => {
|
|
|
470
544
|
it("updates post body", async () => {
|
|
471
545
|
const post = await postService.create({
|
|
472
546
|
format: "note",
|
|
473
|
-
body:
|
|
547
|
+
body: JSON.stringify({
|
|
548
|
+
type: "doc",
|
|
549
|
+
content: [
|
|
550
|
+
{
|
|
551
|
+
type: "paragraph",
|
|
552
|
+
content: [{ type: "text", text: "original" }],
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
}),
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
const updatedBody = JSON.stringify({
|
|
559
|
+
type: "doc",
|
|
560
|
+
content: [
|
|
561
|
+
{
|
|
562
|
+
type: "paragraph",
|
|
563
|
+
content: [{ type: "text", text: "updated content" }],
|
|
564
|
+
},
|
|
565
|
+
],
|
|
474
566
|
});
|
|
475
|
-
|
|
476
567
|
const updated = await postService.update(post.id, {
|
|
477
|
-
body:
|
|
568
|
+
body: updatedBody,
|
|
478
569
|
});
|
|
479
570
|
|
|
480
571
|
expect(updated).not.toBeNull();
|
|
481
|
-
expect(updated?.body).toBe(
|
|
572
|
+
expect(updated?.body).toBe(updatedBody);
|
|
482
573
|
expect(updated?.bodyHtml).toContain("updated content");
|
|
483
574
|
});
|
|
484
575
|
|
|
@@ -547,19 +638,19 @@ describe("PostService", () => {
|
|
|
547
638
|
expect(updated?.updatedAt).toBeGreaterThanOrEqual(originalUpdatedAt);
|
|
548
639
|
});
|
|
549
640
|
|
|
550
|
-
it("updates
|
|
641
|
+
it("updates visibility", async () => {
|
|
551
642
|
const post = await postService.create({
|
|
552
643
|
format: "note",
|
|
553
644
|
body: "test",
|
|
554
645
|
});
|
|
555
646
|
|
|
556
|
-
expect(post.
|
|
647
|
+
expect(post.visibility).toBe("listed");
|
|
557
648
|
|
|
558
649
|
const updated = await postService.update(post.id, {
|
|
559
|
-
|
|
650
|
+
visibility: "featured",
|
|
560
651
|
});
|
|
561
652
|
|
|
562
|
-
expect(updated?.
|
|
653
|
+
expect(updated?.visibility).toBe("featured");
|
|
563
654
|
});
|
|
564
655
|
|
|
565
656
|
it("updates pinned flag", async () => {
|
|
@@ -723,11 +814,11 @@ describe("PostService", () => {
|
|
|
723
814
|
expect(reply.status).toBe("draft");
|
|
724
815
|
});
|
|
725
816
|
|
|
726
|
-
it("inherits
|
|
817
|
+
it("inherits visibility from root post", async () => {
|
|
727
818
|
const root = await postService.create({
|
|
728
819
|
format: "note",
|
|
729
820
|
body: "root",
|
|
730
|
-
|
|
821
|
+
visibility: "featured",
|
|
731
822
|
});
|
|
732
823
|
const reply = await postService.create({
|
|
733
824
|
format: "note",
|
|
@@ -735,7 +826,7 @@ describe("PostService", () => {
|
|
|
735
826
|
replyToId: root.id,
|
|
736
827
|
});
|
|
737
828
|
|
|
738
|
-
expect(reply.
|
|
829
|
+
expect(reply.visibility).toBe("featured");
|
|
739
830
|
});
|
|
740
831
|
|
|
741
832
|
it("getThread returns all posts in a thread", async () => {
|
|
@@ -797,7 +888,7 @@ describe("PostService", () => {
|
|
|
797
888
|
}
|
|
798
889
|
});
|
|
799
890
|
|
|
800
|
-
it("cascades
|
|
891
|
+
it("cascades visibility changes from root to thread", async () => {
|
|
801
892
|
const root = await postService.create({
|
|
802
893
|
format: "note",
|
|
803
894
|
body: "root",
|
|
@@ -808,11 +899,11 @@ describe("PostService", () => {
|
|
|
808
899
|
replyToId: root.id,
|
|
809
900
|
});
|
|
810
901
|
|
|
811
|
-
await postService.update(root.id, {
|
|
902
|
+
await postService.update(root.id, { visibility: "featured" });
|
|
812
903
|
|
|
813
904
|
const thread = await postService.getThread(root.id);
|
|
814
905
|
for (const post of thread) {
|
|
815
|
-
expect(post.
|
|
906
|
+
expect(post.visibility).toBe("featured");
|
|
816
907
|
}
|
|
817
908
|
});
|
|
818
909
|
});
|
|
@@ -102,7 +102,7 @@ describe("SettingsService", () => {
|
|
|
102
102
|
siteFooter: "",
|
|
103
103
|
siteLanguage: "en",
|
|
104
104
|
homeDefaultView: "latest",
|
|
105
|
-
headerNavMaxVisible: "
|
|
105
|
+
headerNavMaxVisible: "2",
|
|
106
106
|
timeZone: "UTC",
|
|
107
107
|
};
|
|
108
108
|
|
|
@@ -154,10 +154,10 @@ describe("SettingsService", () => {
|
|
|
154
154
|
expect(await settingsService.get("HOME_DEFAULT_VIEW")).toBe("featured");
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
it("removes HEADER_NAV_MAX_VISIBLE when set to default (
|
|
157
|
+
it("removes HEADER_NAV_MAX_VISIBLE when set to default (2)", async () => {
|
|
158
158
|
await settingsService.set("HEADER_NAV_MAX_VISIBLE", "5");
|
|
159
159
|
await settingsService.updateGeneral(
|
|
160
|
-
{ ...defaults, headerNavMaxVisible: "
|
|
160
|
+
{ ...defaults, headerNavMaxVisible: "2" },
|
|
161
161
|
{ oldLanguage: "en", fallbackSiteName: "Jant" },
|
|
162
162
|
);
|
|
163
163
|
|
package/src/services/page.ts
CHANGED
|
@@ -27,10 +27,23 @@ export interface PageService {
|
|
|
27
27
|
delete(id: number): Promise<boolean>;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/** Check if an error is a SQLite UNIQUE constraint violation
|
|
30
|
+
/** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
|
|
31
31
|
function isUniqueConstraintError(err: unknown): boolean {
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
let current: unknown = err;
|
|
33
|
+
while (current) {
|
|
34
|
+
const msg = String(current);
|
|
35
|
+
if (
|
|
36
|
+
msg.includes("UNIQUE constraint") ||
|
|
37
|
+
msg.includes("SQLITE_CONSTRAINT")
|
|
38
|
+
) {
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
current =
|
|
42
|
+
current instanceof Error && current.cause !== current
|
|
43
|
+
? current.cause
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
return false;
|
|
34
47
|
}
|
|
35
48
|
|
|
36
49
|
export function createPageService(
|
package/src/services/post.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* CRUD operations for posts with Thread support.
|
|
5
5
|
* Posts have format (note/link/quote), status (draft/published),
|
|
6
|
-
* featured
|
|
6
|
+
* visibility (listed/featured/unlisted), and pinned flag.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { eq, and, isNull, desc, or, inArray, sql } from "drizzle-orm";
|
|
@@ -11,10 +11,18 @@ import type { BatchItem } from "drizzle-orm/batch";
|
|
|
11
11
|
import type { Database } from "../db/index.js";
|
|
12
12
|
import { posts, postCollections } from "../db/schema.js";
|
|
13
13
|
import { now } from "../lib/time.js";
|
|
14
|
-
import {
|
|
14
|
+
import { renderTiptapJson } from "../lib/tiptap-render.js";
|
|
15
|
+
import { extractSummary } from "../lib/summary.js";
|
|
15
16
|
import type { StorageDriver } from "../lib/storage.js";
|
|
16
17
|
import type { MediaService } from "./media.js";
|
|
17
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
Format,
|
|
20
|
+
Status,
|
|
21
|
+
Visibility,
|
|
22
|
+
Post,
|
|
23
|
+
CreatePost,
|
|
24
|
+
UpdatePost,
|
|
25
|
+
} from "../types.js";
|
|
18
26
|
import type { PathRegistryService } from "./path-registry.js";
|
|
19
27
|
import { ConflictError } from "../lib/errors.js";
|
|
20
28
|
|
|
@@ -27,11 +35,13 @@ export interface PostDeleteDeps {
|
|
|
27
35
|
export interface PostFilters {
|
|
28
36
|
format?: Format;
|
|
29
37
|
status?: Status;
|
|
30
|
-
|
|
38
|
+
visibility?: Visibility;
|
|
31
39
|
pinned?: boolean;
|
|
32
40
|
collectionId?: number;
|
|
33
41
|
/** Exclude posts that are replies (have threadId set) */
|
|
34
42
|
excludeReplies?: boolean;
|
|
43
|
+
/** Exclude unlisted posts from results */
|
|
44
|
+
excludeUnlisted?: boolean;
|
|
35
45
|
includeDeleted?: boolean;
|
|
36
46
|
threadId?: number;
|
|
37
47
|
limit?: number;
|
|
@@ -39,14 +49,24 @@ export interface PostFilters {
|
|
|
39
49
|
offset?: number; // offset for page-based pagination
|
|
40
50
|
}
|
|
41
51
|
|
|
52
|
+
/** Config for automatic summary extraction */
|
|
53
|
+
export interface SummaryConfig {
|
|
54
|
+
maxParagraphs: number;
|
|
55
|
+
maxChars: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
export interface PostService {
|
|
43
59
|
getById(id: number): Promise<Post | null>;
|
|
44
60
|
getByPath(path: string): Promise<Post | null>;
|
|
45
61
|
list(filters?: PostFilters): Promise<Post[]>;
|
|
46
62
|
/** Count posts matching filters (ignores cursor, offset, limit) */
|
|
47
63
|
count(filters?: PostFilters): Promise<number>;
|
|
48
|
-
create(data: CreatePost): Promise<Post>;
|
|
49
|
-
update(
|
|
64
|
+
create(data: CreatePost, summaryConfig?: SummaryConfig): Promise<Post>;
|
|
65
|
+
update(
|
|
66
|
+
id: number,
|
|
67
|
+
data: UpdatePost,
|
|
68
|
+
summaryConfig?: SummaryConfig,
|
|
69
|
+
): Promise<Post | null>;
|
|
50
70
|
/**
|
|
51
71
|
* Soft-delete a post and clean up its media (storage files + DB records).
|
|
52
72
|
* Thread roots cascade to all replies.
|
|
@@ -56,10 +76,10 @@ export interface PostService {
|
|
|
56
76
|
*/
|
|
57
77
|
delete(id: number, deps?: PostDeleteDeps): Promise<boolean>;
|
|
58
78
|
getThread(rootId: number): Promise<Post[]>;
|
|
59
|
-
|
|
79
|
+
updateThreadStatusAndVisibility(
|
|
60
80
|
rootId: number,
|
|
61
81
|
status: Status,
|
|
62
|
-
|
|
82
|
+
visibility: Visibility,
|
|
63
83
|
): Promise<void>;
|
|
64
84
|
/** Get reply counts for multiple posts */
|
|
65
85
|
getReplyCounts(postIds: number[]): Promise<Map<number, number>>;
|
|
@@ -70,10 +90,23 @@ export interface PostService {
|
|
|
70
90
|
): Promise<Map<number, Post[]>>;
|
|
71
91
|
}
|
|
72
92
|
|
|
73
|
-
/** Check if an error is a SQLite UNIQUE constraint violation
|
|
93
|
+
/** Check if an error (or any of its causes) is a SQLite UNIQUE constraint violation */
|
|
74
94
|
function isUniqueConstraintError(err: unknown): boolean {
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
let current: unknown = err;
|
|
96
|
+
while (current) {
|
|
97
|
+
const msg = String(current);
|
|
98
|
+
if (
|
|
99
|
+
msg.includes("UNIQUE constraint") ||
|
|
100
|
+
msg.includes("SQLITE_CONSTRAINT")
|
|
101
|
+
) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
current =
|
|
105
|
+
current instanceof Error && current.cause !== current
|
|
106
|
+
? current.cause
|
|
107
|
+
: undefined;
|
|
108
|
+
}
|
|
109
|
+
return false;
|
|
77
110
|
}
|
|
78
111
|
|
|
79
112
|
export function createPostService(
|
|
@@ -87,8 +120,11 @@ export function createPostService(
|
|
|
87
120
|
if (filters.status) {
|
|
88
121
|
conditions.push(eq(posts.status, filters.status));
|
|
89
122
|
}
|
|
90
|
-
if (filters.
|
|
91
|
-
conditions.push(eq(posts.
|
|
123
|
+
if (filters.visibility !== undefined) {
|
|
124
|
+
conditions.push(eq(posts.visibility, filters.visibility));
|
|
125
|
+
}
|
|
126
|
+
if (filters.excludeUnlisted) {
|
|
127
|
+
conditions.push(sql`${posts.visibility} != 'unlisted'`);
|
|
92
128
|
}
|
|
93
129
|
if (filters.pinned !== undefined) {
|
|
94
130
|
conditions.push(eq(posts.pinned, filters.pinned ? 1 : 0));
|
|
@@ -120,7 +156,7 @@ export function createPostService(
|
|
|
120
156
|
id: row.id,
|
|
121
157
|
format: row.format as Format,
|
|
122
158
|
status: row.status as Status,
|
|
123
|
-
|
|
159
|
+
visibility: row.visibility as Visibility,
|
|
124
160
|
pinned: row.pinned,
|
|
125
161
|
path: row.path,
|
|
126
162
|
title: row.title,
|
|
@@ -128,6 +164,7 @@ export function createPostService(
|
|
|
128
164
|
body: row.body,
|
|
129
165
|
bodyHtml: row.bodyHtml,
|
|
130
166
|
quoteText: row.quoteText,
|
|
167
|
+
summary: row.summary,
|
|
131
168
|
rating: row.rating,
|
|
132
169
|
replyToId: row.replyToId,
|
|
133
170
|
threadId: row.threadId,
|
|
@@ -190,27 +227,37 @@ export function createPostService(
|
|
|
190
227
|
return result[0]?.count ?? 0;
|
|
191
228
|
},
|
|
192
229
|
|
|
193
|
-
async create(data) {
|
|
230
|
+
async create(data, summaryConfig) {
|
|
194
231
|
const timestamp = now();
|
|
195
232
|
|
|
196
|
-
const bodyHtml = data.body ?
|
|
233
|
+
const bodyHtml = data.body ? renderTiptapJson(data.body) : null;
|
|
234
|
+
|
|
235
|
+
// Generate summary for titled notes with body content
|
|
236
|
+
let summary: string | null = null;
|
|
237
|
+
if (data.format === "note" && data.title && data.body && summaryConfig) {
|
|
238
|
+
summary = extractSummary(
|
|
239
|
+
data.body,
|
|
240
|
+
summaryConfig.maxParagraphs,
|
|
241
|
+
summaryConfig.maxChars,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
197
244
|
|
|
198
245
|
// Handle thread relationship
|
|
199
246
|
let threadId: number | null = null;
|
|
200
247
|
let status: Status = data.status ?? "published";
|
|
201
|
-
let
|
|
248
|
+
let visibility: Visibility = data.visibility ?? "listed";
|
|
202
249
|
|
|
203
250
|
if (data.replyToId) {
|
|
204
251
|
const parent = await this.getById(data.replyToId);
|
|
205
252
|
if (parent) {
|
|
206
253
|
threadId = parent.threadId ?? parent.id;
|
|
207
|
-
// Inherit status and
|
|
254
|
+
// Inherit status and visibility from root
|
|
208
255
|
const root = parent.threadId
|
|
209
256
|
? await this.getById(parent.threadId)
|
|
210
257
|
: parent;
|
|
211
258
|
if (root) {
|
|
212
259
|
status = root.status as Status;
|
|
213
|
-
|
|
260
|
+
visibility = root.visibility as Visibility;
|
|
214
261
|
}
|
|
215
262
|
}
|
|
216
263
|
}
|
|
@@ -229,7 +276,7 @@ export function createPostService(
|
|
|
229
276
|
.values({
|
|
230
277
|
format: data.format,
|
|
231
278
|
status,
|
|
232
|
-
|
|
279
|
+
visibility,
|
|
233
280
|
pinned: data.pinned ? 1 : 0,
|
|
234
281
|
path: data.path ?? null,
|
|
235
282
|
title: data.title ?? null,
|
|
@@ -237,6 +284,7 @@ export function createPostService(
|
|
|
237
284
|
body: data.body ?? null,
|
|
238
285
|
bodyHtml,
|
|
239
286
|
quoteText: data.quoteText ?? null,
|
|
287
|
+
summary,
|
|
240
288
|
rating: data.rating ?? null,
|
|
241
289
|
replyToId: data.replyToId ?? null,
|
|
242
290
|
threadId,
|
|
@@ -275,7 +323,7 @@ export function createPostService(
|
|
|
275
323
|
return post;
|
|
276
324
|
},
|
|
277
325
|
|
|
278
|
-
async update(id, data) {
|
|
326
|
+
async update(id, data, summaryConfig) {
|
|
279
327
|
const existing = await this.getById(id);
|
|
280
328
|
if (!existing) return null;
|
|
281
329
|
|
|
@@ -310,22 +358,38 @@ export function createPostService(
|
|
|
310
358
|
|
|
311
359
|
if (data.body !== undefined) {
|
|
312
360
|
updates.body = data.body;
|
|
313
|
-
updates.bodyHtml = data.body ?
|
|
361
|
+
updates.bodyHtml = data.body ? renderTiptapJson(data.body) : null;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Recompute summary when body, title, or format change
|
|
365
|
+
if (summaryConfig) {
|
|
366
|
+
const format = data.format ?? (existing.format as Format);
|
|
367
|
+
const title = data.title !== undefined ? data.title : existing.title;
|
|
368
|
+
const body = data.body !== undefined ? data.body : existing.body;
|
|
369
|
+
if (format === "note" && title && body) {
|
|
370
|
+
updates.summary = extractSummary(
|
|
371
|
+
body,
|
|
372
|
+
summaryConfig.maxParagraphs,
|
|
373
|
+
summaryConfig.maxChars,
|
|
374
|
+
);
|
|
375
|
+
} else {
|
|
376
|
+
updates.summary = null;
|
|
377
|
+
}
|
|
314
378
|
}
|
|
315
379
|
|
|
316
|
-
// Handle status/
|
|
380
|
+
// Handle status/visibility change - cascade to thread if this is root
|
|
317
381
|
const statusChanged =
|
|
318
382
|
data.status !== undefined && data.status !== existing.status;
|
|
319
|
-
const
|
|
320
|
-
data.
|
|
321
|
-
|
|
383
|
+
const visibilityChanged =
|
|
384
|
+
data.visibility !== undefined &&
|
|
385
|
+
data.visibility !== existing.visibility;
|
|
322
386
|
|
|
323
387
|
if (statusChanged) updates.status = data.status;
|
|
324
|
-
if (
|
|
388
|
+
if (visibilityChanged) updates.visibility = data.visibility;
|
|
325
389
|
|
|
326
390
|
// Build all write queries for atomic execution via D1 batch
|
|
327
391
|
const needsCascade =
|
|
328
|
-
(statusChanged ||
|
|
392
|
+
(statusChanged || visibilityChanged) && !existing.threadId;
|
|
329
393
|
const needsCollectionSync = data.collectionIds !== undefined;
|
|
330
394
|
const hasExtraWrites = needsCascade || needsCollectionSync;
|
|
331
395
|
|
|
@@ -348,13 +412,8 @@ export function createPostService(
|
|
|
348
412
|
.update(posts)
|
|
349
413
|
.set({
|
|
350
414
|
status: data.status ?? (existing.status as Status),
|
|
351
|
-
|
|
352
|
-
data.
|
|
353
|
-
? data.featured
|
|
354
|
-
: existing.featured === 1
|
|
355
|
-
)
|
|
356
|
-
? 1
|
|
357
|
-
: 0,
|
|
415
|
+
visibility:
|
|
416
|
+
data.visibility ?? (existing.visibility as Visibility),
|
|
358
417
|
updatedAt: timestamp,
|
|
359
418
|
})
|
|
360
419
|
.where(eq(posts.threadId, id)),
|
|
@@ -467,11 +526,11 @@ export function createPostService(
|
|
|
467
526
|
return rows.map(toPost);
|
|
468
527
|
},
|
|
469
528
|
|
|
470
|
-
async
|
|
529
|
+
async updateThreadStatusAndVisibility(rootId, status, visibility) {
|
|
471
530
|
const timestamp = now();
|
|
472
531
|
await db
|
|
473
532
|
.update(posts)
|
|
474
|
-
.set({ status,
|
|
533
|
+
.set({ status, visibility, updatedAt: timestamp })
|
|
475
534
|
.where(eq(posts.threadId, rootId));
|
|
476
535
|
},
|
|
477
536
|
|
package/src/services/search.ts
CHANGED
|
@@ -27,7 +27,7 @@ interface RawSearchRow {
|
|
|
27
27
|
id: number;
|
|
28
28
|
format: string;
|
|
29
29
|
status: string;
|
|
30
|
-
|
|
30
|
+
visibility: string;
|
|
31
31
|
pinned: number;
|
|
32
32
|
path: string | null;
|
|
33
33
|
title: string | null;
|
|
@@ -35,6 +35,7 @@ interface RawSearchRow {
|
|
|
35
35
|
body: string | null;
|
|
36
36
|
body_html: string | null;
|
|
37
37
|
quote_text: string | null;
|
|
38
|
+
summary: string | null;
|
|
38
39
|
rating: number | null;
|
|
39
40
|
collection_id: number | null;
|
|
40
41
|
reply_to_id: number | null;
|
|
@@ -97,7 +98,7 @@ export function createSearchService(d1: D1Database): SearchService {
|
|
|
97
98
|
id: row.id,
|
|
98
99
|
format: row.format as Post["format"],
|
|
99
100
|
status: row.status as Post["status"],
|
|
100
|
-
|
|
101
|
+
visibility: row.visibility as Post["visibility"],
|
|
101
102
|
pinned: row.pinned,
|
|
102
103
|
path: row.path,
|
|
103
104
|
title: row.title,
|
|
@@ -105,6 +106,7 @@ export function createSearchService(d1: D1Database): SearchService {
|
|
|
105
106
|
body: row.body,
|
|
106
107
|
bodyHtml: row.body_html,
|
|
107
108
|
quoteText: row.quote_text,
|
|
109
|
+
summary: row.summary,
|
|
108
110
|
rating: row.rating,
|
|
109
111
|
replyToId: row.reply_to_id,
|
|
110
112
|
threadId: row.thread_id,
|
package/src/services/settings.ts
CHANGED
|
@@ -44,6 +44,7 @@ export interface AvatarUploadDeps {
|
|
|
44
44
|
media: MediaService;
|
|
45
45
|
storage: StorageDriver;
|
|
46
46
|
storageProvider: string;
|
|
47
|
+
maxFileSizeMB: number;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
export interface SettingsService {
|
|
@@ -194,7 +195,7 @@ export function createSettingsService(db: Database): SettingsService {
|
|
|
194
195
|
// Header nav max visible: only update if provided (may be managed separately)
|
|
195
196
|
if (data.headerNavMaxVisible !== undefined) {
|
|
196
197
|
const navMax = parseInt(String(data.headerNavMaxVisible), 10);
|
|
197
|
-
if (!isNaN(navMax) && navMax !==
|
|
198
|
+
if (!isNaN(navMax) && navMax !== 2) {
|
|
198
199
|
await this.set("HEADER_NAV_MAX_VISIBLE", String(navMax));
|
|
199
200
|
} else {
|
|
200
201
|
await this.remove("HEADER_NAV_MAX_VISIBLE");
|
|
@@ -215,7 +216,10 @@ export function createSettingsService(db: Database): SettingsService {
|
|
|
215
216
|
},
|
|
216
217
|
|
|
217
218
|
async uploadAvatar(data, deps) {
|
|
218
|
-
const uploadError = validateUploadFile(data.file as unknown as File
|
|
219
|
+
const uploadError = validateUploadFile(data.file as unknown as File, {
|
|
220
|
+
imagesOnly: true,
|
|
221
|
+
maxFileSizeMB: deps.maxFileSizeMB,
|
|
222
|
+
});
|
|
219
223
|
if (uploadError) {
|
|
220
224
|
throw new ValidationError(uploadError);
|
|
221
225
|
}
|