ocean-brain 0.10.0 → 0.11.0

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.
Files changed (80) hide show
  1. package/dist/mcp.js +58 -32
  2. package/package.json +2 -2
  3. package/server/client/dist/assets/{ArrowRight.es-Bm-ZXpEC.js → ArrowRight.es-BEhnlwfe.js} +1 -1
  4. package/server/client/dist/assets/AuxiliaryPanelHeader-B1k7pZpn.js +1 -0
  5. package/server/client/dist/assets/Calendar-D7QJsAlg.js +25 -0
  6. package/server/client/dist/assets/{Callout-CviyPHKO.js → Callout-BTd1BYtV.js} +1 -1
  7. package/server/client/dist/assets/{Copy.es-BPreRnP9.js → Copy.es-BoLjilxv.js} +1 -1
  8. package/server/client/dist/assets/File.es-Ct7fscBg.js +1 -0
  9. package/server/client/dist/assets/{Graph-yPE3ljW0.js → Graph-DYUb7gQb.js} +1 -1
  10. package/server/client/dist/assets/{Graph-vXQQ0a-Q.js → Graph-LY_IgMYy.js} +1 -1
  11. package/server/client/dist/assets/{Image.es-DnR3qc_c.js → Image.es-OYyN3BYt.js} +1 -1
  12. package/server/client/dist/assets/Note-DsMhwrHw.js +25 -0
  13. package/server/client/dist/assets/NotePropertiesPanel-aCKXAe5F.js +2 -0
  14. package/server/client/dist/assets/{PencilLine.es-B9V6u64t.js → PencilLine.es-0JpEgxFY.js} +1 -1
  15. package/server/client/dist/assets/{Plus.es-CRRp2u5J.js → Plus.es-DRfLW_bI.js} +1 -1
  16. package/server/client/dist/assets/{Reminders-BOtfNeYD.js → Reminders-COr1OHvF.js} +1 -1
  17. package/server/client/dist/assets/Search-Ctkx0BQk.js +31 -0
  18. package/server/client/dist/assets/{SurfaceCard-GMt5xM7v.js → SurfaceCard-t7pdfhEe.js} +1 -1
  19. package/server/client/dist/assets/Tag-BjxtrIQW.js +1 -0
  20. package/server/client/dist/assets/TagNotes-DV_fdRbg.js +1 -0
  21. package/server/client/dist/assets/{Trash.es-svHOb5LN.js → Trash.es-Bo1VyK9Q.js} +1 -1
  22. package/server/client/dist/assets/ViewChip-DlCkL4z4.js +1 -0
  23. package/server/client/dist/assets/ViewNotes-wWWeFJyU.js +1 -0
  24. package/server/client/dist/assets/ViewSectionDialog-DhH8harC.js +102 -0
  25. package/server/client/dist/assets/Views-CWE99SnI.js +1 -0
  26. package/server/client/dist/assets/app-B9WZRrAH.css +1 -0
  27. package/server/client/dist/assets/app-BpSMjzET.js +26 -0
  28. package/server/client/dist/assets/{image.api-QmHYjp3z.js → image.api-CyQisInP.js} +1 -1
  29. package/server/client/dist/assets/index-B-E1Ilh9.js +1 -0
  30. package/server/client/dist/assets/manage-image-azmo5iVw.js +1 -0
  31. package/server/client/dist/assets/manage-image-detail-BF-uSqb_.js +1 -0
  32. package/server/client/dist/assets/{mcp-uFZkdhCi.js → mcp-CCaFd0zm.js} +1 -1
  33. package/server/client/dist/assets/mcp-admin-adapter-PMmgPupc.js +1 -0
  34. package/server/client/dist/assets/{note-ui-CxK8ymUc.js → note-ui-CtijppHK.js} +1 -1
  35. package/server/client/dist/assets/{note.api-BQovzEzd.js → note.api-D3oJmG3q.js} +2 -2
  36. package/server/client/dist/assets/{placeholder-D3b8lZGE.js → placeholder-AusSo8p9.js} +2 -2
  37. package/server/client/dist/assets/{properties-M-yWq1D7.js → properties-BrNJLkxR.js} +1 -1
  38. package/server/client/dist/assets/route-preload.js +2 -2
  39. package/server/client/dist/assets/search-D-xKTPdO.js +1 -0
  40. package/server/client/dist/assets/search-admin-adapter-CJnJmL6w.js +1 -0
  41. package/server/client/dist/assets/{tag.api-DUfjr8bg.js → tag.api-DNSQpqwk.js} +1 -1
  42. package/server/client/dist/assets/{time-CfAYZipR.js → time-D5LRMumI.js} +3 -3
  43. package/server/client/dist/assets/trash-D4-9sv8q.js +1 -0
  44. package/server/client/dist/assets/{useReminderMutate--omDLmBV.js → useReminderMutate-BrGO9M0o.js} +1 -1
  45. package/server/client/dist/index.html +5 -5
  46. package/server/dist/features/note/graphql/note.field.resolver.js +2 -0
  47. package/server/dist/features/note/graphql/note.mutation.resolver.js +19 -14
  48. package/server/dist/features/note/graphql/note.query.resolver.js +15 -5
  49. package/server/dist/features/note/graphql/note.type-defs.js +1 -0
  50. package/server/dist/features/note/services/authoring.js +15 -10
  51. package/server/dist/features/note/services/note-reference-index.js +98 -0
  52. package/server/dist/features/note/services/search.js +5 -0
  53. package/server/dist/features/note/services/snapshot.js +4 -0
  54. package/server/dist/features/note/services/trash.js +2 -0
  55. package/server/dist/features/note/services/write.js +10 -3
  56. package/server/dist/features/search/graphql/resolver.js +13 -1
  57. package/server/dist/features/search/graphql/type-defs.js +7 -0
  58. package/server/dist/features/search/hybrid-ranking.js +4 -2
  59. package/server/dist/features/search/hybrid-search.js +4 -2
  60. package/server/dist/features/search/related-notes.js +186 -0
  61. package/server/dist/main.js +10 -3
  62. package/server/prisma/migrations/20260728090000_0019_note_reference_index/migration.sql +15 -0
  63. package/server/prisma/schema.prisma +16 -0
  64. package/server/client/dist/assets/Calendar-BsNs3NzO.js +0 -25
  65. package/server/client/dist/assets/Note-CmreVzvQ.js +0 -25
  66. package/server/client/dist/assets/NotePropertiesPanel-DXYgE8h2.js +0 -2
  67. package/server/client/dist/assets/Search-C-FB3pKf.js +0 -1
  68. package/server/client/dist/assets/Tag-lBxDRE77.js +0 -1
  69. package/server/client/dist/assets/TagNotes-Bgorp_rC.js +0 -1
  70. package/server/client/dist/assets/ViewNotes-C7d10VFf.js +0 -1
  71. package/server/client/dist/assets/ViewSectionDialog-Bhw-ILop.js +0 -102
  72. package/server/client/dist/assets/Views-C7V3saUo.js +0 -1
  73. package/server/client/dist/assets/app-DQBV36r_.js +0 -50
  74. package/server/client/dist/assets/app-ZgwR5t_I.css +0 -1
  75. package/server/client/dist/assets/index-CaF9d-zG.js +0 -1
  76. package/server/client/dist/assets/manage-image-DCDF8AM9.js +0 -1
  77. package/server/client/dist/assets/manage-image-detail-e0ViRAj-.js +0 -1
  78. package/server/client/dist/assets/mcp-admin-adapter-D39G3udJ.js +0 -1
  79. package/server/client/dist/assets/search-DFnzZKLd.js +0 -1
  80. package/server/client/dist/assets/trash-CClKfP1x.js +0 -1
