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.
- package/dist/mcp.js +58 -32
- package/package.json +2 -2
- package/server/client/dist/assets/{ArrowRight.es-Bm-ZXpEC.js → ArrowRight.es-BEhnlwfe.js} +1 -1
- package/server/client/dist/assets/AuxiliaryPanelHeader-B1k7pZpn.js +1 -0
- package/server/client/dist/assets/Calendar-D7QJsAlg.js +25 -0
- package/server/client/dist/assets/{Callout-CviyPHKO.js → Callout-BTd1BYtV.js} +1 -1
- package/server/client/dist/assets/{Copy.es-BPreRnP9.js → Copy.es-BoLjilxv.js} +1 -1
- package/server/client/dist/assets/File.es-Ct7fscBg.js +1 -0
- package/server/client/dist/assets/{Graph-yPE3ljW0.js → Graph-DYUb7gQb.js} +1 -1
- package/server/client/dist/assets/{Graph-vXQQ0a-Q.js → Graph-LY_IgMYy.js} +1 -1
- package/server/client/dist/assets/{Image.es-DnR3qc_c.js → Image.es-OYyN3BYt.js} +1 -1
- package/server/client/dist/assets/Note-DsMhwrHw.js +25 -0
- package/server/client/dist/assets/NotePropertiesPanel-aCKXAe5F.js +2 -0
- package/server/client/dist/assets/{PencilLine.es-B9V6u64t.js → PencilLine.es-0JpEgxFY.js} +1 -1
- package/server/client/dist/assets/{Plus.es-CRRp2u5J.js → Plus.es-DRfLW_bI.js} +1 -1
- package/server/client/dist/assets/{Reminders-BOtfNeYD.js → Reminders-COr1OHvF.js} +1 -1
- package/server/client/dist/assets/Search-Ctkx0BQk.js +31 -0
- package/server/client/dist/assets/{SurfaceCard-GMt5xM7v.js → SurfaceCard-t7pdfhEe.js} +1 -1
- package/server/client/dist/assets/Tag-BjxtrIQW.js +1 -0
- package/server/client/dist/assets/TagNotes-DV_fdRbg.js +1 -0
- package/server/client/dist/assets/{Trash.es-svHOb5LN.js → Trash.es-Bo1VyK9Q.js} +1 -1
- package/server/client/dist/assets/ViewChip-DlCkL4z4.js +1 -0
- package/server/client/dist/assets/ViewNotes-wWWeFJyU.js +1 -0
- package/server/client/dist/assets/ViewSectionDialog-DhH8harC.js +102 -0
- package/server/client/dist/assets/Views-CWE99SnI.js +1 -0
- package/server/client/dist/assets/app-B9WZRrAH.css +1 -0
- package/server/client/dist/assets/app-BpSMjzET.js +26 -0
- package/server/client/dist/assets/{image.api-QmHYjp3z.js → image.api-CyQisInP.js} +1 -1
- package/server/client/dist/assets/index-B-E1Ilh9.js +1 -0
- package/server/client/dist/assets/manage-image-azmo5iVw.js +1 -0
- package/server/client/dist/assets/manage-image-detail-BF-uSqb_.js +1 -0
- package/server/client/dist/assets/{mcp-uFZkdhCi.js → mcp-CCaFd0zm.js} +1 -1
- package/server/client/dist/assets/mcp-admin-adapter-PMmgPupc.js +1 -0
- package/server/client/dist/assets/{note-ui-CxK8ymUc.js → note-ui-CtijppHK.js} +1 -1
- package/server/client/dist/assets/{note.api-BQovzEzd.js → note.api-D3oJmG3q.js} +2 -2
- package/server/client/dist/assets/{placeholder-D3b8lZGE.js → placeholder-AusSo8p9.js} +2 -2
- package/server/client/dist/assets/{properties-M-yWq1D7.js → properties-BrNJLkxR.js} +1 -1
- package/server/client/dist/assets/route-preload.js +2 -2
- package/server/client/dist/assets/search-D-xKTPdO.js +1 -0
- package/server/client/dist/assets/search-admin-adapter-CJnJmL6w.js +1 -0
- package/server/client/dist/assets/{tag.api-DUfjr8bg.js → tag.api-DNSQpqwk.js} +1 -1
- package/server/client/dist/assets/{time-CfAYZipR.js → time-D5LRMumI.js} +3 -3
- package/server/client/dist/assets/trash-D4-9sv8q.js +1 -0
- package/server/client/dist/assets/{useReminderMutate--omDLmBV.js → useReminderMutate-BrGO9M0o.js} +1 -1
- package/server/client/dist/index.html +5 -5
- package/server/dist/features/note/graphql/note.field.resolver.js +2 -0
- package/server/dist/features/note/graphql/note.mutation.resolver.js +19 -14
- package/server/dist/features/note/graphql/note.query.resolver.js +15 -5
- package/server/dist/features/note/graphql/note.type-defs.js +1 -0
- package/server/dist/features/note/services/authoring.js +15 -10
- package/server/dist/features/note/services/note-reference-index.js +98 -0
- package/server/dist/features/note/services/search.js +5 -0
- package/server/dist/features/note/services/snapshot.js +4 -0
- package/server/dist/features/note/services/trash.js +2 -0
- package/server/dist/features/note/services/write.js +10 -3
- package/server/dist/features/search/graphql/resolver.js +13 -1
- package/server/dist/features/search/graphql/type-defs.js +7 -0
- package/server/dist/features/search/hybrid-ranking.js +4 -2
- package/server/dist/features/search/hybrid-search.js +4 -2
- package/server/dist/features/search/related-notes.js +186 -0
- package/server/dist/main.js +10 -3
- package/server/prisma/migrations/20260728090000_0019_note_reference_index/migration.sql +15 -0
- package/server/prisma/schema.prisma +16 -0
- package/server/client/dist/assets/Calendar-BsNs3NzO.js +0 -25
- package/server/client/dist/assets/Note-CmreVzvQ.js +0 -25
- package/server/client/dist/assets/NotePropertiesPanel-DXYgE8h2.js +0 -2
- package/server/client/dist/assets/Search-C-FB3pKf.js +0 -1
- package/server/client/dist/assets/Tag-lBxDRE77.js +0 -1
- package/server/client/dist/assets/TagNotes-Bgorp_rC.js +0 -1
- package/server/client/dist/assets/ViewNotes-C7d10VFf.js +0 -1
- package/server/client/dist/assets/ViewSectionDialog-Bhw-ILop.js +0 -102
- package/server/client/dist/assets/Views-C7V3saUo.js +0 -1
- package/server/client/dist/assets/app-DQBV36r_.js +0 -50
- package/server/client/dist/assets/app-ZgwR5t_I.css +0 -1
- package/server/client/dist/assets/index-CaF9d-zG.js +0 -1
- package/server/client/dist/assets/manage-image-DCDF8AM9.js +0 -1
- package/server/client/dist/assets/manage-image-detail-e0ViRAj-.js +0 -1
- package/server/client/dist/assets/mcp-admin-adapter-D39G3udJ.js +0 -1
- package/server/client/dist/assets/search-DFnzZKLd.js +0 -1
- 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};
|
package/server/client/dist/assets/{useReminderMutate--omDLmBV.js → useReminderMutate-BrGO9M0o.js}
RENAMED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{j as y,r as l}from"./floating-ui.react-dom-BWTnA00G.js";import{g as 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-
|
|
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-
|
|
15
|
-
<link rel="modulepreload" crossorigin href="/assets/time-
|
|
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-
|
|
18
|
-
<link rel="stylesheet" crossorigin href="/assets/app-
|
|
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
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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));
|
|
@@ -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
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
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 =
|
|
9
|
-
semanticWeight =
|
|
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,
|
|
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
|
+
};
|