From cad05fe21b60264a75a15ceb2a9900b45cc65064 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Dydlin=CC=81ski?= Date: Mon, 24 Aug 2026 22:42:41 +0200 Subject: [PATCH 01/28] feat(checklists): add setting to insert new items at the end of a checklist\n\nAdd a per-user 'newItemInsertion' setting (top|bottom, default top) that\ncontrols whether new checklist items are prepended (current behavior) or\nappended at the end. The createItem server action reads the setting and\nbranches between prepend (order 0, shifting existing items) and append\n(order = max+1, leaving existing items in place).\n\nCloses #434 --- .../Profile/Parts/UserPreferencesTab.tsx | 30 +++++++++++ app/_schemas/user-schemas.ts | 3 ++ app/_server/actions/checklist-item/crud.ts | 13 +++-- app/_translations/en.json | 5 ++ app/_types/index.ts | 1 + app/_types/user.ts | 2 + tests/server-actions/checklist-item.test.ts | 52 +++++++++++++++++++ 7 files changed, 103 insertions(+), 3 deletions(-) diff --git a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx index 3ac1e18e4..15693784c 100644 --- a/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx +++ b/app/_components/FeatureComponents/Profile/Parts/UserPreferencesTab.tsx @@ -33,6 +33,7 @@ import { HideMobileStatusDropdown, HideTimeTrackingOnCards, CodeBlockStyle, + NewItemInsertion, } from "@/app/_types"; import { Modes } from "@/app/_types/enums"; import { DEFAULT_WEEK_START, WeekDay } from "@/app/_consts/calendar"; @@ -75,6 +76,7 @@ const getSettingsFromUser = (user: SanitisedUser | null): Partial markdownTheme: user?.markdownTheme || "prism", defaultChecklistFilter: user?.defaultChecklistFilter || "all", checklistItemClickAction: user?.checklistItemClickAction || "toggle", + newItemInsertion: user?.newItemInsertion || "top", defaultNoteFilter: user?.defaultNoteFilter || "all", quickCreateNotes: user?.quickCreateNotes || "disable", quickCreateNotesCategory: user?.quickCreateNotesCategory || "", @@ -176,6 +178,7 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa "showChecklistEmojis", "defaultChecklistFilter", "checklistItemClickAction", + "newItemInsertion", ]); const hasKanbanChanges = hasChanges([ "hideStatusOnCards", @@ -345,6 +348,11 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa { id: "edit", name: t('settings.checklistItemClickActionEdit') }, ]; + const newItemInsertionOptions = [ + { id: "top", name: t('settings.newItemInsertionTop') }, + { id: "bottom", name: t('settings.newItemInsertionBottom') }, + ]; + const defaultChecklistFilterOptions = [ { id: "all", name: t('checklists.allChecklists') }, { id: "completed", name: t('checklists.completed') }, @@ -893,6 +901,7 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa "showChecklistEmojis", "defaultChecklistFilter", "checklistItemClickAction", + "newItemInsertion", ], checklistSettingsSchema, "Checklists" @@ -1009,6 +1018,27 @@ export const UserPreferencesTab = ({ noteCategories, localeOptions }: SettingsTa {t('settings.checklistItemClickActionDescription')}

+ +
+ + + handleSettingChange( + "newItemInsertion", + value as NewItemInsertion + ) + } + options={newItemInsertionOptions} + placeholder={t('settings.selectNewItemInsertion')} + className="w-full" + /> +

+ {t('settings.newItemInsertionDescription')} +

+
Math.max(max, item.order ?? 0), -1) + 1 + : 0, description: description || undefined, createdBy: currentUser, createdAt: now, @@ -296,7 +301,9 @@ export const createItem = async ( const updatedList = { ...stored, - items: [newItem, ...shiftedItems], + items: insertAtBottom + ? [...stored.items, newItem] + : [newItem, ...shiftedItems], tags: mergedTags, updatedAt: new Date().toISOString(), }; diff --git a/app/_translations/en.json b/app/_translations/en.json index 248e8f1fd..8bbe00b31 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -948,6 +948,11 @@ "checklistItemClickActionDescription": "Choose what happens when you click a task's text. \"Mark as complete\" toggles the checkbox (default); \"Edit task name\" opens the task for inline editing — you'll still need to click the checkbox itself to complete it.", "checklistItemClickActionToggle": "Mark as complete/incomplete", "checklistItemClickActionEdit": "Edit list item", + "newItemInsertion": "New item insertion", + "selectNewItemInsertion": "Select where new items are added", + "newItemInsertionDescription": "Choose whether new items are added to the top (default) or the bottom of a checklist.", + "newItemInsertionTop": "Top of the list", + "newItemInsertionBottom": "Bottom of the list", "selectShowChecklistEmojis": "Select emoji visibility", "showChecklistEmojisDescription": "Show an automatic emoji next to checklist items based on their text." }, diff --git a/app/_types/index.ts b/app/_types/index.ts index bacd97b5d..1029f2b76 100644 --- a/app/_types/index.ts +++ b/app/_types/index.ts @@ -45,6 +45,7 @@ export type { HideTimeTrackingOnCards, CodeBlockStyle, ChecklistItemClickAction, + NewItemInsertion, } from "./user"; export type { diff --git a/app/_types/user.ts b/app/_types/user.ts index 5e3bb45ce..9ae0711a5 100644 --- a/app/_types/user.ts +++ b/app/_types/user.ts @@ -44,6 +44,7 @@ export type HideMobileStatusDropdown = "enable" | "disable"; export type HideTimeTrackingOnCards = "enable" | "disable"; export type CodeBlockStyle = "default" | "themed"; export type ChecklistItemClickAction = "toggle" | "edit"; +export type NewItemInsertion = "top" | "bottom"; export interface User { username: string; @@ -84,6 +85,7 @@ export interface User { hideMobileStatusDropdown?: HideMobileStatusDropdown; hideTimeTrackingOnCards?: HideTimeTrackingOnCards; codeBlockStyle?: CodeBlockStyle; + newItemInsertion?: NewItemInsertion; mfaEnabled?: boolean; mfaSecret?: string; mfaRecoveryCode?: string; diff --git a/tests/server-actions/checklist-item.test.ts b/tests/server-actions/checklist-item.test.ts index d56776a1a..a3fbed7d8 100644 --- a/tests/server-actions/checklist-item.test.ts +++ b/tests/server-actions/checklist-item.test.ts @@ -6,6 +6,7 @@ const mockEnsureDir = vi.fn(); const mockServerWriteFile = vi.fn(); const mockGetUsername = vi.fn(); const mockGetCurrentUser = vi.fn(); +const mockGetUserByUsername = vi.fn(); const mockIsAdmin = vi.fn(); const mockCanReach = vi.fn(); const mockUsersWithAccess = vi.fn(); @@ -25,6 +26,7 @@ vi.mock("@/app/_server/actions/file", () => ({ vi.mock("@/app/_server/actions/users", () => ({ getUsername: (...args: any[]) => mockGetUsername(...args), getCurrentUser: (...args: any[]) => mockGetCurrentUser(...args), + getUserByUsername: (...args: any[]) => mockGetUserByUsername(...args), isAdmin: (...args: any[]) => mockIsAdmin(...args), })); @@ -119,6 +121,7 @@ describe("Checklist Item Actions - Comprehensive Tests", () => { mockServerWriteFile.mockResolvedValue(undefined); mockGetUsername.mockResolvedValue("testuser"); mockGetCurrentUser.mockResolvedValue({ username: "testuser" }); + mockGetUserByUsername.mockResolvedValue(null); mockIsAdmin.mockResolvedValue(false); mockCanReach.mockResolvedValue(true); mockUsersWithAccess.mockResolvedValue([]); @@ -345,6 +348,55 @@ describe("Checklist Item Actions - Comprehensive Tests", () => { expect(result.success).toBe(true); }); + + it("should prepend new item (order 0) by default", async () => { + mockGetUserByUsername.mockResolvedValue(null); + + const formData = createFormData({ + uuid: "test-uuid-123", + text: "New top item", + category: "TestCategory", + }); + + const result = await createItem(mockChecklist, formData); + + expect(result.success).toBe(true); + expect(result.data?.order).toBe(0); + + const writtenList = mockListToMarkdown.mock.calls.at(-1)?.[0]; + expect(writtenList.items[0].id).toBe(result.data?.id); + expect(writtenList.items[0].order).toBe(0); + // existing items shifted down by one + expect(writtenList.items[1].order).toBe(1); + }); + + it("should append new item at the end when newItemInsertion is 'bottom'", async () => { + mockGetUserByUsername.mockResolvedValue({ + username: "testuser", + newItemInsertion: "bottom", + }); + + const formData = createFormData({ + uuid: "test-uuid-123", + text: "New bottom item", + category: "TestCategory", + }); + + const result = await createItem(mockChecklist, formData); + + expect(result.success).toBe(true); + // existing max order is 2 (item-3), so new order is 3 + expect(result.data?.order).toBe(3); + + const writtenList = mockListToMarkdown.mock.calls.at(-1)?.[0]; + // new item is the last element + expect(writtenList.items.at(-1).id).toBe(result.data?.id); + expect(writtenList.items.at(-1).order).toBe(3); + // existing items keep their original order (not shifted) + expect(writtenList.items[0].order).toBe(0); + expect(writtenList.items[1].order).toBe(1); + expect(writtenList.items[2].order).toBe(2); + }); }); describe("deleteItem", () => { From 69b7e81bc60a2310c32ce9ded9fee4055cb717f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Dydlin=CC=81ski?= Date: Mon, 24 Aug 2026 22:44:16 +0200 Subject: [PATCH 02/28] feat(i18n): translate new-item-insertion setting for all locales\n\nAdd newItemInsertion, selectNewItemInsertion, newItemInsertionDescription,\nnewItemInsertionTop and newItemInsertionBottom translations for de, es, fr,\nit, ko, nl, pl, pt, ru, tr, vi, zh, klingon and pirate locales. --- app/_translations/de.json | 5 +++++ app/_translations/es.json | 5 +++++ app/_translations/fr.json | 5 +++++ app/_translations/it.json | 5 +++++ app/_translations/klingon.json | 5 +++++ app/_translations/ko.json | 5 +++++ app/_translations/nl.json | 5 +++++ app/_translations/pirate.json | 5 +++++ app/_translations/pl.json | 5 +++++ app/_translations/pt.json | 5 +++++ app/_translations/ru.json | 5 +++++ app/_translations/tr.json | 5 +++++ app/_translations/vi.json | 7 ++++++- app/_translations/zh.json | 5 +++++ 14 files changed, 71 insertions(+), 1 deletion(-) diff --git a/app/_translations/de.json b/app/_translations/de.json index 946cc1f8a..a611be4cb 100644 --- a/app/_translations/de.json +++ b/app/_translations/de.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Wählen Sie aus, was passiert, wenn Sie auf den Text einer Aufgabe klicken. \n„Als abgeschlossen markieren“ schaltet das Kontrollkästchen ein (Standard); \n„Aufgabennamen bearbeiten“ öffnet die Aufgabe zur Inline-Bearbeitung – Sie müssen noch das Kontrollkästchen selbst anklicken, um sie abzuschließen.", "checklistItemClickActionToggle": "Als vollständig/unvollständig markieren", "checklistItemClickActionEdit": "Listenelement bearbeiten", + "newItemInsertion": "Position neuer Einträge", + "selectNewItemInsertion": "Wählen Sie, wo neue Einträge hinzugefügt werden", + "newItemInsertionDescription": "Legen Sie fest, ob neue Einträge oben (Standard) oder unten in der Checkliste hinzugefügt werden.", + "newItemInsertionTop": "Anfang der Liste", + "newItemInsertionBottom": "Ende der Liste", "useSystemDefault": "Systemstandard verwenden", "selectShowChecklistEmojis": "Emoji-Sichtbarkeit wählen", "showChecklistEmojisDescription": "Zeigt anhand des Textes automatisch ein Emoji neben Checklisteneinträgen." diff --git a/app/_translations/es.json b/app/_translations/es.json index 802a25382..f6c6a24e8 100644 --- a/app/_translations/es.json +++ b/app/_translations/es.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Elija qué sucede cuando hace clic en el texto de una tarea. \n\"Marcar como completo\" activa la casilla de verificación (predeterminada); \n\"Editar nombre de la tarea\" abre la tarea para su edición en línea; aún deberá hacer clic en la casilla de verificación para completarla.", "checklistItemClickActionToggle": "Marcar como completo/incompleto", "checklistItemClickActionEdit": "Editar elemento de la lista", + "newItemInsertion": "Inserción de nuevos elementos", + "selectNewItemInsertion": "Selecciona dónde se añaden los nuevos elementos", + "newItemInsertionDescription": "Elige si los nuevos elementos se añaden al principio (predeterminado) o al final de una lista de verificación.", + "newItemInsertionTop": "Principio de la lista", + "newItemInsertionBottom": "Final de la lista", "useSystemDefault": "Usar el sistema predeterminado", "selectShowChecklistEmojis": "Seleccionar visibilidad de emojis", "showChecklistEmojisDescription": "Muestra un emoji automático junto a los elementos de la lista según su texto." diff --git a/app/_translations/fr.json b/app/_translations/fr.json index 4366f27c2..56442a734 100644 --- a/app/_translations/fr.json +++ b/app/_translations/fr.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Choisissez ce qui se passe lorsque vous cliquez sur le texte d'une tâche. \n« Marquer comme terminé » coche la case (par défaut) ; \n« Modifier le nom de la tâche » ouvre la tâche pour une modification en ligne ; vous devrez toujours cliquer sur la case à cocher elle-même pour la terminer.", "checklistItemClickActionToggle": "Marquer comme complet/incomplet", "checklistItemClickActionEdit": "Modifier un élément de liste", + "newItemInsertion": "Insertion des nouveaux éléments", + "selectNewItemInsertion": "Sélectionnez l'emplacement d'ajout des nouveaux éléments", + "newItemInsertionDescription": "Choisissez si les nouveaux éléments sont ajoutés en haut (par défaut) ou en bas d'une liste de contrôle.", + "newItemInsertionTop": "Haut de la liste", + "newItemInsertionBottom": "Bas de la liste", "useSystemDefault": "Utiliser les valeurs par défaut du système", "selectShowChecklistEmojis": "Choisir la visibilité des émojis", "showChecklistEmojisDescription": "Affiche un émoji automatique à côté des éléments de liste selon leur texte." diff --git a/app/_translations/it.json b/app/_translations/it.json index f6cefd4bb..0d6808c24 100644 --- a/app/_translations/it.json +++ b/app/_translations/it.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Scegli cosa succede quando fai clic sul testo di un'attività. \n\"Segna come completato\" attiva/disattiva la casella di controllo (impostazione predefinita); \n\"Modifica nome attività\" apre l'attività per la modifica in linea: dovrai comunque fare clic sulla casella di controllo stessa per completarla.", "checklistItemClickActionToggle": "Contrassegna come completo/incompleto", "checklistItemClickActionEdit": "Modifica elemento elenco", + "newItemInsertion": "Inserimento nuovi elementi", + "selectNewItemInsertion": "Seleziona dove vengono aggiunti i nuovi elementi", + "newItemInsertionDescription": "Scegli se i nuovi elementi vengono aggiunti in cima (predefinito) o in fondo a una checklist.", + "newItemInsertionTop": "Inizio della lista", + "newItemInsertionBottom": "Fine della lista", "useSystemDefault": "Utilizza l'impostazione predefinita del sistema", "selectShowChecklistEmojis": "Seleziona visibilità emoji", "showChecklistEmojisDescription": "Mostra un'emoji automatica accanto agli elementi della checklist in base al testo." diff --git a/app/_translations/klingon.json b/app/_translations/klingon.json index cfc42aa9e..c91f37690 100644 --- a/app/_translations/klingon.json +++ b/app/_translations/klingon.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "Choose what happens when you click a task's text. \"Mark as complete\" toggles the checkbox (default); \"Edit task name\" opens the task for inline editing — you'll still need to click the checkbox itself to complete it.", "checklistItemClickActionToggle": "Mark as complete/incomplete", "checklistItemClickActionEdit": "Edit list item", + "newItemInsertion": "New item insertion", + "selectNewItemInsertion": "Select where new items are added", + "newItemInsertionDescription": "Choose whether new items are added to the top (default) or the bottom of a checklist.", + "newItemInsertionTop": "Top of the list", + "newItemInsertionBottom": "Bottom of the list", "useSystemDefault": "Use system default", "selectShowChecklistEmojis": "Emoji cha'ghach yIwIv", "showChecklistEmojisDescription": "mu'mey ghop tetlh Segh Emoji cha' pagh." diff --git a/app/_translations/ko.json b/app/_translations/ko.json index 36bddf0f5..0b99a9396 100644 --- a/app/_translations/ko.json +++ b/app/_translations/ko.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "작업 텍스트를 클릭하면 어떻게 되는지 선택하세요. \n\"완료로 표시\"는 확인란을 전환합니다(기본값). \n\"작업 이름 편집\"은 인라인 편집을 위한 작업을 엽니다. 완료하려면 확인란 자체를 클릭해야 합니다.", "checklistItemClickActionToggle": "완료/미완료로 표시", "checklistItemClickActionEdit": "목록 항목 편집", + "newItemInsertion": "새 항목 삽입 위치", + "selectNewItemInsertion": "새 항목이 추가될 위치를 선택하세요", + "newItemInsertionDescription": "체크리스트에서 새 항목을 맨 위(기본값)에 추가할지 맨 아래에 추가할지 선택하세요.", + "newItemInsertionTop": "목록 맨 위", + "newItemInsertionBottom": "목록 맨 아래", "useSystemDefault": "시스템 기본값 사용", "selectShowChecklistEmojis": "이모지 표시 선택", "showChecklistEmojisDescription": "체크리스트 항목의 텍스트에 따라 이모지를 자동으로 표시합니다." diff --git a/app/_translations/nl.json b/app/_translations/nl.json index 25a951038..b5d677feb 100644 --- a/app/_translations/nl.json +++ b/app/_translations/nl.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Kies wat er gebeurt als u op de tekst van een taak klikt. \n\"Markeren als voltooid\" schakelt het selectievakje in (standaard); \nMet 'Taaknaam bewerken' wordt de taak geopend voor inline bewerken. U moet nog steeds op het selectievakje zelf klikken om deze te voltooien.", "checklistItemClickActionToggle": "Markeer als compleet/onvolledig", "checklistItemClickActionEdit": "Lijstitem bewerken", + "newItemInsertion": "Positie van nieuwe items", + "selectNewItemInsertion": "Selecteer waar nieuwe items worden toegevoegd", + "newItemInsertionDescription": "Kies of nieuwe items bovenaan (standaard) of onderaan een checklist worden toegevoegd.", + "newItemInsertionTop": "Bovenaan de lijst", + "newItemInsertionBottom": "Onderaan de lijst", "useSystemDefault": "Gebruik systeemstandaard", "selectShowChecklistEmojis": "Emoji-zichtbaarheid kiezen", "showChecklistEmojisDescription": "Toont automatisch een emoji naast checklistitems op basis van de tekst." diff --git a/app/_translations/pirate.json b/app/_translations/pirate.json index 78332498e..437032aac 100644 --- a/app/_translations/pirate.json +++ b/app/_translations/pirate.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "Choose what happens when you click a task's text. \"Mark as complete\" toggles the checkbox (default); \"Edit task name\" opens the task for inline editing — you'll still need to click the checkbox itself to complete it.", "checklistItemClickActionToggle": "Mark as complete/incomplete", "checklistItemClickActionEdit": "Edit list item", + "newItemInsertion": "Stow new cargo", + "selectNewItemInsertion": "Choose where new loot be stowed", + "newItemInsertionDescription": "Choose whether new cargo be stowed at the top (default) or the bottom o' the manifest.", + "newItemInsertionTop": "Top o' the manifest", + "newItemInsertionBottom": "Bottom o' the manifest", "useSystemDefault": "Use system default", "selectShowChecklistEmojis": "Choose yer rune showin'", "showChecklistEmojisDescription": "Marks each manifest entry with a rune drawn from its words." diff --git a/app/_translations/pl.json b/app/_translations/pl.json index 9dc28d2fe..b7feaf241 100644 --- a/app/_translations/pl.json +++ b/app/_translations/pl.json @@ -922,6 +922,11 @@ "checklistItemClickActionDescription": "Wybierz, co się stanie, gdy klikniesz tekst zadania. \n„Oznacz jako ukończone” przełącza pole wyboru (domyślnie); \n„Edytuj nazwę zadania” otwiera zadanie do edycji bezpośredniej — nadal będziesz musiał kliknąć samo pole wyboru, aby je ukończyć.", "checklistItemClickActionToggle": "Oznacz jako ukończone/niekompletne", "checklistItemClickActionEdit": "Edytuj element listy", + "newItemInsertion": "Wstawianie nowych elementów", + "selectNewItemInsertion": "Wybierz, gdzie dodawane są nowe elementy", + "newItemInsertionDescription": "Wybierz, czy nowe elementy są dodawane na górze (domyślnie) czy na dole listy kontrolnej.", + "newItemInsertionTop": "Góra listy", + "newItemInsertionBottom": "Dół listy", "useSystemDefault": "Użyj ustawień domyślnych systemu", "selectShowChecklistEmojis": "Wybierz widoczność emotikonów", "showChecklistEmojisDescription": "Pokazuje automatyczne emoji obok pozycji listy na podstawie ich tekstu." diff --git a/app/_translations/pt.json b/app/_translations/pt.json index 1689ce83c..c06295561 100644 --- a/app/_translations/pt.json +++ b/app/_translations/pt.json @@ -948,6 +948,11 @@ "checklistItemClickActionDescription": "Escolha o que acontece quando clica no texto de uma tarefa. \"Marcar como concluída\" alterna a caixa de verificação (predefinido); \"Editar nome da tarefa\" abre a tarefa para edição inline — ainda precisará de clicar na própria caixa de verificação para a concluir.", "checklistItemClickActionToggle": "Marcar como concluída/incompleta", "checklistItemClickActionEdit": "Editar item da lista", + "newItemInsertion": "Inserção de novos itens", + "selectNewItemInsertion": "Selecione onde os novos itens são adicionados", + "newItemInsertionDescription": "Escolha se os novos itens são adicionados no topo (padrão) ou no final de uma lista de verificação.", + "newItemInsertionTop": "Topo da lista", + "newItemInsertionBottom": "Final da lista", "selectShowChecklistEmojis": "Selecionar visibilidade dos emojis", "showChecklistEmojisDescription": "Mostra um emoji automático junto dos itens da lista com base no texto." }, diff --git a/app/_translations/ru.json b/app/_translations/ru.json index c7233644c..cf0e1b0c2 100644 --- a/app/_translations/ru.json +++ b/app/_translations/ru.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "Выберите, что произойдет, если щелкнуть текст задачи. \n«Отметить как завершенное» переключает флажок (по умолчанию); \n«Редактировать имя задачи» открывает задачу для оперативного редактирования — для ее завершения все равно нужно будет нажать на сам флажок.", "checklistItemClickActionToggle": "Отметить как завершенное/незавершенное", "checklistItemClickActionEdit": "Изменить элемент списка", + "newItemInsertion": "Добавление новых элементов", + "selectNewItemInsertion": "Выберите, куда добавляются новые элементы", + "newItemInsertionDescription": "Выберите, добавляются ли новые элементы в начало (по умолчанию) или в конец контрольного списка.", + "newItemInsertionTop": "В начало списка", + "newItemInsertionBottom": "В конец списка", "useSystemDefault": "Использовать системные настройки по умолчанию", "selectShowChecklistEmojis": "Выберите отображение эмодзи", "showChecklistEmojisDescription": "Показывает автоматическое эмодзи рядом с пунктами чеклиста по их тексту." diff --git a/app/_translations/tr.json b/app/_translations/tr.json index 2c3e80465..b841f96ae 100644 --- a/app/_translations/tr.json +++ b/app/_translations/tr.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "Bir görevin metnini tıklattığınızda ne olacağını seçin. \n\"Tamamlandı olarak işaretle\" onay kutusunu değiştirir (varsayılan); \n\"Görev adını düzenle\", görevi satır içi düzenleme için açar; bunu tamamlamak için yine de onay kutusunu tıklamanız gerekir.", "checklistItemClickActionToggle": "Tamamlandı/tamamlanmadı olarak işaretle", "checklistItemClickActionEdit": "Liste öğesini düzenle", + "newItemInsertion": "Yeni öğe ekleme konumu", + "selectNewItemInsertion": "Yeni öğelerin nereye ekleneceğini seçin", + "newItemInsertionDescription": "Yeni öğelerin kontrol listesinin üstüne (varsayılan) mı yoksa altına mı ekleneceğini seçin.", + "newItemInsertionTop": "Listenin başı", + "newItemInsertionBottom": "Listenin sonu", "useSystemDefault": "Sistem varsayılanını kullan", "selectShowChecklistEmojis": "Emoji görünürlüğünü seçin", "showChecklistEmojisDescription": "Metnine göre liste öğelerinin yanında otomatik emoji gösterir." diff --git a/app/_translations/vi.json b/app/_translations/vi.json index 996621975..afb4ad888 100644 --- a/app/_translations/vi.json +++ b/app/_translations/vi.json @@ -946,7 +946,12 @@ "selectChecklistItemClickAction": "Chọn hành động nhấp vào mục danh sách kiểm tra", "checklistItemClickActionDescription": "Chọn điều gì sẽ xảy ra khi bạn nhấp vào văn bản của nhiệm vụ. \"Đánh dấu là hoàn thành\" chuyển đổi hộp kiểm (mặc định); \"Chỉnh sửa tên tác vụ\" sẽ mở tác vụ để chỉnh sửa nội tuyến — bạn vẫn cần phải nhấp vào hộp kiểm để hoàn thành tác vụ đó.", "checklistItemClickActionToggle": "Đánh dấu là hoàn thành/chưa hoàn thành", - "checklistItemClickActionEdit": "Chỉnh sửa mục danh sách" + "checklistItemClickActionEdit": "Chỉnh sửa mục danh sách", + "newItemInsertion": "Vị trí thêm mục mới", + "selectNewItemInsertion": "Chọn nơi thêm mục mới", + "newItemInsertionDescription": "Chọn xem các mục mới được thêm vào đầu (mặc định) hay cuối danh sách kiểm tra.", + "newItemInsertionTop": "Đầu danh sách", + "newItemInsertionBottom": "Cuối danh sách" }, "admin": { "title": "Quản trị viên", diff --git a/app/_translations/zh.json b/app/_translations/zh.json index 3eac83368..f7f795279 100644 --- a/app/_translations/zh.json +++ b/app/_translations/zh.json @@ -947,6 +947,11 @@ "checklistItemClickActionDescription": "选择单击任务文本时会发生的情况。 \n“标记为完成”切换复选框(默认); \n“编辑任务名称”打开任务进行内联编辑 - 您仍然需要单击复选框本身来完成它。", "checklistItemClickActionToggle": "标记为完整/不完整", "checklistItemClickActionEdit": "编辑列表项", + "newItemInsertion": "新项目插入位置", + "selectNewItemInsertion": "选择新项目的添加位置", + "newItemInsertionDescription": "选择新项目是添加到清单的顶部(默认)还是底部。", + "newItemInsertionTop": "列表顶部", + "newItemInsertionBottom": "列表底部", "useSystemDefault": "使用系统默认值", "selectShowChecklistEmojis": "选择 Emoji 显示方式", "showChecklistEmojisDescription": "根据清单项目的文字自动在旁边显示 Emoji。" From a80b8a5468dcfac8e65ebcb067f0b7bd16d13126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Dydlin=CC=81ski?= Date: Tue, 25 Aug 2026 12:04:17 +0200 Subject: [PATCH 03/28] add mcp server for remote jotty access via REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a self-contained mcp-server subpackage that exposes a jotty·page instance to MCP clients (Claude Desktop, Cursor, etc.) over stdio or Streamable HTTP, proxying 53 tools to the existing REST API with an API key. - src/client.ts: JottyClient REST wrapper (x-api-key header, timeouts, typed errors) - src/index.ts: env-driven dispatch between stdio and http transports - src/http.ts: Streamable HTTP transport with one McpServer + JottyClient per client session, optional MCP_TOKEN bearer gate, server-side fallback key - src/tools/*: checklists, notes, tasks, kanban boards, discovery (categories, summary, search), admin (exports, logs, rebuild-index), misc (health, user) - scripts/*: protocol smoke test plus stdio/http/live integration tests - README with Claude Desktop and Cursor config and the auth model --- mcp-server/.gitignore | 4 + mcp-server/README.md | 318 ++++++ mcp-server/package-lock.json | 1241 +++++++++++++++++++++++ mcp-server/package.json | 44 + mcp-server/scripts/integration-http.mjs | 84 ++ mcp-server/scripts/integration.mjs | 162 +++ mcp-server/scripts/live-lifecycle.mjs | 243 +++++ mcp-server/scripts/smoke.mjs | 120 +++ mcp-server/src/client.ts | 126 +++ mcp-server/src/http.ts | 217 ++++ mcp-server/src/index.ts | 103 ++ mcp-server/src/tools/admin.ts | 144 +++ mcp-server/src/tools/checklists.ts | 277 +++++ mcp-server/src/tools/discovery.ts | 72 ++ mcp-server/src/tools/index.ts | 35 + mcp-server/src/tools/kanban.ts | 337 ++++++ mcp-server/src/tools/misc.ts | 67 ++ mcp-server/src/tools/notes.ts | 116 +++ mcp-server/src/tools/shared.ts | 52 + mcp-server/src/tools/tasks.ts | 308 ++++++ mcp-server/src/tools/types.ts | 10 + mcp-server/tsconfig.json | 20 + 22 files changed, 4100 insertions(+) create mode 100644 mcp-server/.gitignore create mode 100644 mcp-server/README.md create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/scripts/integration-http.mjs create mode 100644 mcp-server/scripts/integration.mjs create mode 100644 mcp-server/scripts/live-lifecycle.mjs create mode 100644 mcp-server/scripts/smoke.mjs create mode 100644 mcp-server/src/client.ts create mode 100644 mcp-server/src/http.ts create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/tools/admin.ts create mode 100644 mcp-server/src/tools/checklists.ts create mode 100644 mcp-server/src/tools/discovery.ts create mode 100644 mcp-server/src/tools/index.ts create mode 100644 mcp-server/src/tools/kanban.ts create mode 100644 mcp-server/src/tools/misc.ts create mode 100644 mcp-server/src/tools/notes.ts create mode 100644 mcp-server/src/tools/shared.ts create mode 100644 mcp-server/src/tools/tasks.ts create mode 100644 mcp-server/src/tools/types.ts create mode 100644 mcp-server/tsconfig.json diff --git a/mcp-server/.gitignore b/mcp-server/.gitignore new file mode 100644 index 000000000..6b3b97dfb --- /dev/null +++ b/mcp-server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tsbuildinfo +.DS_Store \ No newline at end of file diff --git a/mcp-server/README.md b/mcp-server/README.md new file mode 100644 index 000000000..439a24d04 --- /dev/null +++ b/mcp-server/README.md @@ -0,0 +1,318 @@ +# jotty-mcp + +A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes a +**[jotty·page](https://jotty.page)** instance to AI clients (Claude Desktop, +Cursor, etc.). It proxies every tool call to your Jotty instance's +[REST API](../howto/API.md) using an API key, so an assistant can read and manage +your checklists, notes, kanban boards, and more. + +It ships inside the Jotty repository as a self-contained subpackage with its **own** +`node_modules` (so its `zod` version never clashes with the Next.js app). + +## Two ways to run it + +The same binary supports two transports, picked by `MCP_TRANSPORT`. Pick one: + +### 1. Local (stdio) — run on your personal machine *(default, simplest)* + +The MCP server is a **local process** your AI client launches; it makes outbound +HTTPS calls to your Jotty instance. The API key lives on your machine. Nothing new +runs on the server. + +``` +your machine jotty server +───────────── ──────────────── +Cursor / Claude Desktop + │ spawns child process (stdio) + ▼ + jotty-mcp ──── HTTPS, x-api-key ───► Jotty REST API (unchanged) + JOTTY_URL + JOTTY_API_KEY from env +``` + +Client config (Cursor / Claude Desktop): + +```jsonc +{ "mcpServers": { "jotty": { + "command": "node", + "args": ["/absolute/path/to/mcp-server/dist/index.js"], + "env": { "JOTTY_URL": "https://jotty.example.com", "JOTTY_API_KEY": "ck_…" } +}}} +``` + +→ Best for personal use: key never leaves your machine, Jotty stays untouched. + +### 2. Hosted (HTTP) — run next to the Jotty app + +The MCP server runs **on the Jotty host** as a small listening service; your AI +client just points at a URL. Each client sends its own Jotty `x-api-key`, so +multiple users keep their accounts isolated. Protect the endpoint with +`MCP_TOKEN` (Bearer) since it's network-exposed. + +``` +your machine jotty server +───────────── ──────────────────────────── +Cursor jotty app (:3000) ← unchanged + │ HTTPS to URL ▲ + │ headers: x-api-key + Authorization │ localhost + ▼ │ + jotty-mcp (MCP_TRANSPORT=http, :3001) ───────┘ + JOTTY_URL=http://localhost:3000 +``` + +Client config (Cursor): + +```jsonc +{ "mcpServers": { "jotty": { + "url": "https://jotty.example.com/mcp", + "headers": { "x-api-key": "ck_…", "Authorization": "Bearer your-mcp-token" } +}}} +``` + +→ Best for shared/multi-user: one endpoint, no local process per client. (Claude +Desktop's stdio-only builds should use option 1.) + +Detailed setup for both is below. + +## Not bundled with the Jotty Docker image + +`jotty-mcp` is a **separate program**, not part of the Jotty web app: + +- It is **not** included in the published Jotty Docker image + (`ghcr.io/fccview/jotty:latest`) and is **not** built by `next build` / the + `Dockerfile`. +- It is **not** started by `docker compose` — the compose file only runs the + `jotty` service (and optional `api-docs`). There is no `jotty-mcp` service + defined, so hosting Jotty via Docker Compose runs Jotty only and **does not run + the MCP server**. +- It has its **own** `package.json` / `node_modules`, deliberately decoupled from + the Next.js app so dependency versions (e.g. `zod`) never clash. + +So you must run it yourself, in one of the two ways above: + +- **Local (stdio):** build and run `jotty-mcp` on **your personal machine** — your + AI client (Cursor / Claude Desktop) launches it. Nothing to add to your Jotty + deployment. +- **Hosted (HTTP):** run it as a **separate sidecar** next to Jotty (e.g. its own + container/process, with `JOTTY_URL=http://jotty:3000`). It is never baked into + the Jotty image and never auto-started by Jotty. + +In short: Jotty's deployment is unchanged by this subpackage; `jotty-mcp` is an +optional companion you run separately. + +## What it exposes + +54 tools across the full Jotty API: + +| Area | Tools | +| --- | --- | +| **Checklists** | `list_checklists`, `create_checklist`, `update_checklist`, `delete_checklist`, `create_checklist_item`, `update_checklist_item`, `delete_checklist_item`, `check_item`, `uncheck_item`, `reorder_checklist_items` | +| **Notes** | `list_notes`, `create_note`, `get_note`, `update_note`, `delete_note` | +| **Tasks (kanban columns)** | `list_tasks`, `create_task`, `get_task`, `update_task`, `delete_task`, `get_task_statuses`, `create_task_status`, `update_task_status`, `delete_task_status`, `create_task_item`, `get_task_item`, `move_task_item`, `delete_task_item` | +| **Kanban boards** | `list_boards`, `create_board`, `get_board`, `update_board`, `delete_board`, `set_board_statuses`, `create_board_item`, `update_board_item`, `delete_board_item`, `move_board_item`, `assign_board_item`, `set_board_item_reminder`, `clear_board_item_reminder`, `get_board_calendar` | +| **Discovery** | `list_categories`, `get_summary`, `search` | +| **Admin / exports / logs** | `request_export`, `get_export_progress`, `list_logs`, `export_logs`, `get_logs_stats`, `rebuild_link_index` | +| **Misc** | `health`, `get_user`, `get_current_user`, `rebuild_link_index` | + +Tool arguments are validated with JSON Schema (via `zod`). Items are addressed by +dot-notation index paths (e.g. `0.1`) for nested sub-items, and by UUID for +lists/notes/boards — matching the [API docs](../howto/API.md). + +## Prerequisites + +- Node.js **20+** (uses the built-in `fetch`). +- A running Jotty instance you can reach over HTTP(S). +- A Jotty **API key** — generate one in Jotty → **Profile → Settings → API Key** + (it looks like `ck_…`). + +## Build + +```bash +cd mcp-server +npm install +npm run build # outputs to dist/ +``` + +## Configure (local / stdio mode) + +In local stdio mode the server reads two required environment variables (and one +optional). For HTTP mode env, see [HTTP / remote mode](#http--remote-mode-streamable-http) below. + +| Variable | Required | Description | +| --- | --- | --- | +| `JOTTY_URL` | yes | Base URL of your Jotty instance, e.g. `https://jotty.example.com` | +| `JOTTY_API_KEY` | yes | Your `ck_…` API key | +| `JOTTY_TIMEOUT_MS` | no | Per-request timeout in ms (default `30000`) | + +## Run + +```bash +JOTTY_URL=https://jotty.example.com JOTTY_API_KEY=ck_your_key node dist/index.js +``` + +It speaks MCP over stdio, so you run it as a child process of your MCP client. + +## Use with Claude Desktop + +Add an entry to your `claude_desktop_config.json` (Claude → Settings → Developer → +Edit Config): + +```jsonc +{ + "mcpServers": { + "jotty": { + "command": "node", + "args": ["/absolute/path/to/jotty/mcp-server/dist/index.js"], + "env": { + "JOTTY_URL": "https://jotty.example.com", + "JOTTY_API_KEY": "ck_your_key" + } + } + } +} +``` + +Restart Claude Desktop and you'll see the Jotty tools available. + +## Use with Cursor + +Add to `~/.cursor/mcp.json` (or `.cursor/mcp.json` in your project): + +```jsonc +{ + "mcpServers": { + "jotty": { + "command": "node", + "args": ["/absolute/path/to/jotty/mcp-server/dist/index.js"], + "env": { + "JOTTY_URL": "https://jotty.example.com", + "JOTTY_API_KEY": "ck_your_key" + } + } + } +} +``` + +## HTTP / remote mode (Streamable HTTP) + +The stdio mode above runs the server **on your own machine**. If you'd rather run +the MCP server **on the Jotty host** (or anywhere reachable) and point your client +at a URL, use the HTTP transport. Same binary, selected by `MCP_TRANSPORT=http`. + +### 1. Run the server on the Jotty host + +```bash +# On the server that runs Jotty (e.g. Jotty on :3000) +cd mcp-server && npm install && npm run build + +MCP_TRANSPORT=http \ +JOTTY_URL=http://localhost:3000 \ +MCP_PORT=3001 \ +MCP_HOST=0.0.0.0 \ +MCP_TOKEN=choose-a-long-random-secret \ + node dist/index.js +``` + +Environment (HTTP mode): + +| Variable | Required | Description | +| --- | --- | --- | +| `MCP_TRANSPORT` | yes | set to `http` | +| `JOTTY_URL` | yes | where Jotty's REST API is — usually `http://localhost:3000` when colocated | +| `MCP_PORT` | no | bind port (default `3001`) | +| `MCP_HOST` | no | bind host (default `0.0.0.0`; use `127.0.0.1` for local-only) | +| `MCP_TOKEN` | recommended | shared secret; clients must send `Authorization: Bearer `. Without it the endpoint is open — restrict the network (localhost / Tailscale / reverse proxy). | +| `JOTTY_API_KEY` | optional | server-side fallback Jotty key used only when a client omits its own `x-api-key` | + +### 2. Connect your client to the URL + +The client **does not** set `JOTTY_URL`. It sets the MCP endpoint URL and sends the +Jotty API key as the `x-api-key` header: + +**Cursor** (`~/.cursor/mcp.json`): + +```jsonc +{ + "mcpServers": { + "jotty": { + "url": "https://jotty.example.com/mcp", + "headers": { + "x-api-key": "ck_your_key", + "Authorization": "Bearer choose-a-long-random-secret" + } + } + } +} +``` + +> If you set `MCP_TOKEN` on the server, include `Authorization: Bearer ` in +> the client headers. If you didn't set a token, drop that header. + +**Claude Desktop** does not yet support the `url`/remote form in all builds; for +Claude Desktop use the stdio config above. HTTP mode is intended for Cursor and +any client that supports the Streamable HTTP transport. + +### Auth model summary + +Two independent layers, by design: +- **MCP endpoint auth** (optional, server-side `MCP_TOKEN`): gates who can reach + the MCP server at all (`Authorization: Bearer`). +- **Jotty account auth** (required, per-client `x-api-key`): which Jotty account + each session acts as. Each connecting client gets its own `JottyClient`, so + multiple users on one MCP server keep their own keys/data isolated. + +## Security notes + +- The Jotty API key grants **full access** to the Jotty account it belongs to. + Treat it like a password — put it in your MCP client's config, not in source + files. Rotate it from Jotty → Profile → Settings → API Key. +- **stdio mode:** the server runs locally and only makes outbound HTTPS requests + to your Jotty URL. No inbound ports are opened. +- **HTTP mode:** the server opens a listening port. Always set `MCP_TOKEN` and/or + bind to `127.0.0.1` / put it behind a reverse proxy / Tailscale so the endpoint + isn't publicly reachable. Each session still requires a valid Jotty `x-api-key`. +- Errors from Jotty (bad UUID, missing title, `403`, etc.) are returned to the + model as MCP `isError` results rather than crashing the server. + +## Project layout + +``` +mcp-server/ + src/ + index.ts # entrypoint: env config, dispatches stdio vs http + client.ts # JottyClient: REST fetch wrapper with x-api-key + http.ts # Streamable HTTP transport: per-session McpServer + JottyClient + tools/ + index.ts # registerAllTools() aggregator + shared.ts # json/text/fail/run result helpers + types.ts # ToolModule interface + checklists.ts # /api/checklists tools + notes.ts # /api/notes tools + tasks.ts # /api/tasks tools + kanban.ts # /api/kanban tools + discovery.ts # categories, summary, search + admin.ts # exports, logs, admin + misc.ts # health, user, rebuild-index + scripts/ + smoke.mjs # stdio protocol smoke test (no Jotty needed) + integration.mjs # stdio end-to-end test (needs a running Jotty) + integration-http.mjs # http end-to-end test (needs running Jotty + http server) +``` + +## Tests + +```bash +# Protocol smoke test — no Jotty instance required +JOTTY_URL=http://127.0.0.1:9 JOTTY_API_KEY=ck_dummy node scripts/smoke.mjs + +# stdio end-to-end — run Jotty locally first (e.g. `next dev`), +# inject a test API key for an admin user, then: +JOTTY_URL=http://127.0.0.1:3000 JOTTY_API_KEY=ck_test_… node scripts/integration.mjs + +# http end-to-end — also start the http server, then: +# MCP_TRANSPORT=http JOTTY_URL=http://127.0.0.1:3000 MCP_PORT=3001 node dist/index.js & +MCP_URL=http://127.0.0.1:3001/mcp JOTTY_API_KEY=ck_test_… node scripts/integration-http.mjs +``` + +## License + +MIT, same as Jotty. \ No newline at end of file diff --git a/mcp-server/package-lock.json b/mcp-server/package-lock.json new file mode 100644 index 000000000..23f799755 --- /dev/null +++ b/mcp-server/package-lock.json @@ -0,0 +1,1241 @@ +{ + "name": "jotty-mcp", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "jotty-mcp", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^3.25.76" + }, + "bin": { + "jotty-mcp": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.6.tgz", + "integrity": "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.4", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.4.tgz", + "integrity": "sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.10", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.10.tgz", + "integrity": "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/mcp-server/package.json b/mcp-server/package.json new file mode 100644 index 000000000..8c5608814 --- /dev/null +++ b/mcp-server/package.json @@ -0,0 +1,44 @@ +{ + "name": "jotty-mcp", + "version": "1.0.0", + "description": "MCP server that exposes a remote jotty·page instance (checklists, notes, kanban boards, and more) over stdio via its REST API.", + "type": "module", + "bin": { + "jotty-mcp": "dist/index.js" + }, + "main": "dist/index.js", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch", + "start": "node dist/index.js", + "smoke": "node scripts/smoke.mjs", + "integration": "node scripts/integration.mjs", + "integration:http": "node scripts/integration-http.mjs", + "live": "node scripts/live-lifecycle.mjs" + }, + "keywords": [ + "mcp", + "model-context-protocol", + "jotty", + "jotty.page", + "checklist", + "kanban", + "notes" + ], + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@types/node": "^20.19.0", + "typescript": "^5.9.0" + }, + "engines": { + "node": ">=20" + } +} \ No newline at end of file diff --git a/mcp-server/scripts/integration-http.mjs b/mcp-server/scripts/integration-http.mjs new file mode 100644 index 000000000..b0a1a0533 --- /dev/null +++ b/mcp-server/scripts/integration-http.mjs @@ -0,0 +1,84 @@ +/* + * Real integration test for the HTTP (Streamable HTTP) transport. Uses the MCP + * SDK's own Client against the running jotty-mcp http server, which in turn + * proxies to a running Jotty instance. Validates the full + * Cursor-like client -> MCP http server -> Jotty REST API + * path over the network. + * + * Env: MCP_URL (http endpoint), JOTTY_API_KEY (ck_… sent as x-api-key header). + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; + +const mcpUrl = process.env.MCP_URL || "http://127.0.0.1:3001/mcp"; +const apiKey = process.env.JOTTY_API_KEY; + +let ok = true; +const check = (cond, msg) => { + if (!cond) { + ok = false; + console.error(" FAIL:", msg); + } else { + console.log(" ok:", msg); + } +}; + +const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { + requestInit: { + headers: { "x-api-key": apiKey }, + }, +}); +const client = new Client({ name: "http-integration-test", version: "0.0.1" }); + +try { + await client.connect(transport); + check(true, "client connected to MCP http server"); + + const tools = await client.listTools(); + check(tools.tools.length >= 40, `listTools returned ${tools.tools.length} tools`); + check( + tools.tools.some((t) => t.name === "list_checklists"), + "tool list includes list_checklists", + ); + + // health (public) + const health = await client.callTool({ name: "health", arguments: {} }); + const healthData = JSON.parse(health.content[0].text); + check(healthData.status === "healthy", `health => ${healthData.status} v${healthData.version}`); + check(health.isError !== true, "health is not an error"); + + // list_checklists (authenticated, proxied to Jotty) + const lists = await client.callTool({ name: "list_checklists", arguments: {} }); + const listsData = JSON.parse(lists.content[0].text); + check(Array.isArray(listsData.checklists), `list_checklists => ${listsData.checklists?.length} lists`); + + // get_summary + const summary = await client.callTool({ name: "get_summary", arguments: {} }); + const summaryData = JSON.parse(summary.content[0].text); + check(typeof summaryData.summary.checklists.total === "number", "get_summary returns numbers"); + + // create + delete round-trip over HTTP + const created = await client.callTool({ + name: "create_checklist", + arguments: { title: "HTTP MCP Test", category: "MCP-HTTP", type: "simple" }, + }); + const createdData = JSON.parse(created.content[0].text); + const newId = createdData.data?.id; + check(!!newId, `create_checklist => id ${newId}`); + if (newId) { + const del = await client.callTool({ name: "delete_checklist", arguments: { listId: newId } }); + check(del.content[0].text.includes("true"), "delete_checklist ok"); + } + + // error path: bad uuid + const bad = await client.callTool({ name: "get_note", arguments: { noteId: "not-real-uuid-9999" } }); + check(bad.isError === true, "get_note bad uuid => isError"); + + console.log(ok ? "\nHTTP INTEGRATION TEST PASSED" : "\nHTTP INTEGRATION TEST FAILED"); + process.exit(ok ? 0 : 1); +} catch (err) { + console.error("test crashed:", err); + process.exit(1); +} finally { + await transport.close(); +} \ No newline at end of file diff --git a/mcp-server/scripts/integration.mjs b/mcp-server/scripts/integration.mjs new file mode 100644 index 000000000..9408b3dcf --- /dev/null +++ b/mcp-server/scripts/integration.mjs @@ -0,0 +1,162 @@ +/* + * Real integration test: starts the built jotty-mcp stdio server pointed at a + * live Jotty instance and drives authenticated MCP tool calls through it, + * verifying the full MCP -> REST proxy path returns real data. + * + * Env: JOTTY_URL, JOTTY_API_KEY (the test key injected into users.json). + */ +import { spawn } from "node:child_process"; + +const child = spawn("node", ["dist/index.js"], { + cwd: process.cwd(), + env: process.env, + stdio: ["pipe", "pipe", "pipe"], +}); + +let buf = ""; +const responses = new Map(); +child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line) { + try { + const obj = JSON.parse(line); + if (obj.id != null) responses.set(obj.id, obj); + } catch { + /* ignore */ + } + } + } +}); +const stderr = []; +child.stderr.on("data", (c) => stderr.push(c.toString())); + +let nextId = 1; +const pending = new Map(); +function send(method, params) { + const id = nextId++; + return new Promise((resolve) => { + pending.set(id, resolve); + child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"); + }); +} +// Resolve pending as responses arrive. +const checkInterval = setInterval(() => { + for (const [id, resolve] of pending) { + if (responses.has(id)) { + pending.delete(id); + resolve(responses.get(id)); + } + } +}, 50); + +function callTool(name, args) { + return send("tools/call", { name, arguments: args }); +} + +function parseText(res) { + const t = res?.result?.content?.[0]?.text; + return t ? JSON.parse(t) : null; +} + +async function main() { + let ok = true; + const check = (cond, msg) => { + if (!cond) { + ok = false; + console.error(" FAIL:", msg); + } else { + console.log(" ok:", msg); + } + }; + + // initialize + const init = await send("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "integration-test", version: "0.0.1" }, + }); + child.stdin.write( + JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) + "\n", + ); + check(init?.result?.serverInfo?.name === "jotty", "initialize ok"); + + // health (public) + const health = await callTool("health", {}); + const healthData = parseText(health); + check(healthData?.status === "healthy", "health returns healthy status"); + check(!!healthData?.version, `health version=${healthData?.version}`); + + // list_checklists (authenticated) + const lists = await callTool("list_checklists", {}); + const listsData = parseText(lists); + check(Array.isArray(listsData?.checklists), `list_checklists returns array (len=${listsData?.checklists?.length})`); + const firstList = listsData?.checklists?.[0]; + if (firstList) { + check(!!firstList.id, `first checklist has id: ${firstList.id}`); + check(typeof firstList.title === "string", `first checklist has title: ${firstList.title}`); + } + + // get_summary (authenticated) + const summary = await callTool("get_summary", {}); + const summaryData = parseText(summary); + check(!!summaryData?.summary, "get_summary returns summary object"); + check(typeof summaryData.summary.notes?.total === "number", `summary notes.total=${summaryData.summary.notes?.total}`); + check(typeof summaryData.summary.checklists?.total === "number", `summary checklists.total=${summaryData.summary.checklists?.total}`); + + // list_categories (authenticated) + const cats = await callTool("list_categories", {}); + const catsData = parseText(cats); + check(!!catsData?.categories, "list_categories returns categories"); + check(Array.isArray(catsData.categories?.notes), "categories.notes is array"); + check(Array.isArray(catsData.categories?.checklists), "categories.checklists is array"); + + // list_notes (authenticated) + const notes = await callTool("list_notes", {}); + const notesData = parseText(notes); + check(Array.isArray(notesData?.notes), `list_notes returns array (len=${notesData?.notes?.length})`); + + // create + delete a checklist round-trip + const created = await callTool("create_checklist", { + title: "MCP Integration Test Checklist", + category: "MCP-Test", + type: "simple", + }); + const createdData = parseText(created); + const newId = createdData?.data?.id; + check(!!newId, `create_checklist returned id: ${newId}`); + if (newId) { + const itemRes = await callTool("create_checklist_item", { + listId: newId, + text: "Item added via MCP", + }); + check(itemRes?.result?.content?.[0]?.text.includes("true"), "create_checklist_item ok"); + const del = await callTool("delete_checklist", { listId: newId }); + check(del?.result?.content?.[0]?.text.includes("true"), "delete_checklist ok"); + } + + // search (authenticated) + const search = await callTool("search", { q: "the", type: "note" }); + const searchData = parseText(search); + check(Array.isArray(searchData?.results), `search returns results array (len=${searchData?.results?.length})`); + + // error path: bad UUID should be isError + const bad = await callTool("get_note", { noteId: "not-a-real-uuid-1234" }); + check(bad?.result?.isError === true, "get_note with bad uuid returns isError"); + + clearInterval(checkInterval); + child.stdin.end(); + child.kill(); + + console.log("stderr:", stderr.join("").trim() || "(empty)"); + console.log(ok ? "\nINTEGRATION TEST PASSED" : "\nINTEGRATION TEST FAILED"); + process.exit(ok ? 0 : 1); +} + +main().catch((e) => { + console.error("test crashed:", e); + process.exit(1); +}); \ No newline at end of file diff --git a/mcp-server/scripts/live-lifecycle.mjs b/mcp-server/scripts/live-lifecycle.mjs new file mode 100644 index 000000000..8ce3ecad9 --- /dev/null +++ b/mcp-server/scripts/live-lifecycle.mjs @@ -0,0 +1,243 @@ +/* + * Live MCP lifecycle test against a real Jotty instance. + * + * Drives the actual jotty-mcp tools (MCP Client -> stdio server -> Jotty REST API): + * - health, summary, categories, search (reads) + * - note: create -> update -> get -> delete + * - checklist: create -> add item -> edit item -> check -> uncheck -> delete + * - kanban board: create -> add item -> move status -> assign -> reminder -> delete + * + * Everything created here is deleted at the end so the test account is left clean. + * + * Env: JOTTY_URL, JOTTY_API_KEY + */ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const serverPath = join(here, "..", "dist", "index.js"); + +const jottyUrl = process.env.JOTTY_URL; +const apiKey = process.env.JOTTY_API_KEY; +if (!jottyUrl || !apiKey) { + console.error("JOTTY_URL and JOTTY_API_KEY are required"); + process.exit(2); +} + +const created = { notes: [], checklists: [], boards: [] }; +let ok = true; +const check = (cond, msg) => { + if (!cond) { + ok = false; + console.error(" FAIL:", msg); + } else { + console.log(" ok:", msg); + } +}; + +const transport = new StdioClientTransport({ + command: "node", + args: [serverPath], + env: { + ...process.env, + JOTTY_URL: jottyUrl, + JOTTY_API_KEY: apiKey, + }, +}); +const client = new Client({ name: "live-lifecycle-test", version: "1.0.0" }); + +const tool = async (name, args) => { + const res = await client.callTool({ name, arguments: args ?? {} }); + const text = res.content?.[0]?.text; + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + /* non-json */ + } + return { isError: res.isError === true, data, raw: text }; +}; + +async function main() { + await client.connect(transport); + check(true, "connected to jotty-mcp over stdio"); + + console.log("\n== reads =="); + const health = await tool("health"); + check(health.data?.status === "healthy", `health => ${health.data?.status} v${health.data?.version}`); + + const summary = await tool("get_summary"); + const before = { + notes: summary.data?.summary?.notes?.total, + lists: summary.data?.summary?.checklists?.total, + }; + check(typeof before.notes === "number", `summary before => notes=${before.notes} checklists=${before.lists}`); + + const cats = await tool("list_categories"); + check(!!cats.data?.categories, "list_categories ok"); + + const me = await tool("get_current_user"); + check(!!me.data?.user?.username, `get_current_user => "${me.data?.user?.username}"`); + check(me.data?.user?.apiKey === undefined, "get_current_user does not leak apiKey"); + + console.log("\n== note lifecycle =="); + const createdNote = await tool("create_note", { + title: "MCP Test Note", + content: "# Created via MCP\n\nThis note was created by the jotty-mcp lifecycle test.", + category: "MCP-Test", + }); + const noteId = createdNote.data?.data?.id; + check(!!noteId, `create_note => id ${noteId}`); + created.notes.push(noteId); + + const updatedNote = await tool("update_note", { + noteId, + title: "MCP Test Note (edited)", + content: "# Edited via MCP\n\nContent was updated. Adds a [searchable-token](link).", + }); + check(updatedNote.data?.data?.title?.includes("edited"), `update_note => "${updatedNote.data?.data?.title}"`); + + const gotNote = await tool("get_note", { noteId }); + check( + gotNote.data?.data?.content?.includes("Edited via MCP"), + "get_note returns updated content", + ); + + const noteSearch = await tool("search", { q: "searchable-token", type: "note" }); + check( + (noteSearch.data?.results ?? []).some((r) => r.uuid === noteId || r.id === noteId), + "search finds the note by content", + ); + + console.log("\n== checklist lifecycle =="); + const createdList = await tool("create_checklist", { + title: "MCP Test Checklist", + category: "MCP-Test", + type: "simple", + }); + const listId = createdList.data?.data?.id; + check(!!listId, `create_checklist => id ${listId}`); + created.checklists.push(listId); + + const item1 = await tool("create_checklist_item", { + listId, + text: "First item — created via MCP", + }); + check(item1.data?.success === true, "create_checklist_item #1"); + + const item2 = await tool("create_checklist_item", { + listId, + text: "Second item", + }); + check(item2.data?.success === true, "create_checklist_item #2"); + + const editItem = await tool("update_checklist_item", { + listId, + itemIndex: "0", + text: "First item — EDITED via MCP", + priority: "high", + }); + check(editItem.data?.success === true, "update_checklist_item (text + priority)"); + + const checkIt = await tool("check_item", { listId, itemIndex: "1" }); + check(checkIt.data?.success === true, "check_item #2"); + + const listsNow = await tool("list_checklists", { category: "MCP-Test" }); + const ourList = (listsNow.data?.checklists ?? []).find((l) => l.id === listId); + check(!!ourList, "list_checklists (filtered) returns the new list"); + check(ourList?.items?.length === 2, `list has 2 items (got ${ourList?.items?.length})`); + check(ourList?.items?.[1]?.completed === true, "item #2 is completed after check_item"); + + const uncheckIt = await tool("uncheck_item", { listId, itemIndex: "1" }); + check(uncheckIt.data?.success === true, "uncheck_item #2"); + + console.log("\n== kanban board lifecycle =="); + const createdBoard = await tool("create_board", { + title: "MCP Test Board", + category: "MCP-Test", + statuses: [ + { id: "todo", label: "To Do", order: 0 }, + { id: "doing", label: "Doing", order: 1, color: "#3b82f6" }, + { id: "done", label: "Done", order: 2 }, + ], + }); + const boardId = createdBoard.data?.data?.id; + check(!!boardId, `create_board => id ${boardId}`); + created.boards.push(boardId); + + const boardItem = await tool("create_board_item", { + boardId, + text: "Task A", + status: "todo", + description: "Created via MCP", + }); + const itemId = boardItem.data?.data?.id; + check(!!itemId, `create_board_item => id ${itemId}`); + + const moved = await tool("move_board_item", { boardId, itemId, status: "doing" }); + check(moved.data?.success === true, "move_board_item todo -> doing"); + + const assigned = await tool("assign_board_item", { boardId, itemId, assignee: "" }); + check(assigned.data?.success === true, "assign_board_item (clear)"); + + const reminder = await tool("set_board_item_reminder", { + boardId, + itemId, + datetime: "2099-01-01T09:00:00.000Z", + }); + check(reminder.data?.success === true, "set_board_item_reminder"); + + const gotBoard = await tool("get_board", { boardId }); + const bItem = (gotBoard.data?.board?.items ?? []).find((i) => i.id === itemId); + check(bItem?.status === "doing", `get_board shows item moved to doing`); + check(!!bItem?.reminder?.datetime, "get_board shows reminder set"); + + const clearReminder = await tool("clear_board_item_reminder", { boardId, itemId }); + check(clearReminder.data?.success === true, "clear_board_item_reminder"); + + console.log("\n== summary after =="); + const summary2 = await tool("get_summary"); + const after = { + notes: summary2.data?.summary?.notes?.total, + lists: summary2.data?.summary?.checklists?.total, + }; + check(after.notes === before.notes + 1, `notes total went ${before.notes} -> ${after.notes}`); + // The simple checklist AND the kanban board both count as checklists. + check(after.lists === before.lists + 2, `checklists total went ${before.lists} -> ${after.lists} (+1 list, +1 board)`); + + console.log("\n== cleanup =="); + for (const id of created.boards) { + const r = await tool("delete_board", { boardId: id }); + check(r.data?.success === true, `delete_board ${id}`); + } + for (const id of created.checklists) { + const r = await tool("delete_checklist", { listId: id }); + check(r.data?.success === true, `delete_checklist ${id}`); + } + for (const id of created.notes) { + const r = await tool("delete_note", { noteId: id }); + check(r.data?.success === true, `delete_note ${id}`); + } + + const summary3 = await tool("get_summary"); + const finalCounts = { + notes: summary3.data?.summary?.notes?.total, + lists: summary3.data?.summary?.checklists?.total, + }; + check(finalCounts.notes === before.notes, `notes restored to ${before.notes}`); + check(finalCounts.lists === before.lists, `checklists restored to ${before.lists}`); + + console.log(ok ? "\nLIVE LIFECYCLE TEST PASSED" : "\nLIVE LIFECYCLE TEST FAILED"); + process.exit(ok ? 0 : 1); +} + +main().catch(async (e) => { + console.error("test crashed:", e); + // best-effort cleanup even on crash + for (const id of created.boards) await tool("delete_board", { boardId: id }).catch(() => {}); + for (const id of created.checklists) await tool("delete_checklist", { listId: id }).catch(() => {}); + for (const id of created.notes) await tool("delete_note", { noteId: id }).catch(() => {}); + process.exit(1); +}); \ No newline at end of file diff --git a/mcp-server/scripts/smoke.mjs b/mcp-server/scripts/smoke.mjs new file mode 100644 index 000000000..5f7589cdf --- /dev/null +++ b/mcp-server/scripts/smoke.mjs @@ -0,0 +1,120 @@ +/* + * Smoke test for the jotty-mcp stdio server. Spawns the built server with dummy + * credentials and drives the MCP JSON-RPC protocol over stdin, checking that + * initialize, tools/list and a tools/call (health, against an unreachable URL) + * behave correctly. No real Jotty instance is required. + */ +import { spawn } from "node:child_process"; + +const env = { + ...process.env, + JOTTY_URL: "http://127.0.0.1:9", // port 9 = discard; guarantees a connection failure + JOTTY_API_KEY: "ck_dummy_key_for_smoke_test", +}; + +const child = spawn("node", ["dist/index.js"], { + cwd: process.cwd(), + env, + stdio: ["pipe", "pipe", "pipe"], +}); + +let buf = ""; +const responses = []; +child.stdout.on("data", (chunk) => { + buf += chunk.toString(); + let idx; + while ((idx = buf.indexOf("\n")) >= 0) { + const line = buf.slice(0, idx).trim(); + buf = buf.slice(idx + 1); + if (line) { + try { + responses.push(JSON.parse(line)); + } catch { + responses.push({ _raw: line }); + } + } + } +}); + +const stderr = []; +child.stderr.on("data", (c) => stderr.push(c.toString())); + +const send = (obj) => child.stdin.write(JSON.stringify(obj) + "\n"); + +// 1. initialize +send({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "smoke-test", version: "0.0.1" }, + }, +}); + +// 2. initialized notification (no response expected) +send({ jsonrpc: "2.0", method: "notifications/initialized" }); + +// 3. tools/list +send({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }); + +setTimeout(() => { + // 4. tools/call health — should fail gracefully (network error) with isError + send({ + jsonrpc: "2.0", + id: 3, + method: "tools/call", + params: { name: "health", arguments: {} }, + }); +}, 200); + +setTimeout(() => { + child.stdin.end(); +}, 800); + +child.on("close", (code) => { + const init = responses.find((r) => r.id === 1); + const list = responses.find((r) => r.id === 2); + const call = responses.find((r) => r.id === 3); + + let ok = true; + const check = (cond, msg) => { + if (!cond) { + ok = false; + console.error(" FAIL:", msg); + } else { + console.log(" ok:", msg); + } + }; + + console.log("initialize:"); + check(!!init, "initialize returned a result"); + check(init?.result?.serverInfo?.name === "jotty", "server name is jotty"); + check(!!init?.result?.capabilities?.tools, "advertises tools capability"); + + console.log("tools/list:"); + check(!!list, "tools/list returned a result"); + const names = (list?.result?.tools ?? []).map((t) => t.name); + check(names.length >= 40, `tools/list returned ${names.length} tools (>=40)`); + check(names.includes("list_checklists"), "includes list_checklists"); + check(names.includes("create_note"), "includes create_note"); + check(names.includes("move_board_item"), "includes move_board_item"); + check( + !!list?.result?.tools?.find((t) => t.name === "list_checklists")?.inputSchema, + "list_checklists has an inputSchema", + ); + + console.log("tools/call health (unreachable URL):"); + check(!!call, "health call returned a result"); + check(call?.result?.isError === true, "health call reports isError (network failed)"); + check( + typeof call?.result?.content?.[0]?.text === "string", + "health call returns a text content block", + ); + + console.log("stderr:", stderr.join("").trim() || "(empty)"); + console.log("exit code:", code); + console.log(ok ? "\nSMOKE TEST PASSED" : "\nSMOKE TEST FAILED"); + process.exit(ok ? 0 : 1); +}); \ No newline at end of file diff --git a/mcp-server/src/client.ts b/mcp-server/src/client.ts new file mode 100644 index 000000000..14e06c18e --- /dev/null +++ b/mcp-server/src/client.ts @@ -0,0 +1,126 @@ +/** + * Minimal REST client for a remote jotty·page instance. + * + * Every authenticated Jotty endpoint is reached the same way: send a JSON + * request with the `x-api-key` header and parse the JSON response. This class + * centralises that so the tool modules only describe *what* to call. + */ + +export interface JottyClientOptions { + /** Base URL of the remote Jotty instance, e.g. https://jotty.example.com */ + baseUrl: string; + /** API key (starts with `ck_`) generated from Jotty -> Profile -> Settings. */ + apiKey: string; + /** Optional request timeout in milliseconds (default 30000). */ + timeoutMs?: number; +} + +export class JottyApiError extends Error { + constructor( + message: string, + readonly status: number, + readonly body: unknown, + ) { + super(message); + this.name = "JottyApiError"; + } +} + +type QueryValue = string | number | boolean | undefined | null; + +function buildQuery(params: Record): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === null || value === "") continue; + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + return parts.length ? `?${parts.join("&")}` : ""; +} + +export class JottyClient { + private readonly baseUrl: string; + private readonly apiKey: string; + private readonly timeoutMs: number; + + constructor(options: JottyClientOptions) { + this.baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.apiKey = options.apiKey; + this.timeoutMs = options.timeoutMs ?? 30000; + } + + private async request( + method: string, + path: string, + query: Record = {}, + body?: unknown, + ): Promise { + const url = `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}${buildQuery(query)}`; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + + try { + const headers: Record = { + "x-api-key": this.apiKey, + Accept: "application/json", + }; + const init: RequestInit = { method, headers, signal: controller.signal }; + if (body !== undefined && method !== "GET" && method !== "DELETE") { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + + const res = await fetch(url, init); + const text = await res.text(); + const parsed = text ? safeParse(text) : undefined; + + if (!res.ok) { + const message = + (parsed && typeof parsed === "object" && "error" in parsed + ? String((parsed as { error: unknown }).error) + : `HTTP ${res.status}`) || `HTTP ${res.status}`; + throw new JottyApiError(message, res.status, parsed); + } + return parsed as T; + } catch (err) { + if (err instanceof JottyApiError) throw err; + if (err instanceof Error && err.name === "AbortError") { + throw new JottyApiError( + `Request timed out after ${this.timeoutMs}ms`, + 408, + undefined, + ); + } + throw new JottyApiError( + err instanceof Error ? err.message : "Network request failed", + 0, + undefined, + ); + } finally { + clearTimeout(timer); + } + } + + get(path: string, query: Record = {}): Promise { + return this.request("GET", path, query); + } + post(path: string, body?: unknown, query: Record = {}): Promise { + return this.request("POST", path, query, body); + } + put(path: string, body?: unknown, query: Record = {}): Promise { + return this.request("PUT", path, query, body); + } + patch(path: string, body?: unknown, query: Record = {}): Promise { + return this.request("PATCH", path, query, body); + } + delete(path: string, query: Record = {}): Promise { + return this.request("DELETE", path, query); + } +} + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return text; + } +} \ No newline at end of file diff --git a/mcp-server/src/http.ts b/mcp-server/src/http.ts new file mode 100644 index 000000000..c3e1c45bc --- /dev/null +++ b/mcp-server/src/http.ts @@ -0,0 +1,217 @@ +import { randomUUID } from "node:crypto"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; + +import { JottyClient } from "./client.js"; +import { registerAllTools } from "./tools/index.js"; + +export interface HttpServerOptions { + /** Base URL of the Jotty REST API the server will proxy to. */ + jottyUrl: string; + /** Bind host (default 0.0.0.0). */ + host?: string; + /** Bind port (default 3001). */ + port?: number; + /** Optional request timeout forwarded to each per-session JottyClient. */ + timeoutMs?: number; + /** + * Optional shared secret protecting the MCP endpoint. When set, clients must + * send `Authorization: Bearer `. When unset the endpoint is open — + * rely on network restrictions (localhost / Tailscale / reverse proxy). + */ + mcpToken?: string; + /** + * Optional fallback Jotty API key used when a client connects without an + * `x-api-key` header. Lets you run a single-tenant server where the key is + * configured server-side instead of per-client. + */ + fallbackApiKey?: string; +} + +/** Header the client sends to supply its Jotty API key (ck_…). */ +const JOTTY_API_KEY_HEADER = "x-api-key"; + +/** Read the raw request body as a string. Empty bodies (GET/DELETE) resolve to "". */ +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ""; + req.on("data", (chunk) => { + data += chunk; + }); + req.on("end", () => resolve(data)); + req.on("error", reject); + }); +} + +function getHeader(req: IncomingMessage, name: string): string | undefined { + const value = req.headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; +} + +/** + * Build a fresh per-session transport bound to a fresh McpServer + JottyClient + * carrying this client's API key. Returns the transport once `server.connect` + * has wired everything together (sessionId is populated after the initialize + * request is handled by the caller). + */ +async function createSession( + req: IncomingMessage, + options: Required> & + Pick, +): Promise { + const apiKey = getHeader(req, JOTTY_API_KEY_HEADER) || options.fallbackApiKey; + if (!apiKey) { + throw new Error( + `Missing Jotty API key. Send it as the "${JOTTY_API_KEY_HEADER}" header (value starts with ck_).`, + ); + } + + const client = new JottyClient({ + baseUrl: options.jottyUrl, + apiKey, + timeoutMs: options.timeoutMs, + }); + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + }); + const server = new McpServer({ name: "jotty", version: "1.0.0" }); + registerAllTools(server, client); + + await server.connect(transport); + return transport; +} + +/** + * Start the Streamable HTTP MCP server. Each connecting client is given its + * own `McpServer` + `JottyClient` so per-client API keys and timeouts stay + * isolated. Session state is kept in memory. + */ +export async function startHttpServer(options: HttpServerOptions): Promise { + const { + jottyUrl, + host = "0.0.0.0", + port = 3001, + timeoutMs = 30000, + mcpToken, + fallbackApiKey, + } = options; + + const transports = new Map(); + + const authenticate = (req: IncomingMessage): boolean => { + if (!mcpToken) return true; + const auth = getHeader(req, "authorization") || ""; + return auth === `Bearer ${mcpToken}`; + }; + + const httpServer = createServer( + async (req: IncomingMessage, res: ServerResponse) => { + const path = (req.url || "/").split("?")[0]; + if (path !== "/mcp") { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Not found. Use POST /mcp."); + return; + } + + if (!authenticate(req)) { + res.writeHead(401, { "content-type": "text/plain" }); + res.end("Unauthorized."); + return; + } + + try { + if (req.method === "POST") { + const body = await readBody(req); + let parsed: unknown; + try { + parsed = body ? JSON.parse(body) : undefined; + } catch { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Invalid JSON body."); + return; + } + + const sessionId = getHeader(req, "mcp-session-id"); + if (sessionId) { + // Existing session: hand the message to its transport. + const transport = transports.get(sessionId); + if (!transport) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Session not found."); + return; + } + await transport.handleRequest(req, res, parsed); + return; + } + + // No session id -> must be an initialize request starting a new session. + if (!isInitializeRequest(parsed)) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Missing mcp-session-id; initialize first."); + return; + } + + let transport: StreamableHTTPServerTransport; + try { + transport = await createSession(req, { jottyUrl, timeoutMs, fallbackApiKey }); + } catch (err) { + res.writeHead(401, { "content-type": "text/plain" }); + res.end(err instanceof Error ? err.message : String(err)); + return; + } + await transport.handleRequest(req, res, parsed); + if (transport.sessionId) { + transports.set(transport.sessionId, transport); + } + return; + } + + if (req.method === "GET" || req.method === "DELETE") { + const sessionId = getHeader(req, "mcp-session-id"); + if (!sessionId) { + res.writeHead(400, { "content-type": "text/plain" }); + res.end("Missing mcp-session-id."); + return; + } + const transport = transports.get(sessionId); + if (!transport) { + res.writeHead(404, { "content-type": "text/plain" }); + res.end("Session not found."); + return; + } + await transport.handleRequest(req, res); + return; + } + + res.writeHead(405, { allow: "GET, POST, DELETE", "content-type": "text/plain" }); + res.end("Method not allowed."); + } catch (err) { + console.error("[jotty-mcp] error handling request:", err); + if (!res.headersSent) { + res.writeHead(500, { "content-type": "text/plain" }); + res.end("Internal server error."); + } + } + }, + ); + + return new Promise((resolve, reject) => { + httpServer.on("error", reject); + httpServer.listen(port, host, () => { + console.error(`[jotty-mcp] Streamable HTTP server listening on http://${host}:${port}/mcp`); + console.error(`[jotty-mcp] proxying to Jotty at ${jottyUrl}`); + if (mcpToken) { + console.error("[jotty-mcp] endpoint protected by MCP_TOKEN (Bearer auth)"); + } else { + console.error("[jotty-mcp] WARNING: no MCP_TOKEN set — endpoint is open; restrict network access"); + } + if (fallbackApiKey) { + console.error("[jotty-mcp] using server-side fallback API key when client omits x-api-key"); + } + resolve(); + }); + }); +} \ No newline at end of file diff --git a/mcp-server/src/index.ts b/mcp-server/src/index.ts new file mode 100644 index 000000000..6022e5e22 --- /dev/null +++ b/mcp-server/src/index.ts @@ -0,0 +1,103 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +import { JottyClient } from "./client.js"; +import { registerAllTools } from "./tools/index.js"; +import { startHttpServer } from "./http.js"; + +/** + * Configuration is read from the environment. + * + * Required (both transports): + * JOTTY_URL — base URL of the Jotty REST API the server proxies to + * (stdio: your remote instance; http: usually http://localhost:3000) + * JOTTY_API_KEY — Jotty API key (ck_…). Used directly in stdio mode; in http + * mode it is only a fallback for clients that omit x-api-key. + * + * Transport selection: + * MCP_TRANSPORT — "stdio" (default) | "http" + * + * HTTP mode only: + * MCP_HOST — bind host (default 0.0.0.0) + * MCP_PORT — bind port (default 3001) + * MCP_TOKEN — optional shared secret; clients must send Authorization: Bearer + * + * Optional (both): + * JOTTY_TIMEOUT_MS — per-request timeout in ms (default 30000) + */ +function envOrThrow(name: string, message: string): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error(message); + } + return value; +} + +function timeoutFromEnv(): number { + const n = Number.parseInt(process.env.JOTTY_TIMEOUT_MS ?? "", 10); + return Number.isFinite(n) && n > 0 ? n : 30000; +} + +/** stdio: a single client (the launching AI app) with a single server/client pair. */ +async function runStdio(): Promise { + const baseUrl = envOrThrow( + "JOTTY_URL", + "JOTTY_URL is required (the base URL of your Jotty instance, e.g. https://jotty.example.com).", + ); + const apiKey = envOrThrow( + "JOTTY_API_KEY", + "JOTTY_API_KEY is required (generate one in Jotty -> Profile -> Settings -> API Key).", + ); + + const client = new JottyClient({ baseUrl, apiKey, timeoutMs: timeoutFromEnv() }); + const server = new McpServer({ name: "jotty", version: "1.0.0" }); + registerAllTools(server, client); + + const transport = new StdioServerTransport(); + await server.connect(transport); +} + +/** http: a long-lived Streamable HTTP server with one McpServer per client session. */ +async function runHttp(): Promise { + const jottyUrl = envOrThrow( + "JOTTY_URL", + "JOTTY_URL is required in http mode (where Jotty's REST API is, e.g. http://localhost:3000).", + ); + const port = (() => { + const n = Number.parseInt(process.env.MCP_PORT ?? "", 10); + return Number.isFinite(n) && n > 0 ? n : 3001; + })(); + const host = process.env.MCP_HOST?.trim() || "0.0.0.0"; + + const fallbackApiKey = process.env.JOTTY_API_KEY?.trim() || undefined; + const mcpToken = process.env.MCP_TOKEN?.trim() || undefined; + + await startHttpServer({ + jottyUrl, + host, + port, + timeoutMs: timeoutFromEnv(), + mcpToken, + fallbackApiKey, + }); +} + +async function main(): Promise { + const transport = (process.env.MCP_TRANSPORT?.trim() || "stdio").toLowerCase(); + + if (transport === "http") { + await runHttp(); + // The HTTP server keeps the process alive listening for requests. + } else if (transport === "stdio") { + await runStdio(); + // The stdio transport keeps the process alive reading JSON-RPC from stdin. + } else { + throw new Error(`Unknown MCP_TRANSPORT "${transport}" — use "stdio" or "http".`); + } +} + +main().catch((err) => { + // Configuration/startup errors go to stderr; stdout is reserved for MCP protocol. + console.error(err instanceof Error ? err.message : String(err)); + process.exit(1); +}); \ No newline at end of file diff --git a/mcp-server/src/tools/admin.ts b/mcp-server/src/tools/admin.ts new file mode 100644 index 000000000..4d864a446 --- /dev/null +++ b/mcp-server/src/tools/admin.ts @@ -0,0 +1,144 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Admin-leaning endpoints: data exports and audit logs. Many of these require + * admin permissions on the remote instance; non-admin callers will receive a + * 403 from Jotty, surfaced as an MCP error result. + */ +export const adminModule: ToolModule = { + register(server, client) { + // POST /api/exports + server.registerTool( + "request_export", + { + title: "Request data export", + description: + "Initiate a data export. type is one of all_checklists_notes, user_checklists_notes (needs username), all_users_data, whole_data_folder. Returns a downloadUrl.", + inputSchema: { + type: z + .enum([ + "all_checklists_notes", + "user_checklists_notes", + "all_users_data", + "whole_data_folder", + ]) + .describe("Export type."), + username: z + .string() + .optional() + .describe("Required only for user_checklists_notes."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/exports", { + type: args.type, + username: args.username, + }), + ), + ), + ); + + // GET /api/exports + server.registerTool( + "get_export_progress", + { + title: "Get export progress", + description: "Get the current progress of an ongoing export operation.", + }, + async () => run(() => json(client.get("/api/exports"))), + ); + + // GET /api/logs + server.registerTool( + "list_logs", + { + title: "List audit logs", + description: + "Retrieve audit logs with filtering and pagination. Regular users only see their own logs; admins can filter by username.", + inputSchema: { + username: z.string().optional().describe("Filter by username (admin only)."), + action: z.string().optional().describe("Filter by action type, e.g. login."), + category: z + .enum([ + "auth", + "user", + "checklist", + "note", + "sharing", + "settings", + "encryption", + "api", + "system", + "file", + "upload", + ]) + .optional(), + level: z + .enum(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]) + .optional(), + startDate: z.string().optional().describe("ISO 8601 start date."), + endDate: z.string().optional().describe("ISO 8601 end date."), + success: z.boolean().optional(), + limit: z.number().int().positive().max(1000).optional().describe("Default 50."), + offset: z.number().int().min(0).optional().describe("Default 0."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/logs", { + username: args.username, + action: args.action, + category: args.category, + level: args.level, + startDate: args.startDate, + endDate: args.endDate, + success: args.success, + limit: args.limit, + offset: args.offset, + }), + ), + ), + ); + + // POST /api/logs/export + server.registerTool( + "export_logs", + { + title: "Export audit logs", + description: + "Export audit logs in JSON or CSV format using the same filter options as list_logs.", + inputSchema: { + format: z.enum(["json", "csv"]).describe("Export format."), + filters: z + .record(z.unknown()) + .optional() + .describe("Filter object with the same keys as list_logs."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/logs/export", { + format: args.format, + filters: args.filters ?? {}, + }), + ), + ), + ); + + // GET /api/logs/stats + server.registerTool( + "get_logs_stats", + { + title: "Get audit log statistics", + description: "Aggregated audit log statistics (admin only).", + }, + async () => run(() => json(client.get("/api/logs/stats"))), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/checklists.ts b/mcp-server/src/tools/checklists.ts new file mode 100644 index 000000000..2e0194e52 --- /dev/null +++ b/mcp-server/src/tools/checklists.ts @@ -0,0 +1,277 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Checklists — both "simple" and "task"/"kanban" type lists live under + * /api/checklists. Items are addressed by dot-notation index paths (e.g. "0.1") + * so nested sub-items can be targeted. See howto/API.md for the full reference. + */ +export const checklistsModule: ToolModule = { + register(server, client) { + // GET /api/checklists + server.registerTool( + "list_checklists", + { + title: "List checklists", + description: + "List all checklists for the authenticated user. Optional filters: category, type (simple|task|kanban), and a search query q (matches title or item text).", + inputSchema: { + category: z.string().optional().describe("Filter by category name."), + type: z + .enum(["simple", "task", "kanban"]) + .optional() + .describe("Filter by checklist type."), + q: z.string().optional().describe("Search title or item text."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/checklists", { + category: args.category, + type: args.type, + q: args.q, + }), + ), + ), + ); + + // POST /api/checklists + server.registerTool( + "create_checklist", + { + title: "Create checklist", + description: + "Create a new checklist. type defaults to 'simple'; use 'task' or 'kanban' for advanced lists. category defaults to 'Uncategorized'.", + inputSchema: { + title: z.string().describe("Title of the new checklist."), + category: z.string().optional().describe("Category (defaults to Uncategorized)."), + type: z + .enum(["simple", "task", "kanban"]) + .optional() + .describe("Checklist type (defaults to simple)."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/checklists", { + title: args.title, + category: args.category ?? "Uncategorized", + type: args.type ?? "simple", + }), + ), + ), + ); + + // PUT /api/checklists/{listId} + server.registerTool( + "update_checklist", + { + title: "Update checklist", + description: "Update a checklist's title and/or category.", + inputSchema: { + listId: z.string().describe("UUID of the checklist to update."), + title: z.string().optional().describe("New title."), + category: z.string().optional().describe("New category."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/checklists/${args.listId}`, { + title: args.title, + category: args.category, + }), + ), + ), + ); + + // DELETE /api/checklists/{listId} + server.registerTool( + "delete_checklist", + { + title: "Delete checklist", + description: "Delete a checklist by UUID.", + inputSchema: { + listId: z.string().describe("UUID of the checklist to delete."), + }, + }, + async (args) => + run(() => json(client.delete(`/api/checklists/${args.listId}`))), + ); + + // POST /api/checklists/{listId}/items + server.registerTool( + "create_checklist_item", + { + title: "Create checklist item", + description: + "Add an item to a checklist. For task/kanban lists you may pass a status (e.g. todo|in_progress|completed) and time. Use parentIndex (dot path like '0' or '0.1') to create a nested sub-item.", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + text: z.string().describe("Item text."), + status: z.string().optional().describe("Initial status (task/kanban lists)."), + time: z + .union([z.literal(0), z.array(z.record(z.unknown()))]) + .optional() + .describe("Time tracking value: 0 or an array of time entries."), + parentIndex: z + .string() + .optional() + .describe("Dot-notation index path of the parent item to nest under."), + }, + }, + async (args) => + run(() => + json( + client.post(`/api/checklists/${args.listId}/items`, { + text: args.text, + status: args.status, + time: args.time, + parentIndex: args.parentIndex, + }), + ), + ), + ); + + // PATCH /api/checklists/{listId}/items/{itemIndex} + server.registerTool( + "update_checklist_item", + { + title: "Update checklist item", + description: + "Update one or more fields of a checklist item. itemIndex is a dot path (e.g. '0.1') for nested items. Omitted fields are left unchanged.", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item (e.g. '0' or '0.1')."), + text: z.string().optional(), + description: z.string().optional(), + priority: z + .enum(["critical", "high", "medium", "low", "none"]) + .optional(), + score: z.number().optional(), + startDate: z.string().optional().describe("ISO date string."), + targetDate: z.string().optional().describe("ISO date string."), + estimatedTime: z.number().optional().describe("Estimated hours."), + }, + }, + async (args) => + run(() => + json( + client.patch( + `/api/checklists/${args.listId}/items/${args.itemIndex}`, + { + text: args.text, + description: args.description, + priority: args.priority, + score: args.score, + startDate: args.startDate, + targetDate: args.targetDate, + estimatedTime: args.estimatedTime, + }, + ), + ), + ), + ); + + // DELETE /api/checklists/{listId}/items/{itemIndex} + server.registerTool( + "delete_checklist_item", + { + title: "Delete checklist item", + description: + "Delete a checklist item by dot-notation index path (e.g. '0.1').", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item to delete."), + }, + }, + async (args) => + run(() => + json(client.delete(`/api/checklists/${args.listId}/items/${args.itemIndex}`)), + ), + ); + + // PUT /api/checklists/{listId}/items/{itemIndex}/check + server.registerTool( + "check_item", + { + title: "Check item", + description: "Mark a checklist item as completed.", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/checklists/${args.listId}/items/${args.itemIndex}/check`), + ), + ), + ); + + // PUT /api/checklists/{listId}/items/{itemIndex}/uncheck + server.registerTool( + "uncheck_item", + { + title: "Uncheck item", + description: "Mark a checklist item as incomplete.", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/checklists/${args.listId}/items/${args.itemIndex}/uncheck`), + ), + ), + ); + + // PUT /api/checklists/{listId}/items/reorder + server.registerTool( + "reorder_checklist_items", + { + title: "Reorder checklist items", + description: + "Reorder items by dragging activeItemId relative to overItemId. position is 'before' or 'after'. Set isDropInto=true to drop activeItem as a child of overItem.", + inputSchema: { + listId: z.string().describe("UUID of the checklist."), + activeItemId: z.string().describe("Item id being moved."), + overItemId: z.string().describe("Item id it is moved relative to."), + position: z + .enum(["before", "after"]) + .optional() + .describe("before|after (defaults to before)."), + isDropInto: z + .boolean() + .optional() + .describe("Drop as a child of overItemId instead of reordering siblings."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/checklists/${args.listId}/items/reorder`, { + activeItemId: args.activeItemId, + overItemId: args.overItemId, + position: args.position ?? "before", + isDropInto: args.isDropInto ?? false, + }), + ), + ), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/discovery.ts b/mcp-server/src/tools/discovery.ts new file mode 100644 index 000000000..bc3149720 --- /dev/null +++ b/mcp-server/src/tools/discovery.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Organisation & discovery endpoints: categories, summary statistics, and + * cross-content full-text search. + */ +export const discoveryModule: ToolModule = { + register(server, client) { + // GET /api/categories + server.registerTool( + "list_categories", + { + title: "List categories", + description: + "List all categories for notes and checklists for the authenticated user (archived categories excluded).", + }, + async () => run(() => json(client.get("/api/categories"))), + ); + + // GET /api/summary + server.registerTool( + "get_summary", + { + title: "Get summary statistics", + description: + "Get statistics about notes, checklists, items and tasks with category breakdowns. Admins may query another user with username.", + inputSchema: { + username: z + .string() + .optional() + .describe("Username to query (admin only; defaults to the authenticated user)."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/summary", { + username: args.username, + }), + ), + ), + ); + + // GET /api/search + server.registerTool( + "search", + { + title: "Search notes and checklists", + description: + "Full-text search across notes and checklists. type filters to 'note' or 'checklist'. q must be at least 2 characters.", + inputSchema: { + q: z.string().min(2).describe("Search query (min 2 characters)."), + type: z + .enum(["note", "checklist"]) + .optional() + .describe("Restrict results to notes or checklists."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/search", { + q: args.q, + type: args.type, + }), + ), + ), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts new file mode 100644 index 000000000..4d4177e23 --- /dev/null +++ b/mcp-server/src/tools/index.ts @@ -0,0 +1,35 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { JottyClient } from "../client.js"; +import { checklistsModule } from "./checklists.js"; +import { notesModule } from "./notes.js"; +import { tasksModule } from "./tasks.js"; +import { kanbanModule } from "./kanban.js"; +import { discoveryModule } from "./discovery.js"; +import { adminModule } from "./admin.js"; +import { miscModule } from "./misc.js"; + +const modules = [ + checklistsModule, + notesModule, + tasksModule, + kanbanModule, + discoveryModule, + adminModule, + miscModule, +]; + +/** Total number of tools registered (handy for startup logging). */ +export const toolCount = 54; + +/** + * Register every tool group against the given server/client pair. + * + * In stdio mode this is called once. In HTTP mode it is called once per + * client session, so each session gets its own `JottyClient` carrying that + * client's API key. + */ +export function registerAllTools(server: McpServer, client: JottyClient): void { + for (const mod of modules) { + mod.register(server, client); + } +} \ No newline at end of file diff --git a/mcp-server/src/tools/kanban.ts b/mcp-server/src/tools/kanban.ts new file mode 100644 index 000000000..077766063 --- /dev/null +++ b/mcp-server/src/tools/kanban.ts @@ -0,0 +1,337 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Kanban boards — the dedicated /api/kanban surface for kanban-type lists. + * Unlike /api/tasks (index-based), kanban items are addressed by their item id, + * and boards support assignees, reminders and an ICS calendar export. + */ +export const kanbanModule: ToolModule = { + register(server, client) { + // GET /api/kanban + server.registerTool( + "list_boards", + { + title: "List kanban boards", + description: + "List all kanban boards for the authenticated user. Optional filters: category, status (boards containing an item with this status), and search q.", + inputSchema: { + category: z.string().optional(), + status: z.string().optional(), + q: z.string().optional().describe("Search title or item text."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/kanban", { + category: args.category, + status: args.status, + q: args.q, + }), + ), + ), + ); + + // POST /api/kanban + server.registerTool( + "create_board", + { + title: "Create kanban board", + description: + "Create a new kanban board. statuses defines the columns; defaults to todo/in_progress/completed.", + inputSchema: { + title: z.string().describe("Board title."), + category: z.string().optional().describe("Category (defaults to Uncategorized)."), + statuses: z + .array( + z.object({ + id: z.string(), + label: z.string(), + order: z.number().optional(), + color: z.string().optional(), + }), + ) + .optional() + .describe("Custom Kanban columns."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/kanban", { + title: args.title, + category: args.category ?? "Uncategorized", + statuses: args.statuses, + }), + ), + ), + ); + + // GET /api/kanban/{boardId} + server.registerTool( + "get_board", + { + title: "Get kanban board", + description: "Retrieve a single kanban board by UUID.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + }, + }, + async (args) => + run(() => json(client.get(`/api/kanban/${args.boardId}`))), + ); + + // PUT /api/kanban/{boardId} + server.registerTool( + "update_board", + { + title: "Update kanban board", + description: "Update a board's title and/or category.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + title: z.string().optional(), + category: z.string().optional(), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}`, { + title: args.title, + category: args.category, + }), + ), + ), + ); + + // DELETE /api/kanban/{boardId} + server.registerTool( + "delete_board", + { + title: "Delete kanban board", + description: "Delete a kanban board by UUID.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + }, + }, + async (args) => + run(() => json(client.delete(`/api/kanban/${args.boardId}`))), + ); + + // PUT /api/kanban/{boardId}/statuses + server.registerTool( + "set_board_statuses", + { + title: "Set board statuses", + description: + "Replace the full set of Kanban column statuses for a board. Provide the complete statuses array.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + statuses: z + .array( + z.object({ + id: z.string(), + label: z.string(), + order: z.number().optional(), + color: z.string().optional(), + }), + ) + .describe("Full statuses array to set."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}/statuses`, { + statuses: args.statuses, + }), + ), + ), + ); + + // POST /api/kanban/{boardId}/items + server.registerTool( + "create_board_item", + { + title: "Create board item", + description: "Add an item to a kanban board. Optional status and description.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + text: z.string().describe("Item text."), + status: z.string().optional().describe("Initial status (defaults to todo)."), + description: z.string().optional().describe("Item description/notes."), + }, + }, + async (args) => + run(() => + json( + client.post(`/api/kanban/${args.boardId}/items`, { + text: args.text, + status: args.status, + description: args.description, + }), + ), + ), + ); + + // PUT /api/kanban/{boardId}/items/{itemId} + server.registerTool( + "update_board_item", + { + title: "Update board item", + description: + "Update a kanban item's text, priority, score, assignee and/or reminder. Omitted fields are left unchanged.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id to update."), + text: z.string().optional(), + priority: z + .enum(["critical", "high", "medium", "low", "none"]) + .optional(), + score: z.number().optional(), + assignee: z.string().optional().describe("Username to assign the item to."), + reminder: z + .object({ + datetime: z.string(), + notified: z.boolean().optional(), + }) + .optional() + .describe("Reminder object; set to null to clear."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}/items/${args.itemId}`, { + text: args.text, + priority: args.priority, + score: args.score, + assignee: args.assignee, + reminder: args.reminder, + }), + ), + ), + ); + + // DELETE /api/kanban/{boardId}/items/{itemId} + server.registerTool( + "delete_board_item", + { + title: "Delete board item", + description: "Delete a kanban item by id.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id to delete."), + }, + }, + async (args) => + run(() => + json(client.delete(`/api/kanban/${args.boardId}/items/${args.itemId}`)), + ), + ); + + // PUT /api/kanban/{boardId}/items/{itemId}/status + server.registerTool( + "move_board_item", + { + title: "Move board item", + description: "Move a kanban item to a different column (change its status).", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id to move."), + status: z.string().describe("New status id."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}/items/${args.itemId}/status`, { + status: args.status, + }), + ), + ), + ); + + // PUT /api/kanban/{boardId}/items/{itemId}/assign + server.registerTool( + "assign_board_item", + { + title: "Assign board item", + description: "Assign a kanban item to a user (pass empty string to unassign).", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id to assign."), + assignee: z.string().describe("Username to assign to (empty to clear)."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}/items/${args.itemId}/assign`, { + assignee: args.assignee, + }), + ), + ), + ); + + // PUT /api/kanban/{boardId}/items/{itemId}/reminder + server.registerTool( + "set_board_item_reminder", + { + title: "Set board item reminder", + description: "Set a due-date/time reminder on a kanban item.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id."), + datetime: z.string().describe("ISO 8601 datetime for the reminder."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/kanban/${args.boardId}/items/${args.itemId}/reminder`, { + datetime: args.datetime, + }), + ), + ), + ); + + // DELETE /api/kanban/{boardId}/items/{itemId}/reminder + server.registerTool( + "clear_board_item_reminder", + { + title: "Clear board item reminder", + description: "Remove the reminder from a kanban item.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + itemId: z.string().describe("Item id."), + }, + }, + async (args) => + run(() => + json( + client.delete(`/api/kanban/${args.boardId}/items/${args.itemId}/reminder`), + ), + ), + ); + + // GET /api/kanban/{boardId}/calendar + server.registerTool( + "get_board_calendar", + { + title: "Get board calendar", + description: + "Get calendar events for items on a kanban board that have start/target dates. Returns JSON events.", + inputSchema: { + boardId: z.string().describe("UUID of the board."), + }, + }, + async (args) => + run(() => + json(client.get(`/api/kanban/${args.boardId}/calendar`)), + ), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/misc.ts b/mcp-server/src/tools/misc.ts new file mode 100644 index 000000000..2658b7218 --- /dev/null +++ b/mcp-server/src/tools/misc.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Misc endpoints: public health check, the authenticated user's profile, and + * the admin link-index rebuild. + */ +export const miscModule: ToolModule = { + register(server, client) { + // GET /api/health (public) + server.registerTool( + "health", + { + title: "Health check", + description: + "Check the remote Jotty instance is reachable and report its version. Does not require an API key.", + }, + async () => run(() => json(client.get("/api/health"))), + ); + + // GET /api/user/{username} + server.registerTool( + "get_user", + { + title: "Get user information", + description: + "Retrieve user information. Returns full data for the authenticated user (or admin viewing anyone), otherwise only public fields.", + inputSchema: { + username: z + .string() + .describe("Username to look up. Use your own username for your full profile."), + }, + }, + async (args) => + run(() => json(client.get(`/api/user/${args.username}`))), + ); + + // GET /api/user (the API-key user — "whoami") + server.registerTool( + "get_current_user", + { + title: "Get current user", + description: + "Get the authenticated user's own profile and settings (theme, landing page, pinned lists/notes, editor preferences). Sensitive fields like passwordHash and apiKey are never returned.", + }, + async () => run(() => json(client.get("/api/user"))), + ); + + // POST /api/admin/rebuild-index + server.registerTool( + "rebuild_link_index", + { + title: "Rebuild link index", + description: + "Rebuild the internal link index for a user (admin only). Useful after bulk operations or migrations.", + inputSchema: { + username: z.string().describe("Username whose link index should be rebuilt."), + }, + }, + async (args) => + run(() => + json(client.post("/api/admin/rebuild-index", { username: args.username })), + ), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/notes.ts b/mcp-server/src/tools/notes.ts new file mode 100644 index 000000000..deb3a0387 --- /dev/null +++ b/mcp-server/src/tools/notes.ts @@ -0,0 +1,116 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * Notes — markdown documents under /api/notes. content is GitHub-Flavoured + * Markdown. Each note has a UUID id and an optional category. + */ +export const notesModule: ToolModule = { + register(server, client) { + // GET /api/notes + server.registerTool( + "list_notes", + { + title: "List notes", + description: + "List all notes for the authenticated user. Optional filters: category and search query q (matches title or content).", + inputSchema: { + category: z.string().optional().describe("Filter by category name."), + q: z.string().optional().describe("Search title or content."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/notes", { + category: args.category, + q: args.q, + }), + ), + ), + ); + + // POST /api/notes + server.registerTool( + "create_note", + { + title: "Create note", + description: + "Create a new markdown note. content defaults to empty; category defaults to 'Uncategorized'.", + inputSchema: { + title: z.string().describe("Note title."), + content: z + .string() + .optional() + .describe("Markdown content (defaults to empty)."), + category: z.string().optional().describe("Category (defaults to Uncategorized)."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/notes", { + title: args.title, + content: args.content ?? "", + category: args.category ?? "Uncategorized", + }), + ), + ), + ); + + // GET /api/notes/{noteId} + server.registerTool( + "get_note", + { + title: "Get note", + description: "Retrieve a single note by UUID, including its content.", + inputSchema: { + noteId: z.string().describe("UUID of the note."), + }, + }, + async (args) => + run(() => json(client.get(`/api/notes/${args.noteId}`))), + ); + + // PUT /api/notes/{noteId} + server.registerTool( + "update_note", + { + title: "Update note", + description: + "Update a note's title, content and/or category. Omitted fields are left unchanged.", + inputSchema: { + noteId: z.string().describe("UUID of the note."), + title: z.string().optional(), + content: z.string().optional().describe("New markdown content."), + category: z.string().optional(), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/notes/${args.noteId}`, { + title: args.title, + content: args.content, + category: args.category, + }), + ), + ), + ); + + // DELETE /api/notes/{noteId} + server.registerTool( + "delete_note", + { + title: "Delete note", + description: "Delete a note by UUID.", + inputSchema: { + noteId: z.string().describe("UUID of the note to delete."), + }, + }, + async (args) => + run(() => json(client.delete(`/api/notes/${args.noteId}`))), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/shared.ts b/mcp-server/src/tools/shared.ts new file mode 100644 index 000000000..58725289a --- /dev/null +++ b/mcp-server/src/tools/shared.ts @@ -0,0 +1,52 @@ +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; + +/** + * Build a successful tool result containing pretty-printed JSON. + * + * Accepts either a raw value or a Promise (e.g. a `JottyClient` call) so the + * common pattern `run(() => json(client.get(...)))` awaits the network request + * before serialising. Most Jotty endpoints return JSON, so this is the common + * happy path. + */ +export async function json(data: Promise | unknown): Promise { + const resolved = await data; + return { + content: [{ type: "text", text: JSON.stringify(resolved, null, 2) }], + }; +} + +/** Build a successful tool result with plain text. */ +export function text(value: string): CallToolResult { + return { + content: [{ type: "text", text: value }], + }; +} + +/** + * Build an error tool result. Jotty API errors (bad UUID, missing title, 403, + * etc.) are surfaced to the model as `isError: true` so the client can react, + * rather than crashing the server. + */ +export function fail(message: string): CallToolResult { + return { + isError: true, + content: [{ type: "text", text: message }], + }; +} + +/** + * Run an async handler and convert thrown `JottyApiError`s into structured + * MCP error results. Keeps every tool callback free of repetitive try/catch. + */ +export async function run( + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + if (err instanceof Error) { + return fail(err.message); + } + return fail(String(err)); + } +} \ No newline at end of file diff --git a/mcp-server/src/tools/tasks.ts b/mcp-server/src/tools/tasks.ts new file mode 100644 index 000000000..bd2671c42 --- /dev/null +++ b/mcp-server/src/tools/tasks.ts @@ -0,0 +1,308 @@ +import { z } from "zod"; +import type { ToolModule } from "./types.js"; +import { json, run } from "./shared.js"; + +/** + * A "status" object used to define Kanban columns. Reused across create_task + * and the status endpoints. + */ +const statusShape = { + id: z.string().describe("Unique status id, e.g. 'todo' or 'review'."), + label: z.string().describe("Display label for the column."), + order: z.number().optional().describe("Display order."), + color: z.string().optional().describe("Hex color, e.g. '#3b82f6'."), +}; + +/** + * Tasks — task-type checklists with Kanban columns, exposed via /api/tasks. + * Items are addressed by dot-notation index paths; statuses define the board + * columns. + */ +export const tasksModule: ToolModule = { + register(server, client) { + // GET /api/tasks + server.registerTool( + "list_tasks", + { + title: "List tasks", + description: + "List all task checklists for the authenticated user. Optional filters: category, status (lists containing an item with this status), and search q.", + inputSchema: { + category: z.string().optional(), + status: z + .string() + .optional() + .describe("Only return tasks that contain an item with this status."), + q: z.string().optional().describe("Search title or item text."), + }, + }, + async (args) => + run(() => + json( + client.get("/api/tasks", { + category: args.category, + status: args.status, + q: args.q, + }), + ), + ), + ); + + // POST /api/tasks + server.registerTool( + "create_task", + { + title: "Create task", + description: + "Create a new task checklist (Kanban). statuses defines the columns; defaults to todo/in_progress/completed.", + inputSchema: { + title: z.string().describe("Task list title."), + category: z.string().optional().describe("Category (defaults to Uncategorized)."), + statuses: z + .array(z.object(statusShape)) + .optional() + .describe("Custom Kanban columns."), + }, + }, + async (args) => + run(() => + json( + client.post("/api/tasks", { + title: args.title, + category: args.category ?? "Uncategorized", + statuses: args.statuses, + }), + ), + ), + ); + + // GET /api/tasks/{taskId} + server.registerTool( + "get_task", + { + title: "Get task", + description: "Retrieve a single task checklist by UUID.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + }, + }, + async (args) => run(() => json(client.get(`/api/tasks/${args.taskId}`))), + ); + + // PUT /api/tasks/{taskId} + server.registerTool( + "update_task", + { + title: "Update task", + description: "Update a task's title and/or category.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + title: z.string().optional(), + category: z.string().optional(), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/tasks/${args.taskId}`, { + title: args.title, + category: args.category, + }), + ), + ), + ); + + // DELETE /api/tasks/{taskId} + server.registerTool( + "delete_task", + { + title: "Delete task", + description: "Delete a task checklist by UUID.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + }, + }, + async (args) => + run(() => json(client.delete(`/api/tasks/${args.taskId}`))), + ); + + // GET /api/tasks/{taskId}/statuses + server.registerTool( + "get_task_statuses", + { + title: "Get task statuses", + description: "Retrieve the Kanban column statuses for a task.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + }, + }, + async (args) => + run(() => json(client.get(`/api/tasks/${args.taskId}/statuses`))), + ); + + // POST /api/tasks/{taskId}/statuses + server.registerTool( + "create_task_status", + { + title: "Create task status", + description: "Add a new Kanban column status to a task.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + id: z.string().describe("Unique status id."), + label: z.string().describe("Display label."), + color: z.string().optional().describe("Hex color."), + order: z.number().optional().describe("Display order (defaults to last)."), + }, + }, + async (args) => + run(() => + json( + client.post(`/api/tasks/${args.taskId}/statuses`, { + id: args.id, + label: args.label, + color: args.color, + order: args.order, + }), + ), + ), + ); + + // PUT /api/tasks/{taskId}/statuses/{statusId} + server.registerTool( + "update_task_status", + { + title: "Update task status", + description: "Update a Kanban column status (label, color, order).", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + statusId: z.string().describe("Status id to update."), + label: z.string().optional(), + color: z.string().optional(), + order: z.number().optional(), + }, + }, + async (args) => + run(() => + json( + client.put( + `/api/tasks/${args.taskId}/statuses/${args.statusId}`, + { + label: args.label, + color: args.color, + order: args.order, + }, + ), + ), + ), + ); + + // DELETE /api/tasks/{taskId}/statuses/{statusId} + server.registerTool( + "delete_task_status", + { + title: "Delete task status", + description: + "Delete a Kanban column. Items with this status move to the first available status.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + statusId: z.string().describe("Status id to delete."), + }, + }, + async (args) => + run(() => + json(client.delete(`/api/tasks/${args.taskId}/statuses/${args.statusId}`)), + ), + ); + + // POST /api/tasks/{taskId}/items + server.registerTool( + "create_task_item", + { + title: "Create task item", + description: + "Add an item to a task. Use parentIndex (dot path) to nest under an existing item. status defaults to 'todo'.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + text: z.string().describe("Item text."), + status: z.string().optional().describe("Initial status (defaults to todo)."), + parentIndex: z + .string() + .optional() + .describe("Dot-notation index path of the parent item."), + }, + }, + async (args) => + run(() => + json( + client.post(`/api/tasks/${args.taskId}/items`, { + text: args.text, + status: args.status, + parentIndex: args.parentIndex, + }), + ), + ), + ); + + // GET /api/tasks/{taskId}/items/{itemIndex} + server.registerTool( + "get_task_item", + { + title: "Get task item", + description: "Retrieve a single task item by dot-notation index path.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + itemIndex: z + .string() + .describe("Dot-notation index path (e.g. '0' or '0.1')."), + }, + }, + async (args) => + run(() => + json(client.get(`/api/tasks/${args.taskId}/items/${args.itemIndex}`)), + ), + ); + + // PUT /api/tasks/{taskId}/items/{itemIndex}/status + server.registerTool( + "move_task_item", + { + title: "Move task item", + description: "Move a task item to a different Kanban column (change its status).", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item."), + status: z.string().describe("New status id."), + }, + }, + async (args) => + run(() => + json( + client.put(`/api/tasks/${args.taskId}/items/${args.itemIndex}/status`, { + status: args.status, + }), + ), + ), + ); + + // DELETE /api/tasks/{taskId}/items/{itemIndex} + server.registerTool( + "delete_task_item", + { + title: "Delete task item", + description: "Delete a task item by dot-notation index path.", + inputSchema: { + taskId: z.string().describe("UUID of the task."), + itemIndex: z + .string() + .describe("Dot-notation index path of the item to delete."), + }, + }, + async (args) => + run(() => + json(client.delete(`/api/tasks/${args.taskId}/items/${args.itemIndex}`)), + ), + ); + }, +}; \ No newline at end of file diff --git a/mcp-server/src/tools/types.ts b/mcp-server/src/tools/types.ts new file mode 100644 index 000000000..81cc39a51 --- /dev/null +++ b/mcp-server/src/tools/types.ts @@ -0,0 +1,10 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { JottyClient } from "../client.js"; + +/** + * Every tool group exports one of these. `register` is called once at startup + * with the shared server + REST client, and wires up that group's tools. + */ +export interface ToolModule { + register(server: McpServer, client: JottyClient): void; +} \ No newline at end of file diff --git a/mcp-server/tsconfig.json b/mcp-server/tsconfig.json new file mode 100644 index 000000000..5d9896cdc --- /dev/null +++ b/mcp-server/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": true, + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*"] +} \ No newline at end of file From 3ebdb099a889ee6aca6664070802d8f460bbd6ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dawid=20Dydlin=CC=81ski?= Date: Tue, 25 Aug 2026 12:33:19 +0200 Subject: [PATCH 04/28] fix kanban assign/reminder API endpoints for API-key auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The assign and reminder API routes resolved the user via withApiAuth but then called assignKanbanItem/setKanbanItemReminder, which re-derive the user from the session cookie through getCurrentUser(). With no cookie present, every API-key caller got "Not authenticated" — so PUT .../assign, PUT .../reminder and DELETE .../reminder failed over the REST API. Pass the authenticated username through from the routes (mirroring how the status/move route already passes user.username into updateItemStatus), with a getCurrentUser() fallback so the web UI path is unchanged. --- app/_server/actions/kanban/items.ts | 18 ++++++++++++++---- .../[boardId]/items/[itemId]/assign/route.ts | 2 +- .../[boardId]/items/[itemId]/reminder/route.ts | 4 ++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/_server/actions/kanban/items.ts b/app/_server/actions/kanban/items.ts index 701d1b551..313b2ce87 100644 --- a/app/_server/actions/kanban/items.ts +++ b/app/_server/actions/kanban/items.ts @@ -127,14 +127,19 @@ export const updateKanbanItemScore = async (formData: FormData) => { } }; -export const assignKanbanItem = async (formData: FormData) => { +export const assignKanbanItem = async ( + formData: FormData, + apiUsername?: string, +) => { try { const { uuid, itemId, assignee } = getFormData(formData, [ "uuid", "itemId", "assignee", ]); + // API-key callers pass their username explicitly; web-UI callers rely on + // the session cookie via getCurrentUser(). const [currentUser, list] = await Promise.all([ - getCurrentUser(), + apiUsername ? Promise.resolve({ username: apiUsername }) : getCurrentUser(), getListById(uuid), ]); if (!currentUser) return { error: "Not authenticated" }; @@ -180,14 +185,19 @@ export const assignKanbanItem = async (formData: FormData) => { } }; -export const setKanbanItemReminder = async (formData: FormData) => { +export const setKanbanItemReminder = async ( + formData: FormData, + apiUsername?: string, +) => { try { const { uuid, itemId, reminder: reminderStr } = getFormData(formData, [ "uuid", "itemId", "reminder", ]); + // API-key callers pass their username explicitly; web-UI callers rely on + // the session cookie via getCurrentUser(). const [currentUser, list] = await Promise.all([ - getCurrentUser(), + apiUsername ? Promise.resolve({ username: apiUsername }) : getCurrentUser(), getListById(uuid), ]); if (!currentUser) return { error: "Not authenticated" }; diff --git a/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts b/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts index 732ee095c..b7890b089 100644 --- a/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts +++ b/app/api/kanban/[boardId]/items/[itemId]/assign/route.ts @@ -34,7 +34,7 @@ export async function PUT( formData.append("itemId", params.itemId); formData.append("assignee", assignee || ""); - const result = await assignKanbanItem(formData); + const result = await assignKanbanItem(formData, user.username); if (result.error) { return NextResponse.json({ error: result.error }, { status: 400 }); diff --git a/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts b/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts index 779f7312e..51e06af13 100644 --- a/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts +++ b/app/api/kanban/[boardId]/items/[itemId]/reminder/route.ts @@ -41,7 +41,7 @@ export async function PUT( formData.append("itemId", params.itemId); formData.append("reminder", JSON.stringify({ datetime })); - const result = await setKanbanItemReminder(formData); + const result = await setKanbanItemReminder(formData, user.username); if (result.error) { return NextResponse.json({ error: result.error }, { status: 400 }); @@ -83,7 +83,7 @@ export async function DELETE( formData.append("itemId", params.itemId); formData.append("reminder", ""); - const result = await setKanbanItemReminder(formData); + const result = await setKanbanItemReminder(formData, user.username); if (result.error) { return NextResponse.json({ error: result.error }, { status: 400 }); From 975e24f0b4b004d1ee2a790b0febb649b19623cf Mon Sep 17 00:00:00 2001 From: fccview Date: Sun, 27 Sep 2026 09:35:45 +0100 Subject: [PATCH 05/28] fix tests --- app/_server/actions/checklist-item/crud.ts | 5 +++-- tests/server-actions/checklist-item.test.ts | 13 ++++++++----- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/app/_server/actions/checklist-item/crud.ts b/app/_server/actions/checklist-item/crud.ts index 10285a5f7..e07690c4b 100644 --- a/app/_server/actions/checklist-item/crud.ts +++ b/app/_server/actions/checklist-item/crud.ts @@ -15,7 +15,8 @@ import { extractHashtagsFromContent, normalizeTag, } from "@/app/_utils/tag-utils"; -import { isAdmin, getUsername, getUserByUsername } from "@/app/_server/actions/users"; +import { isAdmin, getUsername } from "@/app/_server/actions/users"; +import { findUserRecord } from "@/app/_server/actions/users/records"; import { Checklist, Item, KanbanPriority, Result } from "@/app/_types"; import { ItemTypes, @@ -282,7 +283,7 @@ export const createItem = async ( order: item.order + 1, })); - const userRecord = await getUserByUsername(currentUser); + const userRecord = await findUserRecord(currentUser); const insertAtBottom = userRecord?.newItemInsertion === "bottom"; const newItem = { diff --git a/tests/server-actions/checklist-item.test.ts b/tests/server-actions/checklist-item.test.ts index a3fbed7d8..d895635c6 100644 --- a/tests/server-actions/checklist-item.test.ts +++ b/tests/server-actions/checklist-item.test.ts @@ -6,7 +6,7 @@ const mockEnsureDir = vi.fn(); const mockServerWriteFile = vi.fn(); const mockGetUsername = vi.fn(); const mockGetCurrentUser = vi.fn(); -const mockGetUserByUsername = vi.fn(); +const mockFindUserRecord = vi.fn(); const mockIsAdmin = vi.fn(); const mockCanReach = vi.fn(); const mockUsersWithAccess = vi.fn(); @@ -26,10 +26,13 @@ vi.mock("@/app/_server/actions/file", () => ({ vi.mock("@/app/_server/actions/users", () => ({ getUsername: (...args: any[]) => mockGetUsername(...args), getCurrentUser: (...args: any[]) => mockGetCurrentUser(...args), - getUserByUsername: (...args: any[]) => mockGetUserByUsername(...args), isAdmin: (...args: any[]) => mockIsAdmin(...args), })); +vi.mock("@/app/_server/actions/users/records", () => ({ + findUserRecord: (...args: any[]) => mockFindUserRecord(...args), +})); + vi.mock("@/app/_server/actions/share/queries", () => ({ canReach: (...args: any[]) => mockCanReach(...args), reachableFile: async (...args: any[]) => @@ -121,7 +124,7 @@ describe("Checklist Item Actions - Comprehensive Tests", () => { mockServerWriteFile.mockResolvedValue(undefined); mockGetUsername.mockResolvedValue("testuser"); mockGetCurrentUser.mockResolvedValue({ username: "testuser" }); - mockGetUserByUsername.mockResolvedValue(null); + mockFindUserRecord.mockResolvedValue(null); mockIsAdmin.mockResolvedValue(false); mockCanReach.mockResolvedValue(true); mockUsersWithAccess.mockResolvedValue([]); @@ -350,7 +353,7 @@ describe("Checklist Item Actions - Comprehensive Tests", () => { }); it("should prepend new item (order 0) by default", async () => { - mockGetUserByUsername.mockResolvedValue(null); + mockFindUserRecord.mockResolvedValue(null); const formData = createFormData({ uuid: "test-uuid-123", @@ -371,7 +374,7 @@ describe("Checklist Item Actions - Comprehensive Tests", () => { }); it("should append new item at the end when newItemInsertion is 'bottom'", async () => { - mockGetUserByUsername.mockResolvedValue({ + mockFindUserRecord.mockResolvedValue({ username: "testuser", newItemInsertion: "bottom", }); From a5a428209aa426cc3ca340e90b20e2da9bce71ff Mon Sep 17 00:00:00 2001 From: fccview Date: Sun, 27 Sep 2026 15:45:30 +0100 Subject: [PATCH 06/28] brain trust ftw --- .github/workflows/pr-checks.yml | 2 +- .github/workflows/prebuild-release.yml | 2 +- Dockerfile | 2 +- README.md | 2 + .../admin/checklist/[uuid]/page.tsx | 16 +- .../admin/note/[uuid]/page.tsx | 8 +- .../brain/[username]/page.tsx | 25 + app/(loggedInRoutes)/brain/page.tsx | 18 + .../checklist/[uuid]/page.tsx | 16 +- app/(loggedInRoutes)/note/[uuid]/page.tsx | 8 +- .../settings/connections/page.tsx | 26 +- .../Admin/Parts/AdminContent.tsx | 5 +- .../Parts/Sharing/SharingNetworkGraph.tsx | 43 -- .../Admin/Parts/SharingNetworkGraph.tsx | 43 -- .../Brain/BrainPageClient.tsx | 223 +++++++ .../Brain/Parts/BrainButton.tsx | 34 + .../Brain/Parts/BrainCanvas2D.tsx | 349 ++++++++++ .../Brain/Parts/BrainInspector.tsx | 187 ++++++ .../Brain/Parts/BrainLegend.tsx | 61 ++ .../Brain/Parts/BrainScene3D.tsx | 449 +++++++++++++ .../Brain/Parts/BrainToolbar.tsx | 199 ++++++ .../Brain/hooks/useBrainPalette.ts | 56 ++ .../Brain/hooks/useBrainPrefs.ts | 105 +++ .../Brain/hooks/useElementSize.ts | 28 + .../Brain/server/BrainPage.tsx | 33 + .../Brain/utils/brain-canvas.ts | 17 + .../Brain/utils/brain-forces.ts | 117 ++++ .../Brain/utils/brain-glyphs.ts | 185 ++++++ .../Brain/utils/brain-graph.ts | 219 +++++++ .../Brain/utils/brain-three.ts | 274 ++++++++ .../Parts/Common/ChecklistHeader.tsx | 3 + .../Checklists/Parts/Simple/ChecklistBody.tsx | 24 +- .../FeatureComponents/Kanban/Kanban.tsx | 19 +- .../Notes/Parts/MentionedInSection.tsx | 84 +++ .../Parts/NoteEditor/NoteEditorContent.tsx | 12 +- .../Parts/NoteEditor/NoteEditorHeader.tsx | 2 + .../Notes/Parts/ReferencedBySection.tsx | 144 ++-- .../Notes/Parts/TableOfContents.tsx | 19 +- .../TipTap/CustomExtensions/InternalLink.tsx | 7 +- .../InternalLinkComponent.tsx | 364 ++++------- .../TipTap/CustomExtensions/SlashCommands.tsx | 36 +- .../Notes/Parts/UnifiedMarkdownRenderer.tsx | 62 +- .../Notes/Parts/WikiLink.tsx | 113 ++++ .../ConnectionsGraph/ConnectionsGraph.tsx | 615 ------------------ .../Parts/ConnectionsGraph/graph-data.ts | 335 ---------- .../Profile/Parts/LinksTab.tsx | 468 ------------- .../Sidebar/Parts/TagsList.tsx | 11 +- .../Sidebar/SettingsSidebar.tsx | 3 +- .../Layout/IndexingRelations.tsx | 25 + app/_consts/relations.ts | 45 ++ app/_providers/AppModeProvider.tsx | 5 - app/_providers/MetadataProvider.tsx | 3 + app/_providers/RelationsProvider.tsx | 21 + app/_server/actions/category/crud.ts | 3 +- app/_server/actions/category/move.ts | 18 +- app/_server/actions/checklist/converters.ts | 21 - app/_server/actions/checklist/creator.ts | 21 - app/_server/actions/checklist/crud.ts | 47 -- app/_server/actions/checklist/readers.ts | 63 +- app/_server/actions/file/atomic.ts | 55 ++ app/_server/actions/file/index.ts | 22 +- app/_server/actions/lib/legacy-lookup.ts | 19 +- app/_server/actions/lib/metadata-cache.ts | 9 + app/_server/actions/lib/stamp-uuid.ts | 54 ++ app/_server/actions/link/index.ts | 369 ----------- app/_server/actions/note/creator.ts | 25 +- app/_server/actions/note/crud.ts | 65 +- app/_server/actions/note/index.ts | 6 +- app/_server/actions/note/parsers.ts | 127 +--- app/_server/actions/note/queries.ts | 8 +- app/_server/actions/note/readers.ts | 74 +-- app/_server/actions/relations/index.ts | 142 ++++ app/_server/actions/relations/indexer.ts | 339 ++++++++++ app/_server/actions/relations/parser.ts | 63 ++ app/_server/actions/relations/paths.ts | 41 ++ app/_server/actions/relations/queries.ts | 269 ++++++++ app/_server/actions/relations/store.ts | 157 +++++ app/_server/actions/relations/suggestions.ts | 65 ++ app/_server/actions/relations/tidy.ts | 223 +++++++ app/_server/actions/relations/tracking.ts | 63 ++ app/_server/actions/share/access.ts | 17 +- app/_server/actions/share/category-info.ts | 9 + app/_server/actions/share/mounts.ts | 4 +- app/_server/actions/share/rename.ts | 130 ++-- app/_server/actions/tags/index.ts | 3 +- app/_server/actions/users/auth.ts | 5 +- app/_server/actions/users/crud.ts | 39 +- app/_translations/en.json | 66 ++ app/_types/context.ts | 2 - app/_types/index.ts | 1 - app/_types/links.ts | 16 - app/_types/relations.ts | 53 ++ app/_types/websocket.ts | 2 +- app/_utils/checklist-utils.ts | 3 +- app/_utils/indexes-utils.ts | 82 --- app/_utils/item-href-utils.ts | 96 +++ app/_utils/markdown-utils.tsx | 57 +- app/_utils/wikilink-utils.ts | 24 + app/_utils/yaml-metadata-utils.ts | 10 + app/api/admin/rebuild-index/route.ts | 27 +- app/layout.tsx | 5 - howto/API.md | 18 +- howto/BRAIN.md | 99 +++ instrumentation.ts | 7 + next.config.mjs | 2 + package.json | 12 +- tests/mock-data/brain-seed.ts | 404 ++++++++++++ tests/security/auth-required.test.ts | 35 +- tests/server-actions/category.test.ts | 1 + tests/server-actions/checklist.test.ts | 27 - tests/server-actions/file.test.ts | 24 +- tests/server-actions/link-index.test.ts | 104 --- tests/server-actions/note-readers.test.ts | 6 + tests/server-actions/note.test.ts | 29 +- tests/server-actions/relations.test.ts | 459 +++++++++++++ tests/server-actions/share-visibility.test.ts | 235 +++++++ tests/server-actions/tags.test.ts | 6 + tests/server-actions/user-deletion.test.ts | 265 ++++++++ tests/server-actions/users.test.ts | 26 + tests/setup.ts | 7 + tests/utils/connections-graph-data.test.ts | 279 -------- tsconfig.json | 2 +- yarn.lock | 505 +++++++------- 123 files changed, 7033 insertions(+), 3710 deletions(-) create mode 100644 app/(loggedInRoutes)/brain/[username]/page.tsx create mode 100644 app/(loggedInRoutes)/brain/page.tsx delete mode 100644 app/_components/FeatureComponents/Admin/Parts/Sharing/SharingNetworkGraph.tsx delete mode 100644 app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx create mode 100644 app/_components/FeatureComponents/Brain/BrainPageClient.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx create mode 100644 app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx create mode 100644 app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts create mode 100644 app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts create mode 100644 app/_components/FeatureComponents/Brain/hooks/useElementSize.ts create mode 100644 app/_components/FeatureComponents/Brain/server/BrainPage.tsx create mode 100644 app/_components/FeatureComponents/Brain/utils/brain-canvas.ts create mode 100644 app/_components/FeatureComponents/Brain/utils/brain-forces.ts create mode 100644 app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts create mode 100644 app/_components/FeatureComponents/Brain/utils/brain-graph.ts create mode 100644 app/_components/FeatureComponents/Brain/utils/brain-three.ts create mode 100644 app/_components/FeatureComponents/Notes/Parts/MentionedInSection.tsx create mode 100644 app/_components/FeatureComponents/Notes/Parts/WikiLink.tsx delete mode 100644 app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx delete mode 100644 app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts delete mode 100644 app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx create mode 100644 app/_components/GlobalComponents/Layout/IndexingRelations.tsx create mode 100644 app/_consts/relations.ts create mode 100644 app/_providers/RelationsProvider.tsx create mode 100644 app/_server/actions/file/atomic.ts create mode 100644 app/_server/actions/lib/stamp-uuid.ts delete mode 100644 app/_server/actions/link/index.ts create mode 100644 app/_server/actions/relations/index.ts create mode 100644 app/_server/actions/relations/indexer.ts create mode 100644 app/_server/actions/relations/parser.ts create mode 100644 app/_server/actions/relations/paths.ts create mode 100644 app/_server/actions/relations/queries.ts create mode 100644 app/_server/actions/relations/store.ts create mode 100644 app/_server/actions/relations/suggestions.ts create mode 100644 app/_server/actions/relations/tidy.ts create mode 100644 app/_server/actions/relations/tracking.ts delete mode 100644 app/_types/links.ts create mode 100644 app/_types/relations.ts delete mode 100644 app/_utils/indexes-utils.ts create mode 100644 app/_utils/item-href-utils.ts create mode 100644 app/_utils/wikilink-utils.ts create mode 100644 howto/BRAIN.md create mode 100644 tests/mock-data/brain-seed.ts delete mode 100644 tests/server-actions/link-index.test.ts create mode 100644 tests/server-actions/relations.test.ts create mode 100644 tests/server-actions/share-visibility.test.ts create mode 100644 tests/server-actions/user-deletion.test.ts delete mode 100644 tests/utils/connections-graph-data.test.ts diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 94e3ea02a..5ff30552c 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -38,7 +38,7 @@ jobs: - name: Setup the best engine ever uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "24" cache: "yarn" - name: Install all dependencies diff --git a/.github/workflows/prebuild-release.yml b/.github/workflows/prebuild-release.yml index 5f53d32f2..1fd5fb875 100644 --- a/.github/workflows/prebuild-release.yml +++ b/.github/workflows/prebuild-release.yml @@ -16,7 +16,7 @@ jobs: - name: Setup the best engine ever uses: actions/setup-node@v4 with: - node-version: '20' + node-version: '24' cache: 'yarn' - name: Install dependencies diff --git a/Dockerfile b/Dockerfile index 77cf66802..c018f820e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:24-alpine AS base FROM base AS deps RUN apk add --no-cache libc6-compat diff --git a/README.md b/README.md index 8e4caf5d0..c0b10d123 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,8 @@ I will always detail these migrations in the release notes. I _highly recommend_ 📖 **For the complete MARKDOWN documentation, see [howto/MARKDOWN.md](howto/MARKDOWN.md)** +📖 **For links, wikilinks and the brain view, see [howto/BRAIN.md](howto/BRAIN.md)** + ## ENCRYPTION diff --git a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx index 60e7792db..8156eb41e 100644 --- a/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/checklist/[uuid]/page.tsx @@ -11,6 +11,8 @@ import type { Metadata } from "next"; import { getMedatadaTitle } from "@/app/_server/actions/config"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; interface AdminChecklistPageProps { params: Promise<{ @@ -67,14 +69,18 @@ export default async function AdminChecklistPage(props: AdminChecklistPageProps) type: "checklist" as const, }; + const relations = await getItemRelations(checklist.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx index 9f39649d4..b6e96f13e 100644 --- a/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/admin/note/[uuid]/page.tsx @@ -12,6 +12,8 @@ import type { Metadata } from "next"; import { getMedatadaTitle } from "@/app/_server/actions/config"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; interface AdminNotePageProps { params: Promise<{ @@ -69,10 +71,14 @@ export default async function AdminNotePage(props: AdminNotePageProps) { type: "note" as const, }; + const relations = await getItemRelations(note.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/brain/[username]/page.tsx b/app/(loggedInRoutes)/brain/[username]/page.tsx new file mode 100644 index 000000000..66c10dafa --- /dev/null +++ b/app/(loggedInRoutes)/brain/[username]/page.tsx @@ -0,0 +1,25 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { BRAIN_FOCUS_PARAM } from "@/app/_consts/relations"; +import { decodeSegment } from "@/app/_utils/global-utils"; +import { BrainPage } from "@/app/_components/FeatureComponents/Brain/server/BrainPage"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("brain"); + return { title: t("title") }; +} + +export default async function UserBrainPage(props: { + params: Promise<{ username: string }>; + searchParams: Promise>; +}) { + const [params, searchParams] = await Promise.all([props.params, props.searchParams]); + return ( + + ); +} diff --git a/app/(loggedInRoutes)/brain/page.tsx b/app/(loggedInRoutes)/brain/page.tsx new file mode 100644 index 000000000..eb38cf503 --- /dev/null +++ b/app/(loggedInRoutes)/brain/page.tsx @@ -0,0 +1,18 @@ +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { BRAIN_FOCUS_PARAM } from "@/app/_consts/relations"; +import { BrainPage } from "@/app/_components/FeatureComponents/Brain/server/BrainPage"; + +export const dynamic = "force-dynamic"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("brain"); + return { title: t("title") }; +} + +export default async function OwnBrainPage(props: { + searchParams: Promise>; +}) { + const searchParams = await props.searchParams; + return ; +} diff --git a/app/(loggedInRoutes)/checklist/[uuid]/page.tsx b/app/(loggedInRoutes)/checklist/[uuid]/page.tsx index 05afed362..0d111b7bd 100644 --- a/app/(loggedInRoutes)/checklist/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/checklist/[uuid]/page.tsx @@ -10,6 +10,8 @@ import { isUuid } from "@/app/_consts/identity"; import { UNCATEGORIZED } from "@/app/_consts/notes"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; import { decodeSegment } from "@/app/_utils/global-utils"; interface ChecklistPageProps { @@ -82,14 +84,18 @@ export default async function ChecklistPage(props: ChecklistPageProps) { type: "checklist" as const, }; + const relations = await getItemRelations(checklist.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/note/[uuid]/page.tsx b/app/(loggedInRoutes)/note/[uuid]/page.tsx index 3b04cefde..84f96082e 100644 --- a/app/(loggedInRoutes)/note/[uuid]/page.tsx +++ b/app/(loggedInRoutes)/note/[uuid]/page.tsx @@ -16,6 +16,8 @@ import { isUuid } from "@/app/_consts/identity"; import { UNCATEGORIZED } from "@/app/_consts/notes"; import { PermissionsProvider } from "@/app/_providers/PermissionsProvider"; import { MetadataProvider } from "@/app/_providers/MetadataProvider"; +import { RelationsProvider } from "@/app/_providers/RelationsProvider"; +import { getItemRelations } from "@/app/_server/actions/relations"; import { decodeSegment } from "@/app/_utils/global-utils"; interface NotePageProps { @@ -90,10 +92,14 @@ export default async function NotePage(props: NotePageProps) { type: "note" as const, }; + const relations = await getItemRelations(note.uuid || ""); + return ( - + + + ); diff --git a/app/(loggedInRoutes)/settings/connections/page.tsx b/app/(loggedInRoutes)/settings/connections/page.tsx index db0bd01f2..386427e2b 100644 --- a/app/(loggedInRoutes)/settings/connections/page.tsx +++ b/app/(loggedInRoutes)/settings/connections/page.tsx @@ -1,24 +1,6 @@ -import { LinksTab } from "@/app/_components/FeatureComponents/Profile/Parts/LinksTab"; -import { readLinkIndex } from "@/app/_server/actions/link"; -import { getUsername } from "@/app/_server/actions/users"; -import { getArchivedItems } from "@/app/_server/actions/archived"; -import { getUserNotes } from "@/app/_server/actions/note"; -import { getUserChecklists } from "@/app/_server/actions/checklist"; -import { filterArchivedLinkIndex } from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data"; +import { permanentRedirect } from "next/navigation"; +import { BRAIN_PATH } from "@/app/_consts/relations"; -export default async function ConnectionsPage() { - const username = await getUsername(); - const [linkIndex, archivedResult, notesResult, checklistsResult] = await Promise.all([ - readLinkIndex(username), - getArchivedItems(), - getUserNotes({ username, metadataOnly: true }), - getUserChecklists({ username, metadataOnly: true }), - ]); - - const archivedItems = archivedResult.success ? archivedResult.data : []; - const filteredLinkIndex = filterArchivedLinkIndex(linkIndex, archivedItems || []); - const notes = notesResult.success ? notesResult.data || [] : []; - const checklists = checklistsResult.success ? checklistsResult.data || [] : []; - - return ; +export default function ConnectionsPage() { + permanentRedirect(BRAIN_PATH); } diff --git a/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx b/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx index d48b7e2cb..3194537de 100644 --- a/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/AdminContent.tsx @@ -13,7 +13,7 @@ import { AdminContentColumn } from "./AdminContentColumn"; import { ExportContent } from "./AdminExport"; import { Accordion } from "@/app/_components/GlobalComponents/Layout/Accordion"; import { UserAvatar } from "@/app/_components/GlobalComponents/User/UserAvatar"; -import { rebuildLinkIndex } from "@/app/_server/actions/link"; +import { reindexRelations } from "@/app/_server/actions/relations"; import { updateTagsFromContent } from "@/app/_server/actions/tags"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { useTranslations } from "next-intl"; @@ -106,7 +106,8 @@ export const AdminContent = ({ const handleRebuildIndex = async (username: string) => { setRebuildingIndex(username); try { - await rebuildLinkIndex(username); + const result = await reindexRelations(username); + if (!result.success) throw new Error(result.error); showToast({ type: "success", title: t('common.success'), diff --git a/app/_components/FeatureComponents/Admin/Parts/Sharing/SharingNetworkGraph.tsx b/app/_components/FeatureComponents/Admin/Parts/Sharing/SharingNetworkGraph.tsx deleted file mode 100644 index dcd90dc16..000000000 --- a/app/_components/FeatureComponents/Admin/Parts/Sharing/SharingNetworkGraph.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { useThemeColors } from "./AdminSharingFunctions"; - -const ResponsiveNetwork = dynamic( - () => import("@nivo/network").then((mod) => mod.ResponsiveNetwork), - { ssr: false } -); - -export const SharingNetworkGraph = ({ data }: { data: any }) => { - const colors = useThemeColors(); - if (!data || data.nodes.length === 0) { - return ( -
- Not enough sharing data to display a network graph. -
- ); - } - - return ( -
- e.distance} - centeringStrength={0.3} - repulsivity={6} - nodeSize={(n: any) => n.size} - activeNodeSize={(n: any) => n.size * 1.5} - nodeColor={(e: any) => e.color} - nodeBorderWidth={1} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - linkThickness={2} - linkBlendMode="multiply" - motionConfig="wobbly" - /> -
- ); -}; diff --git a/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx b/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx deleted file mode 100644 index d41ea6794..000000000 --- a/app/_components/FeatureComponents/Admin/Parts/SharingNetworkGraph.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import dynamic from "next/dynamic"; -import { useThemeColors } from "./Sharing/AdminSharingFunctions"; - -const ResponsiveNetwork = dynamic( - () => import("@nivo/network").then((mod) => mod.ResponsiveNetwork), - { ssr: false } -); - -export const SharingNetworkGraph = ({ data }: { data: any }) => { - const colors = useThemeColors(); - if (!data || data.nodes.length === 0) { - return ( -
- Not enough sharing data to display a network graph. -
- ); - } - - return ( -
- e.distance} - centeringStrength={0.3} - repulsivity={6} - nodeSize={(n: any) => n.size} - activeNodeSize={(n: any) => n.size * 1.5} - nodeColor={(e: any) => e.color} - nodeBorderWidth={1} - nodeBorderColor={{ - from: "color", - modifiers: [["darker", 0.8]], - }} - linkThickness={2} - linkBlendMode="multiply" - motionConfig="wobbly" - /> -
- ); -}; diff --git a/app/_components/FeatureComponents/Brain/BrainPageClient.tsx b/app/_components/FeatureComponents/Brain/BrainPageClient.tsx new file mode 100644 index 000000000..033f2c59b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/BrainPageClient.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import dynamic from "next/dynamic"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { AiBrain04Icon } from "hugeicons-react"; +import { Layout } from "@/app/_components/GlobalComponents/Layout/Layout"; +import { MobileHeader } from "@/app/_components/GlobalComponents/Layout/MobileHeader"; +import { IndexingRelations } from "@/app/_components/GlobalComponents/Layout/IndexingRelations"; +import { useShortcut } from "@/app/_providers/ShortcutsProvider"; +import { useToast } from "@/app/_providers/ToastProvider"; +import { connectItems } from "@/app/_server/actions/relations"; +import { createNote } from "@/app/_server/actions/note"; +import { BrainNodeKinds, RelationsStatus } from "@/app/_consts/relations"; +import { UNCATEGORIZED } from "@/app/_consts/notes"; +import { ItemTypes } from "@/app/_types/enums"; +import { itemHref } from "@/app/_utils/global-utils"; +import type { Category, SanitisedUser } from "@/app/_types"; +import type { BrainGraph, BrainNode } from "@/app/_types/relations"; +import { BrainToolbar } from "./Parts/BrainToolbar"; +import { BrainInspector } from "./Parts/BrainInspector"; +import { BrainLegend } from "./Parts/BrainLegend"; +import { useBrainPalette } from "./hooks/useBrainPalette"; +import { useBrainPrefs } from "./hooks/useBrainPrefs"; +import { BrainDimensions, brainView } from "./utils/brain-graph"; + +const BrainCanvas3D = dynamic(() => import("./Parts/BrainScene3D"), { + ssr: false, +}); +const BrainCanvas2D = dynamic(() => import("./Parts/BrainCanvas2D"), { + ssr: false, +}); + +interface BrainPageClientProps { + graph: BrainGraph; + focus: string | null; + isOwnBrain: boolean; + categories: Category[]; + user: SanitisedUser | null; +} + +const TAG_ROUTE = (tag: string) => `/?mode=tags&tag=${encodeURIComponent(tag)}`; + +export const BrainPageClient = ({ + graph, + focus, + isOwnBrain, + categories, + user, +}: BrainPageClientProps) => { + const t = useTranslations(); + const router = useRouter(); + const { showToast } = useToast(); + const { openSettings, openCreateNoteModal, openCreateCategoryModal } = + useShortcut(); + const palette = useBrainPalette(); + const focusId = + focus && graph.nodes.some((node) => node.id === focus) ? focus : null; + const prefs = useBrainPrefs(Boolean(focusId)); + const [selectedId, setSelectedId] = useState(focusId); + const [flyTo, setFlyTo] = useState<{ id: string; at: number } | null>(null); + + useEffect(() => setSelectedId(focusId), [focusId]); + + const view = useMemo( + () => brainView(graph, prefs.filters, prefs.scope, focusId), + [graph, prefs.filters, prefs.scope, focusId], + ); + const nodeIndex = useMemo( + () => new Map(graph.nodes.map((node) => [node.id, node])), + [graph.nodes], + ); + const selected = selectedId ? nodeIndex.get(selectedId) || null : null; + + const find = useCallback((id: string) => { + setSelectedId(id); + setFlyTo({ id, at: Date.now() }); + }, []); + + const open = useCallback( + async (node: BrainNode) => { + if (node.kind === BrainNodeKinds.NOTE) + return router.push(itemHref(ItemTypes.NOTE, node.id)); + if (node.kind === BrainNodeKinds.CHECKLIST) + return router.push(itemHref(ItemTypes.CHECKLIST, node.id)); + if (node.kind === BrainNodeKinds.TAG) + return router.push(TAG_ROUTE(node.title.replace(/^#/, ""))); + if (!isOwnBrain) return; + + const formData = new FormData(); + formData.append("title", node.title); + formData.append("category", UNCATEGORIZED); + formData.append("rawContent", ""); + const result = await createNote(formData); + if (result.success && result.data?.uuid) { + router.push(itemHref(ItemTypes.NOTE, result.data.uuid)); + return; + } + showToast({ + type: "error", + title: t("common.error"), + message: t("brain.createFailed"), + }); + }, + [router, isOwnBrain, showToast, t], + ); + + const connect = useCallback( + async (source: BrainNode, target: BrainNode) => { + const result = await connectItems(source.id, target.id); + if (!result.success) { + showToast({ + type: "error", + title: t("common.error"), + message: t("brain.connectFailed"), + }); + return; + } + showToast({ + type: "success", + title: t("common.success"), + message: t("brain.connected", { + source: source.title, + target: target.title, + }), + }); + router.refresh(); + }, + [router, showToast, t], + ); + + const Canvas = + prefs.dimension === BrainDimensions.THREE_D ? BrainCanvas3D : BrainCanvas2D; + const building = graph.status === RelationsStatus.BUILDING; + const empty = !building && graph.nodes.length === 0; + + return ( + +
+ + +
+ {building && } + + {empty && ( +
+ +

{t("brain.empty")}

+
+ )} + + {!building && !empty && ( + <> + + +
+
+ find(node.id)} + /> +
+
+ + + + {selected && ( +
+ setSelectedId(null)} + /> +
+ )} + + )} +
+
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx new file mode 100644 index 000000000..6e36a5c90 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainButton.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { AiBrain04Icon } from "hugeicons-react"; +import { brainHref } from "@/app/_consts/relations"; +import { useAppMode } from "@/app/_providers/AppModeProvider"; +import { cn } from "@/app/_utils/global-utils"; + +interface BrainButtonProps { + uuid?: string; + className?: string; +} + +export const BrainButton = ({ uuid, className }: BrainButtonProps) => { + const t = useTranslations(); + const { appSettings } = useAppMode(); + + if (!uuid || appSettings?.editor?.enableBilateralLinks === false) return null; + + return ( + + + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx new file mode 100644 index 000000000..c83c67e34 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainCanvas2D.tsx @@ -0,0 +1,349 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph2D, { + ForceGraphMethods, + LinkObject, + NodeObject, +} from "react-force-graph-2d"; +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import { + edgeColour, + endpointId, + isItemNode, + neighbourMap, + nodeColour, + nodeRadius, + rgba, +} from "../utils/brain-graph"; +import { BrainCanvasProps, FLY_MS } from "../utils/brain-canvas"; +import { useElementSize } from "../hooks/useElementSize"; +import { collideForce, gravityForce, regionForce } from "../utils/brain-forces"; +import { paintHalo, paintStamp } from "../utils/brain-glyphs"; + +type CanvasNode = NodeObject & BrainNode; +type CanvasLink = LinkObject & BrainEdge; +type GraphRef = ForceGraphMethods; + +const FOCUS_ZOOM = 3; +const CLICK_SLOP_PX = 6; +const CLICK_MAX_MS = 450; +const DOUBLE_CLICK_MS = 350; +const REGION_PULL = 0.05; +const GRAVITY = 0.012; +const CHARGE = -110; +const CHARGE_REACH = 500; +const LINK_DISTANCE = 42; +const PERSONAL_SPACE = 5; +const LABEL_MAX_CHARS = 28; +const LABEL_FONT_PX = 12; +const LABEL_PAD_PX = 5; +const HUB_LABELS = 10; +const GLYPH_MIN_PX = 7; +const DIM_ALPHA = 0.15; +const FIT_PADDING = 120; +const STAMP_SCALE = 1.05; + +interface Box { + left: number; + top: number; + right: number; + bottom: number; +} + +const _overlaps = (a: Box, b: Box) => + a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom; + +const _clip = (title: string) => + title.length > LABEL_MAX_CHARS ? `${title.slice(0, LABEL_MAX_CHARS - 1)}…` : title; + +export default function BrainCanvas2D({ + nodes, + edges, + focusId, + selectedId, + colourMode, + palette, + flyTo, + onSelect, + onOpen, +}: BrainCanvasProps) { + const graphRef = useRef(undefined); + const settledRef = useRef(false); + const { ref: containerRef, size } = useElementSize(); + const [hoverId, setHoverId] = useState(null); + const hoverRef = useRef(null); + const pressRef = useRef<{ x: number; y: number; at: number } | null>(null); + const lastClickRef = useRef({ id: "", at: 0 }); + + const graphData = useMemo( + () => ({ + nodes: nodes.map((node) => ({ ...node })) as CanvasNode[], + links: edges.map((edge) => ({ ...edge })) as CanvasLink[], + }), + [nodes, edges], + ); + + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + graph.d3Force("charge")?.strength?.(CHARGE)?.distanceMax?.(CHARGE_REACH); + graph.d3Force("link")?.distance?.(LINK_DISTANCE); + graph.d3Force("collide", collideForce((node) => nodeRadius(node, false) + PERSONAL_SPACE)); + graph.d3Force("region", regionForce(REGION_PULL)); + graph.d3Force("gravity", gravityForce(GRAVITY)); + }, [size.width, graphData]); + + const nodeAt = (event: React.PointerEvent): CanvasNode | null => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return null; + const rect = container.getBoundingClientRect(); + const point = graph.screen2GraphCoords(event.clientX - rect.left, event.clientY - rect.top); + let best: CanvasNode | null = null; + let bestDistance = Infinity; + graphData.nodes.forEach((candidate) => { + const distance = Math.hypot((candidate.x || 0) - point.x, (candidate.y || 0) - point.y); + const reach = Math.max(6, nodeRadius(candidate, false)) * 1.4; + if (distance <= reach && distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + }); + return best; + }; + + const onPointerDown = (event: React.PointerEvent) => { + pressRef.current = { x: event.clientX, y: event.clientY, at: Date.now() }; + }; + + const onPointerUp = (event: React.PointerEvent) => { + const press = pressRef.current; + pressRef.current = null; + if (!press || event.button !== 0) return; + const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y); + if (moved > CLICK_SLOP_PX || Date.now() - press.at > CLICK_MAX_MS) return; + + const node = hoverRef.current || nodeAt(event); + if (!node) { + onSelect(null); + return; + } + const now = Date.now(); + const last = lastClickRef.current; + lastClickRef.current = { id: node.id, at: now }; + if (last.id === node.id && now - last.at < DOUBLE_CLICK_MS) onOpen(node); + else onSelect(node.id); + }; + + const neighbours = useMemo(() => neighbourMap(edges, true), [edges]); + const hubs = useMemo( + () => + new Set( + [...nodes] + .filter(isItemNode) + .sort((a, b) => b.degree - a.degree) + .slice(0, HUB_LABELS) + .map((node) => node.id), + ), + [nodes], + ); + + const anchor = hoverId || selectedId || focusId; + const lit = useMemo( + () => + anchor + ? new Set([anchor, ...Array.from(neighbours.get(anchor) || [])]) + : null, + [anchor, neighbours], + ); + + const centreOn = useCallback( + (id: string) => { + const node = graphData.nodes.find((candidate) => candidate.id === id); + if (!node || node.x === undefined || node.y === undefined) return; + graphRef.current?.centerAt(node.x, node.y, FLY_MS); + graphRef.current?.zoom(FOCUS_ZOOM, FLY_MS); + }, + [graphData.nodes], + ); + + useEffect(() => { + if (flyTo) centreOn(flyTo.id); + }, [flyTo, centreOn]); + + const isLitLink = useCallback( + (link: CanvasLink) => + !lit || + (lit.has(endpointId(link.source)) && lit.has(endpointId(link.target))), + [lit], + ); + + const drawNode = useCallback( + (raw: object, ctx: CanvasRenderingContext2D, scale: number) => { + const node = raw as CanvasNode; + const isAnchor = node.id === focusId || node.id === selectedId; + const radius = nodeRadius(node, node.id === focusId) * STAMP_SCALE; + const x = node.x || 0; + const y = node.y || 0; + + ctx.globalAlpha = !lit || lit.has(node.id) ? 1 : DIM_ALPHA; + if (isAnchor || node.id === hoverId) { + paintHalo( + ctx, + x, + y, + radius * 1.55, + rgba(palette.primary, isAnchor ? 1 : 0.55), + Math.max(1.2 / scale, radius * 0.14), + ); + } + paintStamp( + ctx, + node, + x, + y, + radius, + { + fill: nodeColour(node, colourMode, palette), + edge: rgba(palette.background), + mark: rgba(palette.background), + }, + radius * scale >= GLYPH_MIN_PX, + ); + ctx.globalAlpha = 1; + }, + [focusId, selectedId, hoverId, lit, colourMode, palette], + ); + + const labelOrder = useMemo(() => { + const rank = (node: CanvasNode) => { + if (node.id === hoverId) return 0; + if (node.id === selectedId || node.id === focusId) return 1; + if (lit?.has(node.id)) return 2; + if (!lit && hubs.has(node.id)) return 3; + return -1; + }; + return graphData.nodes + .map((node) => ({ node, rank: rank(node) })) + .filter((entry) => entry.rank >= 0) + .sort((a, b) => a.rank - b.rank || b.node.degree - a.node.degree) + .map((entry) => entry.node); + }, [graphData.nodes, hoverId, selectedId, focusId, lit, hubs]); + + const drawLabels = useCallback( + (ctx: CanvasRenderingContext2D, scale: number) => { + const taken: Box[] = []; + const font = LABEL_FONT_PX / scale; + const pad = LABEL_PAD_PX / scale; + ctx.font = `600 ${font}px Inter, system-ui, sans-serif`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + labelOrder.forEach((node) => { + const text = _clip(node.title); + const width = ctx.measureText(text).width + pad * 2; + const height = font + pad * 1.4; + const x = node.x || 0; + const top = (node.y || 0) + nodeRadius(node, node.id === focusId) * STAMP_SCALE * 1.7 + 2 / scale; + const box = { left: x - width / 2, top, right: x + width / 2, bottom: top + height }; + if (taken.some((other) => _overlaps(box, other))) return; + taken.push(box); + + ctx.beginPath(); + ctx.roundRect(box.left, box.top, width, height, height / 2); + ctx.fillStyle = rgba(palette.card); + ctx.fill(); + ctx.lineWidth = 1 / scale; + ctx.strokeStyle = rgba(palette.border); + ctx.stroke(); + ctx.fillStyle = rgba(palette.foreground); + ctx.fillText(text, x, box.top + height / 2); + }); + }, + [labelOrder, focusId, palette], + ); + + const paintHitArea = useCallback( + (raw: object, colour: string, ctx: CanvasRenderingContext2D) => { + const node = raw as CanvasNode; + ctx.fillStyle = colour; + ctx.beginPath(); + ctx.arc( + node.x || 0, + node.y || 0, + Math.max(6, nodeRadius(node, false)), + 0, + 2 * Math.PI, + ); + ctx.fill(); + }, + [], + ); + + return ( +
+ {size.width > 0 && ( + ""} + nodeCanvasObject={drawNode} + onRenderFramePost={drawLabels} + nodePointerAreaPaint={paintHitArea} + linkColor={(link) => + edgeColour( + link as CanvasLink, + palette, + isLitLink(link as CanvasLink), + ) + } + linkWidth={(link) => + lit && isLitLink(link as CanvasLink) ? 1.6 : 0.6 + } + linkVisibility={(link) => { + const canvasLink = link as CanvasLink; + if (canvasLink.kind !== BrainEdgeKinds.SUGGESTED) return true; + return Boolean(anchor) && + (endpointId(canvasLink.source) === anchor || endpointId(canvasLink.target) === anchor); + }} + linkLineDash={(link) => + (link as CanvasLink).kind === BrainEdgeKinds.SUGGESTED + ? [3, 3] + : null + } + linkDirectionalParticles={(link) => + lit && + isLitLink(link as CanvasLink) && + (link as CanvasLink).kind !== BrainEdgeKinds.TAG + ? 3 + : 0 + } + linkDirectionalParticleWidth={2} + linkDirectionalParticleColor={() => rgba(palette.primary, 0.95)} + onNodeHover={(node) => { + hoverRef.current = (node as CanvasNode | null) || null; + setHoverId(hoverRef.current?.id || null); + }} + onNodeRightClick={(node) => onOpen(node as CanvasNode)} + onEngineStop={() => { + if (settledRef.current) return; + settledRef.current = true; + if (focusId) centreOn(focusId); + else graphRef.current?.zoomToFit(600, FIT_PADDING); + }} + cooldownTicks={120} + /> + )} +
+ ); +} diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx new file mode 100644 index 000000000..dede5d182 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainInspector.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + CheckmarkSquare04Icon, + File02Icon, + FileAddIcon, + Link01Icon, + Tag01Icon, + Cancel01Icon, +} from "hugeicons-react"; +import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; + +interface BrainInspectorProps { + node: BrainNode | null; + nodes: Map; + edges: BrainEdge[]; + canEdit: boolean; + onPick: (id: string) => void; + onOpen: (node: BrainNode) => void; + onConnect: (source: BrainNode, target: BrainNode) => Promise; + onClose: () => void; +} + +const KindIcon = ({ node }: { node: BrainNode }) => { + if (node.kind === BrainNodeKinds.CHECKLIST) + return ; + if (node.kind === BrainNodeKinds.TAG) + return ; + if (node.kind === BrainNodeKinds.GHOST) + return ; + return ; +}; + +interface Neighbour { + node: BrainNode; + edge: BrainEdge; + outgoing: boolean; +} + +export const BrainInspector = ({ + node, + nodes, + edges, + canEdit, + onPick, + onOpen, + onConnect, + onClose, +}: BrainInspectorProps) => { + const t = useTranslations(); + const [connecting, setConnecting] = useState(null); + + const neighbours = useMemo(() => { + if (!node) return [] as Neighbour[]; + return edges + .filter((edge) => edge.source === node.id || edge.target === node.id) + .map((edge) => { + const outgoing = edge.source === node.id; + const other = nodes.get(outgoing ? edge.target : edge.source); + return other ? { node: other, edge, outgoing } : null; + }) + .filter((entry): entry is Neighbour => entry !== null); + }, [node, nodes, edges]); + + if (!node) return null; + + const suggested = neighbours + .filter((entry) => entry.edge.kind === BrainEdgeKinds.SUGGESTED) + .sort((a, b) => b.edge.weight - a.edge.weight); + const outgoing = neighbours.filter( + (entry) => entry.outgoing && entry.edge.kind !== BrainEdgeKinds.SUGGESTED, + ); + const incoming = neighbours.filter( + (entry) => !entry.outgoing && entry.edge.kind !== BrainEdgeKinds.SUGGESTED, + ); + const openable = + node.kind === BrainNodeKinds.NOTE || node.kind === BrainNodeKinds.CHECKLIST; + + const connect = async (other: BrainNode) => { + const [source, target] = + node.kind === BrainNodeKinds.NOTE ? [node, other] : [other, node]; + setConnecting(other.id); + try { + await onConnect(source, target); + } finally { + setConnecting(null); + } + }; + + const canConnect = (other: BrainNode) => + canEdit && + (node.kind === BrainNodeKinds.NOTE || other.kind === BrainNodeKinds.NOTE); + + const section = (title: string, entries: Neighbour[], action = false) => + entries.length === 0 ? null : ( +
+
+ {title} ({entries.length}) +
+ {entries.map(({ node: other, edge }) => ( +
+ + {action && canConnect(other) && ( + + )} +
+ ))} +
+ ); + + return ( + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx new file mode 100644 index 000000000..6234992d0 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainLegend.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainGraph } from "@/app/_types/relations"; +import { BrainPalette, edgeColour } from "../utils/brain-graph"; + +interface BrainLegendProps { + graph: BrainGraph; + palette: BrainPalette; + shown: number; + owner: string | null; +} + +const SWATCHES = [ + BrainEdgeKinds.MENTION, + BrainEdgeKinds.WIKI, + BrainEdgeKinds.SUGGESTED, + BrainEdgeKinds.TAG, +]; + +export const BrainLegend = ({ graph, palette, shown, owner }: BrainLegendProps) => { + const t = useTranslations(); + const items = graph.nodes.filter( + (node) => + node.kind === BrainNodeKinds.NOTE || + node.kind === BrainNodeKinds.CHECKLIST, + ).length; + const links = graph.edges.filter( + (edge) => + edge.kind === BrainEdgeKinds.MENTION || edge.kind === BrainEdgeKinds.WIKI, + ).length; + + return ( +
+ {owner && ( +
+ {t("brain.viewingOwner", { owner })} +
+ )} +
{t("brain.summary", { items, links, shown })}
+
+ {SWATCHES.map((kind) => ( + + + {t(`brain.edges.${kind}`)} + + ))} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx new file mode 100644 index 000000000..c00b156a6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainScene3D.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import ForceGraph3D, { ForceGraphMethods, LinkObject, NodeObject } from "react-force-graph-3d"; +import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; +import { BrainEdgeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import { + endpointId, + hex, + isItemNode, + neighbourMap, + nodeRadius, + nodeRgb, + rgba, + solidEdgeColour, +} from "../utils/brain-graph"; +import { + disposeParts, + forgetStamps, + LABEL_PIXEL_SHARE, + NodeParts, + nodeParts, + PartColours, + pickNode, + recolourParts, + tweenParts, +} from "../utils/brain-three"; +import { BrainCanvasProps } from "../utils/brain-canvas"; +import { useElementSize } from "../hooks/useElementSize"; +import { collideForce, gravityForce, regionForce, spreadRadius } from "../utils/brain-forces"; + +type CanvasNode = NodeObject & BrainNode; +type CanvasLink = LinkObject & BrainEdge; +type GraphRef = ForceGraphMethods; + +const TRANSPARENT = "rgba(0,0,0,0)"; +const DOUBLE_CLICK_MS = 350; +const CLICK_SLOP_PX = 6; +const CLICK_MAX_MS = 450; +const PAN_MS = 700; +const FRAME_MS = 900; +const FOCUS_DISTANCE = 110; +const MIN_FRAME_DISTANCE = 140; +const MAX_FRAME_DISTANCE = 1600; +const FRAME_SCALE = 2.3; +const SAFE_ZONE = 0.28; +const IDLE_ROTATE_MS = 25_000; +const AUTO_ROTATE_SPEED = 0.35; +const HUB_LABEL_SHARE = 8; +const MAX_HUB_LABELS = 8; +const CHARGE = -150; +const CHARGE_REACH = 600; +const LINK_DISTANCE = 58; +const REGION_PULL = 0.05; +const GRAVITY = 0.008; +const PERSONAL_SPACE = 7; +const DECLUTTER_EVERY = 6; +const LABEL_GAP_PX = 4; + +const noLabel = () => ""; +const orbitProps = { controlType: "orbit" } as Record; + +export default function BrainScene3D({ + nodes, + edges, + focusId, + selectedId, + colourMode, + palette, + flyTo, + onSelect, + onOpen, +}: BrainCanvasProps) { + const graphRef = useRef(undefined); + const partsRef = useRef(new Map()); + const framedRef = useRef(false); + const lastClickRef = useRef({ id: "", at: 0 }); + const idleTimerRef = useRef | null>(null); + const { ref: containerRef, size } = useElementSize(); + const [hoverId, setHoverId] = useState(null); + + const graphData = useMemo( + () => ({ + nodes: nodes.map((node) => ({ ...node })) as CanvasNode[], + links: edges.map((edge) => ({ ...edge })) as CanvasLink[], + }), + [nodes, edges], + ); + + const neighbours = useMemo(() => neighbourMap(edges, true), [edges]); + const hubs = useMemo(() => { + const count = Math.min(MAX_HUB_LABELS, Math.ceil(nodes.length / HUB_LABEL_SHARE)); + return new Set( + nodes + .filter(isItemNode) + .sort((a, b) => b.degree - a.degree) + .slice(0, count) + .map((node) => node.id), + ); + }, [nodes]); + + const anchor = hoverId || selectedId || focusId; + const lit = useMemo( + () => (anchor ? new Set([anchor, ...Array.from(neighbours.get(anchor) || [])]) : null), + [anchor, neighbours], + ); + + const aim = useCallback( + (parts: NodeParts, id: string) => { + const chosen = id === selectedId || id === focusId; + const shown = !lit || lit.has(id); + parts.target.body = shown ? 1 : 0.16; + parts.target.ring = chosen ? 0.9 : id === hoverId ? 0.45 : 0; + parts.target.label = + shown && (chosen || id === hoverId || Boolean(lit) || hubs.has(id)) ? 1 : 0; + }, + [selectedId, focusId, hoverId, lit, hubs], + ); + const aimRef = useRef(aim); + aimRef.current = aim; + + useEffect(() => { + partsRef.current.forEach(aim); + }, [aim]); + + const nodeByIdRef = useRef(new Map()); + nodeByIdRef.current = new Map(graphData.nodes.map((node) => [node.id, node])); + const priorityRef = useRef<(id: string) => number>(() => 0); + priorityRef.current = (id: string) => { + if (id === hoverId) return 0; + if (id === selectedId || id === focusId) return 1; + return 2; + }; + + const declutter = useCallback(() => { + const graph = graphRef.current; + const container = containerRef.current; + if (!graph || !container) return; + const height = container.clientHeight * LABEL_PIXEL_SHARE; + const wanted: { parts: NodeParts; node: CanvasNode; x: number; y: number }[] = []; + + partsRef.current.forEach((parts, id) => { + const node = nodeByIdRef.current.get(id); + if (!node || parts.target.label === 0 || node.x === undefined) { + parts.crowded = false; + return; + } + const screen = graph.graph2ScreenCoords(node.x || 0, node.y || 0, node.z || 0); + wanted.push({ parts, node, x: screen.x, y: screen.y }); + }); + + wanted.sort( + (a, b) => + priorityRef.current(a.node.id) - priorityRef.current(b.node.id) || + b.node.degree - a.node.degree, + ); + + const taken: { left: number; right: number; top: number; bottom: number }[] = []; + wanted.forEach(({ parts, x, y }) => { + const width = height * parts.labelAspect; + const box = { + left: x - width / 2 - LABEL_GAP_PX, + right: x + width / 2 + LABEL_GAP_PX, + top: y - height * 1.6, + bottom: y - height * 0.2, + }; + parts.crowded = taken.some( + (other) => + box.left < other.right && other.left < box.right && box.top < other.bottom && other.top < box.bottom, + ); + if (!parts.crowded) taken.push(box); + }); + }, [containerRef]); + const declutterRef = useRef(declutter); + declutterRef.current = declutter; + + useEffect(() => { + let frame = 0; + let tick = 0; + const step = () => { + tick = (tick + 1) % DECLUTTER_EVERY; + if (tick === 0) declutterRef.current(); + partsRef.current.forEach(tweenParts); + frame = requestAnimationFrame(step); + }; + frame = requestAnimationFrame(step); + const parts = partsRef.current; + return () => { + cancelAnimationFrame(frame); + parts.forEach(disposeParts); + parts.clear(); + forgetStamps(); + }; + }, []); + + const controls = () => graphRef.current?.controls() as OrbitControls | undefined; + + const pauseRotation = useCallback(() => { + const orbit = controls(); + if (orbit) orbit.autoRotate = false; + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + idleTimerRef.current = setTimeout(() => { + const idle = controls(); + if (idle) idle.autoRotate = true; + }, IDLE_ROTATE_MS); + }, []); + + useEffect(() => { + const orbit = controls(); + if (!orbit) return; + orbit.enableDamping = true; + orbit.dampingFactor = 0.12; + orbit.autoRotateSpeed = AUTO_ROTATE_SPEED; + orbit.addEventListener("start", pauseRotation); + return () => { + orbit.removeEventListener("start", pauseRotation); + if (idleTimerRef.current) clearTimeout(idleTimerRef.current); + }; + }, [size.width, pauseRotation]); + + useEffect(() => { + const graph = graphRef.current; + if (!graph) return; + graph.d3Force("charge")?.strength?.(CHARGE)?.distanceMax?.(CHARGE_REACH); + graph.d3Force("link")?.distance?.(LINK_DISTANCE); + graph.d3Force( + "collide", + collideForce((node) => nodeRadius(node, false) + PERSONAL_SPACE), + ); + graph.d3Force("region", regionForce(REGION_PULL)); + graph.d3Force("gravity", gravityForce(GRAVITY)); + }, [size.width, graphData]); + + const focusCamera = useCallback( + (id: string, closeUp: boolean) => { + const graph = graphRef.current; + const orbit = controls(); + const node = graphData.nodes.find((candidate) => candidate.id === id); + if (!graph || !orbit || !node || node.x === undefined) return; + + const point = { x: node.x || 0, y: node.y || 0, z: node.z || 0 }; + const offset = graph.camera().position.clone().sub(orbit.target); + + if (closeUp) { + offset.setLength(FOCUS_DISTANCE); + } else { + const screen = graph.graph2ScreenCoords(point.x, point.y, point.z); + const dx = Math.abs(screen.x / size.width - 0.5); + const dy = Math.abs(screen.y / size.height - 0.5); + if (dx < SAFE_ZONE && dy < SAFE_ZONE) return; + } + + pauseRotation(); + graph.cameraPosition( + { x: point.x + offset.x, y: point.y + offset.y, z: point.z + offset.z }, + point, + PAN_MS, + ); + }, + [graphData.nodes, size.width, size.height, pauseRotation], + ); + + useEffect(() => { + if (flyTo) focusCamera(flyTo.id, true); + }, [flyTo, focusCamera]); + + const frameGraph = useCallback(() => { + const graph = graphRef.current; + if (!graph) return; + const radius = spreadRadius(graphData.nodes); + const distance = Math.min( + MAX_FRAME_DISTANCE, + Math.max(MIN_FRAME_DISTANCE, radius * FRAME_SCALE), + ); + graph.cameraPosition({ x: 0, y: 0, z: distance }, { x: 0, y: 0, z: 0 }, FRAME_MS); + const orbit = controls(); + if (orbit) orbit.autoRotate = true; + }, [graphData.nodes]); + + const coloursFor = useCallback( + (node: BrainNode): PartColours => ({ + body: hex(nodeRgb(node, colourMode, palette)), + halo: rgba(palette.primary), + edge: rgba(palette.background), + mark: rgba(palette.background), + ink: rgba(palette.foreground), + paper: rgba(palette.card), + border: rgba(palette.border), + }), + [colourMode, palette], + ); + const coloursRef = useRef(coloursFor); + coloursRef.current = coloursFor; + const focusRef = useRef(focusId); + focusRef.current = focusId; + + const buildNode = useCallback((raw: object) => { + const node = raw as CanvasNode; + const previous = partsRef.current.get(node.id); + if (previous) disposeParts(previous); + const parts = nodeParts( + node, + nodeRadius(node, node.id === focusRef.current), + coloursRef.current(node), + ); + aimRef.current(parts, node.id); + partsRef.current.set(node.id, parts); + return parts.group; + }, []); + + useEffect(() => { + graphData.nodes.forEach((node) => { + const parts = partsRef.current.get(node.id); + if (parts) recolourParts(parts, node, coloursFor(node)); + }); + }, [coloursFor, graphData.nodes]); + + const touches = useCallback( + (link: CanvasLink) => + Boolean(anchor) && + (endpointId(link.source) === anchor || endpointId(link.target) === anchor), + [anchor], + ); + + const linkColour = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + if (!lit) return solidEdgeColour(canvasLink, palette, 0.35); + return solidEdgeColour(canvasLink, palette, touches(canvasLink) ? 0 : 0.85); + }, + [lit, palette, touches], + ); + + const linkWidth = useCallback( + (link: object) => (touches(link as CanvasLink) ? 0.9 : 0), + [touches], + ); + + const linkShown = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + return canvasLink.kind !== BrainEdgeKinds.SUGGESTED || touches(canvasLink); + }, + [touches], + ); + + const particles = useCallback( + (link: object) => { + const canvasLink = link as CanvasLink; + return touches(canvasLink) && canvasLink.kind !== BrainEdgeKinds.TAG ? 2 : 0; + }, + [touches], + ); + + const particleColour = useCallback(() => rgba(palette.primary), [palette]); + + const pressRef = useRef<{ x: number; y: number; at: number } | null>(null); + + const handleClick = useCallback( + (node: CanvasNode) => { + const now = Date.now(); + const last = lastClickRef.current; + lastClickRef.current = { id: node.id, at: now }; + if (last.id === node.id && now - last.at < DOUBLE_CLICK_MS) { + onOpen(node); + return; + } + onSelect(node.id); + focusCamera(node.id, false); + }, + [onOpen, onSelect, focusCamera], + ); + + const onPointerDown = (event: React.PointerEvent) => { + pressRef.current = { x: event.clientX, y: event.clientY, at: Date.now() }; + }; + + const onPointerUp = (event: React.PointerEvent) => { + const press = pressRef.current; + pressRef.current = null; + const graph = graphRef.current; + const container = containerRef.current; + if (!press || !graph || !container || event.button !== 0) return; + const moved = Math.hypot(event.clientX - press.x, event.clientY - press.y); + if (moved > CLICK_SLOP_PX || Date.now() - press.at > CLICK_MAX_MS) return; + + const rect = container.getBoundingClientRect(); + const id = pickNode( + partsRef.current, + graph.camera(), + ((event.clientX - rect.left) / rect.width) * 2 - 1, + -((event.clientY - rect.top) / rect.height) * 2 + 1, + ); + const node = id ? graphData.nodes.find((candidate) => candidate.id === id) : null; + if (node) handleClick(node); + else onSelect(null); + }; + + return ( +
+ {size.width > 0 && ( + + (link as CanvasLink).kind === BrainEdgeKinds.SUGGESTED ? 0.25 : 0 + } + linkDirectionalParticles={particles} + linkDirectionalParticleWidth={1.3} + linkDirectionalParticleSpeed={0.006} + linkDirectionalParticleColor={particleColour} + enableNodeDrag={false} + onNodeHover={(node) => { + setHoverId((node as CanvasNode | null)?.id || null); + if (node) pauseRotation(); + }} + onNodeRightClick={(node) => onOpen(node as CanvasNode)} + onEngineStop={() => { + if (framedRef.current) return; + framedRef.current = true; + if (focusId) focusCamera(focusId, true); + else frameGraph(); + }} + warmupTicks={60} + cooldownTicks={160} + /> + )} +
+ ); +} diff --git a/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx b/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx new file mode 100644 index 000000000..663e37d04 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/Parts/BrainToolbar.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; +import { + CubeIcon, + GridViewIcon, + Search01Icon, + Target02Icon, +} from "hugeicons-react"; +import type { BrainNode } from "@/app/_types/relations"; +import { + BrainColourModes, + BrainDimensions, + BrainFilters, + BrainScope, + MAX_DEPTH, + searchNodes, +} from "../utils/brain-graph"; + +interface BrainToolbarProps { + nodes: BrainNode[]; + dimension: BrainDimensions; + scope: BrainScope; + filters: BrainFilters; + colourMode: BrainColourModes; + hasFocus: boolean; + webglReady: boolean; + onDimension: (dimension: BrainDimensions) => void; + onScope: (scope: BrainScope) => void; + onFilters: (filters: BrainFilters) => void; + onColourMode: (mode: BrainColourModes) => void; + onFind: (node: BrainNode) => void; +} + +const FILTER_KEYS: Array = [ + "notes", + "checklists", + "tags", + "ghosts", + "suggestions", + "orphans", +]; + +const chip = (active: boolean) => + `rounded-full border px-3 py-1 text-md lg:text-xs transition-colors ${ + active + ? "border-primary bg-primary text-primary-foreground" + : "border-border bg-background text-muted-foreground hover:text-foreground" + }`; + +export const BrainToolbar = ({ + nodes, + dimension, + scope, + filters, + colourMode, + hasFocus, + webglReady, + onDimension, + onScope, + onFilters, + onColourMode, + onFind, +}: BrainToolbarProps) => { + const t = useTranslations(); + const [query, setQuery] = useState(""); + const matches = useMemo(() => searchNodes(nodes, query), [nodes, query]); + + return ( +
+
+
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && matches[0]) { + onFind(matches[0]); + setQuery(""); + } + }} + placeholder={t("brain.search")} + aria-label={t("brain.search")} + className="h-10 w-full rounded-jotty border border-input bg-background pl-8 pr-3 text-md lg:text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + {matches.length > 0 && ( +
    + {matches.map((node) => ( +
  • + +
  • + ))} +
+ )} +
+ +
+ {[BrainDimensions.THREE_D, BrainDimensions.TWO_D].map((option) => ( + + ))} +
+ + {hasFocus && ( + + )} + + {scope.local && hasFocus && ( + + )} + + +
+ +
+ {FILTER_KEYS.map((key) => ( + + ))} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts b/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts new file mode 100644 index 000000000..909b49c92 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useBrainPalette.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { BrainPalette, Rgb } from "../utils/brain-graph"; + +const FALLBACK: BrainPalette = { + primary: [139, 59, 208], + foreground: [15, 23, 42], + muted: [100, 116, 139], + background: [255, 255, 255], + card: [255, 255, 255], + border: [226, 232, 240], +}; + +const _triple = ( + styles: CSSStyleDeclaration, + name: string, + fallback: Rgb, +): Rgb => { + const parts = styles + .getPropertyValue(name) + .trim() + .split(/[\s,]+/) + .map(Number) + .filter((value) => Number.isFinite(value)); + return parts.length >= 3 ? [parts[0], parts[1], parts[2]] : fallback; +}; + +const _read = (): BrainPalette => { + const styles = getComputedStyle(document.documentElement); + return { + primary: _triple(styles, "--primary", FALLBACK.primary), + foreground: _triple(styles, "--foreground", FALLBACK.foreground), + muted: _triple(styles, "--muted-foreground", FALLBACK.muted), + background: _triple(styles, "--background", FALLBACK.background), + card: _triple(styles, "--card", FALLBACK.card), + border: _triple(styles, "--border", FALLBACK.border), + }; +}; + +export const useBrainPalette = (): BrainPalette => { + const [palette, setPalette] = useState(FALLBACK); + + useEffect(() => { + const update = () => setPalette(_read()); + update(); + const observer = new MutationObserver(update); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class", "style", "data-theme"], + }); + return () => observer.disconnect(); + }, []); + + return palette; +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts b/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts new file mode 100644 index 000000000..aefee22b6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useBrainPrefs.ts @@ -0,0 +1,105 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { + BrainColourModes, + BrainDimensions, + BrainFilters, + BrainScope, + DEFAULT_FILTERS, +} from "../utils/brain-graph"; + +const STORAGE_KEY = "jotty-brain-prefs"; +const LOCAL_DEPTH = 2; + +interface StoredPrefs { + dimension?: BrainDimensions; + filters?: Partial; + colourMode?: BrainColourModes; + depth?: number; +} + +const _load = (): StoredPrefs => { + try { + return JSON.parse( + window.localStorage.getItem(STORAGE_KEY) || "{}", + ) as StoredPrefs; + } catch { + return {}; + } +}; + +const _save = (prefs: StoredPrefs) => { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + return; + } +}; + +const _webgl = (): boolean => { + try { + const canvas = document.createElement("canvas"); + return Boolean(canvas.getContext("webgl2") || canvas.getContext("webgl")); + } catch { + return false; + } +}; + +const _prefersFlat = (): boolean => + window.matchMedia("(pointer: coarse)").matches || + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export const useBrainPrefs = (hasFocus: boolean) => { + const [dimension, setDimensionState] = useState(BrainDimensions.TWO_D); + const [filters, setFiltersState] = useState(DEFAULT_FILTERS); + const [colourMode, setColourModeState] = useState(BrainColourModes.TYPE); + const [scope, setScopeState] = useState({ + local: hasFocus, + depth: LOCAL_DEPTH, + }); + const [webglReady, setWebglReady] = useState(false); + + useEffect(() => { + const stored = _load(); + const ready = _webgl(); + setWebglReady(ready); + const wanted = + stored.dimension || + (_prefersFlat() ? BrainDimensions.TWO_D : BrainDimensions.THREE_D); + setDimensionState(ready ? wanted : BrainDimensions.TWO_D); + setFiltersState({ ...DEFAULT_FILTERS, ...stored.filters }); + if (stored.colourMode) setColourModeState(stored.colourMode); + if (stored.depth) + setScopeState((current) => ({ ...current, depth: stored.depth! })); + }, []); + + const persist = useCallback( + (patch: StoredPrefs) => _save({ ..._load(), ...patch }), + [], + ); + + return { + dimension, + filters, + colourMode, + scope, + webglReady, + setDimension: (next: BrainDimensions) => { + setDimensionState(next); + persist({ dimension: next }); + }, + setFilters: (next: BrainFilters) => { + setFiltersState(next); + persist({ filters: next }); + }, + setColourMode: (next: BrainColourModes) => { + setColourModeState(next); + persist({ colourMode: next }); + }, + setScope: (next: BrainScope) => { + setScopeState(next); + persist({ depth: next.depth }); + }, + }; +}; diff --git a/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts b/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts new file mode 100644 index 000000000..5e68deb3f --- /dev/null +++ b/app/_components/FeatureComponents/Brain/hooks/useElementSize.ts @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +export const useElementSize = () => { + const ref = useRef(null); + const [size, setSize] = useState({ width: 0, height: 0 }); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + const update = () => { + const rect = element.getBoundingClientRect(); + setSize({ + width: Math.round(rect.width), + height: Math.round(rect.height), + }); + }; + + update(); + const observer = new ResizeObserver(update); + observer.observe(element); + return () => observer.disconnect(); + }, []); + + return { ref, size }; +}; diff --git a/app/_components/FeatureComponents/Brain/server/BrainPage.tsx b/app/_components/FeatureComponents/Brain/server/BrainPage.tsx new file mode 100644 index 000000000..51ac3c48b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/server/BrainPage.tsx @@ -0,0 +1,33 @@ +import { notFound, redirect } from "next/navigation"; +import { getBrain } from "@/app/_server/actions/relations"; +import { getCurrentUser } from "@/app/_server/actions/users"; +import { getCategories } from "@/app/_server/actions/category"; +import { Modes } from "@/app/_types/enums"; +import { isUuid } from "@/app/_consts/identity"; +import { BrainPageClient } from "../BrainPageClient"; + +interface BrainPageProps { + username?: string; + focus?: string; +} + +export const BrainPage = async ({ username, focus }: BrainPageProps) => { + const user = await getCurrentUser(); + if (!user?.username) redirect("/"); + + const [brain, categories] = await Promise.all([ + getBrain(username), + getCategories(Modes.NOTES), + ]); + if (!brain.success || !brain.data) notFound(); + + return ( + + ); +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts b/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts new file mode 100644 index 000000000..ea8ca254b --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-canvas.ts @@ -0,0 +1,17 @@ +import type { BrainEdge, BrainNode } from "@/app/_types/relations"; +import type { BrainColourModes, BrainPalette } from "./brain-graph"; + +export interface BrainCanvasProps { + nodes: BrainNode[]; + edges: BrainEdge[]; + focusId: string | null; + selectedId: string | null; + colourMode: BrainColourModes; + palette: BrainPalette; + flyTo: { id: string; at: number } | null; + onSelect: (id: string | null) => void; + onOpen: (node: BrainNode) => void; +} + +export const FLY_MS = 1100; +export const FLY_DISTANCE = 80; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-forces.ts b/app/_components/FeatureComponents/Brain/utils/brain-forces.ts new file mode 100644 index 000000000..79f4dbed6 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-forces.ts @@ -0,0 +1,117 @@ +import type { BrainNode } from "@/app/_types/relations"; +import { isItemNode } from "./brain-graph"; + +interface SimNode extends BrainNode { + x?: number; + y?: number; + z?: number; + vx?: number; + vy?: number; + vz?: number; +} + +type Force = ((alpha: number) => void) & { initialize: (nodes: SimNode[]) => void }; + +const AXES = ["x", "y", "z"] as const; +const VELOCITY = { x: "vx", y: "vy", z: "vz" } as const; + +const _region = (node: SimNode): string | null => + isItemNode(node) ? node.category || null : null; + +const _force = (tick: (nodes: SimNode[], alpha: number) => void): Force => { + let nodes: SimNode[] = []; + const force = ((alpha: number) => tick(nodes, alpha)) as Force; + force.initialize = (next) => { + nodes = next; + }; + return force; +}; + +export const regionForce = (strength: number): Force => + _force((nodes, alpha) => { + const centres = new Map(); + nodes.forEach((node) => { + const region = _region(node); + if (!region) return; + const centre = centres.get(region) || { x: 0, y: 0, z: 0, count: 0 }; + centre.x += node.x || 0; + centre.y += node.y || 0; + centre.z += node.z || 0; + centre.count += 1; + centres.set(region, centre); + }); + + nodes.forEach((node) => { + const region = _region(node); + const centre = region ? centres.get(region) : undefined; + if (!centre || centre.count < 2) return; + AXES.forEach((axis) => { + const target = centre[axis] / centre.count; + const key = VELOCITY[axis]; + node[key] = (node[key] || 0) + (target - (node[axis] || 0)) * strength * alpha; + }); + }); + }); + +export const gravityForce = (strength: number): Force => + _force((nodes, alpha) => { + nodes.forEach((node) => { + AXES.forEach((axis) => { + const key = VELOCITY[axis]; + node[key] = (node[key] || 0) - (node[axis] || 0) * strength * alpha; + }); + }); + }); + +export const spreadRadius = (nodes: SimNode[], share = 0.9): number => { + const distances = nodes + .map((node) => Math.hypot(node.x || 0, node.y || 0, node.z || 0)) + .sort((a, b) => a - b); + if (distances.length === 0) return 0; + return distances[Math.min(distances.length - 1, Math.floor(distances.length * share))]; +}; + +const _cellKey = (x: number, y: number, z: number) => `${x},${y},${z}`; + +export const collideForce = (radiusOf: (node: SimNode) => number, strength = 0.7): Force => + _force((nodes) => { + const reach = nodes.reduce((max, node) => Math.max(max, radiusOf(node)), 1) * 2; + const grid = new Map(); + const cell = (value?: number) => Math.floor((value || 0) / reach); + + nodes.forEach((node) => { + const key = _cellKey(cell(node.x), cell(node.y), cell(node.z)); + const bucket = grid.get(key); + if (bucket) bucket.push(node); + else grid.set(key, [node]); + }); + + nodes.forEach((node) => { + const [cx, cy, cz] = [cell(node.x), cell(node.y), cell(node.z)]; + const mine = radiusOf(node); + for (let dx = -1; dx <= 1; dx++) { + for (let dy = -1; dy <= 1; dy++) { + for (let dz = -1; dz <= 1; dz++) { + grid.get(_cellKey(cx + dx, cy + dy, cz + dz))?.forEach((other) => { + if (other === node || other.id > node.id) return; + const gap = mine + radiusOf(other); + const x = (node.x || 0) - (other.x || 0); + const y = (node.y || 0) - (other.y || 0); + const z = (node.z || 0) - (other.z || 0); + const distance = Math.hypot(x, y, z) || 0.01; + if (distance >= gap) return; + const push = ((gap - distance) / distance) * strength * 0.5; + node.vx = (node.vx || 0) + x * push; + node.vy = (node.vy || 0) + y * push; + other.vx = (other.vx || 0) - x * push; + other.vy = (other.vy || 0) - y * push; + if (node.z !== undefined) { + node.vz = (node.vz || 0) + z * push; + other.vz = (other.vz || 0) - z * push; + } + }); + } + } + } + }); + }); diff --git a/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts b/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts new file mode 100644 index 000000000..17b0306c0 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-glyphs.ts @@ -0,0 +1,185 @@ +import type { ReactElement } from "react"; +import { + CheckmarkSquare04Icon, + File02Icon, + FileAddIcon, + Tag01Icon, + TaskDaily01Icon, +} from "hugeicons-react"; +import { BrainNodeKinds } from "@/app/_consts/relations"; +import { isKanbanType } from "@/app/_types/enums"; +import type { BrainNode } from "@/app/_types/relations"; + +export enum NodeShapes { + DISC = "disc", + TILE = "tile", + GEM = "gem", + HOLLOW = "hollow", +} + +export enum NodeGlyphs { + PAGE = "page", + TICK = "tick", + COLUMNS = "columns", + HASH = "hash", + PLUS = "plus", +} + +export interface StampInk { + fill: string; + edge: string; + mark: string; +} + +const EDGE_SHARE = 0.16; +const TILE_CORNER = 0.34; +const ICON_SHARE = 1.3; + +export const shapeOf = (node: BrainNode): NodeShapes => { + if (node.kind === BrainNodeKinds.GHOST) return NodeShapes.HOLLOW; + if (node.kind === BrainNodeKinds.TAG) return NodeShapes.GEM; + if (node.kind === BrainNodeKinds.CHECKLIST) return NodeShapes.TILE; + return NodeShapes.DISC; +}; + +export const glyphOf = (node: BrainNode): NodeGlyphs => { + if (node.kind === BrainNodeKinds.GHOST) return NodeGlyphs.PLUS; + if (node.kind === BrainNodeKinds.TAG) return NodeGlyphs.HASH; + if (node.kind === BrainNodeKinds.CHECKLIST) { + return isKanbanType(node.checklistType) ? NodeGlyphs.COLUMNS : NodeGlyphs.TICK; + } + return NodeGlyphs.PAGE; +}; + +export const stampKey = (node: BrainNode, ink: StampInk): string => + [shapeOf(node), glyphOf(node), ink.fill, ink.edge, ink.mark].join("|"); + +const _roundRect = (ctx: CanvasRenderingContext2D, x: number, y: number, size: number, corner: number) => { + const half = size / 2; + ctx.beginPath(); + ctx.moveTo(x - half + corner, y - half); + ctx.arcTo(x + half, y - half, x + half, y + half, corner); + ctx.arcTo(x + half, y + half, x - half, y + half, corner); + ctx.arcTo(x - half, y + half, x - half, y - half, corner); + ctx.arcTo(x - half, y - half, x + half, y - half, corner); + ctx.closePath(); +}; + +const _outline = (ctx: CanvasRenderingContext2D, shape: NodeShapes, x: number, y: number, r: number) => { + if (shape === NodeShapes.TILE) { + _roundRect(ctx, x, y, r * 1.8, r * TILE_CORNER * 1.8); + return; + } + if (shape === NodeShapes.GEM) { + ctx.beginPath(); + ctx.moveTo(x, y - r); + ctx.lineTo(x + r, y); + ctx.lineTo(x, y + r); + ctx.lineTo(x - r, y); + ctx.closePath(); + return; + } + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); +}; + +type IconChild = ReactElement<{ d?: string }> | IconChild[] | null | undefined; + +type IconRender = { + render: (props: object, ref: null) => ReactElement<{ children?: IconChild }>; +}; + +const _flatten = (child: IconChild): ReactElement<{ d?: string }>[] => + Array.isArray(child) ? child.flatMap(_flatten) : child ? [child] : []; + +const GLYPH_ICONS: Record = { + [NodeGlyphs.PAGE]: File02Icon, + [NodeGlyphs.TICK]: CheckmarkSquare04Icon, + [NodeGlyphs.COLUMNS]: TaskDaily01Icon, + [NodeGlyphs.HASH]: Tag01Icon, + [NodeGlyphs.PLUS]: FileAddIcon, +}; + +const ICON_BOX = 24; +const ICON_STROKE = 1.9; +const iconPaths = new Map(); + +const _pathsOf = (glyph: NodeGlyphs): Path2D[] => { + const cached = iconPaths.get(glyph); + if (cached) return cached; + const svg = (GLYPH_ICONS[glyph] as IconRender).render({}, null); + const paths = _flatten(svg.props.children) + .map((child) => child.props.d) + .filter((d): d is string => Boolean(d)) + .map((d) => new Path2D(d)); + iconPaths.set(glyph, paths); + return paths; +}; + +const _glyph = (ctx: CanvasRenderingContext2D, glyph: NodeGlyphs, x: number, y: number, size: number) => { + ctx.save(); + ctx.translate(x - size / 2, y - size / 2); + ctx.scale(size / ICON_BOX, size / ICON_BOX); + ctx.lineWidth = ICON_STROKE; + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + _pathsOf(glyph).forEach((path) => ctx.stroke(path)); + ctx.restore(); +}; + +export const paintStamp = ( + ctx: CanvasRenderingContext2D, + node: BrainNode, + x: number, + y: number, + r: number, + ink: StampInk, + withGlyph = true, +) => { + const shape = shapeOf(node); + const edge = r * EDGE_SHARE; + + if (shape === NodeShapes.HOLLOW) { + _outline(ctx, NodeShapes.DISC, x, y, r + edge / 2); + ctx.fillStyle = ink.edge; + ctx.fill(); + _outline(ctx, NodeShapes.DISC, x, y, r - edge / 2); + ctx.setLineDash([r * 0.45, r * 0.3]); + ctx.lineWidth = edge; + ctx.strokeStyle = ink.fill; + ctx.stroke(); + ctx.setLineDash([]); + if (withGlyph) { + ctx.strokeStyle = ink.fill; + _glyph(ctx, NodeGlyphs.PLUS, x, y, r * ICON_SHARE); + } + return; + } + + _outline(ctx, shape, x, y, r + edge); + ctx.fillStyle = ink.edge; + ctx.fill(); + _outline(ctx, shape, x, y, r); + ctx.fillStyle = ink.fill; + ctx.fill(); + + if (withGlyph) { + ctx.strokeStyle = ink.mark; + _glyph(ctx, glyphOf(node), x, y, r * (shape === NodeShapes.GEM ? ICON_SHARE * 0.8 : ICON_SHARE)); + } +}; + +export const paintHalo = ( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + colour: string, + width: number, +) => { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.lineWidth = width; + ctx.strokeStyle = colour; + ctx.stroke(); +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-graph.ts b/app/_components/FeatureComponents/Brain/utils/brain-graph.ts new file mode 100644 index 000000000..accc7b6a0 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-graph.ts @@ -0,0 +1,219 @@ +import { BrainEdgeKinds, BrainNodeKinds } from "@/app/_consts/relations"; +import type { BrainEdge, BrainGraph, BrainNode } from "@/app/_types/relations"; + +export enum BrainDimensions { + THREE_D = "3d", + TWO_D = "2d", +} + +export enum BrainColourModes { + TYPE = "type", + CATEGORY = "category", +} + +export interface BrainFilters { + notes: boolean; + checklists: boolean; + tags: boolean; + ghosts: boolean; + suggestions: boolean; + orphans: boolean; +} + +export interface BrainScope { + local: boolean; + depth: number; +} + +export interface BrainView { + nodes: BrainNode[]; + edges: BrainEdge[]; +} + +export const DEFAULT_FILTERS: BrainFilters = { + notes: true, + checklists: true, + tags: false, + ghosts: true, + suggestions: true, + orphans: true, +}; + +export const MAX_DEPTH = 3; + +const KIND_FILTER: Record = { + [BrainNodeKinds.NOTE]: "notes", + [BrainNodeKinds.CHECKLIST]: "checklists", + [BrainNodeKinds.TAG]: "tags", + [BrainNodeKinds.GHOST]: "ghosts", +}; + +export const isItemNode = (node?: BrainNode | null): boolean => + node?.kind === BrainNodeKinds.NOTE || node?.kind === BrainNodeKinds.CHECKLIST; + +export const neighbourMap = (edges: BrainEdge[], withSuggested = false) => { + const map = new Map>(); + const add = (from: string, to: string) => { + if (!map.has(from)) map.set(from, new Set()); + map.get(from)!.add(to); + }; + edges.forEach((edge) => { + if (!withSuggested && edge.kind === BrainEdgeKinds.SUGGESTED) return; + add(edge.source, edge.target); + add(edge.target, edge.source); + }); + return map; +}; + +const _within = ( + focus: string, + neighbours: Map>, + depth: number, +) => { + const reached = new Set([focus]); + let frontier = [focus]; + for (let level = 0; level < depth; level++) { + const next: string[] = []; + frontier.forEach((id) => + neighbours.get(id)?.forEach((other) => { + if (reached.has(other)) return; + reached.add(other); + next.push(other); + }), + ); + frontier = next; + } + return reached; +}; + +export const brainView = ( + graph: BrainGraph, + filters: BrainFilters, + scope: BrainScope, + focus: string | null, +): BrainView => { + const edges = graph.edges.filter( + (edge) => + (filters.suggestions || edge.kind !== BrainEdgeKinds.SUGGESTED) && + (filters.tags || edge.kind !== BrainEdgeKinds.TAG), + ); + const linked = neighbourMap(edges); + const local = + scope.local && focus + ? _within(focus, neighbourMap(edges, true), scope.depth) + : null; + + const nodes = graph.nodes.filter((node) => { + if (!filters[KIND_FILTER[node.kind]]) return false; + if (local && !local.has(node.id)) return false; + if (!filters.orphans && !linked.has(node.id) && node.id !== focus) + return false; + return true; + }); + + const kept = new Set(nodes.map((node) => node.id)); + return { + nodes, + edges: edges.filter( + (edge) => kept.has(edge.source) && kept.has(edge.target), + ), + }; +}; + +export const searchNodes = ( + nodes: BrainNode[], + query: string, + limit = 8, +): BrainNode[] => { + const needle = query.trim().toLowerCase(); + if (!needle) return []; + return nodes + .filter((node) => node.title.toLowerCase().includes(needle)) + .sort((a, b) => b.degree - a.degree) + .slice(0, limit); +}; + +const _hue = (text: string): number => { + let hash = 0; + for (let i = 0; i < text.length; i++) + hash = (hash * 31 + text.charCodeAt(i)) >>> 0; + return hash % 360; +}; + +export type Rgb = [number, number, number]; + +export interface BrainPalette { + primary: Rgb; + foreground: Rgb; + muted: Rgb; + background: Rgb; + card: Rgb; + border: Rgb; +} + +export const mix = (from: Rgb, to: Rgb, amount: number): Rgb => [ + Math.round(from[0] + (to[0] - from[0]) * amount), + Math.round(from[1] + (to[1] - from[1]) * amount), + Math.round(from[2] + (to[2] - from[2]) * amount), +]; + +export const rgba = ([r, g, b]: Rgb, alpha = 1): string => + `rgba(${r}, ${g}, ${b}, ${alpha})`; + +export const hex = ([r, g, b]: Rgb): string => + `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, "0")).join("")}`; + +const _categoryTint = (category: string, palette: BrainPalette): Rgb => { + const spread = (_hue(category) % 7) / 7; + return spread < 0.5 + ? mix(palette.primary, palette.foreground, spread * 0.9) + : mix(palette.primary, palette.background, (spread - 0.5) * 0.9); +}; + +export const nodeRgb = ( + node: BrainNode, + mode: BrainColourModes, + palette: BrainPalette, +): Rgb => { + if (node.kind === BrainNodeKinds.GHOST) return palette.muted; + if (node.kind === BrainNodeKinds.TAG) + return mix(palette.primary, palette.background, 0.45); + if (mode === BrainColourModes.CATEGORY) { + return _categoryTint(node.category || node.kind, palette); + } + return node.kind === BrainNodeKinds.CHECKLIST + ? mix(palette.primary, palette.foreground, 0.45) + : palette.primary; +}; + +export const nodeColour = ( + node: BrainNode, + mode: BrainColourModes, + palette: BrainPalette, +): string => rgba(nodeRgb(node, mode, palette)); + +export const edgeRgb = (edge: BrainEdge, palette: BrainPalette): Rgb => { + if (edge.kind === BrainEdgeKinds.SUGGESTED) return palette.muted; + if (edge.kind === BrainEdgeKinds.TAG) return mix(palette.primary, palette.background, 0.55); + if (edge.kind === BrainEdgeKinds.WIKI) return palette.primary; + return mix(palette.foreground, palette.background, 0.35); +}; + +export const edgeColour = (edge: BrainEdge, palette: BrainPalette, lit: boolean): string => + rgba(edgeRgb(edge, palette), lit ? 0.85 : 0.25); + +export const solidEdgeColour = (edge: BrainEdge, palette: BrainPalette, dim: number): string => + rgba(mix(edgeRgb(edge, palette), palette.background, dim)); + +export const nodeRadius = (node: BrainNode, emphasised: boolean): number => { + if (node.kind === BrainNodeKinds.TAG) return 2.2; + const base = Math.min(9, 3.2 + Math.sqrt(node.degree) * 1.1); + return emphasised ? base * 1.35 : base; +}; + +export const endpointId = (value: unknown): string => { + if (typeof value === "string") return value; + if (value && typeof value === "object" && "id" in value) + return String((value as { id: unknown }).id); + return ""; +}; diff --git a/app/_components/FeatureComponents/Brain/utils/brain-three.ts b/app/_components/FeatureComponents/Brain/utils/brain-three.ts new file mode 100644 index 000000000..8ea65daf2 --- /dev/null +++ b/app/_components/FeatureComponents/Brain/utils/brain-three.ts @@ -0,0 +1,274 @@ +import * as THREE from "three"; +import type { BrainNode } from "@/app/_types/relations"; +import { paintStamp, stampKey, StampInk } from "./brain-glyphs"; + +export interface PaintTarget { + body: number; + ring: number; + label: number; +} + +export interface NodeParts { + group: THREE.Group; + body: THREE.Sprite; + hit: THREE.Mesh; + ring: THREE.Sprite; + label: THREE.Sprite; + labelAspect: number; + crowded: boolean; + target: PaintTarget; +} + +const LABEL_FONT_PX = 40; +const LABEL_PAD_X = 18; +const LABEL_PAD_Y = 10; +const LABEL_MAX_CHARS = 32; +const LABEL_SCREEN_HEIGHT = 0.026; +export const LABEL_PIXEL_SHARE = LABEL_SCREEN_HEIGHT * 1.35; +const LABEL_LIFT = 3; +const RING_TEXTURE_PX = 128; +const STAMP_TEXTURE_PX = 128; +const STAMP_PADDING = 1.25; +const TWEEN_RATE = 0.2; +const SETTLED = 0.004; +const HIT_SCALE = 1.8; +const HIT_MIN_EXTRA = 2.5; +const PICKABLE_OPACITY = 0.3; + +const _ignoreRaycast = () => undefined; + +const stamps = new Map(); + +const _stampTexture = (node: BrainNode, ink: StampInk): THREE.CanvasTexture => { + const key = stampKey(node, ink); + const cached = stamps.get(key); + if (cached) return cached; + + const canvas = document.createElement("canvas"); + canvas.width = STAMP_TEXTURE_PX; + canvas.height = STAMP_TEXTURE_PX; + const context = canvas.getContext("2d")!; + const centre = STAMP_TEXTURE_PX / 2; + paintStamp(context, node, centre, centre, centre / STAMP_PADDING / 1.16, ink); + + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + stamps.set(key, texture); + return texture; +}; + +export const forgetStamps = () => { + stamps.forEach((texture) => texture.dispose()); + stamps.clear(); +}; + +const _stampSprite = (node: BrainNode, radius: number, ink: StampInk): THREE.Sprite => { + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: _stampTexture(node, ink), + transparent: true, + alphaTest: 0.05, + opacity: 1, + depthWrite: true, + }), + ); + sprite.raycast = _ignoreRaycast; + sprite.renderOrder = 5; + const size = radius * 2 * STAMP_PADDING; + sprite.scale.set(size, size, 1); + return sprite; +}; + +const _pill = (context: CanvasRenderingContext2D, width: number, height: number) => { + const radius = height / 2; + context.beginPath(); + context.moveTo(radius, 0); + context.arcTo(width, 0, width, height, radius); + context.arcTo(width, height, 0, height, radius); + context.arcTo(0, height, 0, 0, radius); + context.arcTo(0, 0, width, 0, radius); + context.closePath(); +}; + +const _sprite = (canvas: HTMLCanvasElement, fixedSize = false): THREE.Sprite => { + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + const sprite = new THREE.Sprite( + new THREE.SpriteMaterial({ + map: texture, + transparent: true, + opacity: 0, + depthWrite: false, + depthTest: false, + sizeAttenuation: !fixedSize, + }), + ); + sprite.raycast = _ignoreRaycast; + sprite.visible = false; + return sprite; +}; + +const _labelCanvas = (text: string, ink: string, paper: string, border: string): HTMLCanvasElement => { + const shown = text.length > LABEL_MAX_CHARS ? `${text.slice(0, LABEL_MAX_CHARS - 1)}…` : text; + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d")!; + const font = `600 ${LABEL_FONT_PX}px Inter, system-ui, sans-serif`; + context.font = font; + canvas.width = Math.ceil(context.measureText(shown).width) + LABEL_PAD_X * 2; + canvas.height = LABEL_FONT_PX + LABEL_PAD_Y * 2; + + _pill(context, canvas.width, canvas.height); + context.fillStyle = paper; + context.fill(); + context.lineWidth = 3; + context.strokeStyle = border; + context.stroke(); + context.font = font; + context.textBaseline = "middle"; + context.fillStyle = ink; + context.fillText(shown, LABEL_PAD_X, canvas.height / 2 + 2); + return canvas; +}; + +const _labelSprite = (text: string, ink: string, paper: string, border: string): THREE.Sprite => { + const canvas = _labelCanvas(text, ink, paper, border); + const sprite = _sprite(canvas, true); + sprite.renderOrder = 20; + sprite.center.set(0.5, 0); + sprite.scale.set((LABEL_SCREEN_HEIGHT * canvas.width) / canvas.height, LABEL_SCREEN_HEIGHT, 1); + return sprite; +}; + +const _ringCanvas = (colour: string): HTMLCanvasElement => { + const canvas = document.createElement("canvas"); + canvas.width = RING_TEXTURE_PX; + canvas.height = RING_TEXTURE_PX; + const context = canvas.getContext("2d")!; + context.beginPath(); + context.arc(RING_TEXTURE_PX / 2, RING_TEXTURE_PX / 2, RING_TEXTURE_PX / 2 - 6, 0, Math.PI * 2); + context.lineWidth = 6; + context.strokeStyle = colour; + context.stroke(); + return canvas; +}; + +const _ringSprite = (colour: string, radius: number): THREE.Sprite => { + const sprite = _sprite(_ringCanvas(colour)); + sprite.renderOrder = 10; + sprite.scale.set(radius * 3, radius * 3, 1); + return sprite; +}; + +export interface PartColours { + body: string; + halo: string; + edge: string; + mark: string; + ink: string; + paper: string; + border: string; +} + +const _stampInk = (colours: PartColours): StampInk => ({ + fill: colours.body, + edge: colours.edge, + mark: colours.mark, +}); + +export const nodeParts = (node: BrainNode, radius: number, colours: PartColours): NodeParts => { + const group = new THREE.Group(); + const body = _stampSprite(node, radius, _stampInk(colours)); + + const hit = new THREE.Mesh( + new THREE.SphereGeometry(Math.max(radius * HIT_SCALE, radius + HIT_MIN_EXTRA), 12, 8), + new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, depthWrite: false, colorWrite: false }), + ); + + hit.userData.nodeId = node.id; + + const ring = _ringSprite(colours.halo, radius); + const label = _labelSprite(node.title, colours.ink, colours.paper, colours.border); + label.position.set(0, radius + LABEL_LIFT, 0); + + group.add(body, hit, ring, label); + return { + group, + body, + hit, + ring, + label, + labelAspect: label.scale.x / label.scale.y, + crowded: false, + target: { body: 1, ring: 0, label: 0 }, + }; +}; + +const raycaster = new THREE.Raycaster(); +const pointer = new THREE.Vector2(); + +export const pickNode = ( + parts: Map, + camera: THREE.Camera, + ndcX: number, + ndcY: number, +): string | null => { + pointer.set(ndcX, ndcY); + raycaster.setFromCamera(pointer, camera); + const targets = Array.from(parts.values()) + .filter((part) => part.body.material.opacity > PICKABLE_OPACITY) + .map((part) => part.hit); + const [first] = raycaster.intersectObjects(targets, false); + return (first?.object.userData.nodeId as string | undefined) || null; +}; + +const _swapTexture = (sprite: THREE.Sprite, canvas: HTMLCanvasElement) => { + const texture = new THREE.CanvasTexture(canvas); + texture.colorSpace = THREE.SRGBColorSpace; + texture.anisotropy = 4; + sprite.material.map?.dispose(); + sprite.material.map = texture; + sprite.material.needsUpdate = true; +}; + +export const recolourParts = (parts: NodeParts, node: BrainNode, colours: PartColours) => { + const title = node.title; + parts.body.material.map = _stampTexture(node, _stampInk(colours)); + parts.body.material.needsUpdate = true; + _swapTexture(parts.ring, _ringCanvas(colours.halo)); + const canvas = _labelCanvas(title, colours.ink, colours.paper, colours.border); + _swapTexture(parts.label, canvas); +}; + +const _toward = (current: number, target: number): [number, boolean] => { + const next = current + (target - current) * TWEEN_RATE; + return Math.abs(target - next) < SETTLED ? [target, false] : [next, true]; +}; + +export const tweenParts = (parts: NodeParts): boolean => { + const [body, bodyMoving] = _toward(parts.body.material.opacity, parts.target.body); + const [ring, ringMoving] = _toward(parts.ring.material.opacity, parts.target.ring); + const [label, labelMoving] = _toward( + parts.label.material.opacity, + parts.crowded ? 0 : parts.target.label, + ); + + parts.body.material.opacity = body; + parts.ring.material.opacity = ring; + parts.ring.visible = ring > SETTLED; + parts.label.material.opacity = label; + parts.label.visible = label > SETTLED; + + return bodyMoving || ringMoving || labelMoving; +}; + +export const disposeParts = (parts: NodeParts) => { + parts.body.material.dispose(); + parts.hit.geometry.dispose(); + parts.hit.material.dispose(); + [parts.ring, parts.label].forEach((sprite) => { + sprite.material.map?.dispose(); + sprite.material.dispose(); + }); +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx index 815a1452d..79da47028 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistHeader.tsx @@ -14,6 +14,7 @@ import { Copy01Icon, Copy02Icon, } from "hugeicons-react"; +import { BrainButton } from "@/app/_components/FeatureComponents/Brain/Parts/BrainButton"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { Checklist } from "@/app/_types"; import { useChecklist } from "../../../../../_hooks/useChecklist"; @@ -147,6 +148,8 @@ export const ChecklistHeader = ({ )} + + {(permissions?.canEdit || permissions?.canDelete) && (
{ const t = useTranslations(); - const { linkIndex, notes, checklists, appSettings } = useAppMode(); const { isDragging, setIsDragging } = useUIStore(); const [activeItem, setActiveItem] = useState(null); const [overItem, setOverItem] = useState<{ @@ -59,16 +57,6 @@ export const ChecklistBody = ({ position: "before" | "after"; } | null>(null); - const referencingItems = useMemo(() => { - return getReferences( - linkIndex, - localList.uuid, - ItemTypes.CHECKLIST, - notes, - checklists - ); - }, [linkIndex, localList.uuid, notes, checklists]); - const onDragStart = (event: DragStartEvent) => { const findItem = (items: Item[], id: string): Item | undefined => { for (const item of items) { @@ -127,12 +115,7 @@ export const ChecklistBody = ({

- {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( -
- -
- )} + ); } @@ -293,10 +276,7 @@ export const ChecklistBody = ({ - {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( - - )} + ); diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index 99f9a47de..61cf6ee08 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -13,7 +13,6 @@ import { useKanbanBoard } from "@/app/_hooks/kanban/useKanban"; import { useKanbanDnd } from "@/app/_hooks/kanban/useKanbanDnd"; import { ItemTypes, TaskStatus, TaskStatusLabels } from "@/app/_types/enums"; import { ReferencedBySection } from "../Notes/Parts/ReferencedBySection"; -import { getReferences } from "@/app/_utils/indexes-utils"; import { useAppMode } from "@/app/_providers/AppModeProvider"; import { usePermissions } from "@/app/_providers/PermissionsProvider"; import { @@ -64,8 +63,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { const [calendarSelectedItem, setCalendarSelectedItem] = useState< import("@/app/_types").Item | null >(null); - const { linkIndex, notes, checklists, appSettings, allSharedItems } = - useAppMode(); + const { allSharedItems } = useAppMode(); const isShared = allSharedItems?.checklists.some( (sharedChecklist) => sharedChecklist.uuid === checklist.uuid, @@ -328,16 +326,6 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { ], ); - const referencingItems = useMemo(() => { - return getReferences( - linkIndex, - localChecklist.uuid, - ItemTypes.CHECKLIST, - notes, - checklists, - ); - }, [linkIndex, localChecklist.uuid, checklists, notes]); - return (
{permissions?.canEdit && ( @@ -477,10 +465,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => { )}
- {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( - - )} +
diff --git a/app/_components/FeatureComponents/Notes/Parts/MentionedInSection.tsx b/app/_components/FeatureComponents/Notes/Parts/MentionedInSection.tsx new file mode 100644 index 000000000..d33728333 --- /dev/null +++ b/app/_components/FeatureComponents/Notes/Parts/MentionedInSection.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Link04Icon } from "hugeicons-react"; +import { itemHref } from "@/app/_utils/global-utils"; +import { ItemTypes } from "@/app/_types/enums"; +import { useRelations } from "@/app/_providers/RelationsProvider"; +import { useToast } from "@/app/_providers/ToastProvider"; +import { linkMention } from "@/app/_server/actions/relations"; +import type { MentionedIn } from "@/app/_types/relations"; + +export const MentionedInSection = () => { + const router = useRouter(); + const t = useTranslations(); + const { showToast } = useToast(); + const { uuid: targetUuid, mentions } = useRelations(); + const [linking, setLinking] = useState(null); + + if (!targetUuid || mentions.length === 0) return null; + + const link = async (source: MentionedIn) => { + setLinking(source.uuid); + try { + const result = await linkMention(source.uuid, targetUuid); + if (!result.success) { + showToast({ type: "error", title: t("common.error"), message: t("relations.linkFailed") }); + return; + } + showToast({ + type: "success", + title: t("common.success"), + message: t("relations.linked", { title: source.title }), + }); + router.refresh(); + } finally { + setLinking(null); + } + }; + + return ( +
+

+ {t("relations.mentionedIn")} + + ({mentions.length}) + +

+

{t("relations.mentionedInHint")}

+ +
+ {mentions.map((source) => ( +
+ + +
+ ))} +
+
+ ); +}; diff --git a/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorContent.tsx b/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorContent.tsx index 01bc5991b..6c0f30dd5 100644 --- a/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorContent.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorContent.tsx @@ -9,7 +9,6 @@ import { useAppMode } from "@/app/_providers/AppModeProvider"; import { useSettings } from "@/app/_utils/settings-store"; import { useSearchParams } from "next/navigation"; import { useEffect, useRef, useMemo } from "react"; -import { getReferences } from "@/app/_utils/indexes-utils"; import { usePermissions } from "@/app/_providers/PermissionsProvider"; import { MinimalEditorPanel } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/MinimalEditorPanel"; import { useMinimalMode } from "@/app/_hooks/useMinimalMode"; @@ -50,7 +49,7 @@ export const NoteEditorContent = ({ isEditingEncrypted, }: NoteEditorContentProps) => { const t = useTranslations(); - const { user, linkIndex, notes, checklists, appSettings } = useAppMode(); + const { user, notes, checklists } = useAppMode(); const { compactMode } = useSettings(); const searchParams = useSearchParams(); const notesDefaultMode = user?.notesDefaultMode || "view"; @@ -59,10 +58,6 @@ export const NoteEditorContent = ({ const { permissions } = usePermissions(); const isMinimalMode = useMinimalMode(); - const referencingItems = useMemo(() => { - return getReferences(linkIndex, noteId, ItemTypes.NOTE, notes, checklists); - }, [linkIndex, noteId, notes, checklists]); - useEffect(() => { if ( editorRef.current && @@ -171,10 +166,7 @@ export const NoteEditorContent = ({ - {referencingItems.length > 0 && - appSettings?.editor?.enableBilateralLinks && ( - - )} + )} diff --git a/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorHeader.tsx b/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorHeader.tsx index 19d665a42..9312f1bed 100644 --- a/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorHeader.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/NoteEditor/NoteEditorHeader.tsx @@ -2,6 +2,7 @@ import { ShareModal } from "@/app/_components/GlobalComponents/Modals/SharingModals/ShareModal"; import { CategoryTreeSelector } from "@/app/_components/GlobalComponents/Dropdowns/CategoryTreeSelector"; +import { BrainButton } from "@/app/_components/FeatureComponents/Brain/Parts/BrainButton"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { Archive02Icon, @@ -497,6 +498,7 @@ export const NoteEditorHeader = ({ )} )} + { + if (item.type === ItemTypes.NOTE) + return ; + if (isKanbanType(item.checklistType)) + return ; + return ; +}; export const ReferencedBySection = ({ - referencingItems, -}: ReferencedBySectionProps) => { + className = "", +}: { + className?: string; +}) => { const router = useRouter(); const t = useTranslations(); - if (referencingItems.length === 0) return null; + const { appSettings } = useAppMode(); + const { status, backlinks, mentions } = useRelations(); - const handleItemClick = (item: ReferencingItem) => { - router.push(itemHref(item.type as ItemTypes, item.uuid)); - }; + if (appSettings?.editor?.enableBilateralLinks === false) return null; + if (status === RelationsStatus.BUILDING) { + return ( +
+ +
+ ); + } + if (backlinks.length === 0 && mentions.length === 0) return null; return ( -
-

- {t("notes.referencedBy")} - - ({referencingItems.length}) - -

+
+ {backlinks.length > 0 && ( +
+

+ {t("notes.referencedBy")} + + ({backlinks.length}) + +

-
- {referencingItems.map((item) => ( -
handleItemClick(item)} - className="bg-muted/50 border border-border rounded-jotty p-3 cursor-pointer hover:shadow-sm hover:border-primary/30 transition-all duration-200 group" - > -
-
- {item.type === ItemTypes.NOTE ? ( - - ) : ( - - )} -
-
- - {item.title} - -
-
- - {item.type} - - {item.category && item.category !== "Uncategorized" && ( - <> - • - - {item.category.split("/").pop()} +
+ {backlinks.map((item) => ( +
+
+ + ))}
- ))} -
+
+ )} +
); }; diff --git a/app/_components/FeatureComponents/Notes/Parts/TableOfContents.tsx b/app/_components/FeatureComponents/Notes/Parts/TableOfContents.tsx index a2f716dd7..f10e6b2ad 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TableOfContents.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TableOfContents.tsx @@ -6,6 +6,14 @@ import { cn } from "@/app/_utils/global-utils"; import { extractHeadings as extractHeadingsFromMarkdown } from "@/app/_utils/markdown-utils"; import { useTranslations } from "next-intl"; +const HEADINGS = "h1, h2, h3, h4, h5, h6"; +const LINK_PREVIEW = "[data-link-preview]"; + +const ownHeadings = (root: ParentNode): HTMLElement[] => + Array.from(root.querySelectorAll(HEADINGS)).filter( + (heading) => !heading.closest(LINK_PREVIEW), + ); + interface Heading { id: string; text: string; @@ -20,13 +28,8 @@ interface TableOfContentsProps { const findHeadingEl = (id: string, text: string): HTMLElement | null => { const byId = document.getElementById(id); - if (byId) return byId; - const allHeadings = document.querySelectorAll("h1, h2, h3, h4, h5, h6"); - return ( - (Array.from(allHeadings).find( - (h) => h.textContent?.trim() === text - ) as HTMLElement) ?? null - ); + if (byId && !byId.closest(LINK_PREVIEW)) return byId; + return ownHeadings(document).find((h) => h.textContent?.trim() === text) ?? null; }; const useTableOfContents = (content?: string, isEditing?: boolean) => { @@ -49,7 +52,7 @@ const useTableOfContents = (content?: string, isEditing?: boolean) => { const extractHeadingsFromDOM = (): Heading[] => { const contentArea = document.querySelector(".prose, [class*='prose']"); const searchRoot = contentArea || document.body; - const headingElements = searchRoot.querySelectorAll("h1, h2, h3, h4, h5, h6"); + const headingElements = ownHeadings(searchRoot); const extracted: Heading[] = []; headingElements.forEach((element) => { diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLink.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLink.tsx index 9b8731c90..40dcdd2da 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLink.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLink.tsx @@ -30,9 +30,6 @@ export const InternalLink = Node.create({ itemId: { default: null, }, - convertToBidirectional: { - default: false, - }, }; }, @@ -49,7 +46,6 @@ export const InternalLink = Node.create({ category: element.getAttribute("data-category"), uuid: element.getAttribute("data-uuid"), itemId: element.getAttribute("data-item-id"), - convertToBidirectional: element.getAttribute("data-convert-to-bidirectional") === "true", }; }, }, @@ -57,7 +53,7 @@ export const InternalLink = Node.create({ }, renderHTML({ node, HTMLAttributes }) { - const { href, title, type, category, uuid, itemId, convertToBidirectional } = node.attrs; + const { href, title, type, category, uuid, itemId } = node.attrs; const children: DOMOutputSpec[] = []; @@ -78,7 +74,6 @@ export const InternalLink = Node.create({ "data-category": category || "", "data-uuid": uuid || "", "data-item-id": itemId || "", - "data-convert-to-bidirectional": convertToBidirectional ? "true" : "false", ...HTMLAttributes, }, ...children, diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx index b90dbc093..5d0dde360 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/InternalLinkComponent.tsx @@ -1,311 +1,173 @@ "use client"; -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { NodeViewWrapper } from "@tiptap/react"; -import { - File02Icon, - CheckmarkSquare04Icon, - TaskDaily01Icon, - FileLinkIcon, - Attachment01Icon, -} from "hugeicons-react"; +import { File02Icon, CheckmarkSquare04Icon, TaskDaily01Icon } from "hugeicons-react"; import { useRouter } from "next/navigation"; +import { capitalize } from "lodash"; +import { useTranslations } from "next-intl"; import { getNoteById } from "@/app/_server/actions/note"; import { getListById } from "@/app/_server/actions/checklist"; -import { decodeCategoryPath, itemHref } from "@/app/_utils/global-utils"; -import { capitalize } from "lodash"; +import { itemHref } from "@/app/_utils/global-utils"; +import { parseItemHref } from "@/app/_utils/item-href-utils"; import { useAppMode } from "@/app/_providers/AppModeProvider"; import { NoteCard } from "@/app/_components/GlobalComponents/Cards/NoteCard"; import { ChecklistCard } from "@/app/_components/GlobalComponents/Cards/ChecklistCard"; import { Checklist, Note } from "@/app/_types"; import { isKanbanType, ItemTypes } from "@/app/_types/enums"; -import { useTranslations } from "next-intl"; -interface InternalLinkComponentProps { - node: { - attrs: { - href: string; - title: string; - type: string; - category: string; - uuid: string; - itemId: string; - convertToBidirectional: boolean; - }; - }; - editor: any; - updateAttributes: (attrs: Record) => void; +interface InternalLinkAttrs { + href: string; + title: string; + type?: string; + category?: string; + uuid?: string; + itemId?: string; } -const _returnNote = async ( - uuid: string, - router: any, - note?: Note, -): Promise => { - const finalNote = note || (await getNoteById(uuid)); - - if (finalNote?.uuid) { - router.push(itemHref(ItemTypes.NOTE, finalNote.uuid)); - return true; - } +interface InternalLinkComponentProps { + node: { attrs: InternalLinkAttrs }; +} - return false; -}; +type LinkedItem = Partial | Partial; -const _returnChecklist = async ( - uuid: string, - router: any, - checklist?: Checklist, -): Promise => { - const finalChecklist = checklist || (await getListById(uuid)); +const isChecklist = (item?: LinkedItem | null): item is Partial => + Boolean(item && "type" in item && item.type); - if (finalChecklist?.uuid) { - router.push(itemHref(ItemTypes.CHECKLIST, finalChecklist.uuid)); - return true; +const _fetchItem = async (uuid: string, type?: ItemTypes): Promise => { + if (type !== ItemTypes.CHECKLIST) { + const note = await getNoteById(uuid); + if (note) return note; } - - return false; + if (type !== ItemTypes.NOTE) { + const list = await getListById(uuid); + if (list) return list; + } + return null; }; -export const InternalLinkComponent = ({ - node, - editor, - updateAttributes, -}: InternalLinkComponentProps) => { +export const InternalLinkComponent = ({ node }: InternalLinkComponentProps) => { const t = useTranslations(); const router = useRouter(); - const { href, title, uuid, itemId, type, category, convertToBidirectional } = - node.attrs; - const [showPopup, setShowPopup] = useState(false); - const [loadedFullItem, setLoadedFullItem] = useState( - null, - ); - const [isLoadingItem, setIsLoadingItem] = useState(false); - const potentialCategory = href - ?.replace("/jotty/", "") - .replace("/note/", "") - .replace("/checklist/", "") - .split("/") - .slice(1, -1) - .join("/"); + const { href, title, uuid: attrUuid, category } = node.attrs; const { appSettings, notes, checklists } = useAppMode(); + const [showPopup, setShowPopup] = useState(false); + const [loadedItem, setLoadedItem] = useState(null); + const [isLoading, setIsLoading] = useState(false); - const isEditable = editor?.isEditable ?? false; - const isPathBasedLink = - href?.startsWith("/note/") || href?.startsWith("/checklist/"); - const isJottyLink = href?.startsWith("/jotty/"); - - const canToggle = isPathBasedLink || isJottyLink; - - const metadataItem = - (notes.find((n) => n.uuid === uuid) as Partial | undefined) || - (checklists.find((c) => c.uuid === uuid) as Partial | undefined); + const target = useMemo(() => parseItemHref(href), [href]); + const uuid = (target?.uuid || attrUuid || "").toLowerCase(); - const fullItem = loadedFullItem || metadataItem; + const knownItem: LinkedItem | undefined = useMemo( + () => + notes.find((note) => note.uuid?.toLowerCase() === uuid) || + checklists.find((list) => list.uuid?.toLowerCase() === uuid), + [notes, checklists, uuid], + ); - const loadFullItem = useCallback(async () => { - if (loadedFullItem || isLoadingItem || !uuid) return; + const item: LinkedItem | undefined = loadedItem || knownItem; + const type = item + ? isChecklist(item) + ? ItemTypes.CHECKLIST + : ItemTypes.NOTE + : target?.type; - setIsLoadingItem(true); + const loadItem = useCallback(async () => { + if (loadedItem || isLoading || !uuid) return; + setIsLoading(true); try { - const isChecklist = - metadataItem && "type" in metadataItem && metadataItem.type; - if (isChecklist) { - const checklist = await getListById(uuid); - if (checklist) setLoadedFullItem(checklist); - } else { - const note = await getNoteById(uuid); - if (note) setLoadedFullItem(note); - } + setLoadedItem(await _fetchItem(uuid, type)); } catch (error) { - console.warn("Failed to load item for preview:", error); + console.warn("Failed to load linked item:", error); } finally { - setIsLoadingItem(false); + setIsLoading(false); } - }, [uuid, loadedFullItem, isLoadingItem, metadataItem]); + }, [loadedItem, isLoading, uuid, type]); useEffect(() => { - if (showPopup && !loadedFullItem && !isLoadingItem) { - loadFullItem(); - } - }, [showPopup, loadedFullItem, isLoadingItem, loadFullItem]); + if (showPopup) loadItem(); + }, [showPopup, loadItem]); - const handleClick = async (e: React.MouseEvent) => { - e.preventDefault(); + const handleClick = async (event: React.MouseEvent) => { + event.preventDefault(); if (!href) return; - if (href.startsWith("/jotty/")) { - const uuidFromPath = href.replace("/jotty/", ""); - - if (fullItem?.uuid) { - router.push( - itemHref( - "type" in fullItem && fullItem.type - ? ItemTypes.CHECKLIST - : ItemTypes.NOTE, - fullItem.uuid, - ), - ); - return; - } - - try { - if (await _returnNote(uuidFromPath, router)) return; - } catch (error) { - console.warn("Failed to resolve /jotty/ link:", error); - } - try { - if (await _returnChecklist(uuidFromPath, router)) return; - } catch (error) { - console.warn("Failed to resolve /jotty/ link:", error); - } - } else { - router.push(href); + if (uuid && type) { + router.push(itemHref(type, uuid)); return; } - }; - - const handleToggleConversion = (e: React.MouseEvent) => { - e.preventDefault(); - e.stopPropagation(); - if (isJottyLink) { - const isChecklist = fullItem && "type" in fullItem && fullItem.type; - const targetUuid = fullItem?.uuid || uuid || href.replace("/jotty/", ""); - - if (targetUuid) { - updateAttributes({ - href: itemHref( - isChecklist ? ItemTypes.CHECKLIST : ItemTypes.NOTE, - targetUuid, - ), - type: isChecklist ? "checklist" : "note", - category: fullItem?.category || category || "Uncategorized", - convertToBidirectional: false, - }); - } else { - console.warn("Cannot convert jotty to path - missing data"); - } - } else if (isPathBasedLink) { - const resolvedUuid = - uuid || - fullItem?.uuid || - notes.find((n) => n.id === itemId && (n.category || "") === (category || ""))?.uuid || - checklists.find((c) => c.id === itemId && (c.category || "") === (category || ""))?.uuid; - - if (resolvedUuid) { - updateAttributes({ - href: `/jotty/${resolvedUuid}`, - uuid: resolvedUuid, - convertToBidirectional: false, - }); - } else { - console.log("Could not find item to convert"); + if (uuid) { + const found = await _fetchItem(uuid).catch((error) => { + console.warn("Failed to resolve linked item:", error); + return null; + }); + if (found?.uuid) { + router.push(itemHref(isChecklist(found) ? ItemTypes.CHECKLIST : ItemTypes.NOTE, found.uuid)); } + return; } + + router.push(href); }; + const label = item?.title || title; + const shownCategory = item?.category || target?.legacy?.category || category; + return ( { - setShowPopup(true); - }} - onMouseLeave={() => { - setShowPopup(false); - }} + onMouseEnter={() => setShowPopup(true)} + onMouseLeave={() => setShowPopup(false)} className="inline-flex items-center gap-1.5 mx-1 px-2 py-1 bg-primary/10 border border-primary/20 rounded-jotty hover:bg-primary/15 transition-colors cursor-pointer group relative" > - {showPopup && - href && - (href.startsWith("/jotty/") || - href.startsWith("/note/") || - href.startsWith("/checklist/")) && ( - - {isLoadingItem ? ( -
- Loading... -
- ) : loadedFullItem && - "type" in loadedFullItem && - loadedFullItem.type ? ( - {}} - /> - ) : loadedFullItem ? ( - {}} - fullScrollableContent - /> - ) : ( -
- {metadataItem?.title || title} -
- )} -
- )} + {showPopup && ( + + {isLoading ? ( + + {t("common.loading")} + + ) : loadedItem && isChecklist(loadedItem) ? ( + {}} /> + ) : loadedItem ? ( + {}} fullScrollableContent /> + ) : ( + + {label} + + )} + + )} - {fullItem && "type" in fullItem && fullItem.type ? ( - <> - {isKanbanType(fullItem.type) ? ( - - ) : ( - - )} - + {isChecklist(item) ? ( + isKanbanType(item.type) ? ( + + ) : ( + + ) ) : ( )} - {appSettings?.parseContent === "yes" - ? title - : capitalize(title.replace(/-/g, " "))} + {appSettings?.parseContent === "yes" ? label : capitalize(label.replace(/-/g, " "))} - · - - {fullItem?.category || - decodeCategoryPath(potentialCategory) || - "not-found"} - - {isEditable && (isPathBasedLink || canToggle) && ( -
- - {t("editor.linkType")} + {shownCategory && ( + <> + · + + {shownCategory} - -
+ + )} + {!item && !isLoading && ( + + {t("relations.unreachable")} + )}
); diff --git a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx index 806e2114b..b181e7cd3 100644 --- a/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/SlashCommands.tsx @@ -25,6 +25,12 @@ import { TagMentionsList } from "@/app/_components/FeatureComponents/Notes/Parts import { ItemType } from "@/app/_types"; import { ItemTypes } from "@/app/_types/enums"; import { PluginKey } from "@tiptap/pm/state"; +import { itemHref } from "@/app/_utils/global-utils"; + +const WIKILINK_OPEN = "[["; + +const mentionable = (items?: T[]): T[] => + items?.filter((item) => Boolean(item.uuid)) || []; export interface SlashCommandItem { title: string; @@ -317,7 +323,7 @@ export const SlashCommands = Extension.create({ range: any; props: AtMentionItem; }) => { - const linkTarget = props.uuid ? `/jotty/${props.uuid}` : ``; + const linkTarget = props.uuid ? itemHref(props.type, props.uuid) : ""; editor .chain() .focus() @@ -331,7 +337,6 @@ export const SlashCommands = Extension.create({ category: props.category, uuid: props.uuid, itemId: props.id, - convertToBidirectional: false, }, }) .run(); @@ -572,12 +577,8 @@ export const SlashCommands = Extension.create({ return { updateAtMentionData: (notes: any[], checklists: any[], username: string) => () => { - atMentionData.notes = - notes?.filter((note: any) => note.owner === username) || []; - atMentionData.checklists = - checklists?.filter( - (checklist: any) => checklist.owner === username - ) || []; + atMentionData.notes = mentionable(notes); + atMentionData.checklists = mentionable(checklists); return true; }, @@ -590,14 +591,8 @@ export const SlashCommands = Extension.create({ }, addProseMirrorPlugins() { - atMentionData.notes = - this.options.notes?.filter( - (note: any) => note.owner === this.options.username - ) || []; - atMentionData.checklists = - this.options.checklists?.filter( - (checklist: any) => checklist.owner === this.options.username - ) || []; + atMentionData.notes = mentionable(this.options.notes); + atMentionData.checklists = mentionable(this.options.checklists); tagSuggestionData.tags = this.options.tags || []; const plugins = []; @@ -620,6 +615,15 @@ export const SlashCommands = Extension.create({ pluginKey: new PluginKey("atSuggestion"), }) ); + plugins.push( + Suggestion({ + editor: this.editor, + ...this.options.atSuggestion, + char: WIKILINK_OPEN, + allowSpaces: true, + pluginKey: new PluginKey("wikiSuggestion"), + }) + ); } if (this.options.enableTags) { diff --git a/app/_components/FeatureComponents/Notes/Parts/UnifiedMarkdownRenderer.tsx b/app/_components/FeatureComponents/Notes/Parts/UnifiedMarkdownRenderer.tsx index 9b43201b6..f958899cb 100644 --- a/app/_components/FeatureComponents/Notes/Parts/UnifiedMarkdownRenderer.tsx +++ b/app/_components/FeatureComponents/Notes/Parts/UnifiedMarkdownRenderer.tsx @@ -25,10 +25,15 @@ import { prism } from "@/app/_utils/prism-utils"; import { useAppMode } from "@/app/_providers/AppModeProvider"; import { InternalLinkComponent } from "./TipTap/CustomExtensions/InternalLinkComponent"; import { TagLinkViewComponent } from "@/app/_components/FeatureComponents/Tags/TagLinkComponent"; -import { ItemTypes } from "@/app/_types/enums"; import { extractYamlMetadata } from "@/app/_utils/yaml-metadata-utils"; -import { decodeCategoryPath, decodeSegment } from "@/app/_utils/global-utils"; -import { isUuid } from "@/app/_consts/identity"; +import { currentOrigins, parseItemHref } from "@/app/_utils/item-href-utils"; +import { remarkWikilinks, WIKILINK_TAG } from "@/app/_utils/wikilink-utils"; +import { WikiLink } from "./WikiLink"; + +type WikiLinkComponents = Record< + typeof WIKILINK_TAG, + (props: { target?: string; label?: string }) => React.ReactElement +>; import { NoteFooterStats } from "@/app/_components/GlobalComponents/Statistics/NoteFooterStats"; import { useTranslations } from "next-intl"; import { @@ -157,7 +162,10 @@ export const UnifiedMarkdownRenderer = ({ ); } - const components: Partial = { + const components: Partial & WikiLinkComponents = { + [WIKILINK_TAG]: ({ target, label }: { target?: string; label?: string }) => ( + + ), table: ({ node, children, ...props }) => (
{children}
@@ -214,51 +222,21 @@ export const UnifiedMarkdownRenderer = ({ const childText = String(children); const isFileAttachment = childText.startsWith("📎 ") && href; const isVideoAttachment = childText.startsWith("🎥 ") && href; - const isInternalLink = - href && - (href?.includes("/note/") || - href?.includes("/checklist/") || - href?.startsWith("/jotty/")); - - if (isInternalLink) { - let linkType: ItemTypes; - let linkCategory: string | null = null; - let linkUuid: string | null = null; - let linkItemId: string = ""; - - if (href?.startsWith("/jotty/")) { - linkUuid = href.replace("/jotty/", ""); - linkType = ItemTypes.NOTE; - } else { - linkType = href?.includes("/note/") - ? ItemTypes.NOTE - : ItemTypes.CHECKLIST; - const pathParts = href - ?.replace("/checklist/", "") - .replace("/note/", "") - .split("/"); - linkItemId = decodeSegment(pathParts?.[pathParts.length - 1] || ""); - linkUuid = isUuid(linkItemId) ? linkItemId : null; - linkCategory = decodeCategoryPath( - pathParts?.slice(0, -1).join("/") || "", - ); - } + const itemTarget = parseItemHref(href, currentOrigins()); + if (href && itemTarget) { return ( {}} /> ); } @@ -595,7 +573,7 @@ export const UnifiedMarkdownRenderer = ({ className={`prose prose-sm sm:prose-base lg:prose-lg xl:prose-2xl dark:prose-invert [&_ul]:list-disc [&_ol]:list-decimal [&_table]:border-collapse [&_table]:w-full [&_table]:my-4 [&_th]:border [&_th]:border-border [&_th]:px-3 [&_th]:py-2 [&_th]:bg-muted [&_th]:font-semibold [&_th]:text-left [&_td]:border [&_td]:border-border [&_td]:px-3 [&_td]:py-2 [&_tr:nth-child(even)]:bg-muted/50 ${className}`} > diff --git a/app/_components/FeatureComponents/Notes/Parts/WikiLink.tsx b/app/_components/FeatureComponents/Notes/Parts/WikiLink.tsx new file mode 100644 index 000000000..bfbecc7b1 --- /dev/null +++ b/app/_components/FeatureComponents/Notes/Parts/WikiLink.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { FileAddIcon } from "hugeicons-react"; +import { useAppMode } from "@/app/_providers/AppModeProvider"; +import { useOptionalMetadata } from "@/app/_providers/MetadataProvider"; +import { useRelations } from "@/app/_providers/RelationsProvider"; +import { createNote } from "@/app/_server/actions/note"; +import { itemHref } from "@/app/_utils/global-utils"; +import { ItemTypes } from "@/app/_types/enums"; +import { UNCATEGORIZED } from "@/app/_consts/notes"; +import { InternalLinkComponent } from "./TipTap/CustomExtensions/InternalLinkComponent"; + +interface WikiLinkProps { + target?: string; + label?: string; +} + +interface Candidate { + type: ItemTypes; + uuid?: string; + title?: string; + owner?: string; + createdAt?: string; +} + +const titleKey = (title = "") => title.trim().replace(/\s+/g, " ").toLowerCase(); + +const byAge = (a: Candidate, b: Candidate) => + (a.createdAt || "").localeCompare(b.createdAt || "") || + (a.uuid || "").localeCompare(b.uuid || ""); + +export const WikiLink = ({ target = "", label }: WikiLinkProps) => { + const t = useTranslations(); + const router = useRouter(); + const { notes, checklists, user } = useAppMode(); + const { wikis } = useRelations(); + const metadata = useOptionalMetadata(); + const [isCreating, setIsCreating] = useState(false); + + const match = useMemo(() => { + const key = titleKey(target); + const candidates: Candidate[] = [ + ...notes.map((item) => ({ ...item, type: ItemTypes.NOTE })), + ...checklists.map((item) => ({ ...item, type: ItemTypes.CHECKLIST })), + ].filter((item) => item.uuid); + + const bound = wikis[key]?.toLowerCase(); + if (bound) { + const pinned = candidates.find((item) => item.uuid!.toLowerCase() === bound); + if (pinned) return pinned; + } + + return ( + candidates + .filter((item) => (!item.owner || item.owner === user?.username) && titleKey(item.title) === key) + .sort(byAge)[0] || null + ); + }, [notes, checklists, target, wikis, user?.username]); + + if (!user) return {label || target}; + + if (match) { + return ( + + ); + } + + const createFromGhost = async () => { + if (isCreating || !target.trim()) return; + setIsCreating(true); + try { + const formData = new FormData(); + formData.append("title", target.trim()); + formData.append("category", metadata?.category || UNCATEGORIZED); + formData.append("rawContent", ""); + const result = await createNote(formData); + if (result.success && result.data?.uuid) { + router.push(itemHref(ItemTypes.NOTE, result.data.uuid)); + return; + } + console.error("Could not create note from wikilink:", result.error); + } catch (error) { + console.error("Could not create note from wikilink:", error); + } finally { + setIsCreating(false); + } + }; + + return ( + + ); +}; diff --git a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx deleted file mode 100644 index 362bd7d95..000000000 --- a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx +++ /dev/null @@ -1,615 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; -import type { PointerEvent } from "react"; -import ForceGraph2D, { - ForceGraphMethods, - LinkObject, - NodeObject, -} from "react-force-graph-2d"; -import { useRouter } from "next/navigation"; -import { CheckmarkSquare04Icon, File02Icon } from "hugeicons-react"; -import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; -import { ItemTypes } from "@/app/_types/enums"; -import { - ConnectionGraphData, - ConnectionGraphLink, - ConnectionGraphNode, -} from "./graph-data"; - -interface ConnectionsGraphProps { - graphData: ConnectionGraphData; - selectedNodeId: string | null; - onSelectedNodeChange: (nodeId: string | null) => void; - showLabels: boolean; - showArrows: boolean; - nodeScale: number; - linkWidth: number; - linkDistance: number; - repelForce: number; - labels: ConnectionsGraphLabels; -} - -export interface ConnectionsGraphLabels { - unknown: string; - total: string; - inbound: string; - outbound: string; - updated: string; - uuid: string; - openItem: string; - selectItem: string; - inspectHint: string; - linkedItems: string; -} - -type CanvasNode = NodeObject & ConnectionGraphNode; -type CanvasLink = LinkObject & - ConnectionGraphLink; -type GraphRef = ForceGraphMethods< - NodeObject, - LinkObject ->; -type LabelHitBox = { - node: CanvasNode; - left: number; - right: number; - top: number; - bottom: number; -}; -type AxisForce = ((alpha: number) => void) & { - initialize: (nodes: CanvasNode[]) => void; -}; - -const TYPE_COLORS: Record = { - [ItemTypes.NOTE]: "#3b82f6", - [ItemTypes.CHECKLIST]: "#10b981", -}; - -const getLinkId = ( - value: string | number | NodeObject | undefined, -) => { - if (typeof value === "string" || typeof value === "number") { - return String(value); - } - return value?.id ? String(value.id) : ""; -}; - -const MIN_POINTER_RADIUS = 24; -const COARSE_POINTER_RADIUS = 36; -const CLICK_MOVE_TOLERANCE = 10; -const LONG_RANGE_REPEL_FACTOR = 4; -const MIN_REPEL_DISTANCE_MAX = 260; -const CENTER_PULL_STRENGTH = 0.035; - -const getNodeRadius = (node: ConnectionGraphNode, scale: number) => - Math.max(4, Math.min(18, 4 + node.connectionCount * 1.1)) * scale; - -const createAxisForce = (axis: "x" | "y", strength: number): AxisForce => { - let nodes: CanvasNode[] = []; - const velocity = axis === "x" ? "vx" : "vy"; - - const force = ((alpha: number) => { - nodes.forEach((node) => { - const position = node[axis] || 0; - node[velocity] = - (node[velocity] || 0) + (0 - position) * strength * alpha; - }); - }) as AxisForce; - - force.initialize = (nextNodes: CanvasNode[]) => { - nodes = nextNodes; - }; - - return force; -}; - -const formatDate = (value: string) => { - return new Intl.DateTimeFormat(undefined, { - month: "short", - day: "numeric", - year: "numeric", - }).format(new Date(value)); -}; - -export const ConnectionsGraph = ({ - graphData, - selectedNodeId, - onSelectedNodeChange, - showLabels, - showArrows, - nodeScale, - linkWidth, - linkDistance, - repelForce, - labels, -}: ConnectionsGraphProps) => { - const graphRef = useRef(undefined); - const containerRef = useRef(null); - const hasFitGraphRef = useRef(false); - const labelHitBoxesRef = useRef([]); - const pointerDownRef = useRef<{ - x: number; - y: number; - pointerId: number; - } | null>(null); - const router = useRouter(); - const [dimensions, setDimensions] = useState({ width: 900, height: 620 }); - const [isCoarsePointer, setIsCoarsePointer] = useState(false); - - const selectedNode = useMemo( - () => graphData.nodes.find((node) => node.id === selectedNodeId) || null, - [graphData.nodes, selectedNodeId], - ); - const inspectedNode = selectedNode; - const nodeById = useMemo( - () => new Map(graphData.nodes.map((node) => [node.id, node])), - [graphData.nodes], - ); - const linkedNodes = useMemo(() => { - if (!inspectedNode) return []; - - const seen = new Set(); - return graphData.links - .flatMap((link) => { - if (link.source === inspectedNode.id) return [link.target]; - if (link.target === inspectedNode.id) return [link.source]; - return []; - }) - .filter((nodeId) => { - if (seen.has(nodeId)) return false; - seen.add(nodeId); - return true; - }) - .map((nodeId) => nodeById.get(nodeId)) - .filter((node): node is ConnectionGraphNode => Boolean(node)) - .sort((a, b) => a.title.localeCompare(b.title)); - }, [graphData.links, inspectedNode, nodeById]); - const canvasGraphData = useMemo( - () => ({ - nodes: graphData.nodes.map((node) => ({ ...node })), - links: graphData.links.map((link) => ({ ...link })), - }), - [graphData], - ); - - const neighborIds = useMemo(() => { - if (!selectedNodeId) return new Set(); - const ids = new Set([selectedNodeId]); - graphData.links.forEach((link) => { - if (link.source === selectedNodeId) ids.add(link.target); - if (link.target === selectedNodeId) ids.add(link.source); - }); - return ids; - }, [graphData.links, selectedNodeId]); - - useEffect(() => { - const coarsePointerQuery = window.matchMedia("(pointer: coarse)"); - const updatePointerMode = () => - setIsCoarsePointer(coarsePointerQuery.matches); - - updatePointerMode(); - coarsePointerQuery.addEventListener("change", updatePointerMode); - return () => - coarsePointerQuery.removeEventListener("change", updatePointerMode); - }, []); - - useEffect(() => { - const updateDimensions = () => { - if (!containerRef.current) return; - const rect = containerRef.current.getBoundingClientRect(); - setDimensions({ - width: Math.max(320, Math.round(rect.width)), - height: Math.max(420, Math.round(rect.height)), - }); - }; - - updateDimensions(); - const observer = new ResizeObserver(updateDimensions); - if (containerRef.current) observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); - - useEffect(() => { - const chargeForce = graphRef.current?.d3Force("charge"); - chargeForce?.strength(-repelForce); - chargeForce?.distanceMax?.( - Math.max(MIN_REPEL_DISTANCE_MAX, linkDistance * LONG_RANGE_REPEL_FACTOR), - ); - graphRef.current?.d3Force("link")?.distance(linkDistance); - graphRef.current?.d3Force("x", createAxisForce("x", CENTER_PULL_STRENGTH)); - graphRef.current?.d3Force("y", createAxisForce("y", CENTER_PULL_STRENGTH)); - graphRef.current?.d3ReheatSimulation(); - hasFitGraphRef.current = false; - }, [linkDistance, repelForce]); - - useEffect(() => { - graphRef.current?.d3ReheatSimulation(); - hasFitGraphRef.current = false; - }, [graphData.nodes.length, graphData.links.length]); - - const focusNode = (node: CanvasNode) => { - onSelectedNodeChange(node.id); - if (node.x !== undefined && node.y !== undefined) { - graphRef.current?.centerAt(node.x, node.y, 650); - graphRef.current?.zoom(2.4, 650); - } - }; - - const openNode = (node: ConnectionGraphNode) => { - router.push(node.url); - }; - - const findNodeAtPointer = (clientX: number, clientY: number) => { - const graph = graphRef.current; - const container = containerRef.current; - if (!graph || !container) return null; - - const rect = container.getBoundingClientRect(); - const pointerX = clientX - rect.left; - const pointerY = clientY - rect.top; - const zoom = Math.max(0.001, graph.zoom()); - const minimumRadius = isCoarsePointer - ? COARSE_POINTER_RADIUS - : MIN_POINTER_RADIUS; - const graphPosition = graph.screen2GraphCoords(pointerX, pointerY); - - const labelHit = labelHitBoxesRef.current.find( - (box) => - graphPosition.x >= box.left && - graphPosition.x <= box.right && - graphPosition.y >= box.top && - graphPosition.y <= box.bottom, - ); - if (labelHit) return labelHit.node; - - let closestNode: CanvasNode | null = null; - let closestDistance = Number.POSITIVE_INFINITY; - - canvasGraphData.nodes.forEach((node) => { - const canvasNode = node as CanvasNode; - if (canvasNode.x === undefined || canvasNode.y === undefined) return; - - const screenPosition = graph.graph2ScreenCoords( - canvasNode.x, - canvasNode.y, - ); - const distance = Math.hypot( - screenPosition.x - pointerX, - screenPosition.y - pointerY, - ); - const hitRadius = Math.max( - minimumRadius, - getNodeRadius(canvasNode, nodeScale) * zoom, - ); - - if (distance <= hitRadius && distance < closestDistance) { - closestNode = canvasNode; - closestDistance = distance; - } - }); - - return closestNode; - }; - - const handlePointerDownCapture = (event: PointerEvent) => { - pointerDownRef.current = { - x: event.clientX, - y: event.clientY, - pointerId: event.pointerId, - }; - }; - - const handlePointerUpCapture = (event: PointerEvent) => { - const start = pointerDownRef.current; - pointerDownRef.current = null; - if (!start || start.pointerId !== event.pointerId) return; - - const moved = Math.hypot(event.clientX - start.x, event.clientY - start.y); - if (moved > CLICK_MOVE_TOLERANCE) return; - - const node = findNodeAtPointer(event.clientX, event.clientY); - if (!node) return; - - event.preventDefault(); - event.stopPropagation(); - - if (event.detail >= 2) { - openNode(node); - return; - } - - focusNode(node); - }; - - return ( -
-
- - showArrows && (link as CanvasLink).type === "explicit" ? 5 : 0 - } - linkDirectionalArrowRelPos={1} - linkWidth={(link) => { - const canvasLink = link as CanvasLink; - const sourceId = getLinkId(canvasLink.source); - const targetId = getLinkId(canvasLink.target); - const highlighted = - !selectedNodeId || - sourceId === selectedNodeId || - targetId === selectedNodeId; - return highlighted - ? canvasLink.type === "tag" - ? Math.max(0.5, linkWidth * 0.7) - : linkWidth - : Math.max(0.5, linkWidth * 0.5); - }} - linkColor={(link) => { - const canvasLink = link as CanvasLink; - const sourceId = getLinkId(canvasLink.source); - const targetId = getLinkId(canvasLink.target); - if ( - !selectedNodeId || - sourceId === selectedNodeId || - targetId === selectedNodeId - ) { - return canvasLink.type === "tag" - ? "rgba(244,114,182,0.46)" - : "rgba(148,163,184,0.7)"; - } - return "rgba(148,163,184,0.18)"; - }} - nodeRelSize={nodeScale} - nodeVal={(node) => - Math.max(1.8, Math.min(9, 2 + (node as CanvasNode).weight)) - } - nodeCanvasObject={(node, ctx, globalScale) => { - const canvasNode = node as CanvasNode; - const baseColor = - TYPE_COLORS[canvasNode.type] || TYPE_COLORS[ItemTypes.NOTE]; - const radius = getNodeRadius(canvasNode, nodeScale); - const isSelected = selectedNodeId === canvasNode.id; - const isNeighbor = neighborIds.has(canvasNode.id); - const dimmed = selectedNodeId && !isNeighbor; - - ctx.beginPath(); - ctx.arc( - canvasNode.x || 0, - canvasNode.y || 0, - radius, - 0, - 2 * Math.PI, - ); - ctx.fillStyle = dimmed ? "rgba(148,163,184,0.35)" : baseColor; - ctx.fill(); - - if (isSelected) { - ctx.lineWidth = 3 / globalScale; - ctx.strokeStyle = "#f8fafc"; - ctx.stroke(); - } - }} - onRenderFramePost={(ctx, globalScale) => { - labelHitBoxesRef.current = []; - if (!showLabels && !selectedNodeId) return; - - const boxes: LabelHitBox[] = []; - const fontSize = Math.max(10, 13 / globalScale); - const paddingX = 4 / globalScale; - const paddingY = 3 / globalScale; - const gap = 6 / globalScale; - const sortedNodes = [...canvasGraphData.nodes].sort((a, b) => { - if (a.id === selectedNodeId) return -1; - if (b.id === selectedNodeId) return 1; - return b.connectionCount - a.connectionCount; - }); - - ctx.font = `600 ${fontSize}px Inter, system-ui, sans-serif`; - ctx.textAlign = "left"; - ctx.textBaseline = "middle"; - - sortedNodes.forEach((node) => { - const canvasNode = node as CanvasNode; - if (canvasNode.x === undefined || canvasNode.y === undefined) - return; - - const isPriority = canvasNode.id === selectedNodeId; - if (!showLabels && !isPriority) return; - - const radius = getNodeRadius(canvasNode, nodeScale); - const label = canvasNode.label.slice(0, 34); - const textWidth = ctx.measureText(label).width; - const x = canvasNode.x + radius + gap; - const y = canvasNode.y; - const box = { - left: x - paddingX, - right: x + textWidth + paddingX, - top: y - fontSize / 2 - paddingY, - bottom: y + fontSize / 2 + paddingY, - }; - const overlaps = boxes.some( - (existing) => - box.left < existing.right && - box.right > existing.left && - box.top < existing.bottom && - box.bottom > existing.top, - ); - - if (overlaps && !isPriority) return; - - const hitBox = { ...box, node: canvasNode }; - boxes.push(hitBox); - labelHitBoxesRef.current.push(hitBox); - ctx.fillStyle = "rgba(15,23,42,0.92)"; - ctx.fillText(label, x, y); - }); - }} - nodePointerAreaPaint={(node, color, ctx, globalScale) => { - const canvasNode = node as CanvasNode; - const radius = Math.max( - MIN_POINTER_RADIUS / globalScale, - getNodeRadius(canvasNode, nodeScale), - ); - ctx.fillStyle = color; - ctx.beginPath(); - ctx.arc( - canvasNode.x || 0, - canvasNode.y || 0, - radius, - 0, - 2 * Math.PI, - ); - ctx.fill(); - }} - onNodeClick={(node, event) => { - if (event.detail >= 2) { - openNode(node as ConnectionGraphNode); - return; - } - focusNode(node as CanvasNode); - }} - onNodeRightClick={(node) => openNode(node as ConnectionGraphNode)} - onBackgroundClick={() => onSelectedNodeChange(null)} - onNodeDragEnd={(node) => { - node.fx = node.x; - node.fy = node.y; - graphRef.current?.pauseAnimation(); - }} - onEngineStop={() => { - if (!hasFitGraphRef.current) { - graphRef.current?.zoomToFit(450, 40); - hasFitGraphRef.current = true; - } - graphRef.current?.pauseAnimation(); - }} - cooldownTicks={70} - cooldownTime={2500} - d3AlphaDecay={0.08} - enableNodeDrag={!isCoarsePointer} - /> -
- - -
- ); -}; diff --git a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts b/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts deleted file mode 100644 index 1399d3907..000000000 --- a/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { Checklist, ItemType, LinkIndex, Note } from "@/app/_types"; -import { ItemTypes } from "@/app/_types/enums"; -import { itemHref } from "@/app/_utils/global-utils"; -import { isUuid } from "@/app/_consts/identity"; - -export const MAX_GRAPH_NODES = 600; -export const MAX_TAG_LINK_FANOUT = 8; - -export interface ConnectionGraphNode { - id: string; - label: string; - title: string; - type: ItemType; - category: string; - itemId: string; - path: string; - url: string; - tags: string[]; - createdAt?: string; - updatedAt?: string; - inboundCount: number; - outboundCount: number; - connectionCount: number; - weight: number; - orphan: boolean; -} - -export interface ConnectionGraphLink { - source: string; - target: string; - directed: boolean; - type: "explicit" | "tag"; - tag?: string; -} - -export interface ConnectionGraphData { - nodes: ConnectionGraphNode[]; - links: ConnectionGraphLink[]; - totalNodes: number; - totalLinks: number; - truncated: number; -} - -export interface ConnectionGraphFilters { - search: string; - showNotes: boolean; - showChecklists: boolean; - showOrphans: boolean; - showTagLinks: boolean; -} - -type ItemLike = Partial | Partial; - -const fallbackTitle = (type: ItemType, id: string) => - `${type === ItemTypes.CHECKLIST ? "Checklist" : "Note"} ${id.slice(0, 8)}`; - -const getItemKey = (item: ItemLike) => - `${item.category || "Uncategorized"}/${item.id || ""}`; - -const normalizeTag = (tag: string) => tag.trim().replace(/^#/, "").toLowerCase(); - -const addItemToMaps = ( - item: ItemLike, - type: ItemType, - itemsByUuid: Map, - uuidByLegacyKey: Map, -) => { - if (!item.uuid) return; - itemsByUuid.set(item.uuid, { item, type }); - if (item.id) { - uuidByLegacyKey.set(getItemKey(item), item.uuid); - } -}; - -export const resolveGraphId = ( - rawId: string, - uuidByLegacyKey: Map, -) => { - if (isUuid(rawId)) return rawId; - return uuidByLegacyKey.get(rawId) || rawId; -}; - -export const buildArchivedIdSet = (archivedItems: ItemLike[]) => { - const archived = new Set(); - archivedItems.forEach((item) => { - if (item.uuid) archived.add(item.uuid); - if (item.id) archived.add(getItemKey(item)); - }); - return archived; -}; - -export const filterArchivedLinkIndex = ( - linkIndex: LinkIndex, - archivedItems: ItemLike[], -): LinkIndex => { - const archived = buildArchivedIdSet(archivedItems); - const shouldKeep = (id: string) => !archived.has(id); - const filterIds = (ids: string[]) => ids.filter(shouldKeep); - const filterGroup = (group: LinkIndex["notes"]) => - Object.fromEntries( - Object.entries(group) - .filter(([key]) => shouldKeep(key)) - .map(([key, links]) => [ - key, - { - isLinkedTo: { - notes: filterIds(links.isLinkedTo.notes), - checklists: filterIds(links.isLinkedTo.checklists), - }, - isReferencedIn: { - notes: filterIds(links.isReferencedIn.notes), - checklists: filterIds(links.isReferencedIn.checklists), - }, - }, - ]), - ); - - return { - notes: filterGroup(linkIndex.notes || {}), - checklists: filterGroup(linkIndex.checklists || {}), - }; -}; - -export const buildConnectionGraphData = ( - linkIndex: LinkIndex, - notes: Partial[], - checklists: Partial[], - includeOrphans = false, -): ConnectionGraphData => { - const itemsByUuid = new Map(); - const uuidByLegacyKey = new Map(); - - notes.forEach((note) => - addItemToMaps(note, ItemTypes.NOTE, itemsByUuid, uuidByLegacyKey), - ); - checklists.forEach((checklist) => - addItemToMaps( - checklist, - ItemTypes.CHECKLIST, - itemsByUuid, - uuidByLegacyKey, - ), - ); - - const outbound = new Map(); - const inbound = new Map(); - const tagConnections = new Map(); - const linkMap = new Map(); - - const addDirectedLinks = ( - sourceRaw: string, - targets: string[], - targetType: ItemType, - ) => { - const source = resolveGraphId(sourceRaw, uuidByLegacyKey); - if (!itemsByUuid.has(source)) return; - - targets.forEach((targetRaw) => { - const target = resolveGraphId(targetRaw, uuidByLegacyKey); - const targetItem = itemsByUuid.get(target); - if (!targetItem || targetItem.type !== targetType || source === target) { - return; - } - - outbound.set(source, (outbound.get(source) || 0) + 1); - inbound.set(target, (inbound.get(target) || 0) + 1); - - const linkKey = `explicit::${[source, target].sort().join("::")}`; - if (!linkMap.has(linkKey)) { - linkMap.set(linkKey, { source, target, directed: true, type: "explicit" }); - } - }); - }; - - Object.entries(linkIndex.notes || {}).forEach(([source, links]) => { - addDirectedLinks(source, links.isLinkedTo.notes, ItemTypes.NOTE); - addDirectedLinks(source, links.isLinkedTo.checklists, ItemTypes.CHECKLIST); - }); - - Object.entries(linkIndex.checklists || {}).forEach(([source, links]) => { - addDirectedLinks(source, links.isLinkedTo.notes, ItemTypes.NOTE); - addDirectedLinks(source, links.isLinkedTo.checklists, ItemTypes.CHECKLIST); - }); - - const nodesByTag = new Map(); - itemsByUuid.forEach(({ item }, uuid) => { - const uniqueTags = new Set((item.tags || []).map(normalizeTag).filter(Boolean)); - uniqueTags.forEach((tag) => { - if (!nodesByTag.has(tag)) nodesByTag.set(tag, []); - nodesByTag.get(tag)?.push(uuid); - }); - }); - - nodesByTag.forEach((taggedNodeIds, tag) => { - if (taggedNodeIds.length < 2) return; - const sortedIds = [...taggedNodeIds].sort(); - - sortedIds.forEach((source, sourceIndex) => { - const fanOutTargets = sortedIds.slice( - sourceIndex + 1, - sourceIndex + 1 + MAX_TAG_LINK_FANOUT, - ); - fanOutTargets.forEach((target) => { - const linkKey = `tag::${tag}::${source}::${target}`; - if (linkMap.has(linkKey)) return; - - linkMap.set(linkKey, { - source, - target, - directed: false, - type: "tag", - tag, - }); - tagConnections.set(source, (tagConnections.get(source) || 0) + 1); - tagConnections.set(target, (tagConnections.get(target) || 0) + 1); - }); - }); - }); - - const nodes = Array.from(itemsByUuid.entries()) - .map(([uuid, { item, type }]) => { - const inboundCount = inbound.get(uuid) || 0; - const outboundCount = outbound.get(uuid) || 0; - const tagConnectionCount = tagConnections.get(uuid) || 0; - const connectionCount = inboundCount + outboundCount + tagConnectionCount; - const category = item.category || "Uncategorized"; - const itemId = item.id || uuid; - const path = `${category}/${itemId}`; - - return { - id: uuid, - label: item.title || fallbackTitle(type, uuid), - title: item.title || fallbackTitle(type, uuid), - type, - category, - itemId, - path, - url: itemHref(type, uuid), - tags: item.tags || [], - createdAt: item.createdAt, - updatedAt: item.updatedAt, - inboundCount, - outboundCount, - connectionCount, - weight: connectionCount, - orphan: connectionCount === 0, - }; - }) - .filter((node) => includeOrphans || !node.orphan) - .sort((a, b) => b.connectionCount - a.connectionCount); - - const visibleIds = new Set(nodes.map((node) => node.id)); - const links = Array.from(linkMap.values()).filter( - (link) => visibleIds.has(link.source) && visibleIds.has(link.target), - ); - - if (nodes.length > MAX_GRAPH_NODES) { - const keptNodes = nodes.slice(0, MAX_GRAPH_NODES); - const keptIds = new Set(keptNodes.map((node) => node.id)); - return { - nodes: keptNodes, - links: links.filter( - (link) => keptIds.has(link.source) && keptIds.has(link.target), - ), - totalNodes: nodes.length, - totalLinks: links.length, - truncated: nodes.length - MAX_GRAPH_NODES, - }; - } - - return { - nodes, - links, - totalNodes: nodes.length, - totalLinks: links.length, - truncated: 0, - }; -}; - -export const filterConnectionGraphData = ( - graphData: ConnectionGraphData, - filters: ConnectionGraphFilters, -): ConnectionGraphData => { - const query = filters.search.trim().toLowerCase(); - const sourceLinks = filters.showTagLinks - ? graphData.links - : graphData.links.filter((link) => link.type !== "tag"); - const explicitlyConnectedIds = new Set(); - sourceLinks.forEach((link) => { - explicitlyConnectedIds.add(link.source); - explicitlyConnectedIds.add(link.target); - }); - - const matchingIds = new Set( - graphData.nodes - .filter((node) => { - if (!filters.showNotes && node.type === ItemTypes.NOTE) return false; - if (!filters.showChecklists && node.type === ItemTypes.CHECKLIST) { - return false; - } - if (!filters.showOrphans && !explicitlyConnectedIds.has(node.id)) { - return false; - } - if (!query) return true; - - return [ - node.title, - node.label, - node.path, - node.category, - node.type, - ...node.tags, - ] - .join(" ") - .toLowerCase() - .includes(query); - }) - .map((node) => node.id), - ); - - const visibleIds = matchingIds; - - const nodes = graphData.nodes.filter((node) => visibleIds.has(node.id)); - const links = sourceLinks.filter( - (link) => visibleIds.has(link.source) && visibleIds.has(link.target), - ); - - return { - nodes, - links, - totalNodes: nodes.length, - totalLinks: links.length, - truncated: graphData.truncated, - }; -}; diff --git a/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx b/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx deleted file mode 100644 index c5b03eb09..000000000 --- a/app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx +++ /dev/null @@ -1,468 +0,0 @@ -"use client"; - -import { useMemo, useState } from "react"; -import type React from "react"; -import { - File02Icon, - Link04Icon, - RefreshIcon, - SharedWifiIcon, -} from "hugeicons-react"; -import { useTranslations } from "next-intl"; -import { LinkIndex } from "@/app/_types"; -import { Checklist, Note } from "@/app/_types"; -import { getUsername } from "@/app/_server/actions/users"; -import { rebuildLinkIndex } from "@/app/_server/actions/link"; -import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; -import { useToast } from "@/app/_providers/ToastProvider"; -import { ConnectionsGraph } from "./ConnectionsGraph/ConnectionsGraph"; -import { - buildConnectionGraphData, - ConnectionGraphFilters, - filterConnectionGraphData, -} from "./ConnectionsGraph/graph-data"; - -interface LinksTabProps { - linkIndex: LinkIndex; - notes: Partial[]; - checklists: Partial[]; -} - -export const LinksTab = ({ linkIndex, notes, checklists }: LinksTabProps) => { - const t = useTranslations(); - const { showToast } = useToast(); - const [rebuildingIndex, setRebuildingIndex] = useState(false); - const [selectedNodeId, setSelectedNodeId] = useState(null); - const [showLabels, setShowLabels] = useState(false); - const [showArrows, setShowArrows] = useState(true); - const [nodeScale, setNodeScale] = useState(1); - const [linkWidth, setLinkWidth] = useState(1.6); - const [linkDistance, setLinkDistance] = useState(90); - const [repelForce, setRepelForce] = useState(120); - const [filters, setFilters] = useState({ - search: "", - showNotes: true, - showChecklists: true, - showOrphans: false, - showTagLinks: true, - }); - - const graphData = useMemo( - () => buildConnectionGraphData(linkIndex, notes, checklists, true), - [linkIndex, notes, checklists], - ); - - const visibleGraphData = useMemo( - () => filterConnectionGraphData(graphData, filters), - [filters, graphData], - ); - - const connectedItems = graphData.nodes.filter((node) => !node.orphan).length; - - const handleRebuildIndex = async () => { - setRebuildingIndex(true); - try { - const username = await getUsername(); - await rebuildLinkIndex(username); - showToast({ - type: "success", - title: t("common.success"), - message: t("profile.successfullyRebuiltIndexReload"), - }); - window.location.reload(); - } catch (error) { - console.error("Failed to rebuild index:", error); - showToast({ - type: "error", - title: t("common.error"), - message: t("profile.failedToRebuildIndex"), - }); - } finally { - setRebuildingIndex(false); - } - }; - - const updateFilters = (next: Partial) => { - setFilters((current) => ({ ...current, ...next })); - }; - - if (graphData.totalNodes === 0) { - return ( -
-
- -
-
- -
-

- {t("profile.noLinksFound")} -

-

- {t("profile.startCreatingInternalLinks")}{" "} - - /note/your-note - {" "} - {t("profile.orFormat")}{" "} - - /checklist/your-list - {" "} - {t("profile.inYourContent")} -

-
- -
-
-
- ); - } - - return ( -
-
- - -
-
-
-

- {t("profile.linkNetwork")} -

-

- {t("profile.visibleGraphSummary", { - items: visibleGraphData.nodes.length, - links: visibleGraphData.links.length, - })} -

-
- -
- - {graphData.truncated > 0 && ( -

- {t("profile.graphTruncated", { - visible: visibleGraphData.nodes.length, - omitted: graphData.truncated, - })} -

- )} - -
- -
- -
- -
-
-
- ); - - function Header() { - return ( -
-

{t("profile.contentLinks")}

-

- {t("profile.visualizeRelationships")} -

-
- ); - } - - function Stats({ - totalNodes, - totalLinks, - connectedItems, - }: { - totalNodes: number; - totalLinks: number; - connectedItems: number; - }) { - return ( -
-
- } - value={totalNodes} - label={t("checklists.totalItems")} - /> - } - value={totalLinks} - label={t("profile.connectionsTab")} - /> - } - value={connectedItems} - label={t("profile.connectedItems")} - /> -
-
- ); - } - - function Stat({ - icon, - value, - label, - }: { - icon: React.ReactNode; - value: number; - label: string; - }) { - return ( -
-
{icon}
-
-
{value}
-
{label}
-
-
- ); - } -}; - -interface ControlsProps { - filters: ConnectionGraphFilters; - updateFilters: (next: Partial) => void; - showLabels: boolean; - setShowLabels: (value: boolean) => void; - showArrows: boolean; - setShowArrows: (value: boolean) => void; - nodeScale: number; - setNodeScale: (value: number) => void; - linkWidth: number; - setLinkWidth: (value: number) => void; - linkDistance: number; - setLinkDistance: (value: number) => void; - repelForce: number; - setRepelForce: (value: number) => void; -} - -const Controls = ({ - filters, - updateFilters, - showLabels, - setShowLabels, - showArrows, - setShowArrows, - nodeScale, - setNodeScale, - linkWidth, - setLinkWidth, - linkDistance, - setLinkDistance, - repelForce, - setRepelForce, -}: ControlsProps) => { - const t = useTranslations(); - - return ( -
-
- -
- -
- updateFilters({ showNotes: checked })} - /> - updateFilters({ showChecklists: checked })} - /> - updateFilters({ showOrphans: checked })} - /> - - - updateFilters({ showTagLinks: checked })} - /> -
- -
- - - - -
-
- ); -}; - -const Toggle = ({ - label, - checked, - onChange, - disabled = false, -}: { - label: string; - checked: boolean; - onChange: (value: boolean) => void; - disabled?: boolean; -}) => ( - -); - -const Range = ({ - label, - value, - min, - max, - step, - onChange, -}: { - label: string; - value: number; - min: number; - max: number; - step: number; - onChange: (value: number) => void; -}) => ( - -); diff --git a/app/_components/FeatureComponents/Sidebar/Parts/TagsList.tsx b/app/_components/FeatureComponents/Sidebar/Parts/TagsList.tsx index 6d9e71e62..c5fd5631d 100644 --- a/app/_components/FeatureComponents/Sidebar/Parts/TagsList.tsx +++ b/app/_components/FeatureComponents/Sidebar/Parts/TagsList.tsx @@ -42,6 +42,7 @@ const TagRenderer = ({ tag.noteUuids.length > 0 || tag.checklistUuids.length > 0 || children.length > 0; + const hasSubtags = children.length > 0; const isCollapsed = collapsedTags.has(tag.name); return ( @@ -54,16 +55,18 @@ const TagRenderer = ({ )} >
- {!isCollapsed && children.length > 0 && ( + {!isCollapsed && hasSubtags && (
{children.map((childTag) => ( { + const t = useTranslations(); + + return ( +
+ + {t("relations.indexing")} +
+ ); +}; diff --git a/app/_consts/relations.ts b/app/_consts/relations.ts new file mode 100644 index 000000000..65f820987 --- /dev/null +++ b/app/_consts/relations.ts @@ -0,0 +1,45 @@ +export const RELATIONS_DB_NAME = ".relations.db"; +export const RELATIONS_SCHEMA_VERSION = 2; +export const LEGACY_LINK_PREFIX = "/jotty/"; +export const WIKILINK_REGEX = + /\[\[([^\[\]|#^\n]+)(?:[#^][^\[\]|\n]*)?(?:\|([^\[\]\n]*))?\]\]/g; + +export enum LinkKinds { + MENTION = "mention", + WIKI = "wiki", +} + +export enum RelationsStatus { + BUILDING = "building", + READY = "ready", +} + +export enum BrainNodeKinds { + NOTE = "note", + CHECKLIST = "checklist", + GHOST = "ghost", + TAG = "tag", +} + +export enum BrainEdgeKinds { + MENTION = "mention", + WIKI = "wiki", + TAG = "tag", + SUGGESTED = "suggested", +} + +export const MENTION_MIN_TITLE = 3; +export const MENTION_LIMIT = 30; +export const MENTION_SNIPPET_CHARS = 60; + +export const SUGGESTIONS_PER_ITEM = 3; +export const SUGGESTIONS_MAX = 250; +export const SUGGESTION_HUB_LIMIT = 80; + +export const BRAIN_PATH = "/brain"; +export const BRAIN_FOCUS_PARAM = "focus"; + +export const brainHref = (focus?: string, owner?: string): string => { + const base = owner ? `${BRAIN_PATH}/${encodeURIComponent(owner)}` : BRAIN_PATH; + return focus ? `${base}?${BRAIN_FOCUS_PARAM}=${focus}` : base; +}; diff --git a/app/_providers/AppModeProvider.tsx b/app/_providers/AppModeProvider.tsx index 80d8a5b6e..13d0b16d7 100644 --- a/app/_providers/AppModeProvider.tsx +++ b/app/_providers/AppModeProvider.tsx @@ -21,7 +21,6 @@ import { SanitisedUser, } from "@/app/_types"; import { Modes } from "@/app/_types/enums"; -import { LinkIndex } from "../_types"; import { buildTagsIndex } from "../_utils/tag-utils"; import { useSidebarStore } from "../_utils/sidebar-store"; @@ -36,7 +35,6 @@ export const AppModeProvider = ({ pathname, appVersion, initialSettings, - linkIndex, notes, checklists, allSharedItems, @@ -52,7 +50,6 @@ export const AppModeProvider = ({ pathname?: string; appVersion?: string; initialSettings?: AppSettings; - linkIndex?: LinkIndex | null; notes?: Partial[]; checklists?: Partial[]; allSharedItems?: AllSharedItems | null; @@ -175,7 +172,6 @@ export const AppModeProvider = ({ appSettings, appVersion: appVersion || "", usersPublicData, - linkIndex: linkIndex || null, notes: notes || [], checklists: checklists || [], allSharedItems: allSharedItems || null, @@ -198,7 +194,6 @@ export const AppModeProvider = ({ appSettings, appVersion, usersPublicData, - linkIndex, notes, checklists, allSharedItems, diff --git a/app/_providers/MetadataProvider.tsx b/app/_providers/MetadataProvider.tsx index 42ab30de0..f65564638 100644 --- a/app/_providers/MetadataProvider.tsx +++ b/app/_providers/MetadataProvider.tsx @@ -44,3 +44,6 @@ export const useMetadata = (): ItemMetadata => { } return context.metadata; }; + +export const useOptionalMetadata = (): ItemMetadata | null => + useContext(MetadataContext)?.metadata ?? null; diff --git a/app/_providers/RelationsProvider.tsx b/app/_providers/RelationsProvider.tsx new file mode 100644 index 000000000..2c50a5c0b --- /dev/null +++ b/app/_providers/RelationsProvider.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { createContext, ReactNode, useContext } from "react"; +import { RelationsStatus } from "@/app/_consts/relations"; +import type { ItemRelations } from "@/app/_types/relations"; + +const EMPTY: ItemRelations = { uuid: "", status: RelationsStatus.READY, backlinks: [], mentions: [], wikis: {} }; + +const RelationsContext = createContext(EMPTY); + +export const RelationsProvider = ({ + relations, + children, +}: { + relations: ItemRelations; + children: ReactNode; +}) => ( + {children} +); + +export const useRelations = (): ItemRelations => useContext(RelationsContext); diff --git a/app/_server/actions/category/crud.ts b/app/_server/actions/category/crud.ts index 926c5afa4..1528d57cd 100644 --- a/app/_server/actions/category/crud.ts +++ b/app/_server/actions/category/crud.ts @@ -5,6 +5,7 @@ import path from "path"; import { ensureDir, serverDeleteDir, + serverRenamePath, getUserModeDir, } from "@/app/_server/actions/file"; import fs from "fs/promises"; @@ -324,7 +325,7 @@ export const renameCategory = async (formData: FormData) => { return { error: "Category with new name already exists" }; } - await fs.rename(oldCategoryDir, newCategoryDir); + await serverRenamePath(oldCategoryDir, newCategoryDir); const username = await getUsername(); if (mode === Modes.NOTES && username) { diff --git a/app/_server/actions/category/move.ts b/app/_server/actions/category/move.ts index 468e1d972..01ce4824a 100644 --- a/app/_server/actions/category/move.ts +++ b/app/_server/actions/category/move.ts @@ -6,12 +6,13 @@ import { ensureDir, getUserModeDir, readOrderFile, + serverRenamePath, + serverWriteFile, writeOrderFile, } from "@/app/_server/actions/file"; import fs from "fs/promises"; import { Modes } from "@/app/_types/enums"; import { getUsername } from "@/app/_server/actions/users"; -import { rebuildLinkIndexInternal } from "@/app/_server/actions/link"; import { logAudit } from "@/app/_server/actions/log"; import { broadcast } from "@/app/_server/actions/ws/broadcast"; import { isPathSafe } from "@/app/_utils/path-utils"; @@ -119,10 +120,9 @@ const _restamp = async ( ); const content = await fs.readFile(filePath, "utf-8"); - await fs.writeFile( + await serverWriteFile( filePath, updateYamlMetadata(content, { owner, category }), - "utf-8", ); } catch (error) { console.error(`Failed to restamp ${filePath}:`, error); @@ -382,7 +382,7 @@ export const moveNode = async (formData: FormData) => { const oldPath = path.join(oldParentDir, fileName); const newPath = path.join(newParentDir, fileName); - await fs.rename(oldPath, newPath); + await serverRenamePath(oldPath, newPath); if (activeType === "item") { await _restamp(newPath, destLoc.owner, destLoc.category); @@ -430,16 +430,6 @@ export const moveNode = async (formData: FormData) => { console.warn("Failed to commit note move to git history:", error); } } - - try { - await rebuildLinkIndexInternal(sourceLoc.owner); - - if (destLoc.owner !== sourceLoc.owner) { - await rebuildLinkIndexInternal(destLoc.owner); - } - } catch (error) { - console.warn("Failed to update link index:", error); - } } await logAudit({ diff --git a/app/_server/actions/checklist/converters.ts b/app/_server/actions/checklist/converters.ts index 9c4b3862a..edfda364c 100644 --- a/app/_server/actions/checklist/converters.ts +++ b/app/_server/actions/checklist/converters.ts @@ -16,10 +16,6 @@ import { revalidatePath } from "next/cache"; import { listToMarkdown } from "@/app/_utils/checklist-utils"; import { getFormData } from "@/app/_utils/global-utils"; import { UNCATEGORIZED } from "@/app/_consts/notes"; -import { - updateIndexForItem, - parseInternalLinks, -} from "@/app/_server/actions/link"; import { canReach } from "@/app/_server/actions/share/queries"; import { broadcast } from "@/app/_server/actions/ws/broadcast"; import { getListById, getUserChecklists } from "./queries"; @@ -331,23 +327,6 @@ export const clearAllChecklistItems = async (formData: FormData) => { await serverWriteFile(filePath, listToMarkdown(updatedChecklist)); - try { - const content = updatedChecklist.items.map((i) => i.text).join("\n"); - const links = await parseInternalLinks(content); - await updateIndexForItem( - checklist.owner!, - ItemTypes.CHECKLIST, - updatedChecklist.uuid!, - links, - ); - } catch (error) { - console.warn( - "Failed to update link index for checklist:", - updatedChecklist.id, - error, - ); - } - try { revalidatePath("/"); revalidatePath(`/checklist/${checklist.uuid}`); diff --git a/app/_server/actions/checklist/creator.ts b/app/_server/actions/checklist/creator.ts index a10620936..6d973af3a 100644 --- a/app/_server/actions/checklist/creator.ts +++ b/app/_server/actions/checklist/creator.ts @@ -5,7 +5,6 @@ import { ensureDir, serverWriteFile } from "@/app/_server/actions/file"; import { generateUniqueFilename } from "@/app/_utils/filename-utils"; import { listToMarkdown } from "@/app/_utils/checklist-utils"; import { UNCATEGORIZED } from "@/app/_consts/notes"; -import { updateIndexForItem, parseInternalLinks } from "@/app/_server/actions/link"; import { targetDir, bouncer } from "@/app/_server/actions/share/target"; import { generateUuid } from "@/app/_utils/yaml-metadata-utils"; import { logContentEvent } from "@/app/_server/actions/log"; @@ -64,26 +63,6 @@ export const makeList = async ( await serverWriteFile(filePath, listToMarkdown(newList)); - try { - const content = newList.items.map((i) => i.text).join("\n"); - const links = await parseInternalLinks(content); - const indexUsername = target.owner; - if (indexUsername) { - await updateIndexForItem( - indexUsername, - ItemTypes.CHECKLIST, - newList.uuid!, - links, - ); - } - } catch (error) { - console.warn( - "Failed to update link index for new checklist:", - newList.id, - error, - ); - } - await logContentEvent( "checklist_created", "checklist", diff --git a/app/_server/actions/checklist/crud.ts b/app/_server/actions/checklist/crud.ts index 7a70cfab3..94f821261 100644 --- a/app/_server/actions/checklist/crud.ts +++ b/app/_server/actions/checklist/crud.ts @@ -15,12 +15,6 @@ import { generateUniqueFilename, sanitizeFilename } from "@/app/_utils/filename- import { listToMarkdown } from "@/app/_utils/checklist-utils"; import { getFormData } from "@/app/_utils/global-utils"; import { UNCATEGORIZED } from "@/app/_consts/notes"; -import { - updateIndexForItem, - parseInternalLinks, - removeItemFromIndex, - rebuildLinkIndexInternal, -} from "@/app/_server/actions/link"; import { canReach } from "@/app/_server/actions/share/queries"; import { targetDir, @@ -187,37 +181,6 @@ export const updateList = async (formData: FormData) => { await serverWriteFile(filePath, listToMarkdown(updatedList)); - try { - const content = updatedList.items.map((i) => i.text).join("\n"); - const links = await parseInternalLinks(content); - const newItemKey = `${updatedList.category || UNCATEGORIZED}/${updatedList.id - }`; - - const oldItemKey = `${home.category || UNCATEGORIZED}/${currentId}`; - if (oldItemKey !== newItemKey || isMoving) { - await rebuildLinkIndexInternal(home.owner); - - if (destination.owner !== home.owner) { - await rebuildLinkIndexInternal(destination.owner); - } - - revalidatePath("/"); - } - - await updateIndexForItem( - destination.owner, - ItemTypes.CHECKLIST, - updatedList.uuid!, - links - ); - } catch (error) { - console.warn( - "Failed to update link index for checklist:", - updatedList.id, - error - ); - } - if (oldFilePath && oldFilePath !== filePath) { await serverDeleteFile(oldFilePath); } @@ -331,16 +294,6 @@ export const deleteList = async (formData: FormData) => { await serverDeleteFile(filePath); - try { - await removeItemFromIndex(list.owner!, ItemTypes.CHECKLIST, list.uuid!); - } catch (error) { - console.warn( - "Failed to remove checklist from link index:", - list.id, - error - ); - } - try { revalidatePath("/"); revalidatePath(`/checklist/${list.uuid}`); diff --git a/app/_server/actions/checklist/readers.ts b/app/_server/actions/checklist/readers.ts index 8e3a83936..7bc9a5070 100644 --- a/app/_server/actions/checklist/readers.ts +++ b/app/_server/actions/checklist/readers.ts @@ -14,10 +14,10 @@ import { } from "@/app/_server/actions/file"; import { parseMarkdown } from "@/app/_utils/checklist-utils"; import { + createdAtOf, extractYamlMetadata, generateUuid, toIso, - updateYamlMetadata, } from "@/app/_utils/yaml-metadata-utils"; import { grepExtractFrontmatter } from "@/app/_utils/grep-utils"; import type { FileStatsEntry } from "@/app/_server/actions/file"; @@ -26,32 +26,13 @@ import { orderByUuids } from "@/app/_utils/order-utils"; import { getChecklistType } from "./parsers"; import { isDebugFlag } from "@/app/_utils/env-utils"; import { isKanbanType } from "@/app/_types/enums"; -import { singleFlight } from "@/app/_server/actions/lib/concurrency"; +import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid"; +import { SHARED_WITH_KEY } from "@/app/_consts/sharing"; const execAsync = promisify(exec); const debugCrud = isDebugFlag("crud"); -const _stampUuid = async (filePath: string): Promise => - singleFlight(`stamp:${filePath}`, async () => { - try { - const content = await serverReadFile(filePath); - if (!content) return undefined; - - const { metadata } = extractYamlMetadata(content); - if (typeof metadata.uuid === "string" && metadata.uuid) { - return metadata.uuid; - } - - const uuid = generateUuid(); - await serverWriteFile(filePath, updateYamlMetadata(content, { uuid })); - return uuid; - } catch (error) { - console.warn("Failed to stamp UUID on checklist:", filePath, error); - return undefined; - } - }); - export type ChecklistReadResult = | Partial | Checklist @@ -75,7 +56,7 @@ export const readListsRecursively = async ( ? "" : `-not -path "*/${ARCHIVED_DIR_NAME}/*"`; const statsCmd = `find "${dir}" -name "*.md" ${excludeStr} -printf "%p|%W@|%T@\\n"`; - const metaCmd = `grep -rE "^(title|uuid|tags|checklistType):|^[[:space:]]+- |^---$" "${dir}"`; + const metaCmd = `grep -rE "^(title|uuid|tags|checklistType|createdAt|${SHARED_WITH_KEY}):|^[[:space:]]+- |^---$" "${dir}"`; const [statsOut, metaOut] = await Promise.all([ execAsync(statsCmd, { maxBuffer: 10 * 1024 * 1024 }).catch(() => ({ stdout: "", @@ -100,7 +81,8 @@ export const readListsRecursively = async ( ); } const inFrontmatter = new Map(); - let inTagsFile = ""; + let listFile = ""; + let listKey = ""; for (const line of metaLines) { const colonIdx = line.indexOf(":"); if (colonIdx === -1) continue; @@ -119,7 +101,7 @@ export const readListsRecursively = async ( } if (!inFrontmatter.get(filePath)) continue; if (/^\s+-\s/.test(rest)) { - if (inTagsFile === filePath) { + if (listFile === filePath) { const tag = rest.replace(/^\s+-\s+/, "").trim(); if (tag) { if (debugCrud) @@ -132,13 +114,13 @@ export const readListsRecursively = async ( if (!metadataCache!.has(filePath)) metadataCache!.set(filePath, {}); const entry = metadataCache!.get(filePath)!; - if (!Array.isArray(entry.tags)) entry.tags = []; - (entry.tags as string[]).push(tag); + if (!Array.isArray(entry[listKey])) entry[listKey] = []; + (entry[listKey] as string[]).push(tag); } } continue; } - inTagsFile = ""; + listFile = ""; const innerColon = rest.indexOf(":"); if (innerColon === -1) continue; const key = rest.slice(0, innerColon); @@ -149,7 +131,8 @@ export const readListsRecursively = async ( const trimmed = val.trim(); if (trimmed === "") { entry.tags = []; - inTagsFile = filePath; + listFile = filePath; + listKey = key; } else { entry.tags = trimmed .replace(/^\[|\]$/g, "") @@ -157,6 +140,10 @@ export const readListsRecursively = async ( .map((t: string) => t.trim()) .filter(Boolean); } + } else if (key === SHARED_WITH_KEY && val.trim() === "") { + entry[key] = []; + listFile = filePath; + listKey = key; } else { entry[key] = val.trim().replace(/^["']|["']$/g, ""); } @@ -222,14 +209,14 @@ export const readListsRecursively = async ( uuid: typeof metadata?.uuid === "string" ? metadata.uuid - : await _stampUuid(filePath), + : await stampUuid(filePath), title: typeof metadata?.title === "string" ? metadata.title : id, type: isKanbanType(metadata?.checklistType as string) ? "kanban" : "simple", category: categoryPath, items: [], - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner, isShared: false, @@ -241,16 +228,8 @@ export const readListsRecursively = async ( if (isRaw) { const { metadata } = extractYamlMetadata(content); const type = getChecklistType(content); - let uuid = metadata.uuid; - if (!uuid) { - uuid = generateUuid(); - try { - const updatedContent = updateYamlMetadata(content, { uuid }); - await serverWriteFile(filePath, updatedContent); - } catch (error) { - console.warn("Failed to save UUID to checklist file:", error); - } - } + const uuid = + metadata.uuid || (await stampUuid(filePath)) || generateUuid(); return { id, title: id, @@ -258,7 +237,7 @@ export const readListsRecursively = async ( type, category: categoryPath, items: [], - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner, isShared: false, diff --git a/app/_server/actions/file/atomic.ts b/app/_server/actions/file/atomic.ts new file mode 100644 index 000000000..b8a0277d0 --- /dev/null +++ b/app/_server/actions/file/atomic.ts @@ -0,0 +1,55 @@ +import fs from "fs/promises"; +import path from "path"; +import { randomUUID } from "crypto"; +import { extractYamlMetadata, toIso } from "@/app/_utils/yaml-metadata-utils"; + +const FRONTMATTER_OPEN = /^(\uFEFF?---\r?\n)/; + +const _realTarget = async (filePath: string): Promise => { + try { + return await fs.realpath(filePath); + } catch { + return filePath; + } +}; + +export const atomicWrite = async (filePath: string, content: string) => { + const target = await _realTarget(filePath); + const tmpPath = path.join( + path.dirname(target), + `.${path.basename(target)}.${randomUUID()}.tmp`, + ); + + try { + await fs.writeFile(tmpPath, content, "utf-8"); + await fs.rename(tmpPath, target); + } catch (error) { + console.error(`Atomic write failed for ${filePath}:`, error); + await fs.unlink(tmpPath).catch(() => undefined); + throw error; + } +}; + +const _existingCreatedAt = async (filePath: string): Promise => { + try { + const existing = await fs.readFile(filePath, "utf-8"); + const stamped = extractYamlMetadata(existing).metadata.createdAt; + if (stamped) return toIso(stamped); + + const stats = await fs.stat(filePath); + return toIso(stats.birthtime.getTime() > 0 ? stats.birthtime : stats.mtime); + } catch { + return new Date().toISOString(); + } +}; + +export const withCreatedAt = async ( + filePath: string, + content: string, +): Promise => { + if (!FRONTMATTER_OPEN.test(content)) return content; + if (extractYamlMetadata(content).metadata.createdAt) return content; + + const createdAt = await _existingCreatedAt(filePath); + return content.replace(FRONTMATTER_OPEN, `$1createdAt: "${createdAt}"\n`); +}; diff --git a/app/_server/actions/file/index.ts b/app/_server/actions/file/index.ts index 408cc3475..085b754d8 100644 --- a/app/_server/actions/file/index.ts +++ b/app/_server/actions/file/index.ts @@ -10,6 +10,8 @@ import fs from "fs/promises"; import path from "path"; import { randomUUID } from "crypto"; import { Modes } from "@/app/_types/enums"; +import { atomicWrite, withCreatedAt } from "@/app/_server/actions/file/atomic"; +import { isItemFile } from "@/app/_server/actions/relations/paths"; export interface OrderData { categories?: string[]; @@ -140,14 +142,31 @@ export const serverReadFile = async ( } }; +const _tracking = () => import("@/app/_server/actions/relations/tracking"); + export const serverWriteFile = async (filePath: string, content: string) => { await ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content, "utf-8"); + + if (!isItemFile(filePath)) { + await atomicWrite(filePath, content); + return; + } + + const stamped = await withCreatedAt(filePath, content); + await atomicWrite(filePath, stamped); + await (await _tracking()).trackItemWrite(filePath, stamped); +}; + +export const serverRenamePath = async (from: string, to: string) => { + await ensureDir(path.dirname(to)); + await fs.rename(from, to); + await (await _tracking()).trackMove(from, to); }; export const serverDeleteFile = async (filePath: string) => { try { await fs.unlink(filePath); + await (await _tracking()).trackItemDelete(filePath); } catch (error) { const { logAudit } = await import("@/app/_server/actions/log"); await logAudit({ @@ -194,6 +213,7 @@ export const listMdFilesUnderPath = async ( export const serverDeleteDir = async (dirPath: string) => { try { await fs.rm(dirPath, { recursive: true }); + await (await _tracking()).trackTreeDelete(dirPath); } catch (error) { const { logAudit } = await import("@/app/_server/actions/log"); await logAudit({ diff --git a/app/_server/actions/lib/legacy-lookup.ts b/app/_server/actions/lib/legacy-lookup.ts index efd2d417e..992f9faec 100644 --- a/app/_server/actions/lib/legacy-lookup.ts +++ b/app/_server/actions/lib/legacy-lookup.ts @@ -76,24 +76,13 @@ const _uuidFor = async (filePath: string): Promise => { return existing; } - const { extractYamlMetadata, generateUuid, updateYamlMetadata } = - await import("@/app/_utils/yaml-metadata-utils"); const { logAudit } = await import("@/app/_server/actions/log"); - const { singleFlight } = await import("@/app/_server/actions/lib/concurrency"); + const { stampUuid } = await import("@/app/_server/actions/lib/stamp-uuid"); try { - return await singleFlight(`stamp:${filePath}`, async () => { - const content = await fs.readFile(filePath, "utf-8"); - const { metadata } = extractYamlMetadata(content); - - if (typeof metadata.uuid === "string" && metadata.uuid) { - return metadata.uuid; - } - - const stamped = generateUuid(); - await fs.writeFile(filePath, updateYamlMetadata(content, { uuid: stamped }), "utf-8"); - return stamped; - }); + const stamped = await stampUuid(filePath); + if (stamped) return stamped; + throw new Error("uuid stamp refused"); } catch (error) { await logAudit({ level: "WARNING", diff --git a/app/_server/actions/lib/metadata-cache.ts b/app/_server/actions/lib/metadata-cache.ts index ab323f91a..bad60ff07 100644 --- a/app/_server/actions/lib/metadata-cache.ts +++ b/app/_server/actions/lib/metadata-cache.ts @@ -90,6 +90,15 @@ export async function getOrCompute( return [...(await promise)]; } +export function invalidatePath(target: string) { + const abs = path.resolve(target); + dirToKeys.forEach((_, dir) => { + if (abs === dir || abs.startsWith(dir + path.sep) || dir.startsWith(abs + path.sep)) { + invalidateDir(dir); + } + }); +} + export function invalidateCached(key: string) { drop(key); } diff --git a/app/_server/actions/lib/stamp-uuid.ts b/app/_server/actions/lib/stamp-uuid.ts new file mode 100644 index 000000000..da9beb13f --- /dev/null +++ b/app/_server/actions/lib/stamp-uuid.ts @@ -0,0 +1,54 @@ +import fs from "fs/promises"; +import { extractYamlMetadata, generateUuid } from "@/app/_utils/yaml-metadata-utils"; +import { serverWriteFile } from "@/app/_server/actions/file"; +import { singleFlight } from "@/app/_server/actions/lib/concurrency"; + +const FRONTMATTER_OPEN = /^(\uFEFF?---\r?\n)/; +const FRONTMATTER_BLOCK = /^\uFEFF?---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/; +const UUID_LINE = /^uuid:\s*\S+/m; + +const _withUuid = (content: string, uuid: string): string => + FRONTMATTER_OPEN.test(content) + ? content.replace(FRONTMATTER_OPEN, `$1uuid: ${uuid}\n`) + : `---\nuuid: ${uuid}\n---\n\n${content}`; + +const _stampable = (content: string, filePath: string): boolean => { + if (!content.trim()) { + console.warn("Refusing to stamp uuid on an empty read:", filePath); + return false; + } + + const block = content.match(FRONTMATTER_BLOCK)?.[0]; + if (!block) return true; + + if (UUID_LINE.test(block)) { + console.warn("Frontmatter has a uuid line that did not parse, leaving it alone:", filePath); + return false; + } + + const opened = FRONTMATTER_OPEN.test(content); + const parsed = Object.keys(extractYamlMetadata(content).metadata).length > 0; + if (opened && !parsed && block.split("\n").length > 3) { + console.warn("Frontmatter did not parse, refusing to stamp uuid:", filePath); + return false; + } + + return true; +}; + +export const stampUuid = (filePath: string): Promise => + singleFlight(`stamp:${filePath}`, async () => { + try { + const content = await fs.readFile(filePath, "utf-8"); + const existing = extractYamlMetadata(content).metadata.uuid; + if (typeof existing === "string" && existing) return existing; + if (!_stampable(content, filePath)) return undefined; + + const uuid = generateUuid(); + await serverWriteFile(filePath, _withUuid(content, uuid)); + return uuid; + } catch (error) { + console.error("Failed to stamp uuid:", filePath, error); + return undefined; + } + }); diff --git a/app/_server/actions/link/index.ts b/app/_server/actions/link/index.ts deleted file mode 100644 index 1c76ca71f..000000000 --- a/app/_server/actions/link/index.ts +++ /dev/null @@ -1,369 +0,0 @@ -"use server"; - -import path from "path"; -import { getCurrentUser } from "@/app/_server/actions/users"; -import { getUserModeDir } from "@/app/_server/actions/file"; -import { ItemTypes, Modes } from "@/app/_types/enums"; -import { serverReadFile, serverWriteFile } from "@/app/_server/actions/file"; -import { getUserNotes } from "@/app/_server/actions/note"; -import { getUserChecklists } from "@/app/_server/actions/checklist"; -import { ItemType, LinkIndex, ItemLinks } from "@/app/_types"; - -export interface LinkTarget { - type: ItemType; - uuid: string; -} - -const INDEX_FILENAME = ".index.json"; - -const getIndexFilePath = async (username: string): Promise => { - const userDir = await getUserModeDir(Modes.NOTES, username); - return path.join(userDir, INDEX_FILENAME); -}; - -export const readLinkIndex = async (username: string): Promise => { - try { - const indexPath = await getIndexFilePath(username); - const content = await serverReadFile(indexPath); - return JSON.parse(content) as LinkIndex; - } catch { - return { notes: {}, checklists: {} }; - } -}; - -export const writeLinkIndex = async ( - username: string, - index: LinkIndex, -): Promise => { - const indexPath = await getIndexFilePath(username); - await serverWriteFile(indexPath, JSON.stringify(index, null, 2)); -}; - -export const parseInternalLinks = async ( - content: string, -): Promise => { - const links: LinkTarget[] = []; - - const htmlRegex = /data-href="([^"]+)"[^>]*data-type="(note|checklist)"/g; - let match; - while ((match = htmlRegex.exec(content)) !== null) { - const href = match[1]; - const type = match[2] as ItemType; - - if (href?.startsWith("/jotty/")) { - const uuid = href.replace("/jotty/", ""); - links.push({ - type: type || ItemTypes.NOTE, - uuid, - }); - } else if (href && !href.includes("/")) { - links.push({ - type, - uuid: href, - }); - } - } - - if (links.length === 0) { - const markdownRegex = /\[([^\]]+)\]\(([^)]+)\)/g; - while ((match = markdownRegex.exec(content)) !== null) { - const href = match[2]; - - if (href?.startsWith("/jotty/")) { - const uuid = href.replace("/jotty/", ""); - links.push({ - type: ItemTypes.NOTE, - uuid, - }); - } - } - } - - const uniqueLinks = links.filter( - (link, index, self) => - index === - self.findIndex((l) => l.type === link.type && l.uuid === link.uuid), - ); - - return Promise.resolve(uniqueLinks); -}; - -export const updateIndexForItem = async ( - username: string, - itemType: ItemType, - itemUuid: string, - currentLinks: LinkTarget[], -): Promise => { - if (!Array.isArray(currentLinks)) { - currentLinks = []; - } - - const index = await readLinkIndex(username); - - const currentItemKey = `${itemType}s`; - const currentItemLinks = index[currentItemKey][itemUuid] || { - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, - }; - - for (const targetUuid of currentItemLinks.isLinkedTo.notes) { - if (index.notes[targetUuid]) { - const refKey = currentItemKey as keyof ItemLinks["isReferencedIn"]; - index.notes[targetUuid].isReferencedIn[refKey] = index.notes[ - targetUuid - ].isReferencedIn[refKey].filter((ref: string) => ref !== itemUuid); - } - } - - for (const targetUuid of currentItemLinks.isLinkedTo.checklists) { - if (index.checklists[targetUuid]) { - const refKey = currentItemKey as keyof ItemLinks["isReferencedIn"]; - index.checklists[targetUuid].isReferencedIn[refKey] = index.checklists[ - targetUuid - ].isReferencedIn[refKey].filter((ref: string) => ref !== itemUuid); - } - } - - index[currentItemKey][itemUuid] = { - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, - }; - - for (const link of currentLinks) { - const targetKey = `${link.type}s`; - - if (!(index[currentItemKey][itemUuid].isLinkedTo as any)[targetKey]) { - (index[currentItemKey][itemUuid].isLinkedTo as any)[targetKey] = []; - } - if ( - !(index[currentItemKey][itemUuid].isLinkedTo as any)[targetKey].includes( - link.uuid, - ) - ) { - (index[currentItemKey][itemUuid].isLinkedTo as any)[targetKey].push( - link.uuid, - ); - } - - if (!index[targetKey][link.uuid]) { - index[targetKey][link.uuid] = { - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, - }; - } - if (!(index[targetKey][link.uuid].isReferencedIn as any)[currentItemKey]) { - (index[targetKey][link.uuid].isReferencedIn as any)[currentItemKey] = []; - } - if ( - !(index[targetKey][link.uuid].isReferencedIn as any)[ - currentItemKey - ].includes(itemUuid) - ) { - (index[targetKey][link.uuid].isReferencedIn as any)[currentItemKey].push( - itemUuid, - ); - } - } - - await writeLinkIndex(username, index); -}; - -export const removeItemFromIndex = async ( - username: string, - itemType: ItemType, - itemUuid: string, -): Promise => { - const index = await readLinkIndex(username); - const itemKey = `${itemType}s`; - - if (!index[itemKey][itemUuid]) return; - - const itemLinks = index[itemKey][itemUuid]; - - for (const linkedItem of itemLinks.isLinkedTo.notes) { - if (index.notes[linkedItem]) { - index.notes[linkedItem].isReferencedIn[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ] = index.notes[linkedItem].isReferencedIn[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ].filter((ref) => ref !== itemUuid); - } - } - - for (const linkedItem of itemLinks.isLinkedTo.checklists) { - if (index.checklists[linkedItem]) { - index.checklists[linkedItem].isReferencedIn[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ] = index.checklists[linkedItem].isReferencedIn[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ].filter((ref) => ref !== itemUuid); - } - } - - for (const referencingItem of itemLinks.isReferencedIn.notes) { - if (index.notes[referencingItem]) { - index.notes[referencingItem].isLinkedTo[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ] = index.notes[referencingItem].isLinkedTo[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ].filter((link) => link !== itemUuid); - } - } - - for (const referencingItem of itemLinks.isReferencedIn.checklists) { - if (index.checklists[referencingItem]) { - index.checklists[referencingItem].isLinkedTo[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ] = index.checklists[referencingItem].isLinkedTo[ - itemType === ItemTypes.NOTE ? Modes.NOTES : Modes.CHECKLISTS - ].filter((link) => link !== itemUuid); - } - } - - delete index[itemKey][itemUuid]; - - await writeLinkIndex(username, index); -}; - -export const updateItemCategory = async ( - username: string, - itemType: ItemType, - oldItemId: string, - newItemId: string, -): Promise => { - const index = await readLinkIndex(username); - const itemKey = `${itemType}s`; - - if (oldItemId === newItemId) return; - - const oldItemData = index[itemKey][oldItemId]; - if (!oldItemData) return; - - index[itemKey][newItemId] = oldItemData; - delete index[itemKey][oldItemId]; - - for (const referencingNoteId of oldItemData.isReferencedIn.notes) { - if (index.notes[referencingNoteId]) { - index.notes[referencingNoteId].isLinkedTo.notes = index.notes[ - referencingNoteId - ].isLinkedTo.notes.map((link) => (link === oldItemId ? newItemId : link)); - } - } - - for (const referencingChecklistId of oldItemData.isReferencedIn.checklists) { - if (index.checklists[referencingChecklistId]) { - index.checklists[referencingChecklistId].isLinkedTo.notes = - index.checklists[referencingChecklistId].isLinkedTo.notes.map((link) => - link === oldItemId ? newItemId : link, - ); - } - } - - index[itemKey][newItemId].isReferencedIn = oldItemData.isReferencedIn; - - await writeLinkIndex(username, index); -}; - -export const rebuildLinkIndexInternal = async ( - username: string, -): Promise => { - const [notesResult, checklistsResult] = await Promise.all([ - getUserNotes({ username }), - getUserChecklists({ username }), - ]); - - const allNotes = notesResult.success ? notesResult.data || [] : []; - const allChecklists = checklistsResult.success - ? checklistsResult.data || [] - : []; - - const newIndex: LinkIndex = { notes: {}, checklists: {} }; - - for (const note of allNotes) { - if (note.uuid) { - newIndex.notes[note.uuid] = { - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, - }; - } - } - - for (const checklist of allChecklists) { - if (checklist.uuid) { - newIndex.checklists[checklist.uuid] = { - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, - }; - } - } - - for (const note of allNotes) { - if (!note.uuid) continue; - const links = await parseInternalLinks(note.content || ""); - newIndex.notes[note.uuid].isLinkedTo = { - notes: links.filter((l) => l.type === ItemTypes.NOTE).map((l) => l.uuid), - checklists: links - .filter((l) => l.type === ItemTypes.CHECKLIST) - .map((l) => l.uuid), - }; - } - - for (const checklist of allChecklists) { - if (!checklist.uuid) continue; - const content = checklist?.items?.map((i) => i.text).join("\n") || ""; - const links = await parseInternalLinks(content); - newIndex.checklists[checklist.uuid].isLinkedTo = { - notes: links.filter((l) => l.type === ItemTypes.NOTE).map((l) => l.uuid), - checklists: links - .filter((l) => l.type === ItemTypes.CHECKLIST) - .map((l) => l.uuid), - }; - } - - for (const [sourceType, items] of Object.entries(newIndex)) { - for (const [sourceUuid, itemLinks] of Object.entries(items)) { - for (const targetUuid of itemLinks.isLinkedTo.notes) { - if (newIndex.notes[targetUuid]) { - const refType = - sourceType === Modes.NOTES ? Modes.NOTES : Modes.CHECKLISTS; - if ( - !newIndex.notes[targetUuid].isReferencedIn[refType].includes( - sourceUuid, - ) - ) { - newIndex.notes[targetUuid].isReferencedIn[refType].push(sourceUuid); - } - } - } - - for (const targetUuid of itemLinks.isLinkedTo.checklists) { - if (newIndex.checklists[targetUuid]) { - const refType = - sourceType === Modes.NOTES ? Modes.NOTES : Modes.CHECKLISTS; - if ( - !newIndex.checklists[targetUuid].isReferencedIn[refType].includes( - sourceUuid, - ) - ) { - newIndex.checklists[targetUuid].isReferencedIn[refType].push( - sourceUuid, - ); - } - } - } - } - } - - await writeLinkIndex(username, newIndex); -}; - -export const rebuildLinkIndex = async (username: string): Promise => { - const currentUser = await getCurrentUser(); - if (!currentUser) throw new Error("Not authenticated"); - - if (username !== currentUser.username && !currentUser.isAdmin) { - throw new Error("Unauthorized: can only rebuild your own link index"); - } - - await rebuildLinkIndexInternal(username); -}; diff --git a/app/_server/actions/note/creator.ts b/app/_server/actions/note/creator.ts index 3ded2da81..71b162b4b 100644 --- a/app/_server/actions/note/creator.ts +++ b/app/_server/actions/note/creator.ts @@ -11,10 +11,7 @@ import { sanitizeMarkdown } from "@/app/_utils/markdown-utils"; import { extractHashtagsFromContent } from "@/app/_utils/tag-utils"; import { getFormData } from "@/app/_utils/global-utils"; import { UNCATEGORIZED } from "@/app/_consts/notes"; -import { - updateIndexForItem, - parseInternalLinks, -} from "@/app/_server/actions/link"; +import { tidyItemLinks } from "@/app/_server/actions/relations/tidy"; import { extractYamlMetadata as stripYaml, generateUuid, @@ -39,11 +36,14 @@ export const makeNote = async ( const sanitizedContent = sanitizeMarkdown(rawContent); const { metadata, contentWithoutMetadata } = stripYaml(sanitizedContent); - const content = contentWithoutMetadata; - const encryptionMethod = detectEncryptionMethod(content) || undefined; - const encrypted = isEncrypted(content); + const encryptionMethod = + detectEncryptionMethod(contentWithoutMetadata) || undefined; + const encrypted = isEncrypted(contentWithoutMetadata); const target = await targetDir(Modes.NOTES, actor.username, category); + const content = encrypted + ? contentWithoutMetadata + : await tidyItemLinks(contentWithoutMetadata, target.owner); const verdict = await bouncer( target, @@ -96,17 +96,6 @@ export const makeNote = async ( commitNote(target.owner, relativePath, "create", title).catch(() => {}); } - try { - const links = (await parseInternalLinks(newDoc.content)) || []; - await updateIndexForItem(target.owner, "note", newDoc.uuid!, links); - } catch (error) { - console.warn( - "Failed to update link index for new note:", - newDoc.id, - error, - ); - } - await logContentEvent( "note_created", "note", diff --git a/app/_server/actions/note/crud.ts b/app/_server/actions/note/crud.ts index b242ef8d8..b11d29338 100644 --- a/app/_server/actions/note/crud.ts +++ b/app/_server/actions/note/crud.ts @@ -21,11 +21,9 @@ import { extractHashtagsFromContent } from "@/app/_utils/tag-utils"; import { getFormData } from "@/app/_utils/global-utils"; import { UNCATEGORIZED } from "@/app/_consts/notes"; import { - updateIndexForItem, - parseInternalLinks, - removeItemFromIndex, - rebuildLinkIndexInternal, -} from "@/app/_server/actions/link"; + refreshWikilinks, + tidyItemLinks, +} from "@/app/_server/actions/relations/tidy"; import { canReach } from "@/app/_server/actions/share/queries"; import { extractYamlMetadata as stripYaml, @@ -37,7 +35,7 @@ import { import { getSettings } from "@/app/_server/actions/config"; import { logContentEvent } from "@/app/_server/actions/log"; import { commitNote } from "@/app/_server/actions/history"; -import { noteToMarkdown, convertInternalLinksToNewFormat } from "./parsers"; +import { noteToMarkdown } from "./parsers"; import { getNoteById } from "./queries"; import { targetDir, @@ -155,15 +153,12 @@ export const updateNote = async (formData: FormData, autosaveNotes = false) => { const sanitizedContent = sanitizeMarkdown(content); const { metadata: incomingMeta, contentWithoutMetadata } = stripYaml(sanitizedContent); - const processedContent = settings?.editor?.enableBilateralLinks - ? await convertInternalLinksToNewFormat( - contentWithoutMetadata, - actingUsername, - note.category, - ) - : contentWithoutMetadata; - - const convertedContent = processedContent; + const convertedContent = isEncrypted(contentWithoutMetadata) + ? contentWithoutMetadata + : refreshWikilinks( + await tidyItemLinks(contentWithoutMetadata, note.owner || actingUsername), + note.uuid, + ); const encryptionMethod = detectEncryptionMethod(convertedContent) || undefined; @@ -247,40 +242,6 @@ export const updateNote = async (formData: FormData, autosaveNotes = false) => { ).catch(() => {}); } - if (settings?.editor?.enableBilateralLinks) { - try { - const links = (await parseInternalLinks(updatedDoc.content)) || []; - const newItemKey = `${updatedDoc.category || UNCATEGORIZED}/${ - updatedDoc.id - }`; - - const oldItemKey = `${home.category || UNCATEGORIZED}/${currentId}`; - - if (oldItemKey !== newItemKey || isMoving) { - await rebuildLinkIndexInternal(home.owner); - - if (destination.owner !== home.owner) { - await rebuildLinkIndexInternal(destination.owner); - } - - revalidatePath("/"); - } - - await updateIndexForItem( - destination.owner, - "note", - updatedDoc.uuid!, - links, - ); - } catch (error) { - console.warn( - "Failed to update link index for note:", - updatedDoc.id, - error, - ); - } - } - if (oldFilePath && oldFilePath !== filePath) { await serverDeleteFile(oldFilePath); } @@ -404,12 +365,6 @@ export const deleteNote = async (formData: FormData, username?: string) => { ).catch(() => {}); } - try { - await removeItemFromIndex(note.owner!, "note", note.uuid!); - } catch (error) { - console.warn("Failed to remove note from link index:", note.id, error); - } - try { revalidatePath("/"); revalidatePath(`/note/${note.uuid}`); diff --git a/app/_server/actions/note/index.ts b/app/_server/actions/note/index.ts index 2df7eb7f0..bd4c8f914 100644 --- a/app/_server/actions/note/index.ts +++ b/app/_server/actions/note/index.ts @@ -1,8 +1,4 @@ -export { - parseMarkdownNote, - noteToMarkdown, - convertInternalLinksToNewFormat, -} from "./parsers"; +export { parseMarkdownNote, noteToMarkdown } from "./parsers"; export { readNotesRecursively } from "./readers"; export { CheckForNeedsMigration } from "./migration"; export { createNote, updateNote, deleteNote, cloneNote } from "./crud"; diff --git a/app/_server/actions/note/parsers.ts b/app/_server/actions/note/parsers.ts index 9fe95caf3..10a5afd84 100644 --- a/app/_server/actions/note/parsers.ts +++ b/app/_server/actions/note/parsers.ts @@ -6,6 +6,7 @@ import { generateYamlFrontmatter, generateUuid, strayMeta, + createdAtOf, } from "@/app/_utils/yaml-metadata-utils"; import { SHARED_WITH_KEY } from "@/app/_consts/sharing"; @@ -32,7 +33,7 @@ export const parseMarkdownNote = ( content: contentWithoutMetadata, category, createdAt: fileStats - ? fileStats.birthtime.toISOString() + ? createdAtOf(metadata, fileStats.birthtime) : new Date().toISOString(), updatedAt: fileStats ? fileStats.mtime.toISOString() @@ -49,130 +50,6 @@ export const parseMarkdownNote = ( }; }; -export const convertInternalLinksToNewFormat = async ( - content: string, - username?: string, - category?: string -): Promise => { - let convertedContent = content; - - // @ts-ignore - const spanRegex = /]*data-internal-link[^>]*>.*?<\/span>/gs; - const spanMatches = Array.from(content.matchAll(spanRegex)); - - for (const match of spanMatches) { - const [fullMatch] = match; - const hrefMatch = fullMatch.match(/data-href="([^"]*)"/); - const convertMatch = fullMatch.match( - /data-convert-to-bidirectional="([^"]*)"/ - ); - - const href = hrefMatch?.[1]; - const shouldConvert = convertMatch?.[1] === "true"; - - if (!shouldConvert || !href) { - continue; - } - - if (href.startsWith("/jotty/")) { - continue; - } - - if (href.startsWith("/note/")) { - const parts = href.split("/"); - if (parts.length >= 3) { - const categoryAndId = parts.slice(2).join("/"); - const lastSlashIndex = categoryAndId.lastIndexOf("/"); - const id = categoryAndId.substring(lastSlashIndex + 1); - - try { - const { getUserNotes } = await import("./queries"); - const notes = await getUserNotes({ username, allowArchived: true }); - if (notes.success && notes.data) { - const note = notes.data.find((n) => n.id === id); - if (note?.uuid) { - let updatedSpan = fullMatch - .replace(/data-href="[^"]*"/, `data-href="/jotty/${note.uuid}"`) - .replace( - /data-convert-to-bidirectional="true"/, - `data-convert-to-bidirectional="false"` - ); - - if (fullMatch.includes("data-uuid=")) { - updatedSpan = updatedSpan.replace( - /data-uuid="[^"]*"/, - `data-uuid="${note.uuid}"` - ); - } else { - updatedSpan = updatedSpan.replace( - "data-internal-link", - `data-internal-link data-uuid="${note.uuid}"` - ); - } - convertedContent = convertedContent.replace( - fullMatch, - updatedSpan - ); - } - } - } catch (error) { - console.warn("Failed to convert note link:", href, error); - } - } - } else if (href.startsWith("/checklist/")) { - const parts = href.split("/"); - if (parts.length >= 3) { - const categoryAndId = parts.slice(2).join("/"); - const lastSlashIndex = categoryAndId.lastIndexOf("/"); - const id = categoryAndId.substring(lastSlashIndex + 1); - - try { - const { getUserChecklists } = await import("../checklist"); - const checklists = await getUserChecklists({ - username, - isRaw: true, - allowArchived: true, - }); - if (checklists.success && checklists.data) { - const checklist = checklists.data.find((c) => c.id === id); - if (checklist?.uuid) { - let updatedSpan = fullMatch - .replace( - /data-href="[^"]*"/, - `data-href="/jotty/${checklist.uuid}"` - ) - .replace( - /data-convert-to-bidirectional="true"/, - `data-convert-to-bidirectional="false"` - ); - - if (fullMatch.includes("data-uuid=")) { - updatedSpan = updatedSpan.replace( - /data-uuid="[^"]*"/, - `data-uuid="${checklist.uuid}"` - ); - } else { - updatedSpan = updatedSpan.replace( - "data-internal-link", - `data-internal-link data-uuid="${checklist.uuid}"` - ); - } - convertedContent = convertedContent.replace( - fullMatch, - updatedSpan - ); - } - } - } catch (error) { - console.warn("Failed to convert checklist link:", href, error); - } - } - } - } - - return convertedContent; -}; - export const noteToMarkdown = (note: Note): string => { const metadata: Record = { ...(note.extraMetadata || {}) }; metadata.uuid = note.uuid || generateUuid(); diff --git a/app/_server/actions/note/queries.ts b/app/_server/actions/note/queries.ts index 58a2592e9..8f9ad299e 100644 --- a/app/_server/actions/note/queries.ts +++ b/app/_server/actions/note/queries.ts @@ -10,7 +10,11 @@ import { getCurrentUser } from "@/app/_server/actions/users"; import { getUserModeDir, ensureDir } from "@/app/_server/actions/file"; import { readJsonFile } from "@/app/_server/actions/file"; import { parseNoteContent } from "@/app/_utils/client-parser-utils"; -import { toIso } from "@/app/_utils/yaml-metadata-utils"; +import { + createdAtOf, + extractYamlMetadata, + toIso, +} from "@/app/_utils/yaml-metadata-utils"; import { readNotesRecursively } from "./readers"; import { mountsFor, mountedItems } from "@/app/_server/actions/share/mounts"; import { canReachFile } from "@/app/_server/actions/share/access"; @@ -123,7 +127,7 @@ export const getNoteById = async ( title: parsedData.title, content: parsedData.content, category: noteCategory, - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(extractYamlMetadata(rawContent).metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner: ownerUsername, isShared, diff --git a/app/_server/actions/note/readers.ts b/app/_server/actions/note/readers.ts index ceb5e5bb4..8b9d2e64a 100644 --- a/app/_server/actions/note/readers.ts +++ b/app/_server/actions/note/readers.ts @@ -9,10 +9,10 @@ import { readOrderFile, } from "@/app/_server/actions/file"; import { + createdAtOf, extractYamlMetadata, generateUuid, toIso, - updateYamlMetadata, } from "@/app/_utils/yaml-metadata-utils"; import { grepExtractFrontmatter, @@ -25,34 +25,11 @@ import { parseMarkdownNote } from "./parsers"; import { Note } from "@/app/_types"; import { promisify } from "util"; import { exec } from "child_process"; -import { singleFlight } from "@/app/_server/actions/lib/concurrency"; +import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid"; +import { SHARED_WITH_KEY } from "@/app/_consts/sharing"; const execAsync = promisify(exec); -const _stampUuid = async (filePath: string): Promise => - singleFlight(`stamp:${filePath}`, async () => { - try { - const content = await serverReadFile(filePath); - if (!content) return undefined; - - const { metadata } = extractYamlMetadata(content); - if (typeof metadata.uuid === "string" && metadata.uuid) { - return metadata.uuid; - } - - const uuid = generateUuid(); - await fs.writeFile( - filePath, - updateYamlMetadata(content, { uuid }), - "utf-8", - ); - return uuid; - } catch (error) { - console.warn("Failed to stamp UUID on note:", filePath, error); - return undefined; - } - }); - export const readNotesRecursively = async ( dir: string, basePath: string = "", @@ -73,7 +50,7 @@ export const readNotesRecursively = async ( ? "" : `-not -path "*/${ARCHIVED_DIR_NAME}/*"`; const statsCmd = `find "${dir}" -name "*.md" ${excludeStr} -printf "%p|%W@|%T@\\n"`; - const metaCmd = `grep -rE "^(title|uuid|tags|encrypted):|^[[:space:]]+- |^---$" "${dir}"`; + const metaCmd = `grep -rE "^(title|uuid|tags|encrypted|createdAt|${SHARED_WITH_KEY}):|^[[:space:]]+- |^---$" "${dir}"`; const [statsOut, metaOut] = await Promise.all([ execAsync(statsCmd, { maxBuffer: 10 * 1024 * 1024 }).catch(() => ({ stdout: "", @@ -94,7 +71,8 @@ export const readNotesRecursively = async ( const inFrontmatter = new Map(); - let inTagsFile = ""; + let listFile = ""; + let listKey = ""; for (const line of metaOut.stdout.split("\n")) { if (!line) continue; const colonIdx = line.indexOf(":"); @@ -107,19 +85,19 @@ export const readNotesRecursively = async ( } if (!inFrontmatter.get(filePath)) continue; if (/^\s+-\s/.test(rest)) { - if (inTagsFile === filePath) { + if (listFile === filePath) { const tag = rest.replace(/^\s+-\s+/, "").trim(); if (tag) { if (!metadataCache!.has(filePath)) metadataCache!.set(filePath, {}); const entry = metadataCache!.get(filePath)!; - if (!Array.isArray(entry.tags)) entry.tags = []; - (entry.tags as string[]).push(tag); + if (!Array.isArray(entry[listKey])) entry[listKey] = []; + (entry[listKey] as string[]).push(tag); } } continue; } - inTagsFile = ""; + listFile = ""; const innerColon = rest.indexOf(":"); if (innerColon === -1) continue; const key = rest.slice(0, innerColon); @@ -130,7 +108,8 @@ export const readNotesRecursively = async ( const trimmed = val.trim(); if (trimmed === "") { entry.tags = []; - inTagsFile = filePath; + listFile = filePath; + listKey = key; } else { entry.tags = trimmed .replace(/^\[|\]$/g, "") @@ -140,6 +119,10 @@ export const readNotesRecursively = async ( } } else if (key === "encrypted") { entry.encrypted = val.trim() === "true"; + } else if (key === SHARED_WITH_KEY && val.trim() === "") { + entry[key] = []; + listFile = filePath; + listKey = key; } else { entry[key] = val.trim().replace(/^["']|["']$/g, ""); } @@ -216,7 +199,7 @@ export const readNotesRecursively = async ( const uuid = typeof metadata?.uuid === "string" ? metadata.uuid - : await _stampUuid(filePath); + : await stampUuid(filePath); if (!uuid) { console.warn("Skipping note without a resolvable uuid:", filePath); @@ -228,7 +211,7 @@ export const readNotesRecursively = async ( uuid, title: typeof metadata?.title === "string" ? metadata.title : id, category: categoryPath, - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner, isShared: false, @@ -248,7 +231,7 @@ export const readNotesRecursively = async ( const uuid = typeof metadata?.uuid === "string" ? metadata.uuid - : await _stampUuid(filePath); + : await stampUuid(filePath); if (!uuid) { console.warn("Skipping note without a resolvable uuid:", filePath); @@ -261,7 +244,7 @@ export const readNotesRecursively = async ( title: typeof metadata?.title === "string" ? metadata.title : id, content: excerpt, category: categoryPath, - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner, isShared: false, @@ -273,26 +256,15 @@ export const readNotesRecursively = async ( const content = await serverReadFile(filePath); if (isRaw) { const { metadata } = extractYamlMetadata(content); - let uuid = metadata.uuid; - if (!uuid) { - uuid = generateUuid(); - try { - await fs.writeFile( - filePath, - updateYamlMetadata(content, { uuid }), - "utf-8", - ); - } catch (error) { - console.warn("Failed to save UUID to note file:", error); - } - } + const uuid = + metadata.uuid || (await stampUuid(filePath)) || generateUuid(); return { id, uuid, title: id, content: "", category: categoryPath, - createdAt: toIso(stats.birthtime), + createdAt: createdAtOf(metadata, stats.birthtime), updatedAt: toIso(stats.mtime), owner, isShared: false, diff --git a/app/_server/actions/relations/index.ts b/app/_server/actions/relations/index.ts new file mode 100644 index 000000000..9e1039881 --- /dev/null +++ b/app/_server/actions/relations/index.ts @@ -0,0 +1,142 @@ +"use server"; + +import { getCurrentUser, canAccessAllContent } from "@/app/_server/actions/users"; +import { getSettings } from "@/app/_server/actions/config"; +import { getUserIndex } from "@/app/_server/actions/users/helpers"; +import { RelationsStatus } from "@/app/_consts/relations"; +import type { Result } from "@/app/_types"; +import type { BrainGraph, ItemRelations } from "@/app/_types/relations"; +import { backlinksFor, graphFor, visibleItems } from "./queries"; +import { rebuildOwnerRelations, rebuildRelations } from "./indexer"; +import { relationsDb } from "./store"; +import { getNoteById, updateNote } from "@/app/_server/actions/note"; +import { canReach } from "@/app/_server/actions/share/queries"; +import { ItemTypes, PermissionTypes } from "@/app/_types/enums"; +import { itemHref } from "@/app/_utils/global-utils"; +import { escapeLinkText } from "@/app/_utils/item-href-utils"; +import { wrapMention } from "./tidy"; + +const EMPTY_RELATIONS: ItemRelations = { uuid: "", status: RelationsStatus.READY, backlinks: [], mentions: [], wikis: {} }; + +const _linksEnabled = async (): Promise => { + const settings = await getSettings(); + return settings?.editor?.enableBilateralLinks !== false; +}; + +const _ownerOf = (uuid: string): string | null => { + const row = relationsDb() + .prepare("SELECT owner FROM items WHERE uuid = ?") + .get(uuid.toLowerCase()) as { owner?: string } | undefined; + return row?.owner || null; +}; + +export const getItemRelations = async (uuid: string): Promise => { + try { + const user = await getCurrentUser(); + if (!user?.username || !(await _linksEnabled())) return EMPTY_RELATIONS; + + const visible = await visibleItems(user.username); + if (visible.has(uuid.toLowerCase())) return backlinksFor(uuid, visible); + + const owner = _ownerOf(uuid); + if (!owner || !(await canAccessAllContent())) return EMPTY_RELATIONS; + + return backlinksFor(uuid, await visibleItems(owner)); + } catch (error) { + console.error("getItemRelations failed:", error); + return EMPTY_RELATIONS; + } +}; + +export const getBrain = async (username?: string): Promise> => { + try { + const user = await getCurrentUser(); + if (!user?.username) return { success: false, error: "Not authenticated" }; + if (!(await _linksEnabled())) return { success: false, error: "Not found" }; + + const owner = username || user.username; + if (owner !== user.username) { + const peekable = (await canAccessAllContent()) && (await getUserIndex(owner)) !== -1; + if (!peekable) return { success: false, error: "Not found" }; + } + + return { success: true, data: graphFor(owner, await visibleItems(owner)) }; + } catch (error) { + console.error("getBrain failed:", error); + return { success: false, error: "Failed to load relationships" }; + } +}; + +export const reindexRelations = async (username?: string): Promise> => { + const user = await getCurrentUser(); + if (!user?.username) return { success: false, error: "Not authenticated" }; + if (!user.isAdmin) return { success: false, error: "Admins only" }; + + try { + if (!username) return { success: true, data: await rebuildRelations() }; + if ((await getUserIndex(username)) === -1) return { success: false, error: "User not found" }; + return { success: true, data: await rebuildOwnerRelations(username) }; + } catch (error) { + console.error("reindexRelations failed:", error); + return { success: false, error: "Failed to rebuild relationships" }; + } +}; + +const _itemRow = (uuid: string) => + relationsDb() + .prepare("SELECT uuid, type, title FROM items WHERE uuid = ?") + .get(uuid.toLowerCase()) as { uuid: string; type: ItemTypes; title: string } | undefined; + +type Rewriter = (content: string, target: { title: string; href: string }) => string | null; + +const _editLinking = async ( + sourceUuid: string, + targetUuid: string, + rewrite: Rewriter, + label: string, +): Promise> => { + const user = await getCurrentUser(); + if (!user?.username) return { success: false, error: "Not authenticated" }; + + try { + const allowed = await canReach(sourceUuid, ItemTypes.NOTE, user.username, PermissionTypes.EDIT); + if (!allowed) return { success: false, error: "Permission denied" }; + + const [note, target] = [await getNoteById(sourceUuid), _itemRow(targetUuid)]; + if (!note || !target) return { success: false, error: "Not found" }; + if (note.encrypted) return { success: false, error: "Encrypted notes stay closed" }; + + const visible = await visibleItems(user.username); + if (!visible.has(target.uuid)) return { success: false, error: "Not found" }; + + const content = rewrite(note.content || "", { + title: target.title, + href: itemHref(target.type, target.uuid), + }); + if (content === null) return { success: false, error: "Mention not found" }; + + const formData = new FormData(); + formData.append("uuid", note.uuid || sourceUuid); + formData.append("title", note.title); + formData.append("category", note.category || ""); + formData.append("content", content); + + const result = await updateNote(formData); + if (result && "error" in result && result.error) return { success: false, error: result.error }; + return { success: true, data: null }; + } catch (error) { + console.error(`${label} failed:`, error); + return { success: false, error: "Failed to connect items" }; + } +}; + +const _appendLink: Rewriter = (content, { title, href }) => + `${content.trimEnd()}\n\n[${escapeLinkText(title)}](${href})\n`; + +const _wrapFirst: Rewriter = (content, { title, href }) => wrapMention(content, title, href); + +export const connectItems = async (sourceUuid: string, targetUuid: string): Promise> => + _editLinking(sourceUuid, targetUuid, _appendLink, "connectItems"); + +export const linkMention = async (sourceUuid: string, targetUuid: string): Promise> => + _editLinking(sourceUuid, targetUuid, _wrapFirst, "linkMention"); diff --git a/app/_server/actions/relations/indexer.ts b/app/_server/actions/relations/indexer.ts new file mode 100644 index 000000000..2739824ce --- /dev/null +++ b/app/_server/actions/relations/indexer.ts @@ -0,0 +1,339 @@ +import fs from "fs/promises"; +import path from "path"; +import type { DatabaseSync } from "node:sqlite"; +import { LinkKinds, RelationsStatus } from "@/app/_consts/relations"; +import { ItemTypes } from "@/app/_types/enums"; +import { isUuid } from "@/app/_consts/identity"; +import { extractYamlMetadata } from "@/app/_utils/yaml-metadata-utils"; +import { isEncrypted } from "@/app/_utils/encryption-utils"; +import { singleFlight } from "@/app/_server/actions/lib/concurrency"; +import { getAllFileStats } from "@/app/_server/actions/file"; +import { broadcast } from "@/app/_server/actions/ws/broadcast"; +import { readLinks, titleKey } from "./parser"; +import { itemFileInfo, itemTreeRoots } from "./paths"; +import { + inTransaction, + relationsDb, + relationsStatus, + setRelationsStatus, +} from "./store"; + +const RELATIONS_ACTOR = "system"; +const RECONCILE_EVERY_MS = 60_000; + +const _origins = (): string[] => { + try { + return process.env.APP_URL ? [new URL(process.env.APP_URL).origin] : []; + } catch { + return []; + } +}; + +const _tally = (keys: string[]): Map => { + const counts = new Map(); + keys.forEach((key) => counts.set(key, (counts.get(key) || 0) + 1)); + return counts; +}; + +const _prefixOf = (dir: string): string => + path.resolve(dir).replace(/[\\/]+$/, "") + path.sep; + +const _createdAt = (metadata: Record, mtime: number): number => { + const raw = metadata.createdAt; + const stamp = + raw instanceof Date ? raw.getTime() : typeof raw === "string" ? Date.parse(raw) : NaN; + return Number.isFinite(stamp) ? stamp : mtime; +}; + +const _wikiLabels = (wikis: string[]): Map => { + const labels = new Map(); + wikis.forEach((label) => { + const key = titleKey(label); + if (key && !labels.has(key)) labels.set(key, label); + }); + return labels; +}; + +const _byTitle = (db: DatabaseSync, owner: string, key: string, exclude = ""): string | null => { + const row = db + .prepare( + `SELECT uuid FROM items WHERE owner = ? AND title_key = ? AND uuid <> ? + ORDER BY created ASC, uuid ASC LIMIT 1`, + ) + .get(owner, key, exclude) as { uuid?: string } | undefined; + return row?.uuid || null; +}; + +const _bind = (db: DatabaseSync, src: string, text: string, dst: string) => + db + .prepare( + `INSERT INTO bindings (src, text, dst) VALUES (?, ?, ?) + ON CONFLICT(src, text) DO UPDATE SET dst = excluded.dst`, + ) + .run(src, text, dst); + +const _resolveWiki = (db: DatabaseSync, src: string, owner: string, text: string): string | null => { + const bound = db + .prepare( + `SELECT b.dst AS dst FROM bindings b JOIN items i ON i.uuid = b.dst + WHERE b.src = ? AND b.text = ?`, + ) + .get(src, text) as { dst?: string } | undefined; + if (bound?.dst) return bound.dst; + + const found = _byTitle(db, owner, text, src); + if (found) _bind(db, src, text, found); + return found; +}; + +const _adoptOrphans = (db: DatabaseSync, owner: string, uuid: string, key: string) => { + if (_byTitle(db, owner, key) !== uuid) return; + const orphans = db + .prepare( + `SELECT DISTINCT src FROM links WHERE kind = ? AND dst IS NULL AND dst_text = ? + AND src IN (SELECT uuid FROM items WHERE owner = ?)`, + ) + .all(LinkKinds.WIKI, key, owner) as { src: string }[]; + const adopt = db.prepare("UPDATE links SET dst = ? WHERE kind = ? AND dst IS NULL AND dst_text = ? AND src = ?"); + orphans.forEach(({ src }) => { + if (src === uuid) return; + adopt.run(uuid, LinkKinds.WIKI, key, src); + _bind(db, src, key, uuid); + }); +}; + +const _releaseTargets = (db: DatabaseSync, uuids: string[]) => { + const lost = db.prepare( + `SELECT DISTINCT l.src AS src, l.dst_text AS text, i.owner AS owner + FROM links l JOIN items i ON i.uuid = l.src WHERE l.kind = ? AND l.dst = ?`, + ); + const release = db.prepare("UPDATE links SET dst = NULL WHERE kind = ? AND dst = ?"); + const unbind = db.prepare("DELETE FROM bindings WHERE dst = ?"); + const rebind = db.prepare("UPDATE links SET dst = ? WHERE kind = ? AND src = ? AND dst_text = ?"); + uuids.forEach((uuid) => { + const rows = lost.all(LinkKinds.WIKI, uuid) as { src: string; text: string; owner: string }[]; + release.run(LinkKinds.WIKI, uuid); + unbind.run(uuid); + rows.forEach(({ src, text, owner }) => { + const next = _resolveWiki(db, src, owner, text); + if (next) rebind.run(next, LinkKinds.WIKI, src, text); + }); + }); +}; + +export const indexItemFile = ( + filePath: string, + content: string, + mtime: number, +): string | null => { + const info = itemFileInfo(filePath); + if (!info) return null; + + const absPath = path.resolve(filePath); + const { metadata, contentWithoutMetadata } = extractYamlMetadata(content); + const uuid = typeof metadata.uuid === "string" ? metadata.uuid.toLowerCase() : ""; + if (!isUuid(uuid)) return null; + + const title = + typeof metadata.title === "string" && metadata.title.trim() + ? metadata.title.trim() + : path.basename(absPath, ".md"); + const key = titleKey(title); + const encrypted = metadata.encrypted === true || isEncrypted(contentWithoutMetadata); + const parsed = encrypted + ? { targets: [], wikis: [], text: "" } + : readLinks(contentWithoutMetadata, _origins()); + + const mentions = _tally( + parsed.targets + .map((target) => target.uuid) + .filter((dst): dst is string => Boolean(dst) && dst !== uuid), + ); + const wikiLabels = _wikiLabels(parsed.wikis); + const wikis = _tally(parsed.wikis.map(titleKey).filter(Boolean)); + + inTransaction((db) => { + const displaced = db + .prepare("SELECT uuid FROM items WHERE path = ? AND uuid <> ?") + .all(absPath, uuid) as { uuid: string }[]; + displaced.forEach((row) => _forgetUuid(db, row.uuid)); + + db.prepare( + `INSERT INTO items (uuid, path, owner, type, title, title_key, encrypted, created, mtime) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(uuid) DO UPDATE SET path = excluded.path, owner = excluded.owner, + type = excluded.type, title = excluded.title, title_key = excluded.title_key, + encrypted = excluded.encrypted, created = excluded.created, mtime = excluded.mtime`, + ).run( + uuid, + absPath, + info.owner, + info.type, + title, + key, + encrypted ? 1 : 0, + _createdAt(metadata, mtime), + mtime, + ); + + db.prepare("DELETE FROM links WHERE src = ?").run(uuid); + const insert = db.prepare( + "INSERT INTO links (src, dst, dst_text, dst_label, kind, weight) VALUES (?, ?, ?, ?, ?, ?)", + ); + mentions.forEach((weight, dst) => + insert.run(uuid, dst, null, null, LinkKinds.MENTION, weight), + ); + wikis.forEach((weight, text) => { + const dst = _resolveWiki(db, uuid, info.owner, text); + insert.run(uuid, dst, text, wikiLabels.get(text) || text, LinkKinds.WIKI, weight); + }); + + _adoptOrphans(db, info.owner, uuid, key); + + db.prepare("DELETE FROM texts WHERE uuid = ?").run(uuid); + if (parsed.text && info.type === ItemTypes.NOTE) { + db.prepare("INSERT INTO texts (uuid, body) VALUES (?, ?)").run(uuid, parsed.text); + } + }); + + return uuid; +}; + +const _forgetUuid = (db: DatabaseSync, uuid: string) => { + db.prepare("DELETE FROM links WHERE src = ?").run(uuid); + db.prepare("DELETE FROM bindings WHERE src = ?").run(uuid); + db.prepare("DELETE FROM texts WHERE uuid = ?").run(uuid); + db.prepare("DELETE FROM items WHERE uuid = ?").run(uuid); + _releaseTargets(db, [uuid]); +}; + +const _forgetWhere = (where: string, value: string) => { + inTransaction((db) => { + const rows = db.prepare(`SELECT uuid FROM items WHERE ${where}`).all(value) as { + uuid: string; + }[]; + rows.forEach((row) => _forgetUuid(db, row.uuid)); + }); +}; + +export const forgetItemFile = (filePath: string) => + _forgetWhere("path = ?", path.resolve(filePath)); + +export const forgetItemTree = (dir: string) => { + const prefix = _prefixOf(dir); + _forgetWhere("substr(path, 1, length(?1)) = ?1", prefix); +}; + +const _indexFromDisk = async (filePath: string, mtime?: number) => { + try { + const [content, stats] = await Promise.all([ + fs.readFile(filePath, "utf-8"), + mtime === undefined ? fs.stat(filePath) : Promise.resolve(null), + ]); + indexItemFile(filePath, content, mtime ?? Math.floor(stats!.mtimeMs)); + } catch (error) { + console.error(`Relations could not index ${filePath}:`, error); + } +}; + +export const indexItemTree = async (dir: string) => { + const stats = await getAllFileStats(dir); + for (const [filePath, entry] of Array.from(stats)) { + if (itemFileInfo(filePath)) await _indexFromDisk(filePath, entry.mtime.getTime()); + } +}; + +const _indexedMtimes = (): Map => { + const rows = relationsDb().prepare("SELECT path, mtime FROM items").all() as { + path: string; + mtime: number; + }[]; + return new Map(rows.map((row) => [row.path, row.mtime])); +}; + +const _reconcile = async (force = false): Promise => { + const wasBuilding = relationsStatus() === RelationsStatus.BUILDING; + const indexed = _indexedMtimes(); + const onDisk = new Map(); + + for (const root of itemTreeRoots()) { + const stats = await getAllFileStats(root); + stats.forEach((entry, filePath) => { + if (itemFileInfo(filePath)) onDisk.set(path.resolve(filePath), entry.mtime.getTime()); + }); + } + + let changed = 0; + for (const [filePath, mtime] of Array.from(onDisk)) { + if (!force && indexed.get(filePath) === mtime) continue; + await _indexFromDisk(filePath, mtime); + changed++; + } + + for (const filePath of Array.from(indexed.keys())) { + if (onDisk.has(filePath)) continue; + forgetItemFile(filePath); + changed++; + } + + setRelationsStatus(RelationsStatus.READY); + + if (wasBuilding || changed > 0) { + await broadcast({ type: "relations", action: "updated", username: RELATIONS_ACTOR }); + } + + return changed; +}; + +export const reconcileRelations = (force = false): Promise => + singleFlight(`relations:reconcile:${force}`, async () => { + try { + return await _reconcile(force); + } catch (error) { + console.error("Relations reconcile failed:", error); + return 0; + } + }); + +export const rebuildRelations = async (): Promise => { + setRelationsStatus(RelationsStatus.BUILDING); + return reconcileRelations(true); +}; + +export const forgetMissing = async (dir: string) => { + const prefix = _prefixOf(dir); + const rows = relationsDb() + .prepare("SELECT path FROM items WHERE substr(path, 1, length(?1)) = ?1") + .all(prefix) as { path: string }[]; + for (const row of rows) { + const present = await fs.stat(row.path).then( + () => true, + () => false, + ); + if (!present) forgetItemFile(row.path); + } +}; + +export const rebuildOwnerRelations = async (owner: string): Promise => { + const dirs = itemTreeRoots().map((root) => path.join(root, owner)); + for (const dir of dirs) { + await indexItemTree(dir); + await forgetMissing(dir); + } + await broadcast({ type: "relations", action: "updated", username: owner }); + return dirs.length; +}; + +declare global { + var __jottyRelationsCheckedAt: number | undefined; +} + +export const ensureRelations = (): RelationsStatus => { + const status = relationsStatus(); + const checkedAt = globalThis.__jottyRelationsCheckedAt || 0; + if (status === RelationsStatus.BUILDING || Date.now() - checkedAt > RECONCILE_EVERY_MS) { + globalThis.__jottyRelationsCheckedAt = Date.now(); + void reconcileRelations(); + } + return status; +}; diff --git a/app/_server/actions/relations/parser.ts b/app/_server/actions/relations/parser.ts new file mode 100644 index 000000000..c947a0cc3 --- /dev/null +++ b/app/_server/actions/relations/parser.ts @@ -0,0 +1,63 @@ +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkGfm from "remark-gfm"; +import { SKIP, visit } from "unist-util-visit"; +import type { Root } from "mdast"; +import { WIKILINK_REGEX } from "@/app/_consts/relations"; +import { ItemHrefTarget, parseItemHref } from "@/app/_utils/item-href-utils"; + +export interface ParsedLinks { + targets: ItemHrefTarget[]; + wikis: string[]; + text: string; +} + +const HTML_HREF_REGEX = /data-href="([^"]+)"/g; + +const processor = unified().use(remarkParse).use(remarkGfm); + +export const titleKey = (title: string): string => + title.trim().replace(/\s+/g, " ").toLowerCase(); + +const _wikis = (text: string): string[] => + Array.from(text.matchAll(WIKILINK_REGEX), (m) => m[1].trim()).filter( + Boolean, + ); + +export const readLinks = (markdown: string, origins: string[] = []): ParsedLinks => { + const targets: ItemHrefTarget[] = []; + const wikis: string[] = []; + const prose: string[] = []; + if (!markdown.trim()) return { targets, wikis, text: "" }; + + let tree: Root; + try { + tree = processor.parse(markdown) as Root; + } catch (error) { + console.error("Relations parser could not read markdown:", error); + return { targets, wikis, text: "" }; + } + + const addHref = (href: string) => { + const target = parseItemHref(href, origins); + if (target) targets.push(target); + }; + + visit(tree, (node) => { + if (node.type === "link") { + addHref(node.url); + return SKIP; + } + if (node.type === "linkReference") return SKIP; + if (node.type === "definition") { + addHref(node.url); + } else if (node.type === "text") { + wikis.push(..._wikis(node.value)); + prose.push(node.value.replace(WIKILINK_REGEX, " ")); + } else if (node.type === "html") { + Array.from(node.value.matchAll(HTML_HREF_REGEX), (match) => addHref(match[1])); + } + }); + + return { targets, wikis, text: prose.join(" ").replace(/\s+/g, " ").trim() }; +}; diff --git a/app/_server/actions/relations/paths.ts b/app/_server/actions/relations/paths.ts new file mode 100644 index 000000000..63828a841 --- /dev/null +++ b/app/_server/actions/relations/paths.ts @@ -0,0 +1,41 @@ +import path from "path"; +import { ARCHIVED_DIR_NAME, DATA_DIR, EXCLUDED_DIRS } from "@/app/_consts/files"; +import { ItemTypes, Modes } from "@/app/_types/enums"; + +export interface ItemFileInfo { + owner: string; + type: ItemTypes; +} + +const MODE_TYPES: Record = { + [Modes.NOTES]: ItemTypes.NOTE, + [Modes.CHECKLISTS]: ItemTypes.CHECKLIST, +}; + +export const dataRoot = (): string => path.join(process.cwd(), DATA_DIR); + +export const itemTreeRoots = (): string[] => + Object.keys(MODE_TYPES).map((mode) => path.join(dataRoot(), mode)); + +const _hiddenDir = (segment: string): boolean => + EXCLUDED_DIRS.includes(segment) || + (segment.startsWith(".") && segment !== ARCHIVED_DIR_NAME); + +export const itemFileInfo = (filePath: string): ItemFileInfo | null => { + if (!filePath.endsWith(".md")) return null; + + const rel = path.relative(dataRoot(), path.resolve(filePath)); + if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return null; + + const segments = rel.split(path.sep); + const type = MODE_TYPES[segments[0]]; + if (!type || segments.length < 3) return null; + + const folders = segments.slice(2, -1); + if (folders.some(_hiddenDir)) return null; + + return { owner: segments[1], type }; +}; + +export const isItemFile = (filePath: string): boolean => + itemFileInfo(filePath) !== null; diff --git a/app/_server/actions/relations/queries.ts b/app/_server/actions/relations/queries.ts new file mode 100644 index 000000000..1d306eba5 --- /dev/null +++ b/app/_server/actions/relations/queries.ts @@ -0,0 +1,269 @@ +import { getUserNotes } from "@/app/_server/actions/note"; +import { getUserChecklists } from "@/app/_server/actions/checklist"; +import { UNCATEGORIZED } from "@/app/_consts/notes"; +import { + BrainEdgeKinds, + BrainNodeKinds, + LinkKinds, + MENTION_LIMIT, + MENTION_MIN_TITLE, + MENTION_SNIPPET_CHARS, + RelationsStatus, +} from "@/app/_consts/relations"; +import { ItemTypes } from "@/app/_types/enums"; +import type { + BrainEdge, + BrainGraph, + BrainNode, + ItemRelations, + MentionedIn, + RelatedItem, +} from "@/app/_types/relations"; +import { titleKey } from "./parser"; +import { ensureRelations } from "./indexer"; +import { relationsDb } from "./store"; +import { suggestEdges } from "./suggestions"; + +export interface VisibleItem { + uuid: string; + type: ItemTypes; + title: string; + category: string; + owner?: string; + checklistType?: string; + tags: string[]; +} + +interface LinkRow { + src: string; + dst: string | null; + dst_text: string | null; + dst_label: string | null; + kind: LinkKinds; + weight: number; +} + +const GHOST_PREFIX = "ghost:"; +const TAG_PREFIX = "tag:"; + +export const visibleItems = async (username: string): Promise> => { + const [notes, checklists] = await Promise.all([ + getUserNotes({ username, metadataOnly: true }), + getUserChecklists({ username, metadataOnly: true }), + ]); + + const visible = new Map(); + const add = (item: VisibleItem) => { + const uuid = item.uuid.toLowerCase(); + if (uuid) visible.set(uuid, { ...item, uuid }); + }; + + (notes.success ? notes.data || [] : []).forEach((note) => + add({ + uuid: note.uuid || "", + type: ItemTypes.NOTE, + title: note.title || note.id || "", + category: note.category || UNCATEGORIZED, + owner: note.owner, + tags: note.tags || [], + }), + ); + + (checklists.success ? checklists.data || [] : []).forEach((list) => + add({ + uuid: list.uuid || "", + type: ItemTypes.CHECKLIST, + title: list.title || list.id || "", + category: list.category || UNCATEGORIZED, + owner: list.owner, + checklistType: list.type, + tags: list.tags || [], + }), + ); + + return visible; +}; + +const _related = (item: VisibleItem, kind: LinkKinds): RelatedItem => ({ + uuid: item.uuid, + type: item.type, + title: item.title, + category: item.category, + owner: item.owner, + checklistType: item.checklistType, + kind, +}); + +const WORDS = new RegExp("[\\p{L}\\p{N}]+", "gu"); +const EMPTY = { backlinks: [], mentions: [], wikis: {} }; + +const _escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const _phrase = (title: string): RegExp => + new RegExp( + `(? { + const start = Math.max(0, at - MENTION_SNIPPET_CHARS); + const end = Math.min(body.length, at + length + MENTION_SNIPPET_CHARS); + return `${start > 0 ? "…" : ""}${body.slice(start, end).trim()}${end < body.length ? "…" : ""}`; +}; + +const _mentionsOf = ( + item: VisibleItem, + linked: Set, + visible: Map, +): MentionedIn[] => { + if (titleKey(item.title).length < MENTION_MIN_TITLE) return []; + const words = item.title.match(WORDS); + if (!words) return []; + + const query = `"${words.join(" ").replace(/"/g, '""')}"`; + const rows = relationsDb() + .prepare("SELECT uuid, body FROM texts WHERE texts MATCH ? LIMIT 400") + .all(query) as { uuid: string; body: string }[]; + + const phrase = _phrase(item.title); + const found: MentionedIn[] = []; + for (const row of rows) { + if (found.length >= MENTION_LIMIT) break; + const source = visible.get(row.uuid); + if (!source || row.uuid === item.uuid || linked.has(row.uuid)) continue; + const match = phrase.exec(row.body); + if (!match) continue; + const { kind: _kind, ...related } = _related(source, LinkKinds.MENTION); + found.push({ ...related, snippet: _snippet(row.body, match.index, match[0].length) }); + } + return found.sort((a, b) => a.title.localeCompare(b.title)); +}; + +const _outgoingWikis = (uuid: string, visible: Map): Record => { + const rows = relationsDb() + .prepare("SELECT dst_text, dst FROM links WHERE src = ? AND kind = ? AND dst IS NOT NULL") + .all(uuid, LinkKinds.WIKI) as { dst_text: string; dst: string }[]; + return Object.fromEntries( + rows.filter((row) => visible.has(row.dst)).map((row) => [row.dst_text, row.dst]), + ); +}; + +export const backlinksFor = ( + uuid: string, + visible: Map, +): ItemRelations => { + const status = ensureRelations(); + const target = uuid.toLowerCase(); + if (status === RelationsStatus.BUILDING) return { uuid: target, status, ...EMPTY }; + + const rows = relationsDb() + .prepare("SELECT DISTINCT src, kind FROM links WHERE dst = ? ORDER BY kind ASC") + .all(target) as { src: string; kind: LinkKinds }[]; + + const seen = new Set(); + const backlinks: RelatedItem[] = []; + rows.forEach(({ src, kind }) => { + const source = visible.get(src); + if (!source || src === target || seen.has(src)) return; + seen.add(src); + backlinks.push(_related(source, kind)); + }); + + const item = visible.get(target); + return { + uuid: target, + status, + backlinks: backlinks.sort((a, b) => a.title.localeCompare(b.title)), + mentions: item ? _mentionsOf(item, seen, visible) : [], + wikis: _outgoingWikis(target, visible), + }; +}; + +const _itemNode = (item: VisibleItem): BrainNode => ({ + id: item.uuid, + kind: item.type === ItemTypes.CHECKLIST ? BrainNodeKinds.CHECKLIST : BrainNodeKinds.NOTE, + title: item.title, + category: item.category, + owner: item.owner, + checklistType: item.checklistType, + degree: 0, +}); + +export const graphFor = (owner: string, visible: Map): BrainGraph => { + const status = ensureRelations(); + if (status === RelationsStatus.BUILDING) return { status, owner, nodes: [], edges: [] }; + + const sources = JSON.stringify(Array.from(visible.keys())); + const rows = relationsDb() + .prepare( + `SELECT src, dst, dst_text, dst_label, kind, weight FROM links + WHERE src IN (SELECT value FROM json_each(?))`, + ) + .all(sources) as unknown as LinkRow[]; + + const nodes = new Map(); + visible.forEach((item) => nodes.set(item.uuid, _itemNode(item))); + + const edges = new Map(); + const connect = (source: string, target: string, kind: BrainEdgeKinds, weight: number) => { + if (source === target) return; + const key = `${source}>${target}>${kind}`; + const existing = edges.get(key); + if (existing) { + existing.weight += weight; + return; + } + edges.set(key, { source, target, kind, weight }); + }; + + rows.forEach((row) => { + if (row.kind === LinkKinds.MENTION && row.dst && visible.has(row.dst)) { + connect(row.src, row.dst, BrainEdgeKinds.MENTION, row.weight); + return; + } + + if (row.kind !== LinkKinds.WIKI || !row.dst_text) return; + + if (row.dst) { + if (visible.has(row.dst)) connect(row.src, row.dst, BrainEdgeKinds.WIKI, row.weight); + return; + } + + const ghostId = `${GHOST_PREFIX}${row.dst_text}`; + if (!nodes.has(ghostId)) { + nodes.set(ghostId, { + id: ghostId, + kind: BrainNodeKinds.GHOST, + title: row.dst_label || row.dst_text, + degree: 0, + }); + } + connect(row.src, ghostId, BrainEdgeKinds.WIKI, row.weight); + }); + + visible.forEach((item) => { + item.tags.forEach((tag) => { + const name = tag.replace(/^#/, "").toLowerCase(); + if (!name) return; + const tagId = `${TAG_PREFIX}${name}`; + if (!nodes.has(tagId)) { + nodes.set(tagId, { id: tagId, kind: BrainNodeKinds.TAG, title: `#${name}`, degree: 0 }); + } + connect(item.uuid, tagId, BrainEdgeKinds.TAG, 1); + }); + }); + + const edgeList = Array.from(edges.values()); + const suggested = suggestEdges(edgeList, new Set(visible.keys())); + edgeList.forEach((edge) => { + nodes.get(edge.source)!.degree += 1; + nodes.get(edge.target)!.degree += 1; + }); + + return { + status, + owner, + nodes: Array.from(nodes.values()), + edges: [...edgeList, ...suggested], + }; +}; diff --git a/app/_server/actions/relations/store.ts b/app/_server/actions/relations/store.ts new file mode 100644 index 000000000..037309482 --- /dev/null +++ b/app/_server/actions/relations/store.ts @@ -0,0 +1,157 @@ +import fs from "fs"; +import path from "path"; +import { DatabaseSync } from "node:sqlite"; +import { DATA_DIR } from "@/app/_consts/files"; +import { + RELATIONS_DB_NAME, + RELATIONS_SCHEMA_VERSION, + RelationsStatus, +} from "@/app/_consts/relations"; + +declare global { + var __jottyRelations: { db: DatabaseSync; file: string; schema: number } | undefined; +} + +const SCHEMA = ` + CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS items ( + uuid TEXT PRIMARY KEY, + path TEXT NOT NULL UNIQUE, + owner TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT NOT NULL, + title_key TEXT NOT NULL, + encrypted INTEGER NOT NULL DEFAULT 0, + created REAL NOT NULL DEFAULT 0, + mtime REAL NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS items_title ON items(title_key); + CREATE TABLE IF NOT EXISTS links ( + src TEXT NOT NULL, + dst TEXT, + dst_text TEXT, + dst_label TEXT, + kind TEXT NOT NULL, + weight INTEGER NOT NULL DEFAULT 1 + ); + CREATE INDEX IF NOT EXISTS links_src ON links(src); + CREATE INDEX IF NOT EXISTS links_dst ON links(dst); + CREATE INDEX IF NOT EXISTS links_text ON links(dst_text); + CREATE TABLE IF NOT EXISTS bindings ( + src TEXT NOT NULL, + text TEXT NOT NULL, + dst TEXT NOT NULL, + PRIMARY KEY (src, text) + ); + CREATE INDEX IF NOT EXISTS bindings_dst ON bindings(dst); + CREATE VIRTUAL TABLE IF NOT EXISTS texts USING fts5( + uuid UNINDEXED, + body, + tokenize = 'unicode61 remove_diacritics 2' + ); +`; + +export const relationsFile = (): string => + process.env.JOTTY_RELATIONS_DB || + path.join(process.cwd(), DATA_DIR, RELATIONS_DB_NAME); + +const _discard = (file: string) => { + for (const suffix of ["", "-wal", "-shm"]) { + try { + fs.rmSync(`${file}${suffix}`, { force: true }); + } catch (error) { + console.error(`Could not remove stale relations file ${file}${suffix}:`, error); + } + } +}; + +const _schemaVersion = (db: DatabaseSync): number => { + try { + const row = db + .prepare("SELECT value FROM meta WHERE key = 'schema'") + .get() as { value?: string } | undefined; + return Number(row?.value || 0); + } catch { + return 0; + } +}; + +const _prepare = (db: DatabaseSync) => { + db.exec("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL; PRAGMA busy_timeout = 5000;"); + const check = db.prepare("PRAGMA quick_check").get() as Record; + if (Object.values(check)[0] !== "ok") throw new Error("relations db failed quick_check"); + db.exec(SCHEMA); +}; + +const _fresh = (file: string): DatabaseSync => { + fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + _prepare(db); + const setMeta = db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"); + setMeta.run("schema", String(RELATIONS_SCHEMA_VERSION)); + setMeta.run("status", RelationsStatus.BUILDING); + return db; +}; + +const _open = (file: string): DatabaseSync => { + if (!fs.existsSync(file)) return _fresh(file); + + try { + const db = new DatabaseSync(file); + _prepare(db); + if (_schemaVersion(db) === RELATIONS_SCHEMA_VERSION) return db; + db.close(); + } catch (error) { + console.error("Relations db unreadable, rebuilding from notes:", error); + } + + _discard(file); + return _fresh(file); +}; + +export const relationsDb = (): DatabaseSync => { + const file = relationsFile(); + const held = globalThis.__jottyRelations; + if (held?.db.isOpen && held.file === file && held.schema === RELATIONS_SCHEMA_VERSION) { + return held.db; + } + if (held?.db.isOpen) held.db.close(); + + const db = _open(file); + globalThis.__jottyRelations = { db, file, schema: RELATIONS_SCHEMA_VERSION }; + return db; +}; + +export const closeRelationsDb = () => { + const held = globalThis.__jottyRelations; + if (held?.db.isOpen) held.db.close(); + globalThis.__jottyRelations = undefined; +}; + +export const relationsStatus = (): RelationsStatus => { + const row = relationsDb() + .prepare("SELECT value FROM meta WHERE key = 'status'") + .get() as { value?: string } | undefined; + return row?.value === RelationsStatus.READY + ? RelationsStatus.READY + : RelationsStatus.BUILDING; +}; + +export const setRelationsStatus = (status: RelationsStatus) => { + relationsDb() + .prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('status', ?)") + .run(status); +}; + +export const inTransaction = (work: (db: DatabaseSync) => T): T => { + const db = relationsDb(); + db.exec("BEGIN IMMEDIATE"); + try { + const result = work(db); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +}; diff --git a/app/_server/actions/relations/suggestions.ts b/app/_server/actions/relations/suggestions.ts new file mode 100644 index 000000000..b88e7d7ed --- /dev/null +++ b/app/_server/actions/relations/suggestions.ts @@ -0,0 +1,65 @@ +import { + BrainEdgeKinds, + SUGGESTION_HUB_LIMIT, + SUGGESTIONS_MAX, + SUGGESTIONS_PER_ITEM, +} from "@/app/_consts/relations"; +import type { BrainEdge } from "@/app/_types/relations"; + +const _pairKey = (a: string, b: string) => (a < b ? `${a}|${b}` : `${b}|${a}`); + +const _neighbours = (edges: BrainEdge[]): Map> => { + const map = new Map>(); + const add = (from: string, to: string) => { + if (!map.has(from)) map.set(from, new Set()); + map.get(from)!.add(to); + }; + edges.forEach((edge) => { + add(edge.source, edge.target); + add(edge.target, edge.source); + }); + return map; +}; + +export const suggestEdges = (edges: BrainEdge[], items: Set): BrainEdge[] => { + const neighbours = _neighbours(edges); + const linked = new Set(edges.map((edge) => _pairKey(edge.source, edge.target))); + const scores = new Map(); + + neighbours.forEach((around) => { + if (around.size < 2 || around.size > SUGGESTION_HUB_LIMIT) return; + const weight = 1 / Math.log(1 + around.size); + const members = Array.from(around).filter((id) => items.has(id)); + + for (let i = 0; i < members.length; i++) { + for (let j = i + 1; j < members.length; j++) { + const key = _pairKey(members[i], members[j]); + if (linked.has(key)) continue; + scores.set(key, (scores.get(key) || 0) + weight); + } + } + }); + + const ranked = Array.from(scores.entries()).sort((a, b) => b[1] - a[1]); + const top = ranked[0]?.[1] || 1; + const perItem = new Map(); + const picked: BrainEdge[] = []; + + for (const [key, score] of ranked) { + if (picked.length >= SUGGESTIONS_MAX) break; + const [source, target] = key.split("|"); + if ((perItem.get(source) || 0) >= SUGGESTIONS_PER_ITEM) continue; + if ((perItem.get(target) || 0) >= SUGGESTIONS_PER_ITEM) continue; + + perItem.set(source, (perItem.get(source) || 0) + 1); + perItem.set(target, (perItem.get(target) || 0) + 1); + picked.push({ + source, + target, + kind: BrainEdgeKinds.SUGGESTED, + weight: Math.round((score / top) * 100) / 100, + }); + } + + return picked; +}; diff --git a/app/_server/actions/relations/tidy.ts b/app/_server/actions/relations/tidy.ts new file mode 100644 index 000000000..48a65e26f --- /dev/null +++ b/app/_server/actions/relations/tidy.ts @@ -0,0 +1,223 @@ +import { unified } from "unified"; +import remarkParse from "remark-parse"; +import { SKIP, visit } from "unist-util-visit"; +import type { Link, Root, Text } from "mdast"; +import { WIKILINK_REGEX } from "@/app/_consts/relations"; +import { ItemTypes, Modes } from "@/app/_types/enums"; +import { itemHref } from "@/app/_utils/global-utils"; +import { parseItemHref } from "@/app/_utils/item-href-utils"; +import { titleKey } from "./parser"; +import { relationsDb } from "./store"; + +interface Rewrite { + start: number; + end: number; + from: string; + to: string; +} + +const processor = unified().use(remarkParse); + +const _typeOf = (uuid: string): ItemTypes | null => { + try { + const row = relationsDb() + .prepare("SELECT type FROM items WHERE uuid = ?") + .get(uuid) as { type?: string } | undefined; + if (row?.type === ItemTypes.NOTE || row?.type === ItemTypes.CHECKLIST) return row.type; + } catch (error) { + console.error("Relations could not resolve link type:", error); + } + return null; +}; + +const _legacyUuid = async ( + type: ItemTypes, + category: string, + id: string, + owner: string, +): Promise => { + const { legacyResolve } = await import("@/app/_server/actions/lib/legacy-lookup"); + const mode = type === ItemTypes.CHECKLIST ? Modes.CHECKLISTS : Modes.NOTES; + return legacyResolve(mode, category, id, owner); +}; + +const _canonical = async (url: string, owner: string): Promise => { + const target = parseItemHref(url); + if (!target) return null; + + if (target.uuid && target.type) return null; + + if (target.uuid) { + const type = _typeOf(target.uuid); + return type ? itemHref(type, target.uuid) : null; + } + + if (target.legacy && target.type) { + const uuid = await _legacyUuid(target.type, target.legacy.category, target.legacy.id, owner); + return uuid ? itemHref(target.type, uuid) : null; + } + + return null; +}; + +export const tidyItemLinks = async (markdown: string, owner: string): Promise => { + if (!markdown.includes("](")) return markdown; + + let tree: Root; + try { + tree = processor.parse(markdown) as Root; + } catch (error) { + console.error("Could not parse markdown to tidy links:", error); + return markdown; + } + + const links: Link[] = []; + visit(tree, "link", (node: Link) => { + links.push(node); + }); + + const rewrites: Rewrite[] = []; + for (const link of links) { + const start = link.position?.start.offset; + const end = link.position?.end.offset; + if (start === undefined || end === undefined) continue; + + const to = await _canonical(link.url, owner); + if (to) rewrites.push({ start, end, from: link.url, to }); + } + + return rewrites.reverse().reduce((text, rewrite) => { + const source = text.slice(rewrite.start, rewrite.end); + const at = source.lastIndexOf(`(${rewrite.from}`); + if (at === -1) return text; + + const fixed = + source.slice(0, at + 1) + rewrite.to + source.slice(at + 1 + rewrite.from.length); + return text.slice(0, rewrite.start) + fixed + text.slice(rewrite.end); + }, markdown); +}; + +interface Retitle { + uuid: string; + title: string; +} + +const UNSAFE_WIKI_TITLE = /[\[\]|#^\n]/; + +const _retitles = (src: string): Map => { + const rows = relationsDb() + .prepare( + `SELECT b.text AS text, i.uuid AS uuid, i.title AS title, i.title_key AS key + FROM bindings b JOIN items i ON i.uuid = b.dst WHERE b.src = ?`, + ) + .all(src) as { text: string; uuid: string; title: string; key: string }[]; + return new Map( + rows + .filter((row) => row.key !== row.text && !UNSAFE_WIKI_TITLE.test(row.title)) + .map((row) => [row.text, { uuid: row.uuid, title: row.title }]), + ); +}; + +const _carryBindings = (src: string, carried: Map) => { + const insert = relationsDb().prepare( + `INSERT INTO bindings (src, text, dst) VALUES (?, ?, ?) + ON CONFLICT(src, text) DO UPDATE SET dst = excluded.dst`, + ); + carried.forEach((target, key) => insert.run(src, key, target.uuid)); +}; + +export const refreshWikilinks = (markdown: string, srcUuid?: string): string => { + if (!srcUuid || !markdown.includes("[[")) return markdown; + + let retitles: Map; + let tree: Root; + try { + retitles = _retitles(srcUuid.toLowerCase()); + if (retitles.size === 0) return markdown; + tree = processor.parse(markdown) as Root; + } catch (error) { + console.error("Could not refresh wikilinks:", error); + return markdown; + } + + const rewrites: Rewrite[] = []; + visit(tree, "text", (node: Text) => { + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined) return; + const raw = markdown.slice(start, end); + for (const match of Array.from(raw.matchAll(WIKILINK_REGEX))) { + const target = retitles.get(titleKey(match[1])); + if (!target || match.index === undefined) continue; + const at = start + match.index + 2; + rewrites.push({ start: at, end: at + match[1].length, from: match[1], to: target.title }); + } + }); + + if (rewrites.length === 0) return markdown; + + const carried = new Map(); + rewrites.forEach((rewrite) => { + const target = retitles.get(titleKey(rewrite.from)); + if (target) carried.set(titleKey(rewrite.to), target); + }); + try { + _carryBindings(srcUuid.toLowerCase(), carried); + } catch (error) { + console.error("Could not carry wikilink bindings:", error); + return markdown; + } + + return rewrites + .sort((a, b) => b.start - a.start) + .reduce( + (text, rewrite) => text.slice(0, rewrite.start) + rewrite.to + text.slice(rewrite.end), + markdown, + ); +}; + +const _escapeRegex = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const _mentionRegex = (title: string): RegExp => + new RegExp( + `(? + Array.from(raw.matchAll(WIKILINK_REGEX)).some( + (match) => match.index !== undefined && at >= match.index && at < match.index + match[0].length, + ); + +export const wrapMention = (markdown: string, title: string, href: string): string | null => { + let tree: Root; + try { + tree = processor.parse(markdown) as Root; + } catch (error) { + console.error("Could not parse markdown to link a mention:", error); + return null; + } + + const phrase = _mentionRegex(title); + let found: { start: number; end: number; text: string } | null = null; + + visit(tree, (node) => { + if (found) return SKIP; + if (node.type === "link" || node.type === "linkReference") return SKIP; + if (node.type !== "text") return; + const start = node.position?.start.offset; + const end = node.position?.end.offset; + if (start === undefined || end === undefined) return; + + const raw = markdown.slice(start, end); + const match = phrase.exec(raw); + if (!match || _insideWikilink(raw, match.index)) return; + found = { start: start + match.index, end: start + match.index + match[0].length, text: match[0] }; + return SKIP; + }); + + if (!found) return null; + const { start, end, text } = found as { start: number; end: number; text: string }; + const label = text.replace(/[\[\]]/g, "\\$&"); + return `${markdown.slice(0, start)}[${label}](${href})${markdown.slice(end)}`; +}; diff --git a/app/_server/actions/relations/tracking.ts b/app/_server/actions/relations/tracking.ts new file mode 100644 index 000000000..c26c1ae7f --- /dev/null +++ b/app/_server/actions/relations/tracking.ts @@ -0,0 +1,63 @@ +import fs from "fs/promises"; +import path from "path"; +import { invalidatePath } from "@/app/_server/actions/lib/metadata-cache"; +import { dataRoot, itemFileInfo } from "./paths"; +import { + forgetMissing, + forgetItemFile, + forgetItemTree, + indexItemFile, + indexItemTree, +} from "./indexer"; + +const _withinData = (target: string): boolean => { + const rel = path.relative(dataRoot(), path.resolve(target)); + return Boolean(rel) && !rel.startsWith("..") && !path.isAbsolute(rel); +}; + +const _safely = async (label: string, target: string, work: () => unknown) => { + try { + await work(); + } catch (error) { + console.error(`Relations ${label} failed for ${target}:`, error); + } +}; + +export const trackItemWrite = async (filePath: string, content: string) => { + if (!itemFileInfo(filePath)) return; + invalidatePath(filePath); + await _safely("write", filePath, async () => { + const stats = await fs.stat(filePath); + indexItemFile(filePath, content, Math.floor(stats.mtimeMs)); + }); +}; + +export const trackItemDelete = async (filePath: string) => { + if (!itemFileInfo(filePath)) return; + invalidatePath(filePath); + await _safely("delete", filePath, () => forgetItemFile(filePath)); +}; + +export const trackTreeDelete = async (dir: string) => { + if (!_withinData(dir)) return; + invalidatePath(dir); + await _safely("tree delete", dir, () => forgetItemTree(dir)); +}; + +export const trackMove = async (from: string, to: string) => { + if (!_withinData(from) && !_withinData(to)) return; + invalidatePath(from); + invalidatePath(to); + await _safely("move", `${from} -> ${to}`, async () => { + const stats = await fs.stat(to); + if (stats.isDirectory()) { + await indexItemTree(to); + await forgetMissing(from); + return; + } + if (itemFileInfo(to)) { + indexItemFile(to, await fs.readFile(to, "utf-8"), Math.floor(stats.mtimeMs)); + } + forgetItemFile(from); + }); +}; diff --git a/app/_server/actions/share/access.ts b/app/_server/actions/share/access.ts index 717ef627f..9f05b68d1 100644 --- a/app/_server/actions/share/access.ts +++ b/app/_server/actions/share/access.ts @@ -66,6 +66,15 @@ const _merge = ( }); }; +const _narrowest = ( + a: SharingPermissions, + b: SharingPermissions, +): SharingPermissions => ({ + canRead: a.canRead && b.canRead, + canEdit: a.canEdit && b.canEdit, + canDelete: a.canDelete && b.canDelete, +}); + const _chainGrants = async ( userDir: string, startDir: string, @@ -163,6 +172,9 @@ export const canReachFile = async ( return granted(access.users[username], permission); }; +const _isInside = (dir: string, target: string): boolean => + target === dir || target.startsWith(`${dir}${path.sep}`); + export const sharedFiles = async ( mode: Modes, username: string, @@ -170,7 +182,7 @@ export const sharedFiles = async ( const matches = await grepFilesByText(_modeDir(mode), username, "*.md"); const ownDir = path.join(_modeDir(mode), username); - return matches.filter((filePath) => !filePath.startsWith(ownDir)); + return matches.filter((filePath) => !_isInside(ownDir, filePath)); }; const _topMost = (dirs: string[]): string[] => @@ -230,6 +242,7 @@ export const listMounts = async ( const existing = loose.get(access.owner); if (existing) { existing.uuids.push(uuid); + existing.perms = _narrowest(existing.perms, perms); } else { loose.set(access.owner, { uuids: [uuid], perms }); } @@ -272,5 +285,5 @@ export const sharedCats = async ( return matches .map((filePath) => path.dirname(filePath)) - .filter((dirPath) => !dirPath.startsWith(ownDir)); + .filter((dirPath) => !_isInside(ownDir, dirPath)); }; diff --git a/app/_server/actions/share/category-info.ts b/app/_server/actions/share/category-info.ts index 0d1a9cc87..3f284a8ba 100644 --- a/app/_server/actions/share/category-info.ts +++ b/app/_server/actions/share/category-info.ts @@ -92,6 +92,15 @@ export const patchCatInfo = async ( return next; }); +export const mutateCatInfo = async ( + dirPath: string, + mutator: (info: CategoryInfo) => CategoryInfo | null, +): Promise => + runQueued(_lane(dirPath), async () => { + const next = mutator(await readCatInfo(dirPath)); + return next ? writeCatInfo(dirPath, next) : false; + }); + export const catUuid = async (dirPath: string): Promise => runQueued(_lane(dirPath), async () => { const info = await readCatInfo(dirPath); diff --git a/app/_server/actions/share/mounts.ts b/app/_server/actions/share/mounts.ts index 13ca3c9ed..fc76ead99 100644 --- a/app/_server/actions/share/mounts.ts +++ b/app/_server/actions/share/mounts.ts @@ -112,7 +112,9 @@ export const mountedItems = async ( category: _mountedPath(mount, category), isShared: true, sharedFrom: mount.owner, - permissions: mount.permissions, + permissions: + parseSharedWith(item.sharedWith)?.users[username] || + mount.permissions, isLoose: true, }); continue; diff --git a/app/_server/actions/share/rename.ts b/app/_server/actions/share/rename.ts index f89294d11..d42565140 100644 --- a/app/_server/actions/share/rename.ts +++ b/app/_server/actions/share/rename.ts @@ -1,5 +1,5 @@ import path from "path"; -import fs from "fs/promises"; +import { serverReadFile, serverWriteFile } from "@/app/_server/actions/file"; import { revalidateTag } from "next/cache"; import { Modes } from "@/app/_types/enums"; import { SharingPermissions } from "@/app/_types/core"; @@ -7,116 +7,146 @@ import { DATA_DIR } from "@/app/_consts/files"; import { CATEGORY_INFO_FILE, SHARED_WITH_KEY } from "@/app/_consts/sharing"; import { parseSharedWith, toSharedWith } from "@/app/_utils/sharing-utils"; import { grepFilesByText } from "@/app/_utils/grep-utils"; -import { updateYamlMetadata } from "@/app/_utils/yaml-metadata-utils"; -import { readCatInfo, writeCatInfo } from "./category-info"; +import { + extractYamlMetadata, + updateYamlMetadata, +} from "@/app/_utils/yaml-metadata-utils"; +import { broadcast } from "@/app/_server/actions/ws/broadcast"; +import { mutateCatInfo } from "./category-info"; import { dropMounts } from "./mounts"; const RENAMED_MODES = [Modes.NOTES, Modes.CHECKLISTS]; +type Grants = Record; +type Reshape = (users: Grants) => Grants | null; + const _modeTag = (mode: Modes): string => mode === Modes.CHECKLISTS ? "layout-checklists" : "layout-notes"; -const _swapKey = ( - users: Record, - oldName: string, - newName: string, -): Record | null => { - const perms = users[oldName]; - if (!perms) return null; +const _refresh = (mode: Modes): void => { + dropMounts(mode); + revalidateTag(_modeTag(mode), { expire: 0 }); +}; - const next = { ...users, [newName]: perms }; - delete next[oldName]; +const _swapKey = + (oldName: string, newName: string): Reshape => + (users) => { + const perms = users[oldName]; + if (!perms) return null; - return next; -}; + const next = { ...users, [newName]: perms }; + delete next[oldName]; + + return next; + }; + +const _dropKey = + (username: string): Reshape => + (users) => { + if (!users[username]) return null; + + const next = { ...users }; + delete next[username]; -const _renameInFiles = async ( + return next; + }; + +const _reshapeFiles = async ( mode: Modes, - oldName: string, - newName: string, + username: string, + reshape: Reshape, ): Promise => { const modeDir = path.join(process.cwd(), DATA_DIR, mode); - const candidates = await grepFilesByText(modeDir, oldName, "*.md"); + const candidates = await grepFilesByText(modeDir, username, "*.md"); let touched = 0; for (const filePath of candidates) { try { - const content = await fs.readFile(filePath, "utf-8"); - const line = content.match(new RegExp(`^${SHARED_WITH_KEY}:.*$`, "m")); - - if (!line) continue; + const content = await serverReadFile(filePath); + if (!content) continue; - const parsed = parseSharedWith(line[0].split(":").slice(1).join(":").trim()); + const { metadata } = extractYamlMetadata(content); + const parsed = parseSharedWith(metadata[SHARED_WITH_KEY]); if (!parsed || parsed.optedOut) continue; - const swapped = _swapKey(parsed.users, oldName, newName); - if (!swapped) continue; + const reshaped = reshape(parsed.users); + if (!reshaped) continue; const updated = updateYamlMetadata(content, { - [SHARED_WITH_KEY]: toSharedWith(swapped), + [SHARED_WITH_KEY]: toSharedWith(reshaped), }); - await fs.writeFile(filePath, updated, "utf-8"); + await serverWriteFile(filePath, updated); touched += 1; } catch (error) { - console.error(`Failed renaming share in ${filePath}:`, error); + console.error(`Failed reshaping share in ${filePath}:`, error); } } return touched; }; -const _renameInCats = async ( +const _reshapeCats = async ( mode: Modes, - oldName: string, - newName: string, + username: string, + reshape: Reshape, ): Promise => { const modeDir = path.join(process.cwd(), DATA_DIR, mode); - const candidates = await grepFilesByText(modeDir, oldName, CATEGORY_INFO_FILE); + const candidates = await grepFilesByText(modeDir, username, CATEGORY_INFO_FILE); let touched = 0; for (const infoFile of candidates) { const dir = path.dirname(infoFile); try { - const info = await readCatInfo(dir); - if (!info.sharing?.users) continue; + const written = await mutateCatInfo(dir, (info) => { + if (!info.sharing?.users) return null; - const swapped = _swapKey(info.sharing.users, oldName, newName); - if (!swapped) continue; + const reshaped = reshape(info.sharing.users); + if (!reshaped) return null; - await writeCatInfo(dir, { - ...info, - sharing: { ...info.sharing, users: swapped }, + return { ...info, sharing: { ...info.sharing, users: reshaped } }; }); - touched += 1; + if (written) touched += 1; } catch (error) { - console.error(`Failed renaming share in ${dir}:`, error); + console.error(`Failed reshaping share in ${dir}:`, error); } } return touched; }; -export const renameGrants = async ( - oldName: string, - newName: string, +const _reshapeGrants = async ( + username: string, + reshape: Reshape, ): Promise => { let touched = 0; for (const mode of RENAMED_MODES) { const perMode = - (await _renameInFiles(mode, oldName, newName)) + - (await _renameInCats(mode, oldName, newName)); + (await _reshapeFiles(mode, username, reshape)) + + (await _reshapeCats(mode, username, reshape)); - if (perMode > 0) { - dropMounts(mode); - revalidateTag(_modeTag(mode), { expire: 0 }); - } + if (perMode > 0) _refresh(mode); touched += perMode; } return touched; }; + +export const renameGrants = async ( + oldName: string, + newName: string, +): Promise => _reshapeGrants(oldName, _swapKey(oldName, newName)); + +export const revokeGrants = async (username: string): Promise => { + const touched = await _reshapeGrants(username, _dropKey(username)); + + RENAMED_MODES.forEach(_refresh); + + await broadcast({ type: "sharing", action: "deleted", username }); + + return touched; +}; diff --git a/app/_server/actions/tags/index.ts b/app/_server/actions/tags/index.ts index 8a97d3a17..35e719d5b 100644 --- a/app/_server/actions/tags/index.ts +++ b/app/_server/actions/tags/index.ts @@ -2,6 +2,7 @@ import { join } from "path"; import fs from "fs/promises"; +import { serverWriteFile } from "@/app/_server/actions/file"; import { Result } from "@/app/_types"; import { exportWholeDataFolder } from "../export"; import { extractHashtagsFromContent } from "@/app/_utils/tag-utils"; @@ -75,7 +76,7 @@ const processDirectory = async ( { tags: mergedTags }, true, ); - await fs.writeFile(filePath, updatedContent, "utf-8"); + await serverWriteFile(filePath, updatedContent); updated++; } diff --git a/app/_server/actions/users/auth.ts b/app/_server/actions/users/auth.ts index acf4492a1..48ecd9ab8 100644 --- a/app/_server/actions/users/auth.ts +++ b/app/_server/actions/users/auth.ts @@ -25,12 +25,13 @@ export const canAccessAllContent = async (): Promise => { const settingsResult = await getAppSettings(); if (!settingsResult.success || !settingsResult.data) { - return true; + console.error("Content access settings unreadable, refusing admin peek"); + return false; } return settingsResult.data.adminContentAccess !== "no"; } catch (error) { console.error("Error checking content access:", error); - return true; + return false; } }; diff --git a/app/_server/actions/users/crud.ts b/app/_server/actions/users/crud.ts index 87f5e495d..0c0264998 100644 --- a/app/_server/actions/users/crud.ts +++ b/app/_server/actions/users/crud.ts @@ -5,11 +5,15 @@ import { NOTES_DIR, USERS_FILE, } from "@/app/_consts/files"; -import { readJsonFile, writeJsonFile } from "../file"; +import { + readJsonFile, + serverDeleteDir, + serverRenamePath, + writeJsonFile, +} from "../file"; import { Result, SanitisedUser, User } from "@/app/_types"; import { sanitizeUserForClient } from "@/app/_utils/user-sanitize-utils"; import { removeAllSessionsForUser } from "../session"; -import fs from "fs/promises"; import { createHash } from "crypto"; import { ItemTypes } from "@/app/_types/enums"; import { DEFAULT_WEEK_START } from "@/app/_consts/calendar"; @@ -17,7 +21,7 @@ import { getFormData } from "@/app/_utils/global-utils"; import { logUserEvent } from "@/app/_server/actions/log"; import { getUserIndex } from "./helpers"; import { getCurrentUser } from "./queries"; -import { findUserRecord } from "./records"; +import { findUserRecord, mutateUsers } from "./records"; export type UserUpdatePayload = { username?: string; @@ -50,10 +54,8 @@ export async function _deleteUserCore(username: string): Promise> { await removeAllSessionsForUser(username); try { - await fs.rm(CHECKLISTS_DIR(username), { recursive: true, force: true }); - - const docsDir = NOTES_DIR(username); - await fs.rm(docsDir, { recursive: true, force: true }); + await serverDeleteDir(CHECKLISTS_DIR(username)); + await serverDeleteDir(NOTES_DIR(username)); } catch (error) { console.warn( `Warning: Could not clean up data files for ${username}:`, @@ -61,8 +63,23 @@ export async function _deleteUserCore(username: string): Promise> { ); } - allUsers.splice(userIndex, 1); - await writeJsonFile(allUsers, USERS_FILE); + try { + const { revokeGrants } = await import("@/app/_server/actions/share/rename"); + + await revokeGrants(username); + } catch (error) { + console.error(`Could not revoke shares granted to ${username}:`, error); + } + + const removed = await mutateUsers((users) => { + const index = users.findIndex((user: User) => user.username === username); + if (index === -1) return null; + + users.splice(index, 1); + return true; + }); + + if (!removed) return { success: false, error: "Failed to delete user" }; return { success: true, data: null }; } @@ -89,7 +106,7 @@ export async function _updateUserCore( try { const oldChecklistsPath = CHECKLISTS_DIR(targetUsername); const newChecklistsPath = CHECKLISTS_DIR(updates.username); - await fs.rename(oldChecklistsPath, newChecklistsPath); + await serverRenamePath(oldChecklistsPath, newChecklistsPath); } catch (error) { console.warn( `Could not rename checklists directory for ${targetUsername}:`, @@ -100,7 +117,7 @@ export async function _updateUserCore( try { const oldNotesPath = NOTES_DIR(targetUsername); const newNotesPath = NOTES_DIR(updates.username); - await fs.rename(oldNotesPath, newNotesPath); + await serverRenamePath(oldNotesPath, newNotesPath); } catch (error) { console.warn( `Could not rename notes directory for ${targetUsername}:`, diff --git a/app/_translations/en.json b/app/_translations/en.json index 8bbe00b31..60059d291 100644 --- a/app/_translations/en.json +++ b/app/_translations/en.json @@ -1779,5 +1779,71 @@ "sharingFolderMessage": "Open to view “{folder}”", "mentionTitle": "{user} mentioned you in a comment", "mentionMessage": "“{board}”" + }, + "relations": { + "indexing": "Indexing relationships...", + "unreachable": "not reachable", + "createFromWikilink": "Create a note called \"{title}\"", + "mentionedIn": "Mentioned in", + "mentionedInHint": "These notes use this title as plain text. Link one to connect it.", + "linkMention": "Link", + "linked": "Linked the mention in {title}", + "linkFailed": "Couldn't link that mention", + "kind": { + "mention": "Link", + "wiki": "Wikilink" + } + }, + "brain": { + "title": "Brain", + "openBrain": "Open in brain", + "search": "Find something in your brain...", + "dimension": "View", + "dimensions": { + "3d": "3D", + "2d": "2D" + }, + "local": "Around this item", + "global": "Everything", + "depth": "Depth {depth}", + "colourBy": "Colour by", + "colourModes": { + "type": "Type", + "category": "Category" + }, + "filters": { + "notes": "Notes", + "checklists": "Checklists", + "tags": "Tags", + "ghosts": "Not written yet", + "suggestions": "Suggestions", + "orphans": "Loners" + }, + "kind": { + "note": "Note", + "checklist": "Checklist", + "ghost": "Not written yet", + "tag": "Tag" + }, + "edges": { + "mention": "Link", + "wiki": "Wikilink", + "suggested": "Suggested", + "tag": "Tag" + }, + "linksTo": "Links to", + "linkedFrom": "Linked from", + "suggested": "Might be related", + "promote": "Link to {title}", + "lonely": "Nothing connects here yet. Mention it with @ or [[ from another note.", + "open": "Open", + "createGhost": "Write this note", + "openTag": "Show everything tagged", + "createFailed": "Could not create that note", + "connectFailed": "Could not link those two", + "connected": "Linked {source} to {target}", + "empty": "Your brain is quiet. Link notes and checklists with @ or [[ and they will show up here.", + "viewingOwner": "Viewing {owner}'s brain", + "summary": "{items} items, {links} links, {shown} shown" } } diff --git a/app/_types/context.ts b/app/_types/context.ts index dea8404c0..d9ab4f690 100644 --- a/app/_types/context.ts +++ b/app/_types/context.ts @@ -2,7 +2,6 @@ import { Note } from "./note"; import { Checklist } from "./checklist"; import { User, SanitisedUser } from "./user"; import { AppSettings } from "./config"; -import { LinkIndex } from "./links"; import { AllSharedItems, UserSharedItems } from "./sharing"; import { TagsIndex } from "./tags"; @@ -28,7 +27,6 @@ export interface AppModeContextType { appVersion: string; appSettings: AppSettings | null; usersPublicData: Partial[]; - linkIndex: LinkIndex | null; notes: Partial[]; checklists: Partial[]; allSharedItems: AllSharedItems | null; diff --git a/app/_types/index.ts b/app/_types/index.ts index 50a7ca604..ed740f664 100644 --- a/app/_types/index.ts +++ b/app/_types/index.ts @@ -63,7 +63,6 @@ export type { export type { Category } from "./category"; -export type { ItemLinks, LinkIndex } from "./links"; export type { TagInfo, TagsIndex } from "./tags"; diff --git a/app/_types/links.ts b/app/_types/links.ts deleted file mode 100644 index c6c93c1ee..000000000 --- a/app/_types/links.ts +++ /dev/null @@ -1,16 +0,0 @@ -export interface ItemLinks { - isLinkedTo: { - notes: string[]; - checklists: string[]; - }; - isReferencedIn: { - notes: string[]; - checklists: string[]; - }; -} - -export interface LinkIndex { - notes: Record; - checklists: Record; - [key: string]: Record; -} diff --git a/app/_types/relations.ts b/app/_types/relations.ts new file mode 100644 index 000000000..ad019ac64 --- /dev/null +++ b/app/_types/relations.ts @@ -0,0 +1,53 @@ +import type { + BrainEdgeKinds, + BrainNodeKinds, + LinkKinds, + RelationsStatus, +} from "@/app/_consts/relations"; +import type { ItemTypes } from "./enums"; + +export interface RelatedItem { + uuid: string; + type: ItemTypes; + title: string; + category: string; + owner?: string; + checklistType?: string; + kind: LinkKinds; +} + +export interface MentionedIn extends Omit { + snippet: string; +} + +export interface ItemRelations { + uuid: string; + status: RelationsStatus; + backlinks: RelatedItem[]; + mentions: MentionedIn[]; + wikis: Record; +} + +export interface BrainNode { + id: string; + kind: BrainNodeKinds; + title: string; + category?: string; + owner?: string; + checklistType?: string; + degree: number; +} + +export interface BrainEdge { + source: string; + target: string; + kind: BrainEdgeKinds; + weight: number; +} + +export interface BrainGraph { + status: RelationsStatus; + owner: string; + nodes: BrainNode[]; + edges: BrainEdge[]; +} diff --git a/app/_types/websocket.ts b/app/_types/websocket.ts index 67cd34963..3c3ca9baa 100644 --- a/app/_types/websocket.ts +++ b/app/_types/websocket.ts @@ -1,5 +1,5 @@ export interface WsEvent { - type: "checklist" | "note" | "category" | "settings" | "sharing" | "notification"; + type: "checklist" | "note" | "category" | "settings" | "sharing" | "notification" | "relations"; action: "created" | "updated" | "deleted"; entityId?: string; username: string; diff --git a/app/_utils/checklist-utils.ts b/app/_utils/checklist-utils.ts index 90858805a..21e6f5754 100644 --- a/app/_utils/checklist-utils.ts +++ b/app/_utils/checklist-utils.ts @@ -13,6 +13,7 @@ import { generateYamlFrontmatter, generateUuid, strayMeta, + createdAtOf, } from "./yaml-metadata-utils"; import { extractHashtagsFromContent, normalizeTag } from "./tag-utils"; import { SHARED_WITH_KEY } from "@/app/_consts/sharing"; @@ -346,7 +347,7 @@ export const parseMarkdown = ( category, items, createdAt: fileStats - ? fileStats.birthtime.toISOString() + ? createdAtOf(metadata, fileStats.birthtime) : new Date().toISOString(), updatedAt: fileStats ? fileStats.mtime.toISOString() diff --git a/app/_utils/indexes-utils.ts b/app/_utils/indexes-utils.ts deleted file mode 100644 index 8722f178e..000000000 --- a/app/_utils/indexes-utils.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { LinkIndex } from "../_types"; -import { Checklist, ItemType, Note } from "../_types"; -import { ItemTypes } from "../_types/enums"; - -export const createItemMap = (itemsArray: Note[] | Checklist[]) => { - return itemsArray.reduce( - (map: Map, item: Note | Checklist) => { - if (item.uuid) { - map.set(item.uuid, item); - } - return map; - }, - new Map() - ); -}; - -export const getReferencingItems = ( - uuids: string[], - map: Map, - type: ItemType -) => { - return uuids - .map((uuid) => { - const item = map.get(uuid); - - if (item) { - return { - type, - title: item.title, - uuid: item.uuid, - category: item.category || "Uncategorized", - owner: item.owner, - }; - } - return null; - }) - .filter((item): item is NonNullable => item !== null); -}; - -export const getReferences = ( - linkIndex: LinkIndex | null, - itemUuid: string | undefined, - itemType: ItemType, - notes: Partial[], - checklists: Partial[] -) => { - if (!linkIndex || !itemUuid) return []; - - const currentItemData = - itemType === ItemTypes.NOTE - ? linkIndex.notes[itemUuid] - : linkIndex.checklists[itemUuid]; - - const items: Array<{ - type: ItemType; - uuid: string; - title: string; - category: string; - owner: string | undefined; - }> = []; - - const notesMap = createItemMap(notes as Note[]); - const checklistsMap = createItemMap(checklists as Checklist[]); - - if (!currentItemData) return []; - - const referencedNotes = getReferencingItems( - currentItemData.isReferencedIn.notes, - notesMap, - ItemTypes.NOTE - ); - - const referencedChecklists = getReferencingItems( - currentItemData.isReferencedIn.checklists, - checklistsMap, - ItemTypes.CHECKLIST - ); - - items.push(...referencedNotes, ...referencedChecklists); - - return items; -}; diff --git a/app/_utils/item-href-utils.ts b/app/_utils/item-href-utils.ts new file mode 100644 index 000000000..bccb74794 --- /dev/null +++ b/app/_utils/item-href-utils.ts @@ -0,0 +1,96 @@ +import { isUuid } from "@/app/_consts/identity"; +import { LEGACY_LINK_PREFIX } from "@/app/_consts/relations"; +import { ItemTypes } from "@/app/_types/enums"; +import { itemHref } from "@/app/_utils/global-utils"; + +export interface ItemHrefTarget { + uuid?: string; + type?: ItemTypes; + legacy?: { category: string; id: string }; +} + +const PREFIXES: Array<[string, ItemTypes | undefined]> = [ + ["/note/", ItemTypes.NOTE], + ["/checklist/", ItemTypes.CHECKLIST], + [LEGACY_LINK_PREFIX, undefined], +]; + +const _localPath = (href: string, origins: string[]): string | null => { + if (href.startsWith("/")) return href; + try { + const url = new URL(href); + return origins.includes(url.origin) ? url.pathname : null; + } catch { + return null; + } +}; + +const _decode = (segment: string): string => { + try { + return decodeURIComponent(segment); + } catch { + return segment; + } +}; + +export const parseItemHref = ( + href: string | null | undefined, + origins: string[] = [], +): ItemHrefTarget | null => { + if (!href) return null; + + const local = _localPath(href.trim(), origins); + if (!local) return null; + + const pathOnly = local.split(/[?#]/)[0]; + const match = PREFIXES.find(([prefix]) => pathOnly.startsWith(prefix)); + if (!match) return null; + + const [prefix, type] = match; + const parts = pathOnly.slice(prefix.length).split("/").filter(Boolean); + if (parts.length === 0) return null; + + if (parts.length === 1 && isUuid(parts[0])) { + return { uuid: parts[0].toLowerCase(), type }; + } + + if (!type) return null; + + const id = _decode(parts[parts.length - 1]); + if (isUuid(id)) return { uuid: id.toLowerCase(), type }; + + return { + type, + legacy: { category: parts.slice(0, -1).map(_decode).join("/"), id }, + }; +}; + +export const isItemHref = ( + href: string | null | undefined, + origins: string[] = [], +): boolean => parseItemHref(href, origins) !== null; + +const LINK_TEXT_SPECIALS = /([\[\]\\])/g; + +export const escapeLinkText = (text: string): string => + text.replace(LINK_TEXT_SPECIALS, "\\$1"); + +const _asType = (type: string | null | undefined): ItemTypes | undefined => + type === ItemTypes.NOTE || type === ItemTypes.CHECKLIST ? type : undefined; + +export const canonicalItemHref = ( + href: string | null | undefined, + uuid?: string | null, + type?: string | null, +): string | null => { + const target = parseItemHref(href); + const resolvedUuid = target?.uuid || (uuid && isUuid(uuid) ? uuid : undefined); + const resolvedType = target?.type || _asType(type); + + if (resolvedUuid && resolvedType) return itemHref(resolvedType, resolvedUuid); + + return href || null; +}; + +export const currentOrigins = (): string[] => + typeof window === "undefined" ? [] : [window.location.origin]; diff --git a/app/_utils/markdown-utils.tsx b/app/_utils/markdown-utils.tsx index 8ddc93375..ef5a017ae 100644 --- a/app/_utils/markdown-utils.tsx +++ b/app/_utils/markdown-utils.tsx @@ -10,8 +10,12 @@ import { Element } from "hast"; import { addCustomHtmlTurndownRules } from "@/app/_utils/custom-html-utils"; import { html as beautifyHtml } from "js-beautify"; import { TableSyntax } from "@/app/_types"; -import { decodeCategoryPath, decodeSegment } from "./global-utils"; -import { isUuid } from "@/app/_consts/identity"; +import { + canonicalItemHref, + escapeLinkText, + isItemHref, + parseItemHref, +} from "./item-href-utils"; import { getContrastColor } from "./color-utils"; const turndownPluginGfm = require("turndown-plugin-gfm"); @@ -63,6 +67,8 @@ const hasComplexTableContent = (table: HTMLElement): boolean => { return false; }; +const WIKILINK_SPAN = /(!?\[\[[^\[\]\n]+\]\])/; + export const createTurndownService = (tableSyntax?: TableSyntax) => { const service = new TurndownService({ headingStyle: "atx", @@ -81,6 +87,13 @@ export const createTurndownService = (tableSyntax?: TableSyntax) => { }, }); + const escapeMarkdown = service.escape.bind(service); + service.escape = (text: string) => + text + .split(WIKILINK_SPAN) + .map((part, index) => (index % 2 === 1 ? part : escapeMarkdown(part))) + .join(""); + service.addRule("taskItem", { filter: (node) => node.nodeName === "LI" && node.getAttribute("data-type") === "taskItem", @@ -262,11 +275,15 @@ export const createTurndownService = (tableSyntax?: TableSyntax) => { }, replacement: function (content, node) { const element = node as HTMLElement; - const href = element.getAttribute("data-href"); const title = element.getAttribute("data-title"); + const href = canonicalItemHref( + element.getAttribute("data-href"), + element.getAttribute("data-uuid"), + element.getAttribute("data-type"), + ); if (href && title) { - return `[${title}](${href})`; + return `[${escapeLinkText(title)}](${href})`; } return content; @@ -644,36 +661,17 @@ const markdownProcessor = unified() if (node.tagName === "a" && node.properties?.href) { const href = String(node.properties.href); - if ( - href.startsWith("/jotty/") || - href.startsWith("/note/") || - href.startsWith("/checklist/") - ) { + if (isItemHref(href)) { const textContent = node.children?.[0]?.type === "text" ? String(node.children[0].value) : ""; - let uuid = ""; - let type = "note"; - let category = ""; - let itemId = ""; - - if (href.startsWith("/jotty/")) { - uuid = href.replace("/jotty/", ""); - } else if (href.startsWith("/note/")) { - type = "note"; - const pathParts = href.replace("/note/", "").split("/"); - itemId = decodeSegment(pathParts.pop() || ""); - uuid = isUuid(itemId) ? itemId : ""; - category = decodeCategoryPath(pathParts.join("/")); - } else if (href.startsWith("/checklist/")) { - type = "checklist"; - const pathParts = href.replace("/checklist/", "").split("/"); - itemId = decodeSegment(pathParts.pop() || ""); - uuid = isUuid(itemId) ? itemId : ""; - category = decodeCategoryPath(pathParts.join("/")); - } + const target = parseItemHref(href); + const uuid = target?.uuid || ""; + const type = target?.type || ""; + const category = target?.legacy?.category || ""; + const itemId = target?.legacy?.id || uuid; const newChildren: any[] = []; @@ -693,7 +691,6 @@ const markdownProcessor = unified() "data-type": type, "data-category": category, "data-item-id": itemId, - "data-convert-to-bidirectional": "false", }; node.children = newChildren; diff --git a/app/_utils/wikilink-utils.ts b/app/_utils/wikilink-utils.ts new file mode 100644 index 000000000..b12f2eb70 --- /dev/null +++ b/app/_utils/wikilink-utils.ts @@ -0,0 +1,24 @@ +import { findAndReplace } from "mdast-util-find-and-replace"; +import type { Root } from "mdast"; +import { WIKILINK_REGEX } from "@/app/_consts/relations"; + +export const WIKILINK_TAG = "wiki-link"; + +export const remarkWikilinks = () => (tree: Root) => { + findAndReplace(tree, [ + [ + new RegExp(WIKILINK_REGEX.source, "g"), + (_match: string, target: string, alias?: string) => { + const label = (alias || target).trim(); + return { + type: "text", + value: label, + data: { + hName: WIKILINK_TAG, + hProperties: { target: target.trim(), label }, + }, + }; + }, + ], + ]); +}; diff --git a/app/_utils/yaml-metadata-utils.ts b/app/_utils/yaml-metadata-utils.ts index 74f83372a..b86b631b8 100644 --- a/app/_utils/yaml-metadata-utils.ts +++ b/app/_utils/yaml-metadata-utils.ts @@ -10,6 +10,16 @@ export const toIso = (d: Date | string | number | undefined | null): string => { : new Date(0).toISOString(); }; +export const createdAtOf = ( + metadata: Record | null | undefined, + birthtime: Date, +): string => { + const stamped = metadata?.[OwnedMetaKeys.CREATED_AT]; + return stamped instanceof Date || typeof stamped === "string" + ? toIso(stamped) + : toIso(birthtime); +}; + export interface DocumentMetadata { uuid?: string; title?: string; diff --git a/app/api/admin/rebuild-index/route.ts b/app/api/admin/rebuild-index/route.ts index 4a687b492..4348f69b5 100644 --- a/app/api/admin/rebuild-index/route.ts +++ b/app/api/admin/rebuild-index/route.ts @@ -1,22 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import { withApiAuth } from "@/app/_utils/api-utils"; -import { rebuildLinkIndex } from "@/app/_server/actions/link"; +import { getUserIndex } from "@/app/_server/actions/users/helpers"; +import { + rebuildOwnerRelations, + rebuildRelations, +} from "@/app/_server/actions/relations/indexer"; export const dynamic = "force-dynamic"; export async function POST(request: NextRequest) { return withApiAuth(request, async (user) => { + if (!user.isAdmin) { + return NextResponse.json({ error: "Admin access required" }, { status: 403 }); + } + try { - const { username } = await request.json(); + const { username } = await request.json().catch(() => ({})); if (!username) { - return NextResponse.json( - { error: "Username is required" }, - { status: 400 } - ); + await rebuildRelations(); + return NextResponse.json({ + success: true, + message: "Successfully rebuilt link index for every user", + }); + } + + if ((await getUserIndex(username)) === -1) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); } - await rebuildLinkIndex(username); + await rebuildOwnerRelations(username); return NextResponse.json({ success: true, diff --git a/app/layout.tsx b/app/layout.tsx index 2065ce94d..d623c8b5d 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -19,7 +19,6 @@ import { getCategories } from "@/app/_server/actions/category"; import { Modes } from "./_types/enums"; import { getCurrentUser, getUsers } from "./_server/actions/users"; import { readPackageVersion } from "@/app/_server/actions/config"; -import { readLinkIndex } from "@/app/_server/actions/link"; import { headers } from "next/headers"; import { themeInitScript, @@ -170,9 +169,6 @@ export default async function RootLayout({ const checklistCategories = user ? await getCategories(Modes.CHECKLISTS) : { success: false, data: [] }; - const linkIndex = user?.username - ? await readLinkIndex(user.username) - : null; const messages = await getMessages(); const [ @@ -264,7 +260,6 @@ export default async function RootLayout({ pathname={pathname || ""} initialSettings={settings} usersPublicData={users} - linkIndex={linkIndex} notes={notes} checklists={checklists} allSharedItems={allSharedItems} diff --git a/howto/API.md b/howto/API.md index 8071d4475..2eed4ecee 100644 --- a/howto/API.md +++ b/howto/API.md @@ -1178,7 +1178,7 @@ Retrieves all categories for notes and checklists for the authenticated user. Ar **POST** `/api/admin/rebuild-index` -Rebuilds the internal link index for a specific user. This is useful when the link relationships between notes and checklists become inconsistent due to bulk operations, data migrations, or other maintenance tasks. +Rebuilds the relationships index, the list of which notes and checklists link to each other. Jotty keeps it up to date on its own and checks it against your files every minute, so you only need this after editing lots of files outside Jotty, restoring a backup, or if the brain looks wrong. **Request Body:** @@ -1190,7 +1190,7 @@ Rebuilds the internal link index for a specific user. This is useful when the li **Parameters:** -- `username` (required): Username whose link index should be rebuilt +- `username` (optional): Rebuild only this user's items. Leave it out to rebuild every user. **Response:** @@ -1201,12 +1201,18 @@ Rebuilds the internal link index for a specific user. This is useful when the li } ``` +**Errors:** + +- `403`: the API key does not belong to an admin +- `404`: no user with that username + **Notes:** - Only administrators can use this endpoint -- The rebuild process scans all notes and checklists for the specified user and recreates the link relationships -- This operation may take time for users with large amounts of content -- The link index tracks internal references between notes and checklists (e.g., when one note links to another) +- Rebuilding reads your notes and checklists and never changes them +- Encrypted notes are skipped, their content is never read +- Wikilinks keep pointing at the note they were first matched to, so a rebuild does not move them onto a different note with the same title +- The index lives in `data/.relations.db`. If it is deleted, Jotty rebuilds it from your files ### 16. Get User Summary Statistics @@ -1793,7 +1799,7 @@ curl -H "x-api-key: ck_your_api_key_here" \ https://jotty-instance.com/api/exports ``` -### Rebuild link index for a user (admin only) +### Rebuild the link index (admin only) ```bash curl -X POST \ diff --git a/howto/BRAIN.md b/howto/BRAIN.md new file mode 100644 index 000000000..60ecd672d --- /dev/null +++ b/howto/BRAIN.md @@ -0,0 +1,99 @@ +# Links and the Brain + +Notes and checklists can link to each other. Every link works both ways: the item you link to lists the item that links to it under **Referenced By**. The **Brain** draws all of those links as a map you can explore. + +--- + +## Making a link + +There are three ways, and you can mix them freely. + +| How | What you type | What gets saved | +| --- | --- | --- | +| Mention | `@` then pick an item | `[Title](/note/)` | +| Wikilink picker | `[[` then pick an item | `[Title](/note/)` | +| Wikilink | `[[Title]]` typed out in full | `[[Title]]`, exactly as you typed it | + +Links made with the picker point at the item's id, so nothing you do to the item later can break them. Wikilinks are for people who like typing them out, and for notes brought over from Obsidian. + +Wikilinks understand the usual extras: + +- `[[Title|shown text]]` shows different text +- `[[Title#Heading]]` points at a heading +- `[[Title]]` for a note that doesn't exist yet shows a dashed button. Click it to create the note. + +--- + +## How wikilinks find their note + +A wikilink is matched by title, ignoring upper and lower case and extra spaces, against your own notes and checklists. + +- **The first match sticks.** Once `[[Plan]]` has found a note, it keeps pointing at that note. A new note called "Plan" later on doesn't take the link over. +- **Two items with the same title.** The link goes to the older one. +- **Renaming the target.** The link keeps working. The next time you save the note that contains the link, Jotty updates the text to the new title, so `[[Plan]]` becomes `[[Q3 plan]]`. Headings and shown text are kept. Only the note you are saving changes. Other notes catch up when they are next saved, and their links keep working until then. +- **Pointing a link somewhere else.** Edit the text inside `[[ ]]` and save. Different text is looked up again. +- **Deleting the target.** Links move to the next item with that title, or become a "not written yet" button. + +Wikilinks inside code blocks and inline code are left alone. + +--- + +## Mentioned in + +Below **Referenced By**, a **Mentioned in** list shows notes that contain this item's title as plain text but don't link to it yet. Press **Link** to turn the first plain mention in that note into a link. Nothing else in the note changes. + +Titles shorter than three characters are skipped, because they would match almost everything. + +--- + +## Importing from Obsidian + +Copy your vault's Markdown files into a category and Jotty picks them up. The body of each note is left exactly as it was: `[[wikilinks]]` are read, never rewritten on import. + +Jotty adds two lines to each file's frontmatter the first time it sees it: + +- `uuid`, the id Jotty uses for the note's address +- `createdAt`, so the note keeps its creation date + +Wikilinks are only rewritten when you rename a note in Jotty and then save a note that links to it, as described above. + +--- + +## The Brain + +Open it with the brain button at the top of any note or checklist. The item you came from is highlighted. + +- **3D and 2D.** 3D is the default on devices that handle it. Phones, tablets and anyone with reduced motion turned on get 2D. +- **Click** a node to select it and see its links. **Double click** opens it. Right click opens it too. +- **Search** jumps to an item. +- **Around this item** shows just the part of the map around the item you came from, with a depth slider. **Everything** shows the whole map again. +- **Colour by** type or by category. +- **Filters** show or hide notes, checklists, tags, "not written yet" items, suggestions and items with no links. +- **Suggestions** are dashed lines between items that share a lot of neighbours but don't link to each other yet. They are listed as **Might be related** when you select an item, and on your own brain the link button next to one connects them. + +Node shapes: + +| Shape | Meaning | +| --- | --- | +| Circle with a note icon | Note | +| Rounded square with a checklist icon | Checklist | +| Rounded square with a task icon | Kanban board | +| Diamond | Tag | +| Dashed circle | A wikilink to a note that doesn't exist yet | + +Admins can open `/brain/` to see another user's brain when **Admin Content Access** is turned on in the admin settings. + +Turning off **Bilateral Links** in the admin editor settings hides the brain, Referenced By and Mentioned in. + +--- + +## The relationships index + +Jotty keeps the links in `data/.relations.db`, next to your notes. Your Markdown files stay the source of truth. The index holds the links, the titles, and a searchable copy of each unencrypted note's plain text, which powers **Mentioned in**. + +- Encrypted notes are never read. Their links don't appear anywhere. +- If the file is missing or damaged, Jotty rebuilds it from your notes on start. While that runs, Referenced By and the Brain show "Indexing relationships...". +- Files changed outside Jotty are picked up within about a minute. +- Admins can rebuild it from **Admin > Content**, or with the [rebuild API](API.md#15-rebuild-link-index). + +A rebuild keeps track of which note each wikilink first matched. Deleting the file loses that: wikilinks whose target was renamed in Jotty, in notes not saved since, then fall back to matching by title. diff --git a/instrumentation.ts b/instrumentation.ts index 5a11f09ea..165bf90b6 100644 --- a/instrumentation.ts +++ b/instrumentation.ts @@ -109,6 +109,13 @@ export async function register() { console.log(`> WebSocket dev server running on ws://0.0.0.0:${WS_PORT}`); } + if (!(globalThis as any).__jottyRelationsStarted) { + (globalThis as any).__jottyRelationsStarted = true; + import("./app/_server/actions/relations/indexer") + .then(({ reconcileRelations }) => reconcileRelations()) + .catch((err) => console.error("[relations] startup reconcile failed:", err)); + } + if ((globalThis as any).__jottyReminderScanStarted) return; (globalThis as any).__jottyReminderScanStarted = true; diff --git a/next.config.mjs b/next.config.mjs index 767bc697b..943316748 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -22,6 +22,8 @@ const nextConfig = { unoptimized: true, }, async headers() { + if (process.env.NODE_ENV !== "production") return []; + return [ { source: "/:all*(svg|jpg|jpeg|png|gif|ico|webp|avif|woff|woff2|css|js)", diff --git a/package.json b/package.json index a35d8a550..587ca739a 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:coverage": "vitest run --coverage", "mock:data:lists": "tsx tests/mock-data/checklist-generator.ts", "mock:data:notes": "tsx tests/mock-data/note-generator.ts", + "mock:data:brain": "tsx --tsconfig tsconfig.json tests/mock-data/brain-seed.ts", "docker:test": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml build && docker compose -f docker-compose.test.yml up -d", "docker:test:logs": "docker compose -f docker-compose.test.yml logs -f", "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d" @@ -27,7 +28,6 @@ "@fontsource-variable/work-sans": "5.2.8", "@hugeicons/core-free-icons": "3.0.0", "@hugeicons/react": "1.1.2", - "@nivo/network": "0.99.0", "@rcode-link/tiptap-drawio": "1.0.21", "@tiptap/core": "3.31.3", "@tiptap/extension-bubble-menu": "3.31.3", @@ -63,8 +63,9 @@ "jsonwebtoken": "9.0.2", "ldapts": "8.1.7", "libsodium-wrappers-sumo": "0.7.15", + "mdast-util-find-and-replace": "3.0.2", "mermaid": "10.9.8", - "next": "16.3.0", + "next": "16.3.6", "next-intl": "4.9.2", "next-themes": "0.2.1", "openpgp": "6.3.0", @@ -75,6 +76,7 @@ "react-day-picker": "9.14.0", "react-dom": "19.2.4", "react-force-graph-2d": "^1.29.1", + "react-force-graph-3d": "1.29.1", "react-markdown": "10.1.0", "react-masonry-css": "1.0.16", "react-simple-code-editor": "0.14.1", @@ -91,6 +93,7 @@ "speakeasy": "2.0.0", "tailwind-merge": "2.2.0", "tailwind-scrollbar": "4.0.2", + "three": "0.186.1", "tippy.js": "6.3.7", "turndown": "7.2.0", "turndown-plugin-gfm": "1.0.2", @@ -146,11 +149,12 @@ "@types/libsodium-wrappers": "0.7.14", "@types/libsodium-wrappers-sumo": "0.7.8", "@types/lodash": "4.17.20", - "@types/node": "20", + "@types/node": "24", "@types/proper-lockfile": "4.1.4", "@types/react": "19.2.13", "@types/react-dom": "19.2.3", "@types/sanitize-html": "2.16.0", + "@types/three": "0.186.0", "@types/turndown": "5.0.5", "@types/unist": "3.0.3", "@types/uuid": "10.0.0", @@ -158,7 +162,7 @@ "@vitest/coverage-v8": "4.1.8", "autoprefixer": "10.0.1", "eslint": "9", - "eslint-config-next": "16.3.0", + "eslint-config-next": "16.3.6", "postcss": "8.5.26", "serwist": "9.5.4", "tailwindcss": "3.3.0", diff --git a/tests/mock-data/brain-seed.ts b/tests/mock-data/brain-seed.ts new file mode 100644 index 000000000..cdb77092b --- /dev/null +++ b/tests/mock-data/brain-seed.ts @@ -0,0 +1,404 @@ +import { promises as fs } from "fs"; +import path from "path"; +import { createHash } from "crypto"; +import lockfile from "proper-lockfile"; +import { noteToMarkdown } from "../../app/_server/actions/note/parsers"; +import { listToMarkdown } from "../../app/_utils/checklist-utils"; +import type { Checklist, Item, Note } from "../../app/_types"; +import { ChecklistsTypes } from "../../app/_types/enums"; +import { random, randomChoice, randomInt, randomSample, resetSeed } from "./utils"; + +const USER_FLAG = "--user="; +const DEFAULT_USER = "braintest"; +const USERNAME = + process.argv.find((arg) => arg.startsWith(USER_FLAG))?.slice(USER_FLAG.length) || + process.env.BRAIN_SEED_USER || + DEFAULT_USER; +const PASSWORD = process.env.BRAIN_SEED_PASSWORD || "braintest-local-only"; +const SEED_CATEGORY = "Brain Seed"; +const DATA_DIR = path.join(process.cwd(), "data"); +const USERS_FILE = path.join(DATA_DIR, "users", "users.json"); +const NOTES_ROOT = path.join(DATA_DIR, "notes", USERNAME, SEED_CATEGORY); +const LISTS_ROOT = path.join(DATA_DIR, "checklists", USERNAME, SEED_CATEGORY); +const CREATED_MARKER = path.join(DATA_DIR, "notes", USERNAME, ".brain-seed-user"); +const YEAR_MS = 365 * 24 * 60 * 60 * 1000; + +interface Cluster { + category: string; + tag: string; + notes: string[]; + lists: string[]; +} + +const CLUSTERS: Cluster[] = [ + { + category: "Home", + tag: "home", + notes: ["Boiler service history", "Living room repaint plan", "House insurance notes", "Neighbours and contacts", "Moving in checklist retro"], + lists: ["Weekly cleaning rota", "Hardware store run", "Spare parts inventory", "Paint colour shortlist", "Bills to cancel"], + }, + { + category: "Garden", + tag: "garden", + notes: ["Tomato growing log", "Composting notes", "Raised bed layout", "Pest control ideas", "Seed swap contacts"], + lists: ["Spring sowing plan", "Garden centre shopping", "Watering schedule", "Tools to sharpen", "Bulbs to plant"], + }, + { + category: "Recipes", + tag: "cooking", + notes: ["Sourdough starter notes", "Weeknight curry", "Grandma's lasagne", "Meal prep principles", "Fermentation experiments"], + lists: ["Groceries this week", "Pantry staples", "Baking gear wishlist", "Dinner party menu", "Spices to restock"], + }, + { + category: "Travel/Japan", + tag: "travel", + notes: ["Tokyo itinerary", "Kyoto temples shortlist", "JR pass maths", "Japanese phrases", "Onsen etiquette"], + lists: ["Packing list Japan", "Bookings to confirm", "Souvenirs for family", "Before we fly", "Food to try in Osaka"], + }, + { + category: "Work/Projects", + tag: "work", + notes: ["Q3 roadmap", "Search rewrite design", "Incident review April", "Onboarding guide draft", "Architecture decisions"], + lists: ["Sprint board", "Launch checklist", "Hiring pipeline", "Tech debt backlog", "Release notes todo"], + }, + { + category: "Work/Meetings", + tag: "meetings", + notes: ["Weekly sync notes", "One to one with Sam", "Planning offsite", "Customer call notes", "Retro themes"], + lists: ["Action items", "Agenda next week", "Follow ups", "Offsite logistics", "Questions for leadership"], + }, + { + category: "Health", + tag: "health", + notes: ["Running plan", "Physio exercises", "Sleep experiments", "Blood test results summary", "Stretching routine"], + lists: ["Gym programme", "Supplements", "Race day kit", "Appointments", "Healthy snacks"], + }, + { + category: "Reading", + tag: "books", + notes: ["Book notes Deep Work", "Book notes Thinking Fast and Slow", "Quotes I like", "Reading goals", "Sci-fi recommendations"], + lists: ["To read", "Books to lend back", "Library holds", "Gift ideas books", "Audiobooks queue"], + }, + { + category: "Finance", + tag: "money", + notes: ["Budget principles", "Pension research", "Tax year notes", "Subscriptions audit", "Savings goals"], + lists: ["Monthly budget", "Receipts to file", "Accounts to close", "Big purchases", "Tax documents"], + }, + { + category: "Ideas", + tag: "ideas", + notes: ["Side project ideas", "App features wishlist", "Blog post drafts", "Things to learn", "Random thoughts"], + lists: ["Weekend projects", "Domains to buy", "Talks to watch", "Experiments backlog", "Someday maybe"], + }, +]; + +const SHARED_TAGS = ["urgent", "someday", "review", "family"]; +const GHOSTS = ["Unwritten travel journal", "Future house plans", "Recipe book idea", "Marathon training diary"]; +const KANBAN_STATUSES = ["todo", "in_progress", "completed"]; + +let _minted = 0; + +const _uuid = (): string => { + _minted += 1; + const hex = createHash("sha256").update(`${USERNAME}:${_minted}`).digest("hex").slice(0, 32).split(""); + hex[12] = "4"; + hex[16] = ((parseInt(hex[16], 16) & 0x3) | 0x8).toString(16); + const h = hex.join(""); + return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`; +}; + +const _slug = (title: string) => + title.toLowerCase().replace(/['’]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + +interface Seeded { + uuid: string; + title: string; + type: "note" | "checklist"; + cluster: Cluster; + kanban: boolean; +} + +const _href = (item: Seeded, legacy = false) => + legacy ? `/jotty/${item.uuid}` : `/${item.type}/${item.uuid}`; + +const _link = (item: Seeded, legacy = false) => `[${item.title}](${_href(item, legacy)})`; + +const _mention = (item: Seeded) => { + const roll = random(); + if (roll < 0.1) return _link(item, true); + if (roll < 0.3) return `[[${item.title}]]`; + if (roll < 0.36) return `[[${item.title.toLowerCase()}]]`; + if (roll < 0.42) return `[[${item.title}|this one]]`; + return _link(item); +}; + +const _category = (cluster: Cluster) => `${SEED_CATEGORY}/${cluster.category}`; + +const _plan = (): Seeded[] => { + const all: Seeded[] = []; + CLUSTERS.forEach((cluster) => { + cluster.notes.forEach((title) => all.push({ uuid: _uuid(), title, type: "note", cluster, kanban: false })); + cluster.lists.forEach((title, index) => + all.push({ uuid: _uuid(), title, type: "checklist", cluster, kanban: index % 2 === 1 }), + ); + }); + return all; +}; + +const _noteBody = (note: Seeded, all: Seeded[], index: number): string => { + const siblings = all.filter((item) => item.cluster === note.cluster && item.uuid !== note.uuid); + const others = all.filter((item) => item.cluster !== note.cluster); + const lines = [ + `Notes about ${note.title.toLowerCase()}. #${note.cluster.tag}`, + "", + `Related: ${randomSample(siblings, randomInt(1, 3)).map(_mention).join(", ")}.`, + ]; + + if (random() < 0.4) lines.push("", `Also see ${_mention(randomChoice(others))} for context.`); + if (random() < 0.25) lines.push("", `One day: [[${randomChoice(GHOSTS)}]].`); + if (random() < 0.3) lines.push("", `Tagged #${randomChoice(SHARED_TAGS)} for later.`); + if (random() < 0.5) lines.push("", `I keep coming back to ${randomChoice(others).title} when I think about this.`); + if (index % 7 === 0) { + lines.push("", "```md", `[not a real link](/note/${randomChoice(others).uuid}) and [[Not A Wikilink]]`, "```"); + } + return lines.join("\n"); +}; + +const _itemsFor = (list: Seeded, all: Seeded[]): Item[] => { + const notes = all.filter((item) => item.type === "note"); + const siblings = all.filter((item) => item.cluster === list.cluster && item.uuid !== list.uuid); + const count = randomInt(4, 8); + const now = new Date().toISOString(); + + return Array.from({ length: count }, (_, order) => { + const linked = random() < 0.35; + const target = linked ? randomChoice(random() < 0.7 ? siblings : notes) : null; + const text = target + ? `Check ${_link(target)} #${list.cluster.tag}` + : `${randomChoice(["Sort out", "Buy", "Book", "Call about", "Review", "Tidy"])} item ${order + 1}`; + const item: Item = { + id: `${list.uuid.slice(0, 8)}-${order}`, + text, + completed: random() < 0.3, + order, + createdAt: now, + }; + + if (list.kanban) { + item.status = randomChoice(KANBAN_STATUSES); + if (random() < 0.4) item.description = `Context in ${_link(randomChoice(notes))}`; + } else if (random() < 0.2) { + item.children = [ + { + id: `${item.id}-child`, + text: `Sub task, see ${_link(randomChoice(siblings))}`, + completed: false, + order: 0, + createdAt: now, + }, + ]; + } + return item; + }); +}; + +const _write = async ( + root: string, + category: string, + title: string, + content: string, + createdAt = new Date().toISOString(), +) => { + const dir = path.join(root, ...category.split("/")); + await fs.mkdir(dir, { recursive: true }); + const stamped = content.replace(/^---\n/, `---\ncreatedAt: "${createdAt}"\n`); + await fs.writeFile(path.join(dir, `${_slug(title)}.md`), stamped, "utf-8"); +}; + +const _withUsers = async (change: (users: Record[]) => Record[]) => { + const release = await lockfile.lock(USERS_FILE, { retries: { retries: 10, minTimeout: 100 } }); + try { + const users = JSON.parse(await fs.readFile(USERS_FILE, "utf-8")) as Record[]; + const tmp = `${USERS_FILE}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(change(users), null, 2), "utf-8"); + await fs.rename(tmp, USERS_FILE); + } finally { + await release(); + } +}; + +const _ensureUser = () => + _withUsers((users) => { + if (users.some((user) => user.username === USERNAME)) return users; + created = true; + const now = new Date().toISOString(); + return [ + ...users, + { + username: USERNAME, + passwordHash: createHash("sha256").update(PASSWORD).digest("hex"), + isAdmin: false, + createdAt: now, + lastLogin: now, + preferredDateFormat: "system", + preferredTimeFormat: "system", + handedness: "right-handed", + hideMobileStatusDropdown: "disable", + }, + ]; + }); + +let created = false; + +const _exists = (target: string) => + fs.access(target).then( + () => true, + () => false, + ); + +const _remove = async () => { + await fs.rm(NOTES_ROOT, { recursive: true, force: true }); + await fs.rm(LISTS_ROOT, { recursive: true, force: true }); + if (!(await _exists(CREATED_MARKER))) { + console.log(`Removed the ${SEED_CATEGORY} category from ${USERNAME}.`); + return; + } + await fs.rm(path.join(DATA_DIR, "notes", USERNAME), { recursive: true, force: true }); + await fs.rm(path.join(DATA_DIR, "checklists", USERNAME), { recursive: true, force: true }); + await _withUsers((users) => users.filter((user) => user.username !== USERNAME)); + console.log(`Removed ${USERNAME} and all of their items.`); +}; + +const _seed = async () => { + resetSeed(); + await fs.rm(NOTES_ROOT, { recursive: true, force: true }); + await fs.rm(LISTS_ROOT, { recursive: true, force: true }); + await _ensureUser(); + if (created) { + await fs.mkdir(path.dirname(CREATED_MARKER), { recursive: true }); + await fs.writeFile(CREATED_MARKER, "", "utf-8"); + } + + const all = _plan(); + const notes = all.filter((item) => item.type === "note"); + const lists = all.filter((item) => item.type === "checklist"); + const now = new Date().toISOString(); + + for (let index = 0; index < notes.length; index++) { + const seeded = notes[index]; + const note: Note = { + id: _slug(seeded.title), + uuid: seeded.uuid, + title: seeded.title, + content: _noteBody(seeded, all, index), + category: _category(seeded.cluster), + createdAt: now, + updatedAt: now, + owner: USERNAME, + tags: [seeded.cluster.tag], + }; + await _write(NOTES_ROOT, seeded.cluster.category, seeded.title, noteToMarkdown(note)); + } + + const hub: Note = { + id: "index-of-everything", + uuid: _uuid(), + title: "Index of everything", + content: ["Start here.", "", ...randomSample(all, 16).map((item) => `- ${_link(item)}`)].join("\n"), + category: `${SEED_CATEGORY}/Ideas`, + createdAt: now, + updatedAt: now, + owner: USERNAME, + tags: ["ideas", "review"], + }; + await _write(NOTES_ROOT, "Ideas", hub.title, noteToMarkdown(hub)); + + const secret: Note = { + id: "private-journal", + uuid: _uuid(), + title: "Private journal", + content: `-----BEGIN PGP MESSAGE-----\n\nhQEMA${"x".repeat(60)}\n[should never be indexed](/note/${notes[0].uuid})\n-----END PGP MESSAGE-----`, + category: `${SEED_CATEGORY}/Ideas`, + createdAt: now, + updatedAt: now, + owner: USERNAME, + encrypted: true, + encryptionMethod: "pgp", + }; + await _write(NOTES_ROOT, "Ideas", secret.title, noteToMarkdown(secret)); + + for (const loner of ["Loose thought one", "Loose thought two", "Loose thought three"]) { + const note: Note = { + id: _slug(loner), + uuid: _uuid(), + title: loner, + content: "Nothing links here yet.", + category: `${SEED_CATEGORY}/Ideas`, + createdAt: now, + updatedAt: now, + owner: USERNAME, + }; + await _write(NOTES_ROOT, "Ideas", loner, noteToMarkdown(note)); + } + + const twins = ["an old imported copy", "a newer copy"].map((body, index) => ({ + id: `weekly-review-${index}`, + uuid: _uuid(), + title: "Weekly review", + content: `This is ${body}. Wikilinks to [[Weekly review]] should land on the old one.`, + category: `${SEED_CATEGORY}/Ideas`, + createdAt: new Date(Date.now() - (index === 0 ? YEAR_MS : 0)).toISOString(), + updatedAt: now, + owner: USERNAME, + })); + for (const twin of twins) { + await _write(NOTES_ROOT, "Ideas", twin.id, noteToMarkdown(twin as Note), twin.createdAt); + } + + const renamer: Note = { + id: "rename-playground", + uuid: _uuid(), + title: "Rename playground", + content: [ + "Rename the targets of these links, then save this note and watch the text follow.", + "", + `- [[${notes[0].title}]]`, + `- [[${notes[1].title}#Details|with an alias]]`, + `- [[Weekly review]]`, + "", + `Plain mention for the unlinked mentions panel: ${notes[2].title}.`, + ].join("\n"), + category: `${SEED_CATEGORY}/Ideas`, + createdAt: now, + updatedAt: now, + owner: USERNAME, + }; + await _write(NOTES_ROOT, "Ideas", renamer.title, noteToMarkdown(renamer)); + + for (const seeded of lists) { + const list: Checklist = { + id: _slug(seeded.title), + uuid: seeded.uuid, + title: seeded.title, + type: seeded.kanban ? ChecklistsTypes.KANBAN : ChecklistsTypes.SIMPLE, + category: _category(seeded.cluster), + items: _itemsFor(seeded, all), + createdAt: now, + updatedAt: now, + owner: USERNAME, + tags: [seeded.cluster.tag], + } as Checklist; + await _write(LISTS_ROOT, seeded.cluster.category, seeded.title, listToMarkdown(list)); + } + + console.log( + `Seeded ${USERNAME} under ${SEED_CATEGORY}: ${notes.length + 8} notes, ${lists.length} checklists. ` + + "Rebuild relations from Admin > Content, or restart the dev server, to index them.", + ); +}; + +(process.argv.includes("--remove") ? _remove() : _seed()).catch((error) => { + console.error("brain seed failed:", error); + process.exit(1); +}); diff --git a/tests/security/auth-required.test.ts b/tests/security/auth-required.test.ts index 6eead4366..3f9f140cc 100644 --- a/tests/security/auth-required.test.ts +++ b/tests/security/auth-required.test.ts @@ -54,16 +54,6 @@ vi.mock('@/app/_server/actions/log', () => ({ logUserEvent: vi.fn(), })) -vi.mock('@/app/_server/actions/link', async (importOriginal) => { - const actual = await importOriginal() as any - return { - ...actual, - parseInternalLinks: vi.fn().mockResolvedValue([]), - updateIndexForItem: vi.fn(), - removeItemFromIndex: vi.fn(), - } -}) - vi.mock('@/app/_server/actions/ws/broadcast', () => ({ broadcast: vi.fn().mockResolvedValue(undefined), })) @@ -298,10 +288,29 @@ describe('Security: Authentication Required', () => { expect(result).toEqual({ success: true }) }) - it('rebuildLinkIndex should reject unauthenticated requests', async () => { - const { rebuildLinkIndex } = await import('@/app/_server/actions/link') + it('reindexRelations should reject unauthenticated requests', async () => { + const { reindexRelations } = await import('@/app/_server/actions/relations') + + await expect(reindexRelations('anyuser')).resolves.toEqual({ + success: false, + error: 'Not authenticated', + }) + }) + + it('getBrain should reject unauthenticated requests', async () => { + const { getBrain } = await import('@/app/_server/actions/relations') + + await expect(getBrain('anyuser')).resolves.toEqual({ + success: false, + error: 'Not authenticated', + }) + }) + + it('getItemRelations should return nothing to unauthenticated requests', async () => { + const { getItemRelations } = await import('@/app/_server/actions/relations') - await expect(rebuildLinkIndex('anyuser')).rejects.toThrow('Not authenticated') + const result = await getItemRelations('3f2a1b4c-1111-4222-8333-944455556666') + expect(result.backlinks).toEqual([]) }) }) diff --git a/tests/server-actions/category.test.ts b/tests/server-actions/category.test.ts index 7b1107dbd..7c4539c36 100644 --- a/tests/server-actions/category.test.ts +++ b/tests/server-actions/category.test.ts @@ -21,6 +21,7 @@ const mockCatAccess = vi.fn() vi.mock('@/app/_server/actions/file', () => ({ ensureDir: (...args: any[]) => mockEnsureDir(...args), serverDeleteDir: (...args: any[]) => mockServerDeleteDir(...args), + serverRenamePath: (from: string, to: string) => mockFs.rename(from, to), getUserModeDir: (...args: any[]) => mockGetUserModeDir(...args), readOrderFile: (...args: any[]) => mockReadOrderFile(...args), writeOrderFile: (...args: any[]) => mockWriteOrderFile(...args), diff --git a/tests/server-actions/checklist.test.ts b/tests/server-actions/checklist.test.ts index 574581c03..8d3ed1a4f 100644 --- a/tests/server-actions/checklist.test.ts +++ b/tests/server-actions/checklist.test.ts @@ -16,9 +16,6 @@ const mockReadOrderFile = vi.fn(); const mockGetCurrentUser = vi.fn(); const mockCheckUserPermission = vi.fn(); const mockLogContentEvent = vi.fn(); -const mockParseInternalLinks = vi.fn(); -const mockUpdateIndexForItem = vi.fn(); -const mockRemoveItemFromIndex = vi.fn(); vi.mock("@/app/_server/actions/file", () => ({ getUserModeDir: (...args: any[]) => mockGetUserModeDir(...args), @@ -49,15 +46,6 @@ vi.mock("@/app/_server/actions/log", () => ({ logContentEvent: (...args: any[]) => mockLogContentEvent(...args), })); -vi.mock("@/app/_server/actions/link", () => ({ - parseInternalLinks: (...args: any[]) => mockParseInternalLinks(...args), - updateIndexForItem: (...args: any[]) => mockUpdateIndexForItem(...args), - removeItemFromIndex: (...args: any[]) => mockRemoveItemFromIndex(...args), - rebuildLinkIndex: vi.fn().mockResolvedValue(undefined), - rebuildLinkIndexInternal: vi.fn().mockResolvedValue(undefined), - updateItemCategory: vi.fn().mockResolvedValue(undefined), -})); - vi.mock("@/app/_utils/checklist-utils", () => ({ parseMarkdown: vi.fn().mockReturnValue({ id: "test-list", @@ -109,9 +97,6 @@ describe("Checklist Actions", () => { }); mockCheckUserPermission.mockResolvedValue(true); mockLogContentEvent.mockResolvedValue(undefined); - mockParseInternalLinks.mockResolvedValue([]); - mockUpdateIndexForItem.mockResolvedValue(undefined); - mockRemoveItemFromIndex.mockResolvedValue(undefined); }); describe("createList", () => { @@ -177,18 +162,6 @@ describe("Checklist Actions", () => { ); }); - it("should update link index after creation", async () => { - const formData = createFormData({ - title: "Linked List", - category: "TestCategory", - type: "simple", - }); - - await createList(formData); - - expect(mockUpdateIndexForItem).toHaveBeenCalled(); - }); - it("should handle creation errors gracefully", async () => { mockServerWriteFile.mockRejectedValue(new Error("Write failed")); diff --git a/tests/server-actions/file.test.ts b/tests/server-actions/file.test.ts index ccfdb83dc..2a70f89c9 100644 --- a/tests/server-actions/file.test.ts +++ b/tests/server-actions/file.test.ts @@ -223,13 +223,27 @@ describe("File Actions", () => { mockFs.mkdir.mockResolvedValue(undefined); mockFs.writeFile.mockResolvedValue(undefined); + mockFs.rename.mockResolvedValue(undefined); + await serverWriteFile("/path/to/file.txt", "content"); - expect(mockFs.writeFile).toHaveBeenCalledWith( - "/path/to/file.txt", - "content", - "utf-8", - ); + const [tmpPath, written, encoding] = mockFs.writeFile.mock.calls[0]; + expect(tmpPath).toMatch(/^\/path\/to\/\.file\.txt\..+\.tmp$/); + expect(written).toBe("content"); + expect(encoding).toBe("utf-8"); + expect(mockFs.rename).toHaveBeenCalledWith(tmpPath, "/path/to/file.txt"); + }); + + it("should clean up the temp file and rethrow when the rename fails", async () => { + mockFs.access.mockResolvedValue(undefined); + mockFs.writeFile.mockResolvedValue(undefined); + mockFs.rename.mockRejectedValue(new Error("EXDEV")); + mockFs.unlink.mockResolvedValue(undefined); + + await expect(serverWriteFile("/path/to/file.txt", "content")).rejects.toThrow("EXDEV"); + + const [tmpPath] = mockFs.writeFile.mock.calls[0]; + expect(mockFs.unlink).toHaveBeenCalledWith(tmpPath); }); }); diff --git a/tests/server-actions/link-index.test.ts b/tests/server-actions/link-index.test.ts deleted file mode 100644 index f29e88d2c..000000000 --- a/tests/server-actions/link-index.test.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { resetAllMocks } from "../setup"; - -const mockGetCurrentUser = vi.fn(); -const mockGetUserNotes = vi.fn(); -const mockGetUserChecklists = vi.fn(); -const mockGetUserModeDir = vi.fn(); -const mockServerReadFile = vi.fn(); -const mockServerWriteFile = vi.fn(); - -vi.mock("@/app/_server/actions/users", () => ({ - getCurrentUser: (...args: any[]) => mockGetCurrentUser(...args), -})); - -vi.mock("@/app/_server/actions/note", () => ({ - getUserNotes: (...args: any[]) => mockGetUserNotes(...args), -})); - -vi.mock("@/app/_server/actions/checklist", () => ({ - getUserChecklists: (...args: any[]) => mockGetUserChecklists(...args), -})); - -vi.mock("@/app/_server/actions/file", () => ({ - getUserModeDir: (...args: any[]) => mockGetUserModeDir(...args), - serverReadFile: (...args: any[]) => mockServerReadFile(...args), - serverWriteFile: (...args: any[]) => mockServerWriteFile(...args), -})); - -import { - rebuildLinkIndex, - rebuildLinkIndexInternal, -} from "@/app/_server/actions/link"; - -describe("link/index — auth-aware rebuild paths", () => { - beforeEach(() => { - resetAllMocks(); - mockGetUserModeDir.mockResolvedValue("/fake/notes"); - mockServerWriteFile.mockResolvedValue(undefined); - mockGetUserNotes.mockResolvedValue({ success: true, data: [] }); - mockGetUserChecklists.mockResolvedValue({ success: true, data: [] }); - }); - - describe("rebuildLinkIndex (public)", () => { - it("throws when not authenticated", async () => { - mockGetCurrentUser.mockResolvedValue(null); - - await expect(rebuildLinkIndex("alice")).rejects.toThrow( - "Not authenticated", - ); - expect(mockGetUserNotes).not.toHaveBeenCalled(); - }); - - it("throws when the caller is neither the owner nor an admin", async () => { - mockGetCurrentUser.mockResolvedValue({ - username: "bob", - isAdmin: false, - }); - - await expect(rebuildLinkIndex("alice")).rejects.toThrow( - "Unauthorized: can only rebuild your own link index", - ); - expect(mockGetUserNotes).not.toHaveBeenCalled(); - }); - - it("proceeds for the owner", async () => { - mockGetCurrentUser.mockResolvedValue({ - username: "alice", - isAdmin: false, - }); - - await expect(rebuildLinkIndex("alice")).resolves.toBeUndefined(); - expect(mockGetUserNotes).toHaveBeenCalledWith({ username: "alice" }); - }); - - it("proceeds for an admin rebuilding another user's index", async () => { - mockGetCurrentUser.mockResolvedValue({ - username: "admin", - isAdmin: true, - }); - - await expect(rebuildLinkIndex("alice")).resolves.toBeUndefined(); - expect(mockGetUserNotes).toHaveBeenCalledWith({ username: "alice" }); - }); - }); - - describe("rebuildLinkIndexInternal", () => { - it("skips the auth check entirely and rebuilds for the given owner", async () => { - mockGetCurrentUser.mockResolvedValue(null); - - await expect( - rebuildLinkIndexInternal("someone-else"), - ).resolves.toBeUndefined(); - - expect(mockGetCurrentUser).not.toHaveBeenCalled(); - expect(mockGetUserNotes).toHaveBeenCalledWith({ - username: "someone-else", - }); - expect(mockGetUserChecklists).toHaveBeenCalledWith({ - username: "someone-else", - }); - expect(mockServerWriteFile).toHaveBeenCalledTimes(1); - }); - }); -}); \ No newline at end of file diff --git a/tests/server-actions/note-readers.test.ts b/tests/server-actions/note-readers.test.ts index e75c9a980..e85c936bd 100644 --- a/tests/server-actions/note-readers.test.ts +++ b/tests/server-actions/note-readers.test.ts @@ -10,6 +10,7 @@ const mockGrepExcerpt = vi.fn(); vi.mock("@/app/_server/actions/file", () => ({ serverReadDir: (...args: any[]) => mockServerReadDir(...args), serverReadFile: (...args: any[]) => mockServerReadFile(...args), + serverWriteFile: (...args: any[]) => mockFs.writeFile(...args), readOrderFile: (...args: any[]) => mockReadOrderFile(...args), })); @@ -53,6 +54,11 @@ describe("readNotesRecursively uuid contract", () => { mtime: new Date("2024-01-02T00:00:00.000Z"), }); mockFs.writeFile.mockResolvedValue(undefined); + mockFs.readFile.mockImplementation(async (filePath: string) => { + const content = await mockServerReadFile(filePath); + if (content == null) throw new Error("ENOENT"); + return content; + }); }); it("should stamp and return a uuid when the note has none", async () => { diff --git a/tests/server-actions/note.test.ts b/tests/server-actions/note.test.ts index 765e2d516..4afcbe3c8 100644 --- a/tests/server-actions/note.test.ts +++ b/tests/server-actions/note.test.ts @@ -12,9 +12,7 @@ const mockGetCurrentUser = vi.fn(); const mockGetUsername = vi.fn(); const mockCanReach = vi.fn(); const mockLogContentEvent = vi.fn(); -const mockParseInternalLinks = vi.fn(); -const mockUpdateIndexForItem = vi.fn(); -const mockRemoveItemFromIndex = vi.fn(); +const mockTidyItemLinks = vi.fn(); const mockCommitNote = vi.fn(); const mockGetSettings = vi.fn(); const mockExtractHashtagsFromContent = vi.fn(); @@ -72,12 +70,9 @@ vi.mock("@/app/_server/actions/log", () => ({ logContentEvent: (...args: any[]) => mockLogContentEvent(...args), })); -vi.mock("@/app/_server/actions/link", () => ({ - parseInternalLinks: (...args: any[]) => mockParseInternalLinks(...args), - updateIndexForItem: (...args: any[]) => mockUpdateIndexForItem(...args), - removeItemFromIndex: (...args: any[]) => mockRemoveItemFromIndex(...args), - rebuildLinkIndex: vi.fn().mockResolvedValue(undefined), - rebuildLinkIndexInternal: vi.fn().mockResolvedValue(undefined), +vi.mock("@/app/_server/actions/relations/tidy", () => ({ + tidyItemLinks: (...args: any[]) => mockTidyItemLinks(...args), + refreshWikilinks: (markdown: string) => markdown, })); vi.mock("@/app/_server/actions/history", () => ({ @@ -197,9 +192,7 @@ describe("Note Actions", () => { }, ); mockLogContentEvent.mockResolvedValue(undefined); - mockParseInternalLinks.mockResolvedValue([]); - mockUpdateIndexForItem.mockResolvedValue(undefined); - mockRemoveItemFromIndex.mockResolvedValue(undefined); + mockTidyItemLinks.mockImplementation(async (content: string) => content); mockCommitNote.mockResolvedValue(undefined); mockGetSettings.mockResolvedValue({}); mockExtractHashtagsFromContent.mockReturnValue([]); @@ -268,16 +261,22 @@ describe("Note Actions", () => { ); }); - it("should update link index after creation", async () => { + it("should canonicalise internal links before saving", async () => { const formData = createFormData({ title: "Linked Note", category: "TestCategory", - rawContent: "Content with [[link]]", + rawContent: "See [Old](/jotty/3f2a1b4c-1111-4222-8333-944455556666)", }); + mockTidyItemLinks.mockResolvedValueOnce("tidied body"); + await createNote(formData); - expect(mockUpdateIndexForItem).toHaveBeenCalled(); + expect(mockTidyItemLinks).toHaveBeenCalledWith(expect.any(String), "testuser"); + expect(mockServerWriteFile).toHaveBeenCalledWith( + expect.any(String), + expect.stringContaining("tidied body"), + ); }); it("should commit note to history", async () => { diff --git a/tests/server-actions/relations.test.ts b/tests/server-actions/relations.test.ts new file mode 100644 index 000000000..f012cea60 --- /dev/null +++ b/tests/server-actions/relations.test.ts @@ -0,0 +1,459 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import nodeFs from "fs"; +import os from "os"; +import path from "path"; +import { mockFs, resetAllMocks } from "../setup"; +import { ItemTypes } from "@/app/_types/enums"; +import { LinkKinds, RelationsStatus } from "@/app/_consts/relations"; + +vi.unmock("unified"); +vi.unmock("unist-util-visit"); + +const mockBroadcast = vi.fn(); + +vi.mock("@/app/_server/actions/ws/broadcast", () => ({ + broadcast: (...args: unknown[]) => mockBroadcast(...args), +})); + +vi.mock("@/app/_server/actions/note", () => ({ getUserNotes: vi.fn() })); +vi.mock("@/app/_server/actions/checklist", () => ({ getUserChecklists: vi.fn() })); + +import { readLinks } from "@/app/_server/actions/relations/parser"; +import { + forgetItemFile, + forgetItemTree, + indexItemFile, + reconcileRelations, +} from "@/app/_server/actions/relations/indexer"; +import { backlinksFor, graphFor, VisibleItem } from "@/app/_server/actions/relations/queries"; +import { + closeRelationsDb, + relationsStatus, + setRelationsStatus, +} from "@/app/_server/actions/relations/store"; +import { stampUuid } from "@/app/_server/actions/lib/stamp-uuid"; +import { refreshWikilinks, wrapMention } from "@/app/_server/actions/relations/tidy"; + +const A = "aaaaaaaa-1111-4222-8333-944455556666"; +const B = "bbbbbbbb-1111-4222-8333-944455556666"; +const C = "cccccccc-1111-4222-8333-944455556666"; +const SECRET = "dddddddd-1111-4222-8333-944455556666"; + +let root: string; + +const itemPath = (mode: string, owner: string, rel: string) => + path.join(root, "data", mode, owner, rel); + +const OLD = "createdAt: 2020-01-01T00:00:00.000Z\n"; +const NEW = "createdAt: 2026-01-01T00:00:00.000Z\n"; + +const note = (uuid: string, title: string, body: string, extra = "") => + `---\nuuid: ${uuid}\ntitle: ${title}\n${extra}---\n\n${body}`; + +const write = (filePath: string, content: string) => { + nodeFs.mkdirSync(path.dirname(filePath), { recursive: true }); + nodeFs.writeFileSync(filePath, content); + indexItemFile(filePath, content, Math.floor(nodeFs.statSync(filePath).mtimeMs)); +}; + +const visibleOf = (items: Array & { uuid: string; title: string }>) => + new Map( + items.map((item) => [ + item.uuid, + { + type: ItemTypes.NOTE, + category: "Uncategorized", + owner: "alice", + tags: [], + ...item, + } as VisibleItem, + ]), + ); + +const useRealFs = async () => { + const real = await vi.importActual("fs/promises"); + mockFs.readFile.mockImplementation(real.readFile as never); + mockFs.writeFile.mockImplementation(real.writeFile as never); + mockFs.stat.mockImplementation(real.stat as never); + mockFs.rename.mockImplementation(real.rename as never); + mockFs.unlink.mockImplementation(real.unlink as never); + mockFs.mkdir.mockImplementation(real.mkdir as never); + mockFs.access.mockImplementation(real.access as never); +}; + +describe("relations", () => { + beforeEach(async () => { + resetAllMocks(); + await useRealFs(); + root = nodeFs.mkdtempSync(path.join(os.tmpdir(), "jotty-relations-")); + vi.spyOn(process, "cwd").mockReturnValue(root); + process.env.JOTTY_RELATIONS_DB = path.join(root, "relations.db"); + closeRelationsDb(); + setRelationsStatus(RelationsStatus.READY); + }); + + afterEach(() => { + closeRelationsDb(); + vi.restoreAllMocks(); + nodeFs.rmSync(root, { recursive: true, force: true }); + }); + + describe("readLinks", () => { + it("reads canonical, legacy and same-origin links and ignores code", () => { + const parsed = readLinks( + [ + `[Shop [x]](/note/${A}) [Old](/jotty/${B})`, + `[Path](/checklist/Work/groceries) https://jotty.example/checklist/${C}`, + "```", + `[Code](/note/${SECRET}) [[Hidden]]`, + "```", + "`[[inline]]` [[Milk run|errand]] \\[\\[Escaped\\]\\]", + `[Elsewhere](https://other.example/note/${SECRET})`, + ].join("\n"), + ["https://jotty.example"], + ); + + expect(parsed.targets).toEqual([ + { uuid: A, type: ItemTypes.NOTE }, + { uuid: B, type: undefined }, + { type: ItemTypes.CHECKLIST, legacy: { category: "Work", id: "groceries" } }, + { uuid: C, type: ItemTypes.CHECKLIST }, + ]); + expect(parsed.wikis).toEqual(["Milk run", "Escaped"]); + }); + }); + + describe("backlinks", () => { + it("keeps an item's own backlinks when that item is saved again", () => { + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", `see [Beta](/note/${B})`)); + write(itemPath("notes", "alice", "b.md"), note(B, "Beta", "first")); + write(itemPath("notes", "alice", "b.md"), note(B, "Beta", "edited")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Beta" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + }); + + it("gives a checklist its backlinks from a note, even through a /jotty/ link", () => { + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", `[List](/jotty/${C})`)); + write(itemPath("checklists", "alice", "list.md"), note(C, "List", "- [ ] milk")); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: C, title: "List", type: ItemTypes.CHECKLIST }, + ]); + expect(backlinksFor(C, visible).backlinks).toMatchObject([ + { uuid: A, kind: LinkKinds.MENTION }, + ]); + }); + + it("indexes links inside kanban descriptions and sub-items", () => { + write( + itemPath("checklists", "alice", "board.md"), + note(C, "Board", `- [ ] task | status:todo | description:see [Alpha](/note/${A})`), + ); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: C, title: "Board", type: ItemTypes.CHECKLIST }, + ]); + expect(backlinksFor(A, visible).backlinks.map((item) => item.uuid)).toEqual([C]); + }); + + it("never reads links out of an encrypted note", () => { + write( + itemPath("notes", "alice", "secret.md"), + note(SECRET, "Secret", `[Alpha](/note/${A})`, "encrypted: true\n"), + ); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: SECRET, title: "Secret" }, + ]); + expect(backlinksFor(A, visible).backlinks).toEqual([]); + }); + + it("hides backlinks from items the viewer cannot see", () => { + write(itemPath("notes", "bob", "private.md"), note(SECRET, "Private", `[Alpha](/note/${A})`)); + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", "hello")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }]); + expect(backlinksFor(A, visible).backlinks).toEqual([]); + }); + + it("survives a rename written before the old file is removed", () => { + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", `[Beta](/note/${B})`)); + write(itemPath("notes", "alice", "Work/a-renamed.md"), note(A, "Alpha", `[Beta](/note/${B})`)); + forgetItemFile(itemPath("notes", "alice", "a.md")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Beta" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + }); + + it("drops everything under a deleted folder", () => { + write(itemPath("notes", "alice", "Work/a.md"), note(A, "Alpha", `[Beta](/note/${B})`)); + forgetItemTree(itemPath("notes", "alice", "Work")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Beta" }]); + expect(backlinksFor(B, visible).backlinks).toEqual([]); + }); + + it("counts a [[wikilink]] as a backlink once the title exists", () => { + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", "going on a [[milk run]]")); + write(itemPath("notes", "alice", "b.md"), note(B, "Milk Run", "eggs")); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: B, title: "Milk Run" }, + ]); + expect(backlinksFor(B, visible).backlinks).toMatchObject([ + { uuid: A, kind: LinkKinds.WIKI }, + ]); + }); + }); + + describe("graphFor", () => { + it("draws ghosts for unresolved wikilinks and hubs for tags", () => { + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", `[[Someday]] [Beta](/note/${B})`)); + + const graph = graphFor( + "alice", + visibleOf([ + { uuid: A, title: "Alpha", tags: ["home"] }, + { uuid: B, title: "Beta", tags: ["home"] }, + ]), + ); + + expect(graph.nodes.map((node) => node.id).sort()).toEqual( + [A, B, "ghost:someday", "tag:home"].sort(), + ); + expect(graph.edges).toHaveLength(4); + expect(graph.nodes.find((node) => node.id === "ghost:someday")?.title).toBe("Someday"); + }); + + it("leaves out links whose source the viewer cannot see", () => { + write(itemPath("notes", "bob", "private.md"), note(SECRET, "Private", `[Alpha](/note/${A})`)); + + const graph = graphFor("alice", visibleOf([{ uuid: A, title: "Alpha" }])); + expect(graph.edges).toEqual([]); + expect(graph.nodes.map((node) => node.id)).toEqual([A]); + }); + }); + + describe("wikilink bindings", () => { + const aPath = () => itemPath("notes", "alice", "a.md"); + const bPath = () => itemPath("notes", "alice", "b.md"); + const all = () => + visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: B, title: "Plan" }, + { uuid: C, title: "Plan" }, + ]); + + it("keeps a wikilink on its target after the target is renamed", () => { + write(bPath(), note(B, "Plan", "target")); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + write(bPath(), note(B, "Q3 plan", "renamed")); + write(aPath(), note(A, "Alpha", "see [[Plan]] again")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Q3 plan" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + expect(backlinksFor(A, visible).wikis).toEqual({ plan: B }); + }); + + it("does not let a newer item with the same title steal the link", () => { + write(bPath(), note(B, "Plan", "old", OLD)); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + write(itemPath("notes", "alice", "c.md"), note(C, "Plan", "new", NEW)); + + expect(backlinksFor(B, all()).backlinks.map((item) => item.uuid)).toEqual([A]); + expect(backlinksFor(C, all()).backlinks).toEqual([]); + }); + + it("picks the oldest item when a title is ambiguous from the start", () => { + write(itemPath("notes", "alice", "c.md"), note(C, "Plan", "new", NEW)); + write(bPath(), note(B, "Plan", "old", OLD)); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + + expect(backlinksFor(A, all()).wikis).toEqual({ plan: B }); + }); + + it("moves to the next match when the bound target is deleted", () => { + write(bPath(), note(B, "Plan", "old", OLD)); + write(itemPath("notes", "alice", "c.md"), note(C, "Plan", "new", NEW)); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + + forgetItemFile(bPath()); + expect(backlinksFor(A, all()).wikis).toEqual({ plan: C }); + }); + + it("binds a ghost once a note with that title shows up", () => { + write(aPath(), note(A, "Alpha", "one day [[Plan]]")); + expect(graphFor("alice", all()).nodes.some((node) => node.id === "ghost:plan")).toBe(true); + + write(bPath(), note(B, "Plan", "now it exists")); + expect(backlinksFor(B, all()).backlinks.map((item) => item.uuid)).toEqual([A]); + expect(graphFor("alice", all()).nodes.some((node) => node.id === "ghost:plan")).toBe(false); + }); + + it("retargets a link when its text is edited", () => { + write(bPath(), note(B, "Plan", "b")); + write(itemPath("notes", "alice", "c.md"), note(C, "Trip", "c")); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + write(aPath(), note(A, "Alpha", "see [[Trip]]")); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: B, title: "Plan" }, + { uuid: C, title: "Trip" }, + ]); + expect(backlinksFor(B, visible).backlinks).toEqual([]); + expect(backlinksFor(C, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + }); + + it("keeps bindings through a full rebuild", async () => { + write(bPath(), note(B, "Plan", "b")); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + write(bPath(), note(B, "Renamed", "b")); + + const { rebuildRelations } = await import("@/app/_server/actions/relations/indexer"); + await rebuildRelations(); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Renamed" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + }); + }); + + describe("refreshWikilinks", () => { + it("rewrites renamed targets on save, keeping headings and aliases", () => { + write(bPath(), note(B, "Plan", "b")); + const body = "see [[Plan]], [[plan#Budget|the money]] and `[[Plan]]` in code"; + write(aPath(), note(A, "Alpha", body)); + write(bPath(), note(B, "Q3 plan", "b")); + + const saved = refreshWikilinks(body, A); + expect(saved).toBe("see [[Q3 plan]], [[Q3 plan#Budget|the money]] and `[[Plan]]` in code"); + + write(aPath(), note(A, "Alpha", saved)); + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Q3 plan" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + expect(backlinksFor(A, visible).wikis).toEqual({ "q3 plan": B }); + }); + + it("survives a stale save that still carries the old text", () => { + write(bPath(), note(B, "Plan", "b")); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + write(bPath(), note(B, "Q3 plan", "b")); + write(aPath(), note(A, "Alpha", refreshWikilinks("see [[Plan]]", A))); + write(aPath(), note(A, "Alpha", "see [[Plan]]")); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Q3 plan" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + }); + + it("leaves notes alone when nothing was renamed", () => { + write(bPath(), note(B, "Plan", "b")); + write(aPath(), note(A, "Alpha", "see [[plan]]")); + expect(refreshWikilinks("see [[plan]]", A)).toBe("see [[plan]]"); + }); + + const aPath = () => itemPath("notes", "alice", "a.md"); + const bPath = () => itemPath("notes", "alice", "b.md"); + }); + + describe("unlinked mentions", () => { + it("lists notes that name the item in plain text but do not link it", () => { + write(itemPath("notes", "alice", "b.md"), note(B, "Milk Run", "target")); + write(itemPath("notes", "alice", "a.md"), note(A, "Alpha", "Tomorrow is the milk run, again.")); + write(itemPath("notes", "alice", "c.md"), note(C, "Gamma", `already linked [Milk Run](/note/${B})`)); + write(itemPath("notes", "alice", "s.md"), note(SECRET, "Skimmed", "a milkrun is not it")); + + const visible = visibleOf([ + { uuid: A, title: "Alpha" }, + { uuid: B, title: "Milk Run" }, + { uuid: C, title: "Gamma" }, + { uuid: SECRET, title: "Skimmed" }, + ]); + const { mentions } = backlinksFor(B, visible); + expect(mentions.map((item) => item.uuid)).toEqual([A]); + expect(mentions[0].snippet).toContain("the milk run, again"); + }); + + it("never reads encrypted notes", () => { + write(itemPath("notes", "alice", "b.md"), note(B, "Milk Run", "target")); + write( + itemPath("notes", "alice", "s.md"), + note(SECRET, "Secret", "milk run", "encrypted: true\n"), + ); + const visible = visibleOf([ + { uuid: B, title: "Milk Run" }, + { uuid: SECRET, title: "Secret" }, + ]); + expect(backlinksFor(B, visible).mentions).toEqual([]); + }); + + it("wraps the first plain mention, skipping code, links and wikilinks", () => { + const body = "`Milk Run` and [[Milk Run]] and [Milk Run](/x) then the milk run itself"; + expect(wrapMention(body, "Milk Run", `/note/${B}`)).toBe( + `\`Milk Run\` and [[Milk Run]] and [Milk Run](/x) then the [milk run](/note/${B}) itself`, + ); + expect(wrapMention("nothing here", "Milk Run", `/note/${B}`)).toBeNull(); + }); + }); + + describe("reconcileRelations", () => { + it("builds from scratch, then forgets files that disappeared", async () => { + const aPath = itemPath("notes", "alice", "a.md"); + nodeFs.mkdirSync(path.dirname(aPath), { recursive: true }); + nodeFs.writeFileSync(aPath, note(A, "Alpha", `[Beta](/note/${B})`)); + nodeFs.writeFileSync(itemPath("notes", "alice", "b.md"), note(B, "Beta", "hi")); + + setRelationsStatus(RelationsStatus.BUILDING); + await reconcileRelations(); + expect(relationsStatus()).toBe(RelationsStatus.READY); + expect(mockBroadcast).toHaveBeenCalledWith(expect.objectContaining({ type: "relations" })); + + const visible = visibleOf([{ uuid: A, title: "Alpha" }, { uuid: B, title: "Beta" }]); + expect(backlinksFor(B, visible).backlinks.map((item) => item.uuid)).toEqual([A]); + + nodeFs.rmSync(aPath); + await reconcileRelations(); + expect(backlinksFor(B, visible).backlinks).toEqual([]); + }); + }); + + describe("stampUuid", () => { + it("refuses to stamp an empty read instead of writing a stub", async () => { + const filePath = itemPath("notes", "alice", "empty.md"); + nodeFs.mkdirSync(path.dirname(filePath), { recursive: true }); + nodeFs.writeFileSync(filePath, ""); + + await expect(stampUuid(filePath)).resolves.toBeUndefined(); + expect(nodeFs.readFileSync(filePath, "utf-8")).toBe(""); + }); + + it("refuses to stamp frontmatter it could not parse", async () => { + const filePath = itemPath("notes", "alice", "broken.md"); + const broken = "---\ntitle: [unclosed\nfoo: bar\n---\n\nprecious body"; + nodeFs.mkdirSync(path.dirname(filePath), { recursive: true }); + nodeFs.writeFileSync(filePath, broken); + + await expect(stampUuid(filePath)).resolves.toBeUndefined(); + expect(nodeFs.readFileSync(filePath, "utf-8")).toBe(broken); + }); + + it("stamps a uuid without touching the rest of the file", async () => { + const filePath = itemPath("notes", "alice", "plain.md"); + nodeFs.mkdirSync(path.dirname(filePath), { recursive: true }); + nodeFs.writeFileSync(filePath, "---\ntitle: Plain\n---\n\nbody stays"); + + const uuid = await stampUuid(filePath); + const written = nodeFs.readFileSync(filePath, "utf-8"); + + expect(uuid).toMatch(/^[0-9a-f-]{36}$/); + expect(written).toContain(`uuid: ${uuid}\n`); + expect(written).toContain("title: Plain\n"); + expect(written).toContain("createdAt:"); + expect(written.endsWith("body stays")).toBe(true); + }); + }); +}); diff --git a/tests/server-actions/share-visibility.test.ts b/tests/server-actions/share-visibility.test.ts new file mode 100644 index 000000000..dcccf3156 --- /dev/null +++ b/tests/server-actions/share-visibility.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import path from "path"; +import { mkdirSync, rmSync, writeFileSync } from "fs"; + +const { home, root } = vi.hoisted(() => { + const os = process.getBuiltinModule("os"); + const fs = process.getBuiltinModule("fs"); + const nodePath = process.getBuiltinModule("path"); + const previous = process.cwd(); + + process.chdir(fs.mkdtempSync(nodePath.join(os.tmpdir(), "jotty-share-visibility-"))); + + return { home: previous, root: process.cwd() }; +}); + +vi.unmock("fs/promises"); +vi.unmock("@/app/_utils/checklist-utils"); + +vi.mock("@/app/_server/actions/ws/broadcast", () => ({ + broadcast: vi.fn(), +})); + +vi.mock("@/app/_server/actions/users", async (importOriginal) => ({ + ...(await importOriginal()), + canAccessAllContent: async () => false, +})); + +import { getCategories } from "@/app/_server/actions/category/queries"; +import { getUserChecklists } from "@/app/_server/actions/checklist/queries"; +import { getUserNotes } from "@/app/_server/actions/note/queries"; +import { dropMounts, mountsFor } from "@/app/_server/actions/share/mounts"; +import { canReach } from "@/app/_server/actions/share/queries"; +import { + ItemTypes, + Modes, + PermissionTypes, +} from "@/app/_types/enums"; +import { DATA_DIR } from "@/app/_consts/files"; +import { CATEGORY_INFO_FILE } from "@/app/_consts/sharing"; + +const READ_ONLY = { canRead: true, canEdit: false, canDelete: false }; +const FULL = { canRead: true, canEdit: true, canDelete: true }; + +const LIST_FULL = "11111111-1111-4111-8111-111111111111"; +const LIST_READ = "22222222-2222-4222-8222-222222222222"; +const NOTE_FULL = "33333333-3333-4333-8333-333333333333"; +const NOTE_READ = "44444444-4444-4444-8444-444444444444"; +const NOTE_LISTED = "55555555-5555-4555-8555-555555555555"; + +const modeDir = (mode: Modes, owner: string, category = ""): string => + path.join(root, DATA_DIR, mode, owner, category); + +const writeItem = ( + mode: Modes, + owner: string, + category: string, + name: string, + frontmatter: string[], + body = "- [ ] milk", +) => { + const dir = modeDir(mode, owner, category); + mkdirSync(dir, { recursive: true }); + writeFileSync( + path.join(dir, `${name}.md`), + ["---", ...frontmatter, "---", body, ""].join("\n"), + ); +}; + +const writeFolder = ( + mode: Modes, + owner: string, + category: string, + info: Record, +) => { + const dir = modeDir(mode, owner, category); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, CATEGORY_INFO_FILE), JSON.stringify(info)); +}; + +const permsOf = (items: { uuid?: string; permissions?: unknown }[]) => + Object.fromEntries(items.map((item) => [item.uuid, item.permissions])); + +afterAll(() => { + process.chdir(home); + rmSync(root, { recursive: true, force: true }); +}); + +beforeEach(() => { + rmSync(path.join(root, DATA_DIR), { recursive: true, force: true }); + dropMounts(Modes.NOTES); + dropMounts(Modes.CHECKLISTS); +}); + +describe("Share visibility on disk", () => { + it("shows a folder share to a recipient with no content of their own (#582)", async () => { + writeItem(Modes.CHECKLISTS, "alice", "Groceries", "weekly", [ + `uuid: ${LIST_FULL}`, + "title: Weekly", + ]); + writeFolder(Modes.CHECKLISTS, "alice", "Groceries", { + uuid: "groceries-uuid", + sharing: { users: { bob: READ_ONLY }, inherit: true }, + }); + + const categories = await getCategories(Modes.CHECKLISTS, "bob"); + const lists = await getUserChecklists({ + username: "bob", + metadataOnly: true, + preserveOrder: true, + }); + + expect(categories.data).toEqual([ + expect.objectContaining({ + name: "Groceries", + sharedFrom: "alice", + permissions: READ_ONLY, + }), + ]); + expect(lists.data?.map((list) => list.uuid)).toEqual([LIST_FULL]); + }); + + it("gives each loose share its own permissions instead of the first one found (#604)", async () => { + writeItem(Modes.CHECKLISTS, "alice", "Misc", "full-access", [ + `uuid: ${LIST_FULL}`, + "title: Full Access", + "sharedWith: bob:rwd", + ]); + writeItem(Modes.CHECKLISTS, "alice", "Misc", "read-only", [ + `uuid: ${LIST_READ}`, + "title: Read Only", + "sharedWith: bob:r", + ]); + + const lists = await getUserChecklists({ + username: "bob", + metadataOnly: true, + preserveOrder: true, + }); + const categories = await getCategories(Modes.CHECKLISTS, "bob"); + + expect(permsOf(lists.data || [])).toEqual({ + [LIST_FULL]: FULL, + [LIST_READ]: READ_ONLY, + }); + expect(categories.data?.[0]).toEqual( + expect.objectContaining({ isLoose: true, permissions: READ_ONLY }), + ); + }); + + it("lets an item grant narrow a folder grant in the sidebar data (#604)", async () => { + writeFolder(Modes.NOTES, "alice", "Test", { + uuid: "test-uuid", + sharing: { users: { bob: FULL }, inherit: true }, + }); + writeItem( + Modes.NOTES, + "alice", + "Test", + "full-access", + [`uuid: ${NOTE_FULL}`, "title: Full Access"], + "hello", + ); + writeItem( + Modes.NOTES, + "alice", + "Test", + "read-only", + [`uuid: ${NOTE_READ}`, "title: Read Only", "sharedWith: bob:r"], + "hello", + ); + writeItem( + Modes.NOTES, + "alice", + "Test", + "yaml-list", + [`uuid: ${NOTE_LISTED}`, "title: Listed", "sharedWith:", " - bob:rw"], + "hello", + ); + + const notes = await getUserNotes({ + username: "bob", + metadataOnly: true, + preserveOrder: true, + }); + + expect(permsOf(notes.data || [])).toEqual({ + [NOTE_FULL]: FULL, + [NOTE_READ]: READ_ONLY, + [NOTE_LISTED]: { canRead: true, canEdit: true, canDelete: false }, + }); + expect( + await canReach(NOTE_READ, ItemTypes.NOTE, "bob", PermissionTypes.DELETE), + ).toBe(false); + }); + + it("hides an item from a folder share once the item opts out (#604)", async () => { + writeFolder(Modes.NOTES, "alice", "Test", { + uuid: "test-uuid", + sharing: { users: { bob: FULL }, inherit: true }, + }); + writeItem( + Modes.NOTES, + "alice", + "Test", + "private", + [`uuid: ${NOTE_FULL}`, "title: Private", "sharedWith: none"], + "hello", + ); + + const notes = await getUserNotes({ + username: "bob", + metadataOnly: true, + preserveOrder: true, + }); + + expect(notes.data).toEqual([]); + }); + + it("does not mistake another user's folder for the viewer's own when names share a prefix", async () => { + writeFolder(Modes.CHECKLISTS, "bobby", "Garage", { + uuid: "garage-uuid", + sharing: { users: { bob: READ_ONLY }, inherit: true }, + }); + writeItem(Modes.CHECKLISTS, "bobby", "Garage", "tools", [ + `uuid: ${LIST_READ}`, + "title: Tools", + ]); + + const mounts = await mountsFor(Modes.CHECKLISTS, "bob"); + + expect(mounts).toEqual([ + expect.objectContaining({ owner: "bobby", categoryUuid: "garage-uuid" }), + ]); + }); +}); diff --git a/tests/server-actions/tags.test.ts b/tests/server-actions/tags.test.ts index e86049c34..e7c325e80 100644 --- a/tests/server-actions/tags.test.ts +++ b/tests/server-actions/tags.test.ts @@ -6,6 +6,12 @@ const mockExtractYamlMetadata = vi.fn(); const mockUpdateYamlMetadata = vi.fn(); const mockExtractHashtagsFromContent = vi.fn(); +vi.mock("@/app/_server/actions/file", async (importOriginal) => ({ + ...((await importOriginal()) as object), + serverWriteFile: (filePath: string, content: string) => + mockFs.writeFile(filePath, content, "utf-8"), +})); + vi.mock("@/app/_server/actions/export", () => ({ exportWholeDataFolder: (...args: any[]) => mockExportWholeDataFolder(...args), })); diff --git a/tests/server-actions/user-deletion.test.ts b/tests/server-actions/user-deletion.test.ts new file mode 100644 index 000000000..3aa9b49d5 --- /dev/null +++ b/tests/server-actions/user-deletion.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from "vitest"; +import path from "path"; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "fs"; + +const { home, root } = vi.hoisted(() => { + const os = process.getBuiltinModule("os"); + const fs = process.getBuiltinModule("fs"); + const nodePath = process.getBuiltinModule("path"); + const previous = process.cwd(); + + process.chdir(fs.mkdtempSync(nodePath.join(os.tmpdir(), "jotty-user-deletion-"))); + + return { home: previous, root: process.cwd() }; +}); + +vi.unmock("fs/promises"); + +const mockBroadcast = vi.fn(); + +vi.mock("@/app/_server/actions/ws/broadcast", () => ({ + broadcast: (...args: unknown[]) => mockBroadcast(...args), +})); + +vi.mock("@/app/_server/actions/relations/tracking", () => ({ + trackItemWrite: vi.fn(), + trackItemDelete: vi.fn(), + trackTreeDelete: vi.fn(), + trackMove: vi.fn(), +})); + +vi.mock("@/app/_server/actions/users", async (importOriginal) => ({ + ...(await importOriginal()), + canAccessAllContent: async () => false, +})); + +import { _deleteUserCore } from "@/app/_server/actions/users/crud"; +import { revokeGrants } from "@/app/_server/actions/share/rename"; +import { dropMounts, mountsFor } from "@/app/_server/actions/share/mounts"; +import { canReach } from "@/app/_server/actions/share/queries"; +import { ItemTypes, Modes, PermissionTypes } from "@/app/_types/enums"; +import { DATA_DIR, USERS_FILE } from "@/app/_consts/files"; +import { CATEGORY_INFO_FILE } from "@/app/_consts/sharing"; +import { extractYamlMetadata } from "@/app/_utils/yaml-metadata-utils"; + +const READ_ONLY = { canRead: true, canEdit: false, canDelete: false }; +const WRITABLE = { canRead: true, canEdit: true, canDelete: false }; + +const PLAN = "11111111-1111-4111-8111-111111111111"; +const SOLO = "22222222-2222-4222-8222-222222222222"; +const BOB_NOTE = "33333333-3333-4333-8333-333333333333"; + +const dirOf = (mode: Modes, owner: string, category = ""): string => + path.join(root, DATA_DIR, mode, owner, category); + +const fileOf = (mode: Modes, owner: string, category: string, name: string) => + path.join(dirOf(mode, owner, category), `${name}.md`); + +const writeItem = ( + mode: Modes, + owner: string, + category: string, + name: string, + frontmatter: string[], + body: string, +) => { + mkdirSync(dirOf(mode, owner, category), { recursive: true }); + writeFileSync( + fileOf(mode, owner, category, name), + ["---", ...frontmatter, "---", body, ""].join("\n"), + ); +}; + +const writeFolder = ( + mode: Modes, + owner: string, + category: string, + info: Record, +) => { + mkdirSync(dirOf(mode, owner, category), { recursive: true }); + writeFileSync( + path.join(dirOf(mode, owner, category), CATEGORY_INFO_FILE), + JSON.stringify(info), + ); +}; + +const folderInfo = (mode: Modes, owner: string, category: string) => + JSON.parse( + readFileSync( + path.join(dirOf(mode, owner, category), CATEGORY_INFO_FILE), + "utf-8", + ), + ); + +const frontmatterOf = (filePath: string) => + extractYamlMetadata(readFileSync(filePath, "utf-8")); + +const seedUsers = (users: Record[]) => { + mkdirSync(path.dirname(path.join(root, USERS_FILE)), { recursive: true }); + writeFileSync(path.join(root, USERS_FILE), JSON.stringify(users)); +}; + +const seedShares = () => { + writeItem( + Modes.NOTES, + "alice", + "Work", + "plan", + [`uuid: ${PLAN}`, "title: Plan", "sharedWith: bob:rw, carol:r"], + "The plan body stays exactly as it was.", + ); + writeItem( + Modes.NOTES, + "alice", + "Work", + "solo", + [`uuid: ${SOLO}`, "title: Solo", "sharedWith: bob:rwd"], + "Only bob had this one.", + ); + writeFolder(Modes.CHECKLISTS, "alice", "Groceries", { + uuid: "groceries-uuid", + sharing: { users: { bob: WRITABLE, carol: READ_ONLY }, inherit: true }, + order: { items: ["keep-me"] }, + }); + writeItem( + Modes.NOTES, + "bob", + "Mine", + "bobs", + [`uuid: ${BOB_NOTE}`, "title: Bobs", "sharedWith: carol:rw"], + "Bob's own note.", + ); + writeFolder(Modes.NOTES, "bob", "Mine", { + uuid: "bob-folder-uuid", + sharing: { users: { carol: WRITABLE }, inherit: true }, + }); +}; + +afterAll(() => { + process.chdir(home); + rmSync(root, { recursive: true, force: true }); +}); + +beforeEach(() => { + mockBroadcast.mockReset(); + rmSync(path.join(root, DATA_DIR), { recursive: true, force: true }); + dropMounts(Modes.NOTES); + dropMounts(Modes.CHECKLISTS); +}); + +describe("revokeGrants", () => { + it("drops only the revoked user from item and folder grants", async () => { + seedShares(); + + const touched = await revokeGrants("bob"); + + const plan = frontmatterOf(fileOf(Modes.NOTES, "alice", "Work", "plan")); + expect(plan.metadata.sharedWith).toBe("carol:r"); + expect(plan.metadata.uuid).toBe(PLAN); + expect(plan.contentWithoutMetadata).toBe( + "The plan body stays exactly as it was.", + ); + + const groceries = folderInfo(Modes.CHECKLISTS, "alice", "Groceries"); + expect(groceries.sharing.users).toEqual({ carol: READ_ONLY }); + expect(groceries.sharing.inherit).toBe(true); + expect(groceries.uuid).toBe("groceries-uuid"); + expect(groceries.order).toEqual({ items: ["keep-me"] }); + + expect(touched).toBe(3); + expect(mockBroadcast).toHaveBeenCalledWith( + expect.objectContaining({ type: "sharing", username: "bob" }), + ); + }); + + it("keeps an item private instead of falling back to the folder grant", async () => { + seedShares(); + writeFolder(Modes.NOTES, "alice", "Work", { + uuid: "work-uuid", + sharing: { users: { carol: WRITABLE }, inherit: true }, + }); + + await revokeGrants("bob"); + + const solo = frontmatterOf(fileOf(Modes.NOTES, "alice", "Work", "solo")); + expect(solo.metadata.sharedWith).toBe("none"); + expect( + await canReach(SOLO, ItemTypes.NOTE, "carol", PermissionTypes.READ), + ).toBe(false); + }); + + it("leaves files that never mentioned the user alone", async () => { + seedShares(); + const before = readFileSync( + fileOf(Modes.NOTES, "bob", "Mine", "bobs"), + "utf-8", + ); + + await revokeGrants("dave"); + + expect( + readFileSync(fileOf(Modes.NOTES, "bob", "Mine", "bobs"), "utf-8"), + ).toBe(before); + expect( + folderInfo(Modes.CHECKLISTS, "alice", "Groceries").sharing.users, + ).toEqual({ bob: WRITABLE, carol: READ_ONLY }); + }); +}); + +describe("_deleteUserCore", () => { + it("removes the deleted user from every share and every mount (#600)", async () => { + seedShares(); + seedUsers([ + { username: "alice", passwordHash: "x", isAdmin: true, isSuperAdmin: true }, + { username: "bob", passwordHash: "x", isAdmin: false }, + { username: "carol", passwordHash: "x", isAdmin: false }, + ]); + + expect(await mountsFor(Modes.NOTES, "carol")).toHaveLength(2); + + const result = await _deleteUserCore("bob"); + + expect(result.success).toBe(true); + expect(existsSync(dirOf(Modes.NOTES, "bob"))).toBe(false); + + const users = JSON.parse(readFileSync(path.join(root, USERS_FILE), "utf-8")); + expect(users.map((user: { username: string }) => user.username)).toEqual([ + "alice", + "carol", + ]); + + expect( + frontmatterOf(fileOf(Modes.NOTES, "alice", "Work", "plan")).metadata + .sharedWith, + ).toBe("carol:r"); + expect( + folderInfo(Modes.CHECKLISTS, "alice", "Groceries").sharing.users, + ).toEqual({ carol: READ_ONLY }); + + const carolNotes = await mountsFor(Modes.NOTES, "carol"); + expect(carolNotes.map((mount) => mount.owner)).toEqual(["alice"]); + expect(await mountsFor(Modes.CHECKLISTS, "bob")).toEqual([]); + }); + + it("refuses to delete the super admin and leaves shares untouched", async () => { + seedShares(); + seedUsers([ + { username: "alice", passwordHash: "x", isAdmin: true, isSuperAdmin: true }, + { username: "bob", passwordHash: "x", isAdmin: true }, + ]); + + const result = await _deleteUserCore("alice"); + + expect(result.success).toBe(false); + expect( + frontmatterOf(fileOf(Modes.NOTES, "alice", "Work", "plan")).metadata + .sharedWith, + ).toBe("bob:rw, carol:r"); + }); +}); diff --git a/tests/server-actions/users.test.ts b/tests/server-actions/users.test.ts index 64aed87e6..fda4a2548 100644 --- a/tests/server-actions/users.test.ts +++ b/tests/server-actions/users.test.ts @@ -7,6 +7,7 @@ const mockGetSessionId = vi.fn() const mockReadSessions = vi.fn() const mockLogUserEvent = vi.fn() const mockLogAudit = vi.fn() +const mockRevokeGrants = vi.fn() vi.mock('@/app/_server/actions/file', () => ({ readJsonFile: (...args: any[]) => mockReadJsonFile(...args), @@ -19,6 +20,11 @@ vi.mock('@/app/_server/actions/session', () => ({ removeAllSessionsForUser: vi.fn().mockResolvedValue(undefined), })) +vi.mock('@/app/_server/actions/share/rename', () => ({ + revokeGrants: (...args: any[]) => mockRevokeGrants(...args), + renameGrants: vi.fn(), +})) + vi.mock('@/app/_server/actions/log', () => ({ logUserEvent: (...args: any[]) => mockLogUserEvent(...args), logAudit: (...args: any[]) => mockLogAudit(...args), @@ -52,6 +58,7 @@ describe('Users Actions', () => { mockReadSessions.mockResolvedValue({ 'session-123': 'testuser' }) mockLogUserEvent.mockResolvedValue(undefined) mockLogAudit.mockResolvedValue(undefined) + mockRevokeGrants.mockResolvedValue(0) mockFs.rm.mockResolvedValue(undefined) }) @@ -342,6 +349,7 @@ describe('Users Actions', () => { expect(result.success).toBe(true) expect(mockWriteJsonFile).toHaveBeenCalled() + expect(mockRevokeGrants).toHaveBeenCalledWith('testuser') }) }) @@ -496,6 +504,24 @@ describe('Users Actions', () => { expect(result.success).toBe(false) expect(result.error).toBe('Cannot delete the super admin (system owner)') + expect(mockRevokeGrants).not.toHaveBeenCalled() + }) + + it('should revoke every share granted to the deleted user and drop their record', async () => { + mockReadJsonFile.mockResolvedValue([ + { username: 'adminuser', passwordHash: 'hash', isAdmin: true }, + { username: 'leaver', passwordHash: 'hash', isAdmin: false }, + ]) + mockReadSessions.mockResolvedValue({ 'session-123': 'adminuser' }) + + const result = await deleteUser(createFormData({ username: 'leaver' })) + + expect(result.success).toBe(true) + expect(mockRevokeGrants).toHaveBeenCalledWith('leaver') + expect(mockLock).toHaveBeenCalled() + + const [written] = mockWriteJsonFile.mock.calls.at(-1)! + expect(written.map((user: { username: string }) => user.username)).toEqual(['adminuser']) }) }) diff --git a/tests/setup.ts b/tests/setup.ts index dd6fa54be..5115dc608 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -7,6 +7,13 @@ */ import { vi, beforeAll, afterAll } from "vitest"; +import os from "os"; +import path from "path"; + +process.env.JOTTY_RELATIONS_DB = path.join( + os.tmpdir(), + `jotty-relations-test-${process.pid}.db`, +); const originalConsoleError = console.error; const originalConsoleWarn = console.warn; diff --git a/tests/utils/connections-graph-data.test.ts b/tests/utils/connections-graph-data.test.ts deleted file mode 100644 index b12a38c8d..000000000 --- a/tests/utils/connections-graph-data.test.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { ItemTypes } from "@/app/_types/enums"; -import { LinkIndex } from "@/app/_types"; -import { - buildConnectionGraphData, - filterArchivedLinkIndex, - filterConnectionGraphData, -} from "@/app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/graph-data"; - -const noteA = "11111111-1111-4111-8111-111111111111"; -const noteB = "22222222-2222-4222-8222-222222222222"; -const noteC = "33333333-3333-4333-8333-333333333333"; -const listA = "44444444-4444-4444-8444-444444444444"; -const listB = "55555555-5555-4555-8555-555555555555"; - -const emptyLinks = () => ({ - isLinkedTo: { notes: [], checklists: [] }, - isReferencedIn: { notes: [], checklists: [] }, -}); - -const baseIndex = (): LinkIndex => ({ - notes: {}, - checklists: {}, -}); - -const notes = [ - { - uuid: noteA, - id: "alpha", - title: "Alpha", - category: "Work", - createdAt: "2024-01-01T00:00:00.000Z", - updatedAt: "2024-01-02T00:00:00.000Z", - tags: ["project"], - }, - { - uuid: noteB, - id: "beta", - title: "Beta", - category: "Work", - createdAt: "2024-01-03T00:00:00.000Z", - updatedAt: "2024-01-04T00:00:00.000Z", - tags: ["reference"], - }, - { - uuid: noteC, - id: "orphan", - title: "Orphan", - category: "Personal", - createdAt: "2024-01-05T00:00:00.000Z", - updatedAt: "2024-01-06T00:00:00.000Z", - }, -]; - -const checklists = [ - { - uuid: listA, - id: "tasks", - title: "Tasks", - type: "simple" as const, - category: "Work", - items: [], - createdAt: "2024-01-07T00:00:00.000Z", - updatedAt: "2024-01-08T00:00:00.000Z", - }, - { - uuid: listB, - id: "archive", - title: "Archive", - type: "task" as const, - category: "Old", - items: [], - createdAt: "2024-01-09T00:00:00.000Z", - updatedAt: "2024-01-10T00:00:00.000Z", - }, -]; - -describe("connections graph data", () => { - it("builds note-to-note explicit links", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { notes: [noteB], checklists: [] }, - }; - - const graph = buildConnectionGraphData(index, notes, checklists); - - expect(graph.nodes.map((node) => node.id).sort()).toEqual([noteA, noteB]); - expect(graph.links).toEqual([{ source: noteA, target: noteB, directed: true, type: "explicit" }]); - expect(graph.nodes.find((node) => node.id === noteA)?.outboundCount).toBe(1); - expect(graph.nodes.find((node) => node.id === noteB)?.inboundCount).toBe(1); - }); - - it("builds note-to-checklist and checklist-to-note links", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { notes: [], checklists: [listA] }, - }; - index.checklists[listA] = { - ...emptyLinks(), - isLinkedTo: { notes: [noteB], checklists: [] }, - }; - - const graph = buildConnectionGraphData(index, notes, checklists); - - expect(graph.links).toHaveLength(2); - expect(graph.links).toContainEqual({ source: noteA, target: listA, directed: true, type: "explicit" }); - expect(graph.links).toContainEqual({ source: listA, target: noteB, directed: true, type: "explicit" }); - expect(graph.nodes.find((node) => node.id === listA)?.type).toBe(ItemTypes.CHECKLIST); - }); - - it("connects items that share tags", () => { - const index = baseIndex(); - const taggedNotes = [ - { ...notes[0], tags: ["project", "shared"] }, - { ...notes[1], tags: ["shared"] }, - { ...notes[2], tags: ["other"] }, - ]; - - const graph = buildConnectionGraphData(index, taggedNotes, checklists); - - expect(graph.links).toContainEqual({ - source: noteA, - target: noteB, - directed: false, - type: "tag", - tag: "shared", - }); - expect(graph.links).not.toContainEqual( - expect.objectContaining({ source: noteA, target: noteC, type: "tag" }), - ); - expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(1); - expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(1); - }); - - it("de-dupes duplicate bidirectional visible edges", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { notes: [noteB], checklists: [] }, - }; - index.notes[noteB] = { - ...emptyLinks(), - isLinkedTo: { notes: [noteA], checklists: [] }, - }; - - const graph = buildConnectionGraphData(index, notes, checklists); - - expect(graph.links).toHaveLength(1); - expect(graph.links[0]).toMatchObject({ source: noteA, target: noteB, type: "explicit" }); - expect(graph.nodes.find((node) => node.id === noteA)?.connectionCount).toBe(2); - expect(graph.nodes.find((node) => node.id === noteB)?.connectionCount).toBe(2); - }); - - it("filters archived items by UUID first and legacy key fallback", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { notes: [noteB], checklists: ["Old/archive"] }, - }; - index.notes[noteB] = emptyLinks(); - index.checklists["Old/archive"] = emptyLinks(); - - const filtered = filterArchivedLinkIndex(index, [ - { uuid: noteB, id: "wrong", title: "Wrong", content: "", createdAt: "", updatedAt: "" }, - { id: "archive", title: "Archive", type: "task", category: "Old", items: [], createdAt: "", updatedAt: "" }, - ]); - - expect(filtered.notes[noteB]).toBeUndefined(); - expect(filtered.checklists["Old/archive"]).toBeUndefined(); - expect(filtered.notes[noteA].isLinkedTo.notes).toEqual([]); - expect(filtered.notes[noteA].isLinkedTo.checklists).toEqual([]); - }); - - it("filters missing targets", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { - notes: ["66666666-6666-4666-8666-666666666666"], - checklists: [], - }, - }; - - const graph = buildConnectionGraphData(index, notes, checklists); - - expect(graph.nodes).toEqual([]); - expect(graph.links).toEqual([]); - }); - - it("includes or excludes orphan nodes", () => { - const index = baseIndex(); - - expect(buildConnectionGraphData(index, notes, checklists).nodes).toEqual([]); - - const graph = buildConnectionGraphData(index, notes, checklists, true); - - expect(graph.nodes.map((node) => node.id)).toContain(noteC); - expect(graph.nodes.find((node) => node.id === noteC)?.orphan).toBe(true); - }); - - it("filters visible graph data by search and type", () => { - const index = baseIndex(); - index.notes[noteA] = { - ...emptyLinks(), - isLinkedTo: { notes: [], checklists: [listA] }, - }; - - const graph = buildConnectionGraphData(index, notes, checklists, true); - const filtered = filterConnectionGraphData(graph, { - search: "tasks", - showNotes: true, - showChecklists: true, - showOrphans: false, - showTagLinks: true, - }); - - expect(filtered.nodes.map((node) => node.id)).toEqual([listA]); - expect(filtered.links).toEqual([]); - }); - - it("can hide tag links from the visible graph", () => { - const index = baseIndex(); - const taggedNotes = [ - { ...notes[0], tags: ["shared"] }, - { ...notes[1], tags: ["shared"] }, - ]; - const graph = buildConnectionGraphData(index, taggedNotes, checklists); - const filtered = filterConnectionGraphData(graph, { - search: "", - showNotes: true, - showChecklists: true, - showOrphans: false, - showTagLinks: false, - }); - - expect(graph.links.some((link) => link.type === "tag")).toBe(true); - expect(filtered.links).toEqual([]); - expect(filtered.nodes).toEqual([]); - }); - - it("puts the graph before the controls on mobile while keeping desktop controls first", () => { - const source = readFileSync( - "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx", - "utf8", - ); - - expect(source).toContain("order-2 lg:order-1"); - expect(source).toContain("order-1 lg:order-2"); - expect(source.indexOf("order-2 lg:order-1")).toBeLessThan( - source.indexOf("order-1 lg:order-2"), - ); - }); - - it("uses existing profile translation keys for graph labels", () => { - const source = readFileSync( - "app/_components/FeatureComponents/Profile/Parts/LinksTab.tsx", - "utf8", - ); - - expect(source).not.toContain('t("common.unknown")'); - expect(source).toContain('t("profile.unknown")'); - }); - - it("paints a larger node pointer area for mobile tapping", () => { - const source = readFileSync( - "app/_components/FeatureComponents/Profile/Parts/ConnectionsGraph/ConnectionsGraph.tsx", - "utf8", - ); - - expect(source).toContain("MIN_POINTER_RADIUS"); - expect(source).toContain("nodePointerAreaPaint"); - expect(source).toContain("globalScale"); - expect(source).toContain("MIN_POINTER_RADIUS / globalScale"); - expect(source).toContain("enableNodeDrag={!isCoarsePointer}"); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 9283d88b8..c53d3deda 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,7 @@ "lib": [ "dom", "dom.iterable", - "es6", + "es2022", "webworker" ], "allowJs": true, diff --git a/yarn.lock b/yarn.lock index 4fe493c82..35011cdec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,17 @@ # yarn lockfile v1 +"3d-force-graph@^1.79": + version "1.80.0" + resolved "https://registry.yarnpkg.com/3d-force-graph/-/3d-force-graph-1.80.0.tgz#295d84b44b8df7f416c2f2b838b5c441989a0d13" + integrity sha512-tzI353gW1nXPpnC7VTa3JjMg+3cp77qOLUFO0vucPTfF+q5R6sQsNsIqVTbRIb7RSypn14nBa4yfkOe9ThxASw== + dependencies: + accessor-fn "1" + kapsule "^1.16" + three ">=0.179 <1" + three-forcegraph "1" + three-render-objects "^1.41" + "@babel/code-frame@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7" @@ -140,6 +151,11 @@ resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.2.tgz#9a6e2d05f4b6692e1801cd4fb176ad823930ed5e" integrity sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g== +"@babel/runtime@^7.17.8": + version "7.29.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.29.7.tgz#12022450c45a4da6d8d8287b18a4ff2ddb23f768" + integrity sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw== + "@babel/template@^7.29.7": version "7.29.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700" @@ -238,6 +254,11 @@ resolved "https://registry.yarnpkg.com/@date-fns/tz/-/tz-1.4.1.tgz#2d905f282304630e07bef6d02d2e7dbf3f0cc4e4" integrity sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA== +"@dimforge/rapier3d-compat@~0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz#7b3365e1dfdc5cd957b45afe920b4ac06c7cd389" + integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== + "@dnd-kit/accessibility@^3.1.1": version "3.1.1" resolved "https://registry.yarnpkg.com/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz#3b4202bd6bb370a0730f6734867785919beac6af" @@ -927,134 +948,58 @@ "@emnapi/runtime" "^1.4.3" "@tybys/wasm-util" "^0.10.0" -"@next/env@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/env/-/env-16.3.0.tgz#9a109a0e1367044e082edf0ca265231768314dc7" - integrity sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw== +"@next/env@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/env/-/env-16.3.6.tgz#47d93ed498a55f7db8bb309eeab0b5964b7964d6" + integrity sha512-x9Vblze1EbtltQYnNH38xCPWU3TVfBd1eXqA3+w9+BTpedkkdNpAaltXlGQ/nsc1+E0mVTNrtcbX3GoO09zeLQ== -"@next/eslint-plugin-next@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.0.tgz#6131082c29ca68ea52ade8d361530445bdc9423e" - integrity sha512-OqgJ8PN0d04KcPhDX/PTY5tJUJZxlbrt7O7FBsm4XE0XW2JDrKnDXsc9uo9WUimJGPoo2j+JRGhyXApC//mvbw== +"@next/eslint-plugin-next@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.6.tgz#285f6d86748a6480261a54810da679aeb32531cc" + integrity sha512-jowwDX+7DOlDIjJLgTMxudw+k37QnWu1JkZLkSi9MaJBfDYcfhAPMKBhXL0idYzFN/AGg//axnOR4cLkHX/Rng== dependencies: "@eslint-community/eslint-utils" "4.9.1" fast-glob "3.3.1" -"@next/swc-darwin-arm64@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz#d4f492a29837745e945e306d49d4c1c9181353df" - integrity sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw== - -"@next/swc-darwin-x64@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz#98534e7cff6b8b5f7bc941fdee72926b00cd68ee" - integrity sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw== - -"@next/swc-linux-arm64-gnu@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz#aa84de7859471415fcfd36e03d01667137e6d9a6" - integrity sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg== - -"@next/swc-linux-arm64-musl@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz#3c7211003c6ac82d06ae6944388132c8811425c7" - integrity sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q== - -"@next/swc-linux-x64-gnu@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz#e4412d917512729b1faf5c5e31f0ad53a38e2c6a" - integrity sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw== - -"@next/swc-linux-x64-musl@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz#e91531118ad14df129cbf00df7c4ca4a29152e7b" - integrity sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g== - -"@next/swc-win32-arm64-msvc@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz#b9b1307c21886e8ab99c3064c1417b279132ad49" - integrity sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw== - -"@next/swc-win32-x64-msvc@16.3.0": - version "16.3.0" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz#2045249907f2b9da5cabb7da69b75556c82f4671" - integrity sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA== - -"@nivo/annotations@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/annotations/-/annotations-0.99.0.tgz#ac6417022d9281b5b40b134d352c5039302aa5f6" - integrity sha512-jCuuXPbvpaqaz4xF7k5dv0OT2ubn5Nt0gWryuTe/8oVsC/9bzSuK8bM9vBty60m9tfO+X8vUYliuaCDwGksC2g== - dependencies: - "@nivo/colors" "0.99.0" - "@nivo/core" "0.99.0" - "@nivo/theming" "0.99.0" - "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" - lodash "^4.17.21" - -"@nivo/colors@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/colors/-/colors-0.99.0.tgz#f844a0f0de0597b35829405541e145f6db50c830" - integrity sha512-hyYt4lEFIfXOUmQ6k3HXm3KwhcgoJpocmoGzLUqzk7DzuhQYJo+4d5jIGGU0N/a70+9XbHIdpKNSblHAIASD3w== - dependencies: - "@nivo/core" "0.99.0" - "@nivo/theming" "0.99.0" - "@types/d3-color" "^3.0.0" - "@types/d3-scale" "^4.0.8" - "@types/d3-scale-chromatic" "^3.0.0" - d3-color "^3.1.0" - d3-scale "^4.0.2" - d3-scale-chromatic "^3.0.0" - lodash "^4.17.21" - -"@nivo/core@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/core/-/core-0.99.0.tgz#91ccf3d2419fcfb5f740dba468f0d6f059933af4" - integrity sha512-olCItqhPG3xHL5ei+vg52aB6o+6S+xR2idpkd9RormTTUniZb8U2rOdcQojOojPY5i9kVeQyLFBpV4YfM7OZ9g== - dependencies: - "@nivo/theming" "0.99.0" - "@nivo/tooltip" "0.99.0" - "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" - "@types/d3-shape" "^3.1.6" - d3-color "^3.1.0" - d3-format "^1.4.4" - d3-interpolate "^3.0.1" - d3-scale "^4.0.2" - d3-scale-chromatic "^3.0.0" - d3-shape "^3.2.0" - d3-time-format "^3.0.0" - lodash "^4.17.21" - react-virtualized-auto-sizer "^1.0.26" - use-debounce "^10.0.4" - -"@nivo/network@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/network/-/network-0.99.0.tgz#502f6df6a95de173bbc9566b5304aaec8fd4c1d6" - integrity sha512-s+xSFpbv7Z6fNQQdq07v8NXOt15a9M2qgpocyCzm7gWHEsTkFBjNu5l5X5xA2eL9PuQ4vQbrAf64XNYA/rzKbw== - dependencies: - "@nivo/annotations" "0.99.0" - "@nivo/colors" "0.99.0" - "@nivo/core" "0.99.0" - "@nivo/theming" "0.99.0" - "@nivo/tooltip" "0.99.0" - "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" - "@types/d3-force" "^2.1.0" - d3-force "^2.0.1" - -"@nivo/theming@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/theming/-/theming-0.99.0.tgz#89de03832081153093dcfc2eb2fdaaf3424da963" - integrity sha512-KvXlf0nqBzh/g2hAIV9bzscYvpq1uuO3TnFN3RDXGI72CrbbZFTGzprPju3sy/myVsauv+Bb+V4f5TZ0jkYKRg== - dependencies: - lodash "^4.17.21" - -"@nivo/tooltip@0.99.0": - version "0.99.0" - resolved "https://registry.yarnpkg.com/@nivo/tooltip/-/tooltip-0.99.0.tgz#63a1bc3b428cb2a07a7f763ad8547e39dd4bcf13" - integrity sha512-weoEGR3xAetV4k2P6k96cdamGzKQ5F2Pq+uyDaHr1P3HYArM879Pl+x+TkU0aWjP6wgUZPx/GOBiV1Hb1JxIqg== - dependencies: - "@nivo/core" "0.99.0" - "@nivo/theming" "0.99.0" - "@react-spring/web" "9.4.5 || ^9.7.2 || ^10.0" +"@next/swc-darwin-arm64@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.6.tgz#e92a4bfae82e0e0d2056a31806af16b7cd9b38bc" + integrity sha512-E/7GEqaUkt8mk/T8v9lAnrhzR06kdq1ZBkC12F8tAMkdIadwNp3H1KqHynDHrpcTlGCUdq/qu6vUL2aYVyYBdw== + +"@next/swc-darwin-x64@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.6.tgz#0feec7995215c85bd18df527983836897757ff80" + integrity sha512-yBE893/nDWTlaiBD1p+qgt7NUen4U5R6FXyH0s67Npq1S3E0cVSef1WIXC2xBRgQvwAvJq6DnS6Y6PrY0cy4Ew== + +"@next/swc-linux-arm64-gnu@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.6.tgz#f4a27ea38008e47ef670bacd4b4e92d0789209c2" + integrity sha512-KJDpjBqBPYlvkivmyrp+Qys6k/7ksbqGQvRVc6ZEGfR+cjQxx+nUkJaWmNZJsmoOrqYNbaXByF8wa0lBwDhB3Q== + +"@next/swc-linux-arm64-musl@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.6.tgz#1b2dbad64636efdb49e574d8234526129e3b2a34" + integrity sha512-mqNg2K+hvWskSRb/QM+Ix412DvBsuSF0XV+frTSw5vmoucNnIlynFwKYew8D01bfATErMOM7Bujrf0BA5DRKFA== + +"@next/swc-linux-x64-gnu@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.6.tgz#783f3fdf6e9f2bdf9d3f58e12cbed938c862c617" + integrity sha512-nFncBNGAYouRHjRVaITs9beZRfhX4ssVwpnvPIAbkZVH6LtGoAVlH4bJ8Cnf9SOo9bsXgPFer/GdHtEE3JNOkw== + +"@next/swc-linux-x64-musl@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.6.tgz#c19df8320300d80bb5dc1b503796ec4e7d2f15f8" + integrity sha512-5Mf3cHDGR/Iz0ng2Bj3zUR3p5QS9YK3Hn2QiAfavFmyF48zwThAjpFoiTKNIcOHLYS4zEk+gzyJ/9deQ2ZB8yQ== + +"@next/swc-win32-arm64-msvc@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.6.tgz#1e4e70b213f1d49223f5eb8aa0490b9fc615e807" + integrity sha512-0jkJy0C2kbrJWTk4YLa3xk80pVBpx8FCHJym7CnUfDAXe/FWv5qT7SQJbR0KuemyxaEDlEx5WT4VQJoTW+/9Qw== + +"@next/swc-win32-x64-msvc@16.3.6": + version "16.3.6" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.6.tgz#d7b84132a78b38792223b810e3fa0b20c980d895" + integrity sha512-/YXjI1e5OXcZ7YpxRwgP/1jAV/SBKTzeVKqN2mk7mLpcICsyn3Gl5+dIfDTJp70M0ccMhyMMRso4v6mPDCGepg== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -1482,51 +1427,6 @@ dependencies: "@tiptap/extension-image" "^2.0.3" -"@react-spring/animated@~10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/animated/-/animated-10.0.3.tgz#b42f7041a51d38f395e9ba5fb53ca68c34cd324f" - integrity sha512-7MrxADV3vaUADn2V9iYhaIL6iOWRx9nCJjYrsk2AHD2kwPr6fg7Pt0v+deX5RnCDmCKNnD6W5fasiyM8D+wzJQ== - dependencies: - "@react-spring/shared" "~10.0.3" - "@react-spring/types" "~10.0.3" - -"@react-spring/core@~10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/core/-/core-10.0.3.tgz#3b4f3991f5902ce46770c2c1ef05c8e53c3a0f73" - integrity sha512-D4DwNO68oohDf/0HG2G0Uragzb9IA1oXblxrd6MZAcBcUQG2EHUWXewjdECMPLNmQvlYVyyBRH6gPxXM5DX7DQ== - dependencies: - "@react-spring/animated" "~10.0.3" - "@react-spring/shared" "~10.0.3" - "@react-spring/types" "~10.0.3" - -"@react-spring/rafz@~10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/rafz/-/rafz-10.0.3.tgz#9b328c3992b23d6317452998670636d6b783f2c4" - integrity sha512-Ri2/xqt8OnQ2iFKkxKMSF4Nqv0LSWnxXT4jXFzBDsHgeeH/cHxTLupAWUwmV9hAGgmEhBmh5aONtj3J6R/18wg== - -"@react-spring/shared@~10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/shared/-/shared-10.0.3.tgz#654d03c74d3277bae1a565aff981979536be6002" - integrity sha512-geCal66nrkaQzUVhPkGomylo+Jpd5VPK8tPMEDevQEfNSWAQP15swHm+MCRG4wVQrQlTi9lOzKzpRoTL3CA84Q== - dependencies: - "@react-spring/rafz" "~10.0.3" - "@react-spring/types" "~10.0.3" - -"@react-spring/types@~10.0.3": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/types/-/types-10.0.3.tgz#0c2d7a7e783a6f652bcd24cac80ed569bc2ad8d9" - integrity sha512-H5Ixkd2OuSIgHtxuHLTt7aJYfhMXKXT/rK32HPD/kSrOB6q6ooeiWAXkBy7L8F3ZxdkBb9ini9zP9UwnEFzWgQ== - -"@react-spring/web@9.4.5 || ^9.7.2 || ^10.0": - version "10.0.3" - resolved "https://registry.yarnpkg.com/@react-spring/web/-/web-10.0.3.tgz#ae3a9ea2362b1d70d2ec36a1e2747c6cee2540a9" - integrity sha512-ndU+kWY81rHsT7gTFtCJ6mrVhaJ6grFmgTnENipzmKqot4HGf5smPNK+cZZJqoGeDsj9ZsiWPW4geT/NyD484A== - dependencies: - "@react-spring/animated" "~10.0.3" - "@react-spring/core" "~10.0.3" - "@react-spring/shared" "~10.0.3" - "@react-spring/types" "~10.0.3" - "@reduxjs/toolkit@1.x.x || 2.x.x": version "2.11.2" resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-2.11.2.tgz#582225acea567329ca6848583e7dd72580d38e82" @@ -1891,10 +1791,10 @@ resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/helpers@0.5.15": - version "0.5.15" - resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" - integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== +"@swc/helpers@0.5.23": + version "0.5.23" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.23.tgz#19287d0d86d962b111376039a50c792902c9a86a" + integrity sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw== dependencies: tslib "^2.8.0" @@ -2170,6 +2070,11 @@ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-25.0.0.tgz#7266baebcc3affe62a3a54318a3ea82d904cd0b9" integrity sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A== +"@tweenjs/tween.js@~23.1.3": + version "23.1.3" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d" + integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== + "@tybys/wasm-util@^0.10.0": version "0.10.1" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" @@ -2197,7 +2102,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== -"@types/d3-color@*", "@types/d3-color@^3.0.0": +"@types/d3-color@*": version "3.1.3" resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== @@ -2207,11 +2112,6 @@ resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== -"@types/d3-force@^2.1.0": - version "2.1.7" - resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-2.1.7.tgz#5ae22a54ea0eb0819081557429faeb72ab5eaf88" - integrity sha512-x5pvWw0HUBrcpMaMOd70ICEL27gOeC9hyhilTc+OP+4tErgEg3w+fZWA475eTrG7gi8BB0TNdfGRprpy09Vo9A== - "@types/d3-interpolate@^3.0.1": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" @@ -2229,14 +2129,14 @@ resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== -"@types/d3-scale@^4.0.2", "@types/d3-scale@^4.0.3", "@types/d3-scale@^4.0.8": +"@types/d3-scale@^4.0.2", "@types/d3-scale@^4.0.3": version "4.0.9" resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== dependencies: "@types/d3-time" "*" -"@types/d3-shape@^3.1.0", "@types/d3-shape@^3.1.6": +"@types/d3-shape@^3.1.0": version "3.1.8" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== @@ -2367,12 +2267,12 @@ dependencies: undici-types "~7.19.0" -"@types/node@20": - version "20.19.39" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.39.tgz#e98a3b575574070cd34b784bd173767269f95e99" - integrity sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw== +"@types/node@24": + version "24.19.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-24.19.0.tgz#becd0549e091f749520b8db8a446ec1559c88a0b" + integrity sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew== dependencies: - undici-types "~6.21.0" + undici-types ">=7.24.0 <7.24.7" "@types/prismjs@1.26.5": version "1.26.5" @@ -2436,6 +2336,23 @@ dependencies: "@types/node" "*" +"@types/stats.js@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e" + integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== + +"@types/three@0.186.0": + version "0.186.0" + resolved "https://registry.yarnpkg.com/@types/three/-/three-0.186.0.tgz#fc936323d7eeb885052fd5de352e3772f488ea99" + integrity sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ== + dependencies: + "@dimforge/rapier3d-compat" "~0.12.0" + "@tweenjs/tween.js" "~23.1.3" + "@types/stats.js" "*" + "@types/webxr" ">=0.5.17" + fflate "~0.8.3" + meshoptimizer "~1.1.1" + "@types/trusted-types@2.0.7", "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -2466,6 +2383,11 @@ resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-10.0.0.tgz#e9c07fe50da0f53dc24970cca94d619ff03f6f6d" integrity sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ== +"@types/webxr@>=0.5.17": + version "0.5.24" + resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44" + integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg== + "@types/ws@8.18.1": version "8.18.1" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" @@ -3455,7 +3377,7 @@ cytoscape@^3.28.1: resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.2.tgz#3a58906b4002b7c237f54dfc9b971983757da791" integrity sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw== -"d3-array@1 - 2", d3-array@2: +"d3-array@1 - 2": version "2.12.1" resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-2.12.1.tgz#e20b41aafcdffdf5d50928004ececf815a465e81" integrity sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ== @@ -3497,7 +3419,7 @@ d3-chord@3: dependencies: d3-path "1 - 3" -"d3-color@1 - 3", d3-color@3, d3-color@^3.1.0: +"d3-color@1 - 3", d3-color@3: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== @@ -3516,11 +3438,6 @@ d3-delaunay@6: dependencies: delaunator "5" -"d3-dispatch@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-2.0.0.tgz#8a18e16f76dd3fcaef42163c97b926aa9b55e7cf" - integrity sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA== - "d3-dispatch@1 - 3", d3-dispatch@3: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" @@ -3575,25 +3492,11 @@ d3-force@3: d3-quadtree "1 - 3" d3-timer "1 - 3" -d3-force@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/d3-force/-/d3-force-2.1.1.tgz#f20ccbf1e6c9e80add1926f09b51f686a8bc0937" - integrity sha512-nAuHEzBqMvpFVMf9OX75d00OxvOXdxY+xECIXjW6Gv8BRrXu6gAWbv/9XKrvfJ5i5DCokDW7RYE50LRoK092ew== - dependencies: - d3-dispatch "1 - 2" - d3-quadtree "1 - 2" - d3-timer "1 - 2" - "d3-format@1 - 3", d3-format@3: version "3.1.2" resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.2.tgz#01fdb46b58beb1f55b10b42ad70b6e344d5eb2ae" integrity sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg== -d3-format@^1.4.4: - version "1.4.5" - resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-1.4.5.tgz#374f2ba1320e3717eb74a9356c67daee17a7edb4" - integrity sha512-J0piedu6Z8iB6TbIGfZgDzfXxUFN3qQRMofy2oPdXzQibYGqPB/9iMcxr/TGalU+2RsyDO+U4f33id8tbnSRMQ== - d3-geo@3: version "3.1.1" resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-3.1.1.tgz#6027cf51246f9b2ebd64f99e01dc7c3364033a4d" @@ -3633,11 +3536,6 @@ d3-polygon@3: resolved "https://registry.yarnpkg.com/d3-polygon/-/d3-polygon-3.0.1.tgz#0b45d3dd1c48a29c8e057e6135693ec80bf16398" integrity sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg== -"d3-quadtree@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-2.0.0.tgz#edbad045cef88701f6fee3aee8e93fb332d30f9d" - integrity sha512-b0Ed2t1UUalJpc3qXzKi+cPGxeXRr4KU9YSlocN74aTzp6R/Ud43t79yLLqxHRWZfsvWXmbDWPpoENK1K539xw== - "d3-quadtree@1 - 3", d3-quadtree@3: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-quadtree/-/d3-quadtree-3.0.1.tgz#6dca3e8be2b393c9a9d514dabbd80a92deef1a4f" @@ -3656,7 +3554,7 @@ d3-sankey@^0.12.3: d3-array "1 - 2" d3-shape "^1.2.0" -"d3-scale-chromatic@1 - 3", d3-scale-chromatic@3, d3-scale-chromatic@^3.0.0: +"d3-scale-chromatic@1 - 3", d3-scale-chromatic@3: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#34c39da298b23c20e02f1a4b239bd0f22e7f1314" integrity sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ== @@ -3680,7 +3578,7 @@ d3-sankey@^0.12.3: resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@3, d3-shape@^3.1.0, d3-shape@^3.2.0: +d3-shape@3, d3-shape@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== @@ -3701,20 +3599,6 @@ d3-shape@^1.2.0: dependencies: d3-time "1 - 3" -d3-time-format@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-3.0.0.tgz#df8056c83659e01f20ac5da5fdeae7c08d5f1bb6" - integrity sha512-UXJh6EKsHBTjopVqZBhFysQcoXSv/5yLONZvkQ5Kk3qbwiUYkdX17Xa1PT6U1ZWXGGfB1ey5L8dKMlFq2DO0Ag== - dependencies: - d3-time "1 - 2" - -"d3-time@1 - 2": - version "2.1.1" - resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-2.1.1.tgz#e9d8a8a88691f4548e68ca085e5ff956724a6682" - integrity sha512-/eIQe/eR4kCQwq7yxi7z4c6qEXf2IYGcjoWB5OOQy4Tq9Uv39/947qlDcN2TLkiTzQWzvnsuYPB9TrWaNfipKQ== - dependencies: - d3-array "2" - "d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@3, d3-time@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" @@ -3722,11 +3606,6 @@ d3-time-format@^3.0.0: dependencies: d3-array "2 - 3" -"d3-timer@1 - 2": - version "2.0.0" - resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-2.0.0.tgz#055edb1d170cfe31ab2da8968deee940b56623e6" - integrity sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA== - "d3-timer@1 - 3", d3-timer@3, d3-timer@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" @@ -3803,6 +3682,13 @@ damerau-levenshtein@^1.0.8: resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" integrity sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA== +data-bind-mapper@1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/data-bind-mapper/-/data-bind-mapper-1.0.3.tgz#275e55fd170331b1146479f3c7eb4256b8239481" + integrity sha512-QmU3lyEnbENQPo0M1F9BMu4s6cqNNp8iJA+b/HP2sSb7pf3dxwF3+EP1eO69rwBfH9kFJ1apmzrtogAmVt2/Xw== + dependencies: + accessor-fn "1" + data-view-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" @@ -4240,12 +4126,12 @@ escape-string-regexp@^5.0.0: resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8" integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw== -eslint-config-next@16.3.0: - version "16.3.0" - resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.3.0.tgz#b6e7cdceb55d98577837937898d1be71e9d17273" - integrity sha512-lPrf1kHsMJEZqO0uXkNB400c5MGrhrTk3BNX7P0ol4gt61+iUlQfjy9TyIOEA9eOXrf+5+mYbT/JsY8+zqUByQ== +eslint-config-next@16.3.6: + version "16.3.6" + resolved "https://registry.yarnpkg.com/eslint-config-next/-/eslint-config-next-16.3.6.tgz#46adc0ce206ccb09e8a982875cbc7867f2be1391" + integrity sha512-1Upt3U7BDwU+ilpe2byZjAfts9oNq4d4fv/zXEvs8/4yS+cwOQW/WCxUNy8gCDquX67SzeehDvKblVC6ZBMocQ== dependencies: - "@next/eslint-plugin-next" "16.3.0" + "@next/eslint-plugin-next" "16.3.6" eslint-import-resolver-node "^0.3.6" eslint-import-resolver-typescript "^3.5.2" eslint-plugin-import "^2.32.0" @@ -4554,6 +4440,11 @@ fdir@^6.4.4, fdir@^6.5.0: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== +fflate@~0.8.3: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + file-entry-cache@^8.0.0: version "8.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" @@ -5764,7 +5655,7 @@ lodash.throttle@4.1.1: resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== -lodash@4.18.1, lodash@^4.17.15, lodash@^4.17.21: +lodash@4.18.1, lodash@^4.17.15: version "4.18.1" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== @@ -5838,7 +5729,7 @@ math-intrinsics@^1.1.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== -mdast-util-find-and-replace@^3.0.0: +mdast-util-find-and-replace@3.0.2, mdast-util-find-and-replace@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz#70a3174c894e14df722abf43bc250cbae44b11df" integrity sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg== @@ -6079,6 +5970,11 @@ mermaid@10.9.8, mermaid@^11.12.1: uuid "^9.0.0 || ^10 || ^11.1.0 || ^12 || ^13 || ^14.0.0" web-worker "^1.2.0" +meshoptimizer@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-1.1.1.tgz#20c7d050d59bdc573154814f6a37d47d89908cca" + integrity sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g== + micromark-core-commonmark@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz#1386628df59946b2d39fb2edfd10f3e8e0a75bb8" @@ -6642,27 +6538,58 @@ next-themes@0.2.1: resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45" integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== -next@16.3.0: - version "16.3.0" - resolved "https://registry.yarnpkg.com/next/-/next-16.3.0.tgz#03ed9eaf21bac38ceab5aa6f3b03f0fff587b42b" - integrity sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A== +next@16.3.6: + version "16.3.6" + resolved "https://registry.yarnpkg.com/next/-/next-16.3.6.tgz#29a6cecb103bd2642a0ce322e68ecdb38a0f0af5" + integrity sha512-L+otWM/aQbYTx98aZhgEoMb4bZAXx1YVW4UMA/vuCyCoWG5HJyZUili8QAkqzrcC+5///tsz3s0M+SlyB5bLMw== dependencies: - "@next/env" "16.3.0" - "@swc/helpers" "0.5.15" + "@next/env" "16.3.6" + "@swc/helpers" "0.5.23" baseline-browser-mapping "^2.9.19" caniuse-lite "^1.0.30001579" postcss "8.5.23" styled-jsx "5.1.6" optionalDependencies: - "@next/swc-darwin-arm64" "16.3.0" - "@next/swc-darwin-x64" "16.3.0" - "@next/swc-linux-arm64-gnu" "16.3.0" - "@next/swc-linux-arm64-musl" "16.3.0" - "@next/swc-linux-x64-gnu" "16.3.0" - "@next/swc-linux-x64-musl" "16.3.0" - "@next/swc-win32-arm64-msvc" "16.3.0" - "@next/swc-win32-x64-msvc" "16.3.0" - sharp "^0.35.3" + "@next/swc-darwin-arm64" "16.3.6" + "@next/swc-darwin-x64" "16.3.6" + "@next/swc-linux-arm64-gnu" "16.3.6" + "@next/swc-linux-arm64-musl" "16.3.6" + "@next/swc-linux-x64-gnu" "16.3.6" + "@next/swc-linux-x64-musl" "16.3.6" + "@next/swc-win32-arm64-msvc" "16.3.6" + "@next/swc-win32-x64-msvc" "16.3.6" + sharp "^0.35.4" + +ngraph.events@^1.0.0, ngraph.events@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/ngraph.events/-/ngraph.events-1.4.0.tgz#3153c0a5760172c744f7b2dbc5a3d8a72703e357" + integrity sha512-NeDGI4DSyjBNBRtA86222JoYietsmCXbs8CEB0dZ51Xeh4lhVl1y3wpWLumczvnha8sFQIW4E0vvVWwgmX2mGw== + +ngraph.forcelayout@3: + version "3.3.1" + resolved "https://registry.yarnpkg.com/ngraph.forcelayout/-/ngraph.forcelayout-3.3.1.tgz#981e1baee5e0593c490bc27219169f9cedfa4f8b" + integrity sha512-MKBuEh1wujyQHFTW57y5vd/uuEOK0XfXYxm3lC7kktjJLRdt/KEKEknyOlc6tjXflqBKEuYBBcu7Ax5VY+S6aw== + dependencies: + ngraph.events "^1.0.0" + ngraph.merge "^1.0.0" + ngraph.random "^1.0.0" + +ngraph.graph@20: + version "20.1.2" + resolved "https://registry.yarnpkg.com/ngraph.graph/-/ngraph.graph-20.1.2.tgz#903389a5370a864cb1a47c522f54994b38cb3923" + integrity sha512-W/G3GBR3Y5UxMLHTUCPP9v+pbtpzwuAEIqP5oZV+9IwgxAIEZwh+Foc60iPc1idlnK7Zxu0p3puxAyNmDvBd0Q== + dependencies: + ngraph.events "^1.4.0" + +ngraph.merge@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/ngraph.merge/-/ngraph.merge-1.0.0.tgz#d763cdfa48b1bbd4270ea246f06c9c8ff5d3477c" + integrity sha512-5J8YjGITUJeapsomtTALYsw7rFveYkM+lBj3QiYZ79EymQcuri65Nw3knQtFxQBU1r5iOaVRXrSwMENUPK62Vg== + +ngraph.random@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/ngraph.random/-/ngraph.random-1.2.0.tgz#3864ffbb9971e920db6c5f33ce5abc3d2439e3d2" + integrity sha512-4EUeAGbB2HWX9njd6bP6tciN6ByJfoaAvmVL9QTaZSeXrW46eNGA9GajiXiPBbvFqxUWFkEbyo6x5qsACUuVfA== node-addon-api@^7.0.0: version "7.1.1" @@ -7015,6 +6942,13 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" +polished@4: + version "4.3.1" + resolved "https://registry.yarnpkg.com/polished/-/polished-4.3.1.tgz#5a00ae32715609f83d89f6f31d0f0261c6170548" + integrity sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA== + dependencies: + "@babel/runtime" "^7.17.8" + possible-typed-array-names@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz#93e3582bc0e5426586d9d07b79ee40fc841de4ae" @@ -7312,6 +7246,15 @@ react-force-graph-2d@^1.29.1: prop-types "15" react-kapsule "^2.5" +react-force-graph-3d@1.29.1: + version "1.29.1" + resolved "https://registry.yarnpkg.com/react-force-graph-3d/-/react-force-graph-3d-1.29.1.tgz#fee5a4b74ca1d4623358bb1057bfdffa8c3fe404" + integrity sha512-5Vp+PGpYnO+zLwgK2NvNqdXHvsWLrFzpDfJW1vUA1twjo9SPvXqfUYQrnRmAbD+K2tOxkZw1BkbH31l5b4TWHg== + dependencies: + "3d-force-graph" "^1.79" + prop-types "15" + react-kapsule "^2.5" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -7386,11 +7329,6 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: get-nonce "^1.0.0" tslib "^2.0.0" -react-virtualized-auto-sizer@^1.0.26: - version "1.0.26" - resolved "https://registry.yarnpkg.com/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.26.tgz#e9470ef6a778dc4f1d5fd76305fa2d8b610c357a" - integrity sha512-CblNyiNVw2o+hsa5/49NH2ogGxZ+t+3aweRvNSq7TVjDIlwk7ir4lencEg5HxHeSzwNarSkNkiu0qJSOXtxm5A== - react@19.2.4: version "19.2.4" resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" @@ -7823,7 +7761,7 @@ set-proto@^1.0.0: es-errors "^1.3.0" es-object-atoms "^1.0.0" -sharp@0.35.3, sharp@^0.35.3: +sharp@0.35.3, sharp@^0.35.4: version "0.35.3" resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.35.3.tgz#41ed21a46406be163eacd0be7b364b42fcf2885f" integrity sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q== @@ -8288,6 +8226,38 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +three-forcegraph@1: + version "1.43.4" + resolved "https://registry.yarnpkg.com/three-forcegraph/-/three-forcegraph-1.43.4.tgz#9e85a75e729af143440d4a05edf3b197d717310e" + integrity sha512-FtmiZP/T16ZQaHza3JDaDn0YTXFtg9e7pGnTeU8nzu0NNkx7MpWbF/GvmpbQsWHx3rukHtkRv1fTorLPB3FDEA== + dependencies: + accessor-fn "1" + d3-array "1 - 3" + d3-force-3d "2 - 3" + d3-scale "1 - 4" + d3-scale-chromatic "1 - 3" + data-bind-mapper "1" + kapsule "^1.16" + ngraph.forcelayout "3" + ngraph.graph "20" + tinycolor2 "1" + +three-render-objects@^1.41: + version "1.42.0" + resolved "https://registry.yarnpkg.com/three-render-objects/-/three-render-objects-1.42.0.tgz#32ad1a6f83ecb92ec67573c79f7865b44d491aa6" + integrity sha512-KYfkPrYGEbIK8ChFocWqOF1aAN80FBUBWVYB8mB2oBpVuVN+52FvvngVYB5ieFANQu7Rt21rPYZ/xKaAgVWWRQ== + dependencies: + "@tweenjs/tween.js" "18 - 25" + accessor-fn "1" + float-tooltip "^1.7" + kapsule "^1.16" + polished "4" + +three@0.186.1, "three@>=0.179 <1": + version "0.186.1" + resolved "https://registry.yarnpkg.com/three/-/three-0.186.1.tgz#6d50f70c2c437f844179bbb56d6f5b774e1ca38a" + integrity sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA== + tiny-invariant@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz#46680b7a873a0d5d10005995eb90a70d74d60127" @@ -8298,7 +8268,7 @@ tinybench@^2.9.0: resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== -tinycolor2@^1.6.0: +tinycolor2@1, tinycolor2@^1.6.0: version "1.6.0" resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== @@ -8493,10 +8463,10 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -undici-types@~6.21.0: - version "6.21.0" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" - integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== +"undici-types@>=7.24.0 <7.24.7": + version "7.24.6" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" + integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== undici-types@~7.19.0: version "7.19.2" @@ -8619,11 +8589,6 @@ use-callback-ref@^1.3.3: dependencies: tslib "^2.0.0" -use-debounce@^10.0.4: - version "10.1.1" - resolved "https://registry.yarnpkg.com/use-debounce/-/use-debounce-10.1.1.tgz#b08b596b60a55fd4c18b44b37fdc02f058baf30a" - integrity sha512-kvds8BHR2k28cFsxW8k3nc/tRga2rs1RHYCqmmGqb90MEeE++oALwzh2COiuBLO1/QXiOuShXoSN2ZpWnMmvuQ== - use-intl@^4.9.2: version "4.13.0" resolved "https://registry.yarnpkg.com/use-intl/-/use-intl-4.13.0.tgz#bc60d5b71864a5adf906c901fc660b6c578885f9" From f25790a446abc89c674f8e0aa37e2349e66a10ac Mon Sep 17 00:00:00 2001 From: fccview Date: Sun, 27 Sep 2026 16:15:03 +0100 Subject: [PATCH 07/28] better look for links and better guides overall --- .../skills/jotty/references/persistence.md | 23 +- AGENTS.md | 4 +- .../Admin/Parts/EditorSettingsTab.tsx | 6 - .../Parts/Common/ChecklistHeading.tsx | 8 +- .../Parts/Common/ChecklistItemText.tsx | 85 +++ .../Checklists/Parts/Common/ItemLinkPopup.tsx | 22 + .../Parts/Simple/NestedChecklistItem.tsx | 43 +- .../Kanban/ArchivedItemsModal.tsx | 3 +- .../FeatureComponents/Kanban/CalendarView.tsx | 3 +- .../FeatureComponents/Kanban/Kanban.tsx | 2 +- .../FeatureComponents/Kanban/KanbanCard.tsx | 3 +- .../Kanban/KanbanCardDetail.tsx | 12 +- .../Kanban/KanbanCardDetailSubtasks.tsx | 9 +- .../FeatureComponents/Kanban/KanbanColumn.tsx | 32 +- .../Kanban/KanbanItemContent.tsx | 8 +- .../CustomExtensions/CalloutExtension.tsx | 3 +- .../InternalLinkComponent.tsx | 62 +- .../Notes/Parts/UnifiedMarkdownRenderer.tsx | 24 +- .../Notes/Parts/WikiLink.tsx | 41 +- app/_consts/callouts.ts | 17 + app/_consts/user-settings.ts | 1 + app/_hooks/useItemLinkSuggestions.ts | 114 +++ app/_server/actions/relations/indexer.ts | 49 +- app/_server/actions/relations/watcher.ts | 122 +++ app/_server/actions/share/operations.ts | 58 +- app/_translations/de.json | 2 - app/_translations/en.json | 2 - app/_translations/es.json | 2 - app/_translations/fr.json | 2 - app/_translations/it.json | 2 - app/_translations/klingon.json | 2 - app/_translations/ko.json | 2 - app/_translations/nl.json | 2 - app/_translations/pirate.json | 2 - app/_translations/pl.json | 2 - app/_translations/pt.json | 2 - app/_translations/ru.json | 2 - app/_translations/tr.json | 2 - app/_translations/vi.json | 2 - app/_translations/zh.json | 2 - app/_utils/callout-utils.ts | 12 + app/_utils/item-href-utils.ts | 9 +- app/_utils/markdown-utils.tsx | 9 +- app/_utils/wikilink-utils.ts | 6 +- app/api/admin/rebuild-index/route.ts | 21 +- howto/API.md | 708 +++++++++--------- howto/BRAIN.md | 35 +- howto/CUSTOMISATIONS.md | 30 +- howto/DOCKER.md | 127 ++-- howto/ENCRYPTION.md | 184 +++-- howto/ENV-VARIABLES.md | 66 +- howto/LDAP.md | 91 +-- howto/MARKDOWN.md | 65 +- howto/MFA.md | 69 +- howto/PATCHES.md | 40 +- howto/PWA.md | 80 +- howto/SHORTCUTS.md | 43 +- howto/SSO.md | 79 +- howto/TRANSLATIONS.md | 66 +- howto/UNRAID.md | 59 +- instrumentation.ts | 2 + tests/api/rebuild-index.test.ts | 83 ++ tests/server-actions/relations.test.ts | 62 ++ tests/server-actions/share-migration.test.ts | 4 + tests/server-actions/sharing.test.ts | 4 + tests/server-actions/users.test.ts | 11 + tests/utils/callout-utils.test.ts | 68 ++ tests/utils/item-link-text.test.ts | 37 + 68 files changed, 1739 insertions(+), 1115 deletions(-) create mode 100644 app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistItemText.tsx create mode 100644 app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx create mode 100644 app/_consts/callouts.ts create mode 100644 app/_hooks/useItemLinkSuggestions.ts create mode 100644 app/_server/actions/relations/watcher.ts create mode 100644 app/_utils/callout-utils.ts create mode 100644 tests/api/rebuild-index.test.ts create mode 100644 tests/utils/callout-utils.test.ts create mode 100644 tests/utils/item-link-text.test.ts diff --git a/.agents/skills/jotty/references/persistence.md b/.agents/skills/jotty/references/persistence.md index 5e9d26b0a..97ddeb03c 100644 --- a/.agents/skills/jotty/references/persistence.md +++ b/.agents/skills/jotty/references/persistence.md @@ -1,6 +1,6 @@ # File persistence -There is no database. A missed lock, a half-written file, or a loop that stats 800 notes is a real outage for a real person. +Your files are the database. The only other store is a derived SQLite index of relationships, which can always be rebuilt from them. A missed lock, a half-written file, or a loop that stats 800 notes is a real outage for a real person. ## Layout @@ -11,12 +11,12 @@ data/ users/session-data.json notes// markdown notes in category folders checklists// markdown lists in category folders - notes//.index.json link index, keyed by uuid /.category-info.json folder uuid, sharing, order notifications/.json encryption// PGP key files, never the passphrase logs/ .schema-version + .relations.db derived links index (SQLite, WAL). Disposable. ``` `.sharing.json` and `.order.json` are leftovers. Current code reads them during migration. New writes go to `.category-info.json`. @@ -27,7 +27,7 @@ Schema version is `DATA_SCHEMA_VERSION` in `app/_consts/files.ts`. Old shapes st Notes and checklists are both `.md` with YAML frontmatter. Checklists are not JSON. `noteToMarkdown` / `listToMarkdown` write them. Readers parse them. -Frontmatter carries `uuid`, `title`, timestamps, tags, encryption flags, checklist type. Body is markdown. Encrypted body is opaque ciphertext. Do not index it. +Frontmatter carries `uuid`, `title`, `createdAt`, tags, encryption flags, checklist type. Body is markdown. Encrypted body is opaque ciphertext. Do not index it. ## Helpers @@ -35,10 +35,11 @@ Frontmatter carries `uuid`, `title`, timestamps, tags, encryption flags, checkli - `ensureDir` - `readJsonFile` / `writeJsonFile` (atomic temp + rename) -- `serverReadFile` / `serverWriteFile` / `serverDeleteFile` +- `serverReadFile` / `serverWriteFile` / `serverDeleteFile` / `serverDeleteDir` / `serverRenamePath` - `getUserModeDir` +- `stampUuid` in `lib/stamp-uuid.ts` for giving an item file its uuid -`writeCatInfo` is also atomic. `serverWriteFile` is a straight write. Prefer the atomic helpers for JSON. Do not invent a third writer. +`serverWriteFile` is atomic (temp file + rename), stamps `createdAt` on item files that lack it, invalidates the metadata cache and updates the relations index. Delete, rename and move go through the helpers above for the same reasons. A raw `fs.writeFile` or `fs.rename` on an item file skips all of that. Do not invent another writer. Paths: `path.join(process.cwd(), ...)`. Constants in `app/_consts/files.ts`. Never a relative `"data/..."` you hope is cwd. @@ -67,11 +68,15 @@ Jotty is one Node process. Those in-process maps are enough until someone cluste ## Indexes -`.index.json` under notes is the **link** index, keyed by item uuid. Create, update, delete, and move must call `updateIndexForItem` / `removeItemFromIndex`. It does not rebuild itself. +`data/.relations.db` is the **relations** index, in `app/_server/actions/relations/`. It holds items, links between them, wikilink bindings and plain note text for "Mentioned in". It is derived and disposable: a missing, corrupt or old-schema file is discarded and rebuilt from the markdown on start, with the UI showing "Indexing relationships...". Bump `RELATIONS_SCHEMA_VERSION` when its shape changes. No migration, it just rebuilds. -Folder order and sharing live in `.category-info.json` (`order.items` is a uuid list). +- Writes keep it current through the file helpers (`trackItemWrite` and friends in `relations/tracking.ts`). Use the helpers and you get it for free. +- Changes made outside Jotty are caught by a recursive `fs.watch` on the notes and checklists roots, throttled to one pass a minute, which only stats the paths that changed. If the watcher can't start, reads fall back to a full mtime reconcile at most once a minute. +- Encrypted notes are indexed by uuid and title only. Their body is never parsed. +- `bindings` remembers which uuid each `[[wikilink]]` text first resolved to, per source note. That memory survives edits and rebuilds, and is lost only if the file is deleted. +- Queries are permission scoped through `visibleItems(username)`. Never return rows the viewer can't see. -If you add a write path and forget the index, search and the graph go stale with nothing in the logs. +Folder order and sharing live in `.category-info.json` (`order.items` is a uuid list). ## Path containment @@ -83,4 +88,4 @@ Username is not a path segment you trust from the client either. Session usernam ## Data on this machine -The `data/` directory in a running instance holds real notes. Tests use mocks and temp dirs. `yarn mock:data:notes` / `yarn mock:data:lists` fill a named user for local poking. Do not empty, reshape, or "fix" `data/` to make a test pass. +The `data/` directory in a running instance holds real notes. Tests use mocks and temp dirs. `yarn mock:data:notes` / `yarn mock:data:lists` fill a named user for local poking. `yarn mock:data:brain --user=` writes a linked set under a `Brain Seed` category, and `--remove` deletes only that category. Do not empty, reshape, or "fix" `data/` to make a test pass. diff --git a/AGENTS.md b/AGENTS.md index ef7f48ce7..74623e6cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Jotty -Jotty is a self-hosted app for notes, checklists and Kanban boards. There is no database. Every note, checklist, user and share is a file on a disk that the person running it owns. +Jotty is a self-hosted app for notes, checklists and Kanban boards. Every note, checklist, user and share is a file on a disk that the person running it owns. The one database, `data/.relations.db`, is a derived index of links. Deleting it loses nothing anybody wrote: it is rebuilt from those files. Never make it the only home of user data. If we corrupt one of those files there is no restore button and nobody to email. There is a person and whatever backup they happened to take. @@ -12,7 +12,7 @@ Read, change, write back on a shared file without a lock loses data. It has happ A write that fails halfway leaves a truncated note where a note used to be. Use the existing file helpers, they write atomically. -Indexes do not rebuild themselves. Add a write path, miss the rebuild, and search goes stale with nothing in the logs to say so. +Indexes go stale quietly. The file helpers keep the caches and the relations index in step, so write through them. A raw `fs` write on an item file leaves search, backlinks and the brain wrong with nothing in the logs to say so. Loops are expensive. One helper reading one item is fine. That same helper running once per item for a user with 800 notes is a stat storm. Caches and indexes already exist for this, look for them before you walk a directory yourself. diff --git a/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx b/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx index 24ae1f753..e5a0d8e61 100644 --- a/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx +++ b/app/_components/FeatureComponents/Admin/Parts/EditorSettingsTab.tsx @@ -253,15 +253,9 @@ export const EditorSettingsTab = () => { (null); const tagMentionsRef = useRef<{ onKeyDown: (event: KeyboardEvent) => boolean; focusSearch: () => void }>(null); const { user, tagsIndex, tagsEnabled } = useAppMode(); + const linkSuggestions = useItemLinkSuggestions(newItemText, setNewItemText, inputRef); const { showCompletedSuggestions: sessionShowCompletedSuggestions } = useSettings(); @@ -161,6 +164,7 @@ export const ChecklistHeading = ({ }; const handleInputKeyDown = (e: React.KeyboardEvent) => { + if (linkSuggestions.handleKeyDown(e)) return; if (!showTagSuggestions || !tagMentionsRef.current) return; if (e.key === "Escape") { setShowTagSuggestions(false); @@ -300,7 +304,9 @@ export const ChecklistHeading = ({ />
)} - {showSuggestions && + + {!linkSuggestions.open && + showSuggestions && filteredSuggestions.length > 0 && newItemText.trim() !== "" && (
label.replace(/\\(.)/g, "$1"); + +const _hush = (event: SyntheticEvent) => event.stopPropagation(); + +const Inline = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +export const ChecklistItemText = ({ + text, + tagsEnabled = false, + compact = false, +}: ChecklistItemTextProps) => { + const { appSettings } = useAppMode(); + const linksEnabled = appSettings?.editor?.enableBilateralLinks !== false; + const parts: ReactNode[] = []; + let last = 0; + + for (const match of Array.from(text.matchAll(TOKENS))) { + const at = match.index ?? 0; + const [whole, label, href, wikiTarget, wikiLabel, tag] = match; + let piece: ReactNode = null; + + if (href !== undefined && linksEnabled) { + const target = parseItemHref(href, currentOrigins()); + if (target) { + piece = ( + + ); + } + } else if (wikiTarget !== undefined && linksEnabled) { + piece = ( + + ); + } else if (tag !== undefined && tagsEnabled) { + piece = ; + } + + if (!piece) continue; + if (at > last) parts.push(text.slice(last, at)); + parts.push( + + {piece} + , + ); + last = at + whole.length; + } + + if (last < text.length) parts.push(text.slice(last)); + return <>{parts.map((part, index) => {part})}; +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx b/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx new file mode 100644 index 000000000..e4d702c0e --- /dev/null +++ b/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { AtMentionsList } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/AtMentionsList"; +import type { ItemLinkSuggestions } from "@/app/_hooks/useItemLinkSuggestions"; + +export const ItemLinkPopup = ({ suggestions }: { suggestions: ItemLinkSuggestions }) => { + if (!suggestions.open) return null; + + return ( +
+ suggestions.pick(item as (typeof suggestions.items)[number])} + /> +
+ ); +}; diff --git a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx index 524cd7ecc..a66fb1684 100644 --- a/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx +++ b/app/_components/FeatureComponents/Checklists/Parts/Simple/NestedChecklistItem.tsx @@ -22,9 +22,11 @@ import { useShowEmojis } from "@/app/_hooks/useShowEmojis"; import { useEmojiCache } from "@/app/_hooks/useEmojiCache"; import { Checklist, Item } from "@/app/_types"; import { useAppMode } from "@/app/_providers/AppModeProvider"; -import { TagLinkViewComponent } from "@/app/_components/FeatureComponents/Tags/TagLinkComponent"; import { Input } from "@/app/_components/GlobalComponents/FormElements/Input"; import { useTagSuggestions } from "@/app/_hooks/useTagSuggestions"; +import { useItemLinkSuggestions } from "@/app/_hooks/useItemLinkSuggestions"; +import { ItemLinkPopup } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup"; +import { ChecklistItemText } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ChecklistItemText"; import { TagMentionsList } from "@/app/_components/FeatureComponents/Notes/Parts/TipTap/CustomExtensions/TagMentionsList"; import LastModifiedCreatedInfo from "../Common/LastModifiedCreatedInfo"; import { RecurrenceIndicator } from "@/app/_components/GlobalComponents/Indicators/RecurrenceIndicator"; @@ -124,6 +126,12 @@ const NestedChecklistItemComponent = ({ addSubItemInputRef, { tagsIndex, tagsEnabled: !!tagsEnabled } ); + const editLinkSuggestions = useItemLinkSuggestions(editText, setEditText, inputRef); + const subItemLinkSuggestions = useItemLinkSuggestions( + newSubItemText, + setNewSubItemText, + addSubItemInputRef, + ); useEffect(() => { const editorId = `checklist-item-${item.id}`; @@ -179,6 +187,7 @@ const NestedChecklistItemComponent = ({ }; const handleEditKeyDown = (e: React.KeyboardEvent) => { + if (editLinkSuggestions.handleKeyDown(e)) return; if ( editTagSuggestions.showTagSuggestions && editTagSuggestions.tagMentionsRef.current @@ -259,27 +268,6 @@ const NestedChecklistItemComponent = ({ const displayText = showEmojis ? `${emoji} ${cleanText}` : cleanText; const hasChildren = item.children && item.children.length > 0; - const renderTextWithHashtags = (text: string): React.ReactNode[] => { - const hashtagPattern = /#([a-zA-Z][a-zA-Z0-9_/-]*)/g; - const parts: React.ReactNode[] = []; - let lastIndex = 0; - let match; - while ((match = hashtagPattern.exec(text)) !== null) { - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - parts.push( - e.stopPropagation()}> - - - ); - lastIndex = match.index + match[0].length; - } - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - return parts; - }; const isChild = level > 0; const dropdownOptions = [ @@ -412,6 +400,7 @@ const NestedChecklistItemComponent = ({ />
)} +
@@ -642,6 +631,7 @@ const NestedChecklistItemComponent = ({ /> )} +
{ + if (subItemLinkSuggestions.handleKeyDown(e)) return; + subItemTagSuggestions.handleKeyDown(e); + }} placeholder={t("checklists.addSubItemPlaceholder")} autoFocus /> diff --git a/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx b/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx index 6181155a8..484fcd321 100644 --- a/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx +++ b/app/_components/FeatureComponents/Kanban/ArchivedItemsModal.tsx @@ -1,5 +1,6 @@ "use client"; +import { plainItemText } from "@/app/_utils/item-href-utils"; import { useState, useMemo } from "react"; import { Archive02Icon } from "hugeicons-react"; import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal"; @@ -105,7 +106,7 @@ export const ArchivedItemsModal = ({ }} >
-

{item.text}

+

{plainItemText(item.text)}

{item.archivedAt && (

diff --git a/app/_components/FeatureComponents/Kanban/CalendarView.tsx b/app/_components/FeatureComponents/Kanban/CalendarView.tsx index 2ff5ee70f..2f54a185c 100644 --- a/app/_components/FeatureComponents/Kanban/CalendarView.tsx +++ b/app/_components/FeatureComponents/Kanban/CalendarView.tsx @@ -1,5 +1,6 @@ "use client"; +import { plainItemText } from "@/app/_utils/item-href-utils"; import { KeyboardEvent } from "react"; import { Checklist, Item } from "@/app/_types"; import { useCalendarView } from "@/app/_hooks/kanban/useCalendarView"; @@ -203,7 +204,7 @@ export const CalendarView = ({ checklist, onItemClick }: CalendarViewProps) => { onClick={() => onItemClick?.(item)} className="text-xs px-2 py-1 rounded-jotty border border-border bg-muted/30 text-foreground cursor-pointer hover:bg-muted/50 transition-colors" > - {item.text} + {plainItemText(item.text)}

))}
diff --git a/app/_components/FeatureComponents/Kanban/Kanban.tsx b/app/_components/FeatureComponents/Kanban/Kanban.tsx index 61cf6ee08..d80692ba6 100644 --- a/app/_components/FeatureComponents/Kanban/Kanban.tsx +++ b/app/_components/FeatureComponents/Kanban/Kanban.tsx @@ -258,7 +258,7 @@ export const Kanban = ({ checklist, onUpdate }: KanbanBoardProps) => {
onOpenDetail(item)} className={cn( "group bg-background border rounded-jotty p-3 transition-all duration-200 hover:shadow-md cursor-grab active:cursor-grabbing min-w-0", diff --git a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx index 97388e9a2..a0a680c4d 100644 --- a/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx +++ b/app/_components/FeatureComponents/Kanban/KanbanCardDetail.tsx @@ -1,6 +1,9 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; +import { plainItemText } from "@/app/_utils/item-href-utils"; +import { useState, useEffect, useMemo, useRef } from "react"; +import { useItemLinkSuggestions } from "@/app/_hooks/useItemLinkSuggestions"; +import { ItemLinkPopup } from "@/app/_components/FeatureComponents/Checklists/Parts/Common/ItemLinkPopup"; import { Modal } from "@/app/_components/GlobalComponents/Modals/Modal"; import { Button } from "@/app/_components/GlobalComponents/Buttons/Button"; import { Item, Checklist, KanbanPriority } from "@/app/_types"; @@ -94,6 +97,8 @@ export const KanbanCardDetail = ({ const [item, setItem] = useState(initialItem); const [isEditing, setIsEditing] = useState(false); const [editText, setEditText] = useState(initialItem.text); + const titleInputRef = useRef(null); + const titleLinks = useItemLinkSuggestions(editText, setEditText, titleInputRef); const [editDescription, setEditDescription] = useState(_unsanitizeDescription(initialItem.description || "")); const [scoreInput, setScoreInput] = useState(initialItem.score?.toString() || ""); const [reminderInput, setReminderInput] = useState(initialItem.reminder?.datetime || ""); @@ -423,7 +428,7 @@ export const KanbanCardDetail = ({ setEditText(e.target.value)} className="w-full px-3 py-2 bg-background border border-input rounded-jotty focus:outline-none focus:border-ring transition-all text-base" placeholder={t("checklists.enterTaskTitle")} onKeyDown={(e) => { + if (titleLinks.handleKeyDown(e)) return; if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSave(); } else if (e.key === "Escape") { e.preventDefault(); setEditText(item.text); setEditDescription(_unsanitizeDescription(item.description || "")); setIsEditing(false); } }} /> +