@@ -0,0 +1 @@
1
+ import{j as e,r as E}from"./floating-ui.react-dom-BWTnA00G.js";import{u as M,b as k,i as L,m as _,N as I,d as Q,a as q}from"./time-D5LRMumI.js";import{Q as B,h as O,u as H,i as K,j as W,q as y,r as Y,p as $,P as w,E as z,T as o,B as j,k as G,l as i,e as U,m as V,n as Z,M as x}from"./note.api-D3oJmG3q.js";import{S as C}from"./SurfaceCard-t7pdfhEe.js";import{S as J,N as X}from"./url-9nrbQs7Z.js";import"./NotePropertiesPanel-aCKXAe5F.js";import"./AuxiliaryPanelHeader-B1k7pZpn.js";import"./Plus.es-DRfLW_bI.js";const m=25,v=O(J),S=30,R=`Deleted notes stay here for ${S} days before permanent removal`,g={title:"Permanently delete note?",description:"This note is referenced by the notes below. If you permanently delete it, those links will stay broken. Other notes will not be edited automatically.",confirmLabel:"Delete now",confirmVariant:"danger"},P=s=>Q(s).format("YYYY-MM-DD HH:mm"),ee=({note:s,onClose:n})=>{const h=s?.id??"",l=s?.title||"Untitled note",a=q({queryKey:y.notes.trashDetail(h),enabled:!!s,queryFn:async()=>{const d=await Z(h);if(d.type==="error")throw d;if(!d.trashedNote)throw new Error("Trashed note not found");return d.trashedNote}}),u=a.data?.contentAsMarkdown.trim()??"";return e.jsxs(x,{isOpen:!!s,onClose:n,variant:"inspect",children:[e.jsx(x.Header,{title:l,onClose:n}),e.jsx(x.Body,{children:e.jsxs("div",{className:"flex flex-col gap-3",children:[s&&e.jsxs(o,{as:"p",variant:"meta",tone:"secondary",children:["Deleted ",P(s.deletedAt)]}),a.isLoading&&e.jsx(o,{as:"p",variant:"meta",tone:"secondary",children:"Loading deleted note content..."}),a.isError&&e.jsx(o,{as:"p",variant:"meta",tone:"error",children:"Failed to load deleted note content."}),!a.isLoading&&!a.isError&&u&&e.jsx("pre",{className:"max-h-[60vh] overflow-auto whitespace-pre-wrap break-words rounded-[14px] border border-border-subtle bg-hover-subtle/50 px-4 py-3 text-sm leading-6 text-fg-secondary",children:u}),!a.isLoading&&!a.isError&&!u&&e.jsx(o,{as:"p",variant:"meta",tone:"secondary",className:"rounded-[14px] border border-border-subtle bg-hover-subtle/50 px-4 py-3",children:"No readable content was found for this deleted note."})]})}),e.jsx(x.Footer,{children:e.jsx(j,{variant:"ghost",size:"sm",onClick:n,children:"Close"})})]})},te=()=>e.jsx(w,{title:"Trash",variant:"default",heading:e.jsx(i,{width:118,height:24,className:"rounded-full"}),description:e.jsx(i,{width:352,height:16,className:"rounded-full"}),children:e.jsx("div",{className:"grid gap-3",children:Array.from({length:3},(s,n)=>e.jsx(C,{padding:"compact",children:e.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"min-w-0 flex-1 space-y-2",children:[e.jsx(i,{width:"40%",height:18,className:"rounded-full"}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[e.jsx(i,{width:168,height:14,className:"rounded-full"}),e.jsx(i,{width:176,height:14,className:"rounded-full"})]}),e.jsxs("div",{className:"flex flex-wrap gap-2",children:[e.jsx(i,{width:56,height:24,className:"rounded-full"}),e.jsx(i,{width:72,height:24,className:"rounded-full"})]})]}),e.jsx(i,{width:92,height:32,className:"rounded-[12px]"})]})},n))})}),se=()=>{const{page:s}=v.useSearch(),n=v.useNavigate(),h=H(),l=U(),a=M(),[u,d]=E.useState(null),[f,b]=E.useState(null),{data:c}=K({queryKey:y.notes.trash({limit:m,offset:(s-1)*m}),queryFn:async()=>{const t=await W({limit:m,offset:(s-1)*m});if(t.type==="error")throw t;return t.trashedNotes}}),N=k({mutationFn:Y,onSuccess:async t=>{if(t.type==="error")throw t;await L(h),l("The note has been restored."),n({to:X,params:{id:t.restoreTrashedNote.id}})},onError:()=>{l("Failed to restore the note.")}}),p=k({mutationFn:$,onSuccess:async t=>{if(t.type==="error")throw t;await h.invalidateQueries({queryKey:y.notes.trashAll(),exact:!1}),l("The note has been permanently deleted.")},onError:()=>{l("Failed to permanently delete the note.")}}),D=()=>{if(!f)return;const{id:t}=f;b(null),p.mutate(t)},A=async t=>{const r=await V(t);if(r.type==="error"){l("Failed to check linked notes before permanently deleting this note.");return}if(h.setQueryData(y.notes.backReferences(t),r.backReferences),r.backReferences.length>0){b({id:t,backReferences:r.backReferences});return}await a("Permanently delete this note? This cannot be undone.")&&p.mutate(t)},T=c.totalCount>0?`Trash (${c.totalCount})`:void 0,F=c.notes.find(t=>t.id===u)??null;return c.notes.length===0?e.jsx(w,{title:"Trash",variant:"default",heading:T,description:R,children:e.jsx(z,{title:"Trash is empty",description:`Delete a note and you can restore it here for ${S} days before permanent removal`})}):e.jsxs(w,{title:"Trash",variant:"default",heading:T,description:R,children:[e.jsxs("div",{className:"flex flex-col gap-4",children:[e.jsx("div",{className:"grid gap-3",children:c.notes.map(t=>e.jsx(C,{padding:"compact",children:e.jsxs("div",{className:"flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between",children:[e.jsxs("div",{className:"min-w-0 flex-1",children:[e.jsxs("div",{className:"flex min-w-0 items-start gap-1.5",children:[e.jsx(o,{as:"h2",variant:"body",weight:"medium",className:"min-w-0 flex-1 break-words leading-[1.25]",children:t.title||"Untitled note"}),t.pinned&&e.jsx("span",{title:"Pinned",className:"inline-flex h-5 w-5 shrink-0 items-center justify-center text-fg-tertiary",children:e.jsx(_,{className:"h-3.5 w-3.5"})})]}),e.jsx("div",{className:"mt-1 flex flex-wrap items-center gap-x-3 gap-y-1",children:e.jsxs(o,{as:"div",variant:"meta",weight:"medium",tone:"secondary",children:["Deleted ",P(t.deletedAt)]})}),t.tagNames.length>0&&e.jsx("div",{className:"mt-2 flex flex-wrap gap-1.5",children:t.tagNames.map(r=>e.jsx(o,{as:"span",variant:"label",weight:"medium",tone:"tertiary",className:"rounded-full border border-border-subtle bg-hover-subtle px-2 py-0.5",children:r},`${t.id}-${r}`))}),t.contentPreview&&e.jsx("div",{className:"mt-3 rounded-[14px] border border-border-subtle bg-hover-subtle/50 px-3 py-2",children:e.jsx(o,{as:"p",variant:"meta",tone:"secondary",className:"line-clamp-3 whitespace-pre-wrap break-words",children:t.contentPreview})})]}),e.jsxs("div",{className:"flex shrink-0 flex-wrap gap-2 sm:justify-end",children:[e.jsx(j,{variant:"subtle",size:"sm",onClick:()=>d(t.id),children:"View content"}),e.jsx(j,{variant:"subtle",size:"sm",isLoading:N.isPending&&N.variables===t.id,onClick:()=>N.mutate(t.id),children:"Restore"}),e.jsx(j,{variant:"soft-danger",size:"sm",isLoading:p.isPending&&p.variables===t.id,onClick:()=>A(t.id),children:"Delete now"})]})]})},t.id))}),c.totalCount>m&&e.jsx(G,{page:s,last:Math.ceil(c.totalCount/m),onChange:t=>{n({search:r=>({...r,page:t})})}})]}),e.jsx(ee,{note:F,onClose:()=>d(null)}),e.jsx(I,{isOpen:!!f,title:g.title,description:g.description,references:f?.backReferences??[],confirmLabel:g.confirmLabel,confirmVariant:g.confirmVariant,onClose:()=>b(null),onConfirm:D})]})},he=()=>{const{page:s}=v.useSearch();return e.jsx(B,{fallback:e.jsx(te,{}),errorTitle:"Failed to load trash",errorDescription:"Retry loading deleted notes",resetKeys:[s],children:e.jsx(se,{})})};export{he as default};
@@ -1,4 +1,4 @@
1
- import{j as y,r as l}from"./floating-ui.react-dom-BWTnA00G.js";import{g as u,h as f,q as n,u as R,d as $}from"./note.api-BQovzEzd.js";const g=(e={})=>({limit:e.limit??10,offset:e.offset??0}),D=async(e,t)=>u(`
1
+ import{j as y,r as l}from"./floating-ui.react-dom-BWTnA00G.js";import{g as u,i as f,q as n,u as R,e as $}from"./note.api-D3oJmG3q.js";const g=(e={})=>({limit:e.limit??10,offset:e.offset??0}),D=async(e,t)=>u(`
2
2
  query FetchNoteReminders($noteId: ID!, $pagination: PaginationInput) {
3
3
  noteReminders(noteId: $noteId, pagination: $pagination) {
4
4
  totalCount
@@ -8,14 +8,14 @@
8
8
  <link rel="icon" type="image/png" href="/icon.png" />
9
9
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
10
10
  <title>Ocean Brain</title>
11
- <script type="module" crossorigin src="/assets/app-DQBV36r_.js"></script>
11
+ <script type="module" crossorigin src="/assets/app-BpSMjzET.js"></script>
12
12
  <link rel="modulepreload" crossorigin href="/assets/floating-ui.react-dom-BWTnA00G.js">
13
13
  <link rel="modulepreload" crossorigin href="/assets/url-9nrbQs7Z.js">
14
- <link rel="modulepreload" crossorigin href="/assets/note.api-BQovzEzd.js">
15
- <link rel="modulepreload" crossorigin href="/assets/time-CfAYZipR.js">
14
+ <link rel="modulepreload" crossorigin href="/assets/note.api-D3oJmG3q.js">
15
+ <link rel="modulepreload" crossorigin href="/assets/time-D5LRMumI.js">
16
16
  <link rel="modulepreload" crossorigin href="/assets/preload-helper-BXl3LOEh.js">
17
- <link rel="modulepreload" crossorigin href="/assets/Graph-vXQQ0a-Q.js">
18
- <link rel="stylesheet" crossorigin href="/assets/app-ZgwR5t_I.css">
17
+ <link rel="modulepreload" crossorigin href="/assets/Graph-LY_IgMYy.js">
18
+ <link rel="stylesheet" crossorigin href="/assets/app-B9WZRrAH.css">
19
19
  </head>
20
20
 
21
21
  <body>
@@ -1,9 +1,11 @@
1
1
  import { listNoteProperties } from "../../../features/note/services/properties.js";
2
+ import { buildNoteContentPreview } from "../../../features/note/services/search.js";
2
3
  import { renderNoteSnapshotContentAsMarkdown } from "../../../features/note/services/snapshot.js";
3
4
  import models from "../../../models.js";
4
5
  const noteFieldResolvers = {
5
6
  tags: async (note) => models.tag.findMany({ where: { notes: { some: { id: note.id } } } }),
6
7
  properties: async (note) => listNoteProperties(note.id),
8
+ contentPreview: (note) => buildNoteContentPreview(note.content),
7
9
  contentAsMarkdown: async (note) => {
8
10
  const { blocksToMarkdown } = await import("../../../modules/blocknote.js");
9
11
  return blocksToMarkdown(note.content);
@@ -1,5 +1,6 @@
1
1
  import { GraphQLError } from "graphql";
2
2
  import { extractBlocksByType, parseNoteContent } from "../../../features/note/services/content-blocks.js";
3
+ import { replaceNoteReferences } from "../../../features/note/services/note-reference-index.js";
3
4
  import {
4
5
  createNotePropertyDefinition,
5
6
  deleteNotePropertyDefinition,
@@ -63,22 +64,26 @@ const noteMutationResolvers = {
63
64
  const replacedContent = await replacePlaceholders(note.content);
64
65
  const parsedContent = replacedContent ? parseRequiredNoteContent(replacedContent) : null;
65
66
  const blocks = parsedContent ? extractBlocksByType("tag", parsedContent) : [];
66
- const createdNote = await models.note.create({
67
- data: {
68
- title: replacedTitle,
69
- content: replacedContent,
70
- ...buildNoteSearchProjection({
67
+ const noteResult = await models.$transaction(async (tx) => {
68
+ const createdNote = await tx.note.create({
69
+ data: {
71
70
  title: replacedTitle,
72
- content: replacedContent
73
- }),
74
- ...note.layout && { layout: note.layout }
75
- }
71
+ content: replacedContent,
72
+ ...buildNoteSearchProjection({
73
+ title: replacedTitle,
74
+ content: replacedContent
75
+ }),
76
+ ...note.layout && { layout: note.layout }
77
+ }
78
+ });
79
+ const noteResult2 = replacedContent ? await tx.note.update({
80
+ where: { id: createdNote.id },
81
+ data: { tags: { set: toTagConnections(blocks) } }
82
+ }) : createdNote;
83
+ await replaceNoteReferences(tx, createdNote.id, replacedContent);
84
+ return noteResult2;
76
85
  });
77
- const noteResult = replacedContent ? await models.note.update({
78
- where: { id: createdNote.id },
79
- data: { tags: { set: toTagConnections(blocks) } }
80
- }) : createdNote;
81
- notifySemanticSearchNoteChanged(createdNote.id);
86
+ notifySemanticSearchNoteChanged(noteResult.id);
82
87
  return noteResult;
83
88
  },
84
89
  updateNote: async (_, {
@@ -194,12 +194,22 @@ const createAllNotesQueryResolver = (deps = {
194
194
  };
195
195
  };
196
196
  const createBackReferencesQueryResolver = (deps = {
197
- findCandidateNotes: (noteId) => models.note.findMany({
198
- orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
199
- where: {
200
- NOT: { id: noteId }
197
+ findCandidateNotes: async (noteId) => {
198
+ const references = await models.noteReference.findMany({
199
+ where: { targetNoteId: noteId },
200
+ select: { sourceNoteId: true }
201
+ });
202
+ if (references.length === 0) {
203
+ return [];
201
204
  }
202
- })
205
+ return models.note.findMany({
206
+ orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }],
207
+ where: {
208
+ id: { in: references.map((reference) => reference.sourceNoteId) },
209
+ NOT: { id: noteId }
210
+ }
211
+ });
212
+ }
203
213
  }) => {
204
214
  return async (_, { id }) => {
205
215
  const notes = await deps.findCandidateNotes(Number(id));
@@ -98,6 +98,7 @@ const noteType = gql`
98
98
  id: ID!
99
99
  title: String!
100
100
  content: String!
101
+ contentPreview: String!
101
102
  contentAsMarkdown: String!
102
103
  createdAt: String!
103
104
  updatedAt: String!
@@ -1,5 +1,6 @@
1
1
  import models from "../../../models.js";
2
2
  import { extractTagIdsFromContentJson, markdownToBlocksJson } from "../../../modules/blocknote.js";
3
+ import { replaceNoteReferences } from "./note-reference-index.js";
3
4
  import { buildNoteSearchProjection } from "./search.js";
4
5
  class InvalidNoteAuthoringInputError extends Error {
5
6
  constructor(message) {
@@ -64,17 +65,21 @@ const createNoteAuthoringService = (deps) => {
64
65
  };
65
66
  const defaultNoteAuthoringService = createNoteAuthoringService({
66
67
  createNote: async (input) => {
67
- return models.note.create({
68
- data: {
69
- title: input.title,
70
- content: input.content,
71
- ...buildNoteSearchProjection({
68
+ return models.$transaction(async (tx) => {
69
+ const note = await tx.note.create({
70
+ data: {
72
71
  title: input.title,
73
- content: input.content
74
- }),
75
- ...input.layout ? { layout: input.layout } : {},
76
- ...input.tagIds ? { tags: { connect: input.tagIds.map((id) => ({ id: Number(id) })) } } : {}
77
- }
72
+ content: input.content,
73
+ ...buildNoteSearchProjection({
74
+ title: input.title,
75
+ content: input.content
76
+ }),
77
+ ...input.layout ? { layout: input.layout } : {},
78
+ ...input.tagIds ? { tags: { connect: input.tagIds.map((id) => ({ id: Number(id) })) } } : {}
79
+ }
80
+ });
81
+ await replaceNoteReferences(tx, note.id, input.content);
82
+ return note;
78
83
  });
79
84
  },
80
85
  findPlaceholders: async (templates) => {
@@ -0,0 +1,98 @@
1
+ import models from "../../../models.js";
2
+ import { extractReferenceBlocksFromContent, normalizeReferenceId } from "./content-blocks.js";
3
+ const NOTE_REFERENCE_INDEX_VERSION = 1;
4
+ const NOTE_REFERENCE_INDEX_STATE_ID = 1;
5
+ const SQLITE_MAX_INT_ID = 2147483647;
6
+ const REFERENCE_INDEX_REBUILD_BATCH_SIZE = 250;
7
+ const toNoteId = (value) => {
8
+ const normalized = normalizeReferenceId(value);
9
+ if (!normalized || !/^[1-9]\d*$/.test(normalized)) {
10
+ return null;
11
+ }
12
+ const id = Number(normalized);
13
+ return Number.isSafeInteger(id) && id <= SQLITE_MAX_INT_ID ? id : null;
14
+ };
15
+ const extractNoteReferenceIds = (content) => {
16
+ return Array.from(
17
+ new Set(
18
+ extractReferenceBlocksFromContent(content).map((block) => toNoteId(block.props?.id)).filter((id) => id !== null)
19
+ )
20
+ );
21
+ };
22
+ const replaceNoteReferences = async (db, sourceNoteId, content) => {
23
+ const targetIds = extractNoteReferenceIds(content).filter((targetNoteId) => targetNoteId !== sourceNoteId);
24
+ await db.noteReference.deleteMany({ where: { sourceNoteId } });
25
+ if (targetIds.length === 0) {
26
+ return 0;
27
+ }
28
+ const references = targetIds.map((targetNoteId) => ({ sourceNoteId, targetNoteId }));
29
+ if (references.length > 0) {
30
+ await db.noteReference.createMany({ data: references });
31
+ }
32
+ return references.length;
33
+ };
34
+ const rebuildNoteReferenceIndex = async () => {
35
+ await models.noteReference.deleteMany();
36
+ let cursor = 0;
37
+ let processedCount = 0;
38
+ while (true) {
39
+ const notes = await models.note.findMany({
40
+ where: { id: { gt: cursor } },
41
+ orderBy: { id: "asc" },
42
+ take: REFERENCE_INDEX_REBUILD_BATCH_SIZE,
43
+ select: {
44
+ id: true,
45
+ content: true
46
+ }
47
+ });
48
+ if (notes.length === 0) {
49
+ break;
50
+ }
51
+ const references = notes.flatMap(
52
+ (note) => extractNoteReferenceIds(note.content).filter((targetNoteId) => targetNoteId !== note.id).map((targetNoteId) => ({
53
+ sourceNoteId: note.id,
54
+ targetNoteId
55
+ }))
56
+ );
57
+ if (references.length > 0) {
58
+ await models.noteReference.createMany({ data: references });
59
+ processedCount += references.length;
60
+ }
61
+ cursor = notes[notes.length - 1].id;
62
+ }
63
+ await models.noteReferenceIndexState.upsert({
64
+ where: { id: NOTE_REFERENCE_INDEX_STATE_ID },
65
+ create: {
66
+ id: NOTE_REFERENCE_INDEX_STATE_ID,
67
+ version: NOTE_REFERENCE_INDEX_VERSION,
68
+ updatedAt: /* @__PURE__ */ new Date()
69
+ },
70
+ update: {
71
+ version: NOTE_REFERENCE_INDEX_VERSION
72
+ }
73
+ });
74
+ return processedCount;
75
+ };
76
+ let activeRebuild = null;
77
+ const ensureNoteReferenceIndex = async () => {
78
+ const state = await models.noteReferenceIndexState.findUnique({
79
+ where: { id: NOTE_REFERENCE_INDEX_STATE_ID },
80
+ select: { version: true }
81
+ });
82
+ if (state?.version === NOTE_REFERENCE_INDEX_VERSION) {
83
+ return 0;
84
+ }
85
+ if (!activeRebuild) {
86
+ activeRebuild = rebuildNoteReferenceIndex().finally(() => {
87
+ activeRebuild = null;
88
+ });
89
+ }
90
+ return activeRebuild;
91
+ };
92
+ export {
93
+ NOTE_REFERENCE_INDEX_STATE_ID,
94
+ NOTE_REFERENCE_INDEX_VERSION,
95
+ ensureNoteReferenceIndex,
96
+ extractNoteReferenceIds,
97
+ replaceNoteReferences
98
+ };
@@ -140,6 +140,10 @@ const extractVisibleSearchTextFromContent = (content) => {
140
140
  return "";
141
141
  }
142
142
  };
143
+ const NOTE_CONTENT_PREVIEW_MAX_LENGTH = 100;
144
+ const buildNoteContentPreview = (content) => {
145
+ return extractVisibleSearchTextFromContent(content).slice(0, NOTE_CONTENT_PREVIEW_MAX_LENGTH);
146
+ };
143
147
  const matchesNoteSearchQuery = (note, query) => {
144
148
  const parsedQuery = typeof query === "string" ? parseNoteSearchQuery(query) : query;
145
149
  if (!parsedQuery.hasFilters) {
@@ -166,6 +170,7 @@ export {
166
170
  NOTE_SEARCH_IGNORED_NODE_TYPES,
167
171
  NOTE_SEARCH_PASS_THROUGH_NODE_TYPES,
168
172
  NOTE_SEARCH_TEXT_SCHEMA_VERSION,
173
+ buildNoteContentPreview,
169
174
  buildNoteSearchProjection,
170
175
  buildNoteSearchText,
171
176
  extractVisibleSearchTextFromContent,
@@ -10,6 +10,7 @@ import {
10
10
  SNAPSHOT_RETENTION_DAYS
11
11
  } from "../../../modules/recovery-retention.js";
12
12
  import { calculateMarkdownSha256 } from "./markdown-patch.js";
13
+ import { replaceNoteReferences } from "./note-reference-index.js";
13
14
  import { resolveRestoredPropertyTarget } from "./property-restore.js";
14
15
  import { buildNoteSearchProjection, extractVisibleSearchTextFromContent } from "./search.js";
15
16
  import { createNoteVersionConflictError, MissingNoteVersionError, parseNoteVersion } from "./write-conflict.js";
@@ -561,6 +562,9 @@ const defaultNoteSnapshotService = createNoteSnapshotService({
561
562
  ...tagIds !== void 0 ? { tags: { set: tagIds.map((tagId) => ({ id: tagId })) } } : {}
562
563
  }
563
564
  });
565
+ if (noteInput.content !== void 0) {
566
+ await replaceNoteReferences(tx, note.id, noteInput.content);
567
+ }
564
568
  if (properties !== void 0) {
565
569
  await tx.noteProperty.deleteMany({ where: { noteId: id } });
566
570
  for (const property of properties) {
@@ -4,6 +4,7 @@ import {
4
4
  RECOVERY_CLEANUP_BATCH_LIMIT,
5
5
  TRASH_RETENTION_DAYS
6
6
  } from "../../../modules/recovery-retention.js";
7
+ import { replaceNoteReferences } from "./note-reference-index.js";
7
8
  import { resolveRestoredPropertyTarget } from "./property-restore.js";
8
9
  import { buildNoteSearchProjection, extractVisibleSearchTextFromContent } from "./search.js";
9
10
  const TRASHED_NOTE_CONTENT_PREVIEW_MAX_LENGTH = 240;
@@ -289,6 +290,7 @@ const noteTrashService = createNoteTrashService({
289
290
  } : {}
290
291
  }
291
292
  });
293
+ await replaceNoteReferences(tx, note.id, restoredContent);
292
294
  for (const property of deletedNote.properties ?? []) {
293
295
  const target = await resolveRestoredPropertyTarget(property, {
294
296
  findDefinitionByKey: (key) => tx.propertyDefinition.findUnique({ where: { key } }),
@@ -1,4 +1,5 @@
1
1
  import models, { Prisma } from "../../../models.js";
2
+ import { replaceNoteReferences } from "./note-reference-index.js";
2
3
  import { buildNoteSearchProjection } from "./search.js";
3
4
  import { captureNoteBaseline } from "./snapshot.js";
4
5
  import { createNoteVersionConflictError, MissingNoteVersionError, parseNoteVersion } from "./write-conflict.js";
@@ -101,9 +102,15 @@ const defaultNoteWriteService = createNoteWriteService({
101
102
  }
102
103
  }),
103
104
  captureBaseline: captureNoteBaseline,
104
- updateNote: ({ where, data }) => models.note.update({
105
- where,
106
- data
105
+ updateNote: ({ where, data }) => models.$transaction(async (tx) => {
106
+ const note = await tx.note.update({
107
+ where,
108
+ data
109
+ });
110
+ if (data.content !== void 0) {
111
+ await replaceNoteReferences(tx, note.id, data.content);
112
+ }
113
+ return note;
107
114
  }),
108
115
  isRecordNotFoundError
109
116
  });
@@ -1,4 +1,5 @@
1
1
  import { searchNotesHybrid } from "../hybrid-search.js";
2
+ import { searchRelatedNotes } from "../related-notes.js";
2
3
  const createSearchNotesResolver = (search = searchNotesHybrid) => {
3
4
  return async (_, { query, pagination, mode = "HYBRID" }) => {
4
5
  return search({
@@ -9,12 +10,23 @@ const createSearchNotesResolver = (search = searchNotesHybrid) => {
9
10
  });
10
11
  };
11
12
  };
13
+ const createSearchRelatedNotesResolver = (findRelatedNotes = searchRelatedNotes) => {
14
+ return async (_, { noteId, limit = 5 }) => {
15
+ const numericNoteId = Number(noteId);
16
+ if (!Number.isSafeInteger(numericNoteId) || numericNoteId <= 0) {
17
+ return [];
18
+ }
19
+ return findRelatedNotes(numericNoteId, Math.min(5, Math.max(0, Number(limit))));
20
+ };
21
+ };
12
22
  const searchResolvers = {
13
23
  Query: {
14
- searchNotes: createSearchNotesResolver()
24
+ searchNotes: createSearchNotesResolver(),
25
+ searchRelatedNotes: createSearchRelatedNotesResolver()
15
26
  }
16
27
  };
17
28
  export {
18
29
  createSearchNotesResolver,
30
+ createSearchRelatedNotesResolver,
19
31
  searchResolvers
20
32
  };
@@ -12,6 +12,12 @@ const searchTypeDefs = gql`
12
12
  semantic: Boolean!
13
13
  }
14
14
 
15
+ type SearchRelatedNote {
16
+ id: ID!
17
+ title: String!
18
+ reasons: [String!]!
19
+ }
20
+
15
21
  type SearchNotesResult {
16
22
  totalCount: Int!
17
23
  notes: [Note!]!
@@ -23,6 +29,7 @@ const searchTypeDefs = gql`
23
29
 
24
30
  extend type Query {
25
31
  searchNotes(query: String!, pagination: PaginationInput!, mode: SearchMode = HYBRID): SearchNotesResult!
32
+ searchRelatedNotes(noteId: ID!, limit: Int = 5): [SearchRelatedNote!]!
26
33
  }
27
34
  `;
28
35
  export {
@@ -1,3 +1,5 @@
1
+ const DEFAULT_LEXICAL_WEIGHT = 1;
2
+ const DEFAULT_SEMANTIC_WEIGHT = 1.25;
1
3
  const DEFAULT_RANK_CONSTANT = 60;
2
4
  const uniqueNoteIds = (noteIds) => {
3
5
  return [...new Set(noteIds.filter((noteId) => Number.isInteger(noteId) && noteId > 0))];
@@ -5,8 +7,8 @@ const uniqueNoteIds = (noteIds) => {
5
7
  const fuseHybridSearchRanks = ({
6
8
  lexicalNoteIds,
7
9
  semanticNoteIds,
8
- lexicalWeight = 1,
9
- semanticWeight = 1,
10
+ lexicalWeight = DEFAULT_LEXICAL_WEIGHT,
11
+ semanticWeight = DEFAULT_SEMANTIC_WEIGHT,
10
12
  rankConstant = DEFAULT_RANK_CONSTANT
11
13
  }) => {
12
14
  const lexicalIds = uniqueNoteIds(lexicalNoteIds);
@@ -8,6 +8,7 @@ import { runDataMaintenanceInBackground } from "../../modules/data-maintenance.j
8
8
  import { fuseHybridSearchRanks } from "./hybrid-ranking.js";
9
9
  import { getDefaultSemanticSearchManager } from "./search-manager.js";
10
10
  const MAX_HYBRID_CANDIDATES = 80;
11
+ const MAX_HYBRID_SEMANTIC_CANDIDATES = 30;
11
12
  const unavailableSemanticAttempt = (error) => ({
12
13
  available: false,
13
14
  used: false,
@@ -134,6 +135,7 @@ const createHybridNoteSearch = (dependencies = defaultDependencies) => {
134
135
  };
135
136
  }
136
137
  const candidateLimit = MAX_HYBRID_CANDIDATES;
138
+ const semanticCandidateLimit = mode === "hybrid" ? MAX_HYBRID_SEMANTIC_CANDIDATES : MAX_HYBRID_CANDIDATES;
137
139
  const [lexicalNoteIds, semanticAttempt] = await Promise.all([
138
140
  mode === "semantic" ? Promise.resolve([]) : dependencies.listLexicalNoteIds(normalizedQuery, candidateLimit),
139
141
  mode === "lexical" ? Promise.resolve({
@@ -141,11 +143,11 @@ const createHybridNoteSearch = (dependencies = defaultDependencies) => {
141
143
  used: false,
142
144
  matches: [],
143
145
  error: null
144
- }) : dependencies.trySemanticSearch(normalizedQuery, candidateLimit).catch((error) => unavailableSemanticAttempt(error))
146
+ }) : dependencies.trySemanticSearch(normalizedQuery, semanticCandidateLimit).catch((error) => unavailableSemanticAttempt(error))
145
147
  ]);
146
148
  const rankedCandidates = fuseHybridSearchRanks({
147
149
  lexicalNoteIds,
148
- semanticNoteIds: semanticAttempt.matches.map((match) => match.noteId)
150
+ semanticNoteIds: semanticAttempt.matches.slice(0, semanticCandidateLimit).map((match) => match.noteId)
149
151
  });
150
152
  const pageCandidates = rankedCandidates.slice(offset, offset + limit);
151
153
  const notes = await dependencies.findNotesByIds(pageCandidates.map((candidate) => candidate.noteId));
@@ -0,0 +1,186 @@
1
+ import models, { Prisma } from "../../models.js";
2
+ const MAX_RELATED_NOTES = 5;
3
+ const normalizeLimit = (limit) => Math.min(MAX_RELATED_NOTES, Math.max(0, Math.trunc(limit)));
4
+ const toDate = (value) => value instanceof Date ? value : new Date(value);
5
+ const buildReasons = (candidate) => {
6
+ const reasons = [];
7
+ if (candidate.linked) {
8
+ reasons.push("Linked from this note");
9
+ }
10
+ if (candidate.backlink) {
11
+ reasons.push("Backlink to this note");
12
+ }
13
+ for (const tagName of candidate.sharedTagNames) {
14
+ reasons.push(`Shares ${tagName}`);
15
+ }
16
+ return reasons;
17
+ };
18
+ const compareCandidates = (left, right) => {
19
+ const leftScore = (left.linked ? 100 : 0) + (left.backlink ? 100 : 0) + left.sharedTagNames.length * 20;
20
+ const rightScore = (right.linked ? 100 : 0) + (right.backlink ? 100 : 0) + right.sharedTagNames.length * 20;
21
+ if (leftScore !== rightScore) {
22
+ return rightScore - leftScore;
23
+ }
24
+ const updatedAtDifference = right.updatedAt.getTime() - left.updatedAt.getTime();
25
+ return updatedAtDifference !== 0 ? updatedAtDifference : left.id - right.id;
26
+ };
27
+ const findCandidatesFromIndex = async ({
28
+ noteId,
29
+ tagIds,
30
+ limit
31
+ }) => {
32
+ const rows = tagIds.length === 0 ? await models.$queryRaw(Prisma.sql`
33
+ WITH candidate_scores AS (
34
+ SELECT
35
+ note."id" AS "id",
36
+ note."title" AS "title",
37
+ note."updatedAt" AS "updatedAt",
38
+ CASE WHEN EXISTS (
39
+ SELECT 1
40
+ FROM "NoteReference" AS outgoing
41
+ WHERE outgoing."sourceNoteId" = ${noteId}
42
+ AND outgoing."targetNoteId" = note."id"
43
+ ) THEN 1 ELSE 0 END AS "linked",
44
+ CASE WHEN EXISTS (
45
+ SELECT 1
46
+ FROM "NoteReference" AS incoming
47
+ WHERE incoming."targetNoteId" = ${noteId}
48
+ AND incoming."sourceNoteId" = note."id"
49
+ ) THEN 1 ELSE 0 END AS "backlink",
50
+ 0 AS "sharedTagCount"
51
+ FROM "Note" AS note
52
+ WHERE note."id" <> ${noteId}
53
+ AND (
54
+ EXISTS (
55
+ SELECT 1
56
+ FROM "NoteReference" AS outgoing
57
+ WHERE outgoing."sourceNoteId" = ${noteId}
58
+ AND outgoing."targetNoteId" = note."id"
59
+ )
60
+ OR EXISTS (
61
+ SELECT 1
62
+ FROM "NoteReference" AS incoming
63
+ WHERE incoming."targetNoteId" = ${noteId}
64
+ AND incoming."sourceNoteId" = note."id"
65
+ )
66
+ )
67
+ )
68
+ SELECT *, ("linked" * 100 + "backlink" * 100 + "sharedTagCount" * 20) AS "score"
69
+ FROM candidate_scores
70
+ ORDER BY "score" DESC, "updatedAt" DESC, "id" ASC
71
+ LIMIT ${limit}
72
+ `) : await models.$queryRaw(Prisma.sql`
73
+ WITH candidate_scores AS (
74
+ SELECT
75
+ note."id" AS "id",
76
+ note."title" AS "title",
77
+ note."updatedAt" AS "updatedAt",
78
+ CASE WHEN EXISTS (
79
+ SELECT 1
80
+ FROM "NoteReference" AS outgoing
81
+ WHERE outgoing."sourceNoteId" = ${noteId}
82
+ AND outgoing."targetNoteId" = note."id"
83
+ ) THEN 1 ELSE 0 END AS "linked",
84
+ CASE WHEN EXISTS (
85
+ SELECT 1
86
+ FROM "NoteReference" AS incoming
87
+ WHERE incoming."targetNoteId" = ${noteId}
88
+ AND incoming."sourceNoteId" = note."id"
89
+ ) THEN 1 ELSE 0 END AS "backlink",
90
+ COUNT(DISTINCT noteTags."B") AS "sharedTagCount"
91
+ FROM "Note" AS note
92
+ LEFT JOIN "_NoteToTag" AS noteTags
93
+ ON noteTags."A" = note."id"
94
+ AND noteTags."B" IN (${Prisma.join(tagIds)})
95
+ WHERE note."id" <> ${noteId}
96
+ AND (
97
+ EXISTS (
98
+ SELECT 1
99
+ FROM "NoteReference" AS outgoing
100
+ WHERE outgoing."sourceNoteId" = ${noteId}
101
+ AND outgoing."targetNoteId" = note."id"
102
+ )
103
+ OR EXISTS (
104
+ SELECT 1
105
+ FROM "NoteReference" AS incoming
106
+ WHERE incoming."targetNoteId" = ${noteId}
107
+ AND incoming."sourceNoteId" = note."id"
108
+ )
109
+ OR noteTags."B" IS NOT NULL
110
+ )
111
+ GROUP BY note."id", note."title", note."updatedAt"
112
+ )
113
+ SELECT *, ("linked" * 100 + "backlink" * 100 + "sharedTagCount" * 20) AS "score"
114
+ FROM candidate_scores
115
+ ORDER BY "score" DESC, "updatedAt" DESC, "id" ASC
116
+ LIMIT ${limit}
117
+ `);
118
+ if (rows.length === 0) {
119
+ return [];
120
+ }
121
+ const candidateIds = rows.map((row) => row.id);
122
+ const candidateNotes = await models.note.findMany({
123
+ where: { id: { in: candidateIds } },
124
+ select: {
125
+ id: true,
126
+ tags: {
127
+ select: {
128
+ id: true,
129
+ name: true
130
+ }
131
+ }
132
+ }
133
+ });
134
+ const tagsByNoteId = new Map(candidateNotes.map((note) => [note.id, note.tags]));
135
+ const sourceTagIds = new Set(tagIds);
136
+ return rows.map((row) => ({
137
+ id: row.id,
138
+ title: row.title || "Untitled",
139
+ updatedAt: toDate(row.updatedAt),
140
+ linked: Number(row.linked) === 1,
141
+ backlink: Number(row.backlink) === 1,
142
+ sharedTagNames: (tagsByNoteId.get(row.id) ?? []).filter((tag) => sourceTagIds.has(tag.id)).map((tag) => tag.name).sort((left, right) => left.localeCompare(right))
143
+ }));
144
+ };
145
+ const defaultDependencies = {
146
+ findSourceNote: (noteId) => models.note.findUnique({
147
+ where: { id: noteId },
148
+ select: {
149
+ id: true,
150
+ tags: {
151
+ select: {
152
+ id: true,
153
+ name: true
154
+ }
155
+ }
156
+ }
157
+ }),
158
+ findCandidates: findCandidatesFromIndex
159
+ };
160
+ const createSearchRelatedNotes = (dependencies = defaultDependencies) => {
161
+ return async (noteId, limit = MAX_RELATED_NOTES) => {
162
+ const normalizedLimit = normalizeLimit(limit);
163
+ if (!Number.isSafeInteger(noteId) || noteId <= 0 || normalizedLimit === 0) {
164
+ return [];
165
+ }
166
+ const source = await dependencies.findSourceNote(noteId);
167
+ if (!source) {
168
+ return [];
169
+ }
170
+ const candidates = await dependencies.findCandidates({
171
+ noteId,
172
+ tagIds: source.tags.map((tag) => tag.id),
173
+ limit: normalizedLimit
174
+ });
175
+ return candidates.filter((candidate) => candidate.linked || candidate.backlink || candidate.sharedTagNames.length > 0).sort(compareCandidates).slice(0, normalizedLimit).map((candidate) => ({
176
+ id: candidate.id,
177
+ title: candidate.title || "Untitled",
178
+ reasons: buildReasons(candidate)
179
+ }));
180
+ };
181
+ };
182
+ const searchRelatedNotes = createSearchRelatedNotes();
183
+ export {
184
+ createSearchRelatedNotes,
185
+ searchRelatedNotes
186
+ };