/// import { Cell, computed, type Default, handler, lift, NAME, navigateTo, pattern, UI, wish, } from "commontools"; import Note from "./note.tsx"; // Simple random ID generator (crypto.randomUUID not available in pattern env) const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 11)}`; import Notebook from "./notebook.tsx"; // Types for notes in the space type NoteCharm = { [NAME]?: string; title?: string; content?: string; isHidden?: boolean; noteId?: string; }; type NotebookCharm = { [NAME]?: string; title?: string; notes?: NoteCharm[]; }; type AllCharmsType = NoteCharm[]; interface Input { importMarkdown: Default; } interface Output { exportedMarkdown: string; importMarkdown: string; noteCount: number; } // HTML comment markers for bulletproof note delimiting const NOTE_START_MARKER = ""; // HTML comment markers for notebook blocks (for hierarchical export/import) const NOTEBOOK_START_MARKER = ""; // Helper to resolve proxy value to primitive string (for export function) function resolveValue(value: unknown): string { try { return JSON.parse(JSON.stringify(value)) as string; } catch { return String(value ?? ""); } } // Helper to resolve proxy value to boolean (for isHidden export) // Tries multiple approaches since OpaqueRef serialization can be tricky function resolveBooleanValue(value: unknown, parentObj?: unknown): boolean { // First try: serialize the property directly try { const resolved = JSON.parse(JSON.stringify(value)); if (resolved === true || resolved === "true") return true; } catch { // ignore } // Second try: if we have the parent object, serialize it and extract the property if (parentObj) { try { const serialized = JSON.parse(JSON.stringify(parentObj)); if (serialized?.isHidden === true) return true; } catch { // ignore } } // Fallback: check string representation return String(value) === "true"; } // Helper to get notebook names that contain a note (by noteId) function getNotebookNamesForNote( note: NoteCharm, notebooks: NotebookCharm[], ): string[] { // Use JSON.parse(JSON.stringify()) to fully resolve proxy values const noteId = resolveValue(note?.noteId); if (!noteId) return []; const names: string[] = []; for (const nb of notebooks) { const nbNotes = nb?.notes ?? []; for (const n of nbNotes) { // Compare resolved string values if (resolveValue(n?.noteId) === noteId) { // Strip emoji prefix and count suffix from notebook name const rawName = (nb as any)?.[NAME] ?? ""; const cleanName = rawName .replace(/^π\s*/, "") .replace(/\s*\(\d+\)$/, ""); if (cleanName) names.push(cleanName); break; } } } return names; } // Strip entity IDs from mentions: [[Name (id)]] β [[Name]] // This makes exports portable across spaces (IDs are space-specific) function stripMentionIds(content: string): string { return content.replace(/\[\[([^\]]*?)\s*\([^)]+\)\]\]/g, "[[$1]]"); } // Helper to check if a charm is a notebook (by NAME prefix) function isNotebookCharm(charm: unknown): boolean { const name = (charm as any)?.[NAME]; return typeof name === "string" && name.startsWith("π"); } // Helper to get clean notebook title (strip emoji and count) function getCleanNotebookTitle(notebook: unknown): string { const rawName = (notebook as any)?.[NAME] ?? (notebook as any)?.title ?? ""; return rawName.replace(/^π\s*/, "").replace(/\s*\(\d+\)$/, ""); } // Helper to get noteIds and child notebook titles from a notebook's contents function getNotebookContents( notebook: NotebookCharm, ): { noteIds: string[]; childNotebookTitles: string[] } { const notes = (notebook as any)?.notes ?? []; const noteIds: string[] = []; const childNotebookTitles: string[] = []; for (const item of notes) { if (isNotebookCharm(item)) { // It's a nested notebook const title = getCleanNotebookTitle(item); if (title) childNotebookTitles.push(title); } else { // It's a note - get its noteId const noteId = resolveValue((item as any)?.noteId); if (noteId) noteIds.push(noteId); } } return { noteIds, childNotebookTitles }; } // Plain function version for imperative use in handlers (lift() doesn't work in handlers) // Now includes hierarchical notebook export (v2 format) // allCharmsRaw is the raw allCharms array for looking up isHidden values function filterAndFormatNotesPlain( charms: NoteCharm[], notebooks: NotebookCharm[], allCharmsRaw?: unknown[], ): { markdown: string; count: number; notebookCount: number } { // Filter to only note charms (have title and content properties) const notes = charms.filter( (charm) => charm?.title !== undefined && charm?.content !== undefined, ); // Format each note with HTML comment block markers (including noteId, notebooks, and isHidden) const formattedNotes = notes.map((note) => { const title = resolveValue(note?.title) || "Untitled Note"; const rawContent = resolveValue(note?.content) || ""; // Strip mention IDs for portable export const content = stripMentionIds(rawContent); const noteId = resolveValue(note?.noteId) || ""; const notebookNames = getNotebookNamesForNote(note, notebooks); // Resolve Cell/OpaqueRef to get actual boolean value (pass parent for fallback serialization) const isHidden = resolveBooleanValue((note as any)?.isHidden, note); // Escape quotes in title for the attribute const escapedTitle = title.replace(/"/g, """); const notebooksStr = notebookNames.join(", "); return `${NOTE_START_MARKER} title="${escapedTitle}" noteId="${noteId}" notebooks="${notebooksStr}" isHidden="${isHidden}" -->\n\n${content}\n\n${NOTE_END_MARKER}`; }); // Format each notebook with hierarchy info const formattedNotebooks = notebooks.map((notebook) => { const title = getCleanNotebookTitle(notebook); const escapedTitle = title.replace(/"/g, """); // Look up isHidden from allCharmsRaw by matching NAME (more reliable than direct property access) let isHidden = false; if (allCharmsRaw) { const notebookName = (notebook as any)?.[NAME]; for (const charm of allCharmsRaw) { const charmName = (charm as any)?.[NAME]; if (charmName === notebookName) { // Found the matching charm - try to get isHidden isHidden = resolveBooleanValue((charm as any)?.isHidden, charm); break; } } } else { // Fallback to direct property access isHidden = resolveBooleanValue((notebook as any)?.isHidden, notebook); } const { noteIds, childNotebookTitles } = getNotebookContents(notebook); // Escape commas in child notebook titles and join const noteIdsStr = noteIds.join(","); const childNotebooksStr = childNotebookTitles .map((t) => t.replace(/,/g, ",")) .join(","); return `${NOTEBOOK_START_MARKER} title="${escapedTitle}" isHidden="${isHidden}" noteIds="${noteIdsStr}" childNotebooks="${childNotebooksStr}" -->\n${NOTEBOOK_END_MARKER}`; }); // Add timestamp header with format version const timestamp = new Date().toISOString(); const header = `\n\n\n\n`; // Combine notes and notebooks sections const notesSection = formattedNotes.length > 0 ? `\n\n${formattedNotes.join("\n\n")}` : ""; const notebooksSection = formattedNotebooks.length > 0 ? `\n\n\n\n${formattedNotebooks.join("\n\n")}` : ""; const markdown = notes.length === 0 && notebooks.length === 0 ? "No notes or notebooks found in this space." : header + notesSection + notebooksSection; return { markdown, count: notes.length, notebookCount: notebooks.length, }; } // Filter charms to only include notes and format as markdown with HTML comment blocks // Takes a combined input object since lift() only accepts one argument // Currently unused - kept for potential future use const _filterAndFormatNotes = lift( (input: { charms: NoteCharm[]; notebooks: NotebookCharm[]; }): { notes: NoteCharm[]; markdown: string; count: number } => { const { charms, notebooks } = input; // Filter to only note charms (have title and content properties) const notes = charms.filter( (charm) => charm?.title !== undefined && charm?.content !== undefined, ); if (notes.length === 0) { return { notes: [], markdown: "No notes found in this space.", count: 0 }; } // Format each note with HTML comment block markers (including noteId and notebooks) const formatted = notes.map((note) => { const title = note?.title || "Untitled Note"; const rawContent = note?.content || ""; // Strip mention IDs for portable export const content = stripMentionIds(rawContent); const noteId = note?.noteId || ""; const notebookNames = getNotebookNamesForNote(note, notebooks); // Escape quotes in title for the attribute const escapedTitle = title.replace(/"/g, """); const notebooksStr = notebookNames.join(", "); return `${NOTE_START_MARKER} title="${escapedTitle}" noteId="${noteId}" notebooks="${notebooksStr}" -->\n\n${content}\n\n${NOTE_END_MARKER}`; }); return { notes, markdown: formatted.join("\n\n"), count: notes.length, }; }, ); // Parsed note data type (v2 with isHidden) type ParsedNote = { title: string; content: string; noteId?: string; notebooks?: string[]; isHidden?: boolean; }; // Parsed notebook data type (v2 hierarchical) type ParsedNotebook = { title: string; isHidden: boolean; noteIds: string[]; childNotebookTitles: string[]; }; // Parse markdown with HTML comment blocks into individual notes (plain function for use in handlers) // Supports v1 format (no isHidden) and v2 format (with isHidden) function parseMarkdownToNotesPlain(markdown: string): ParsedNote[] { if (!markdown || markdown.trim() === "") return []; const notes: ParsedNote[] = []; // Regex to match COMMON_NOTE blocks with all attributes (v1 and v2 compatible) // v1: title, noteId, notebooks // v2: title, noteId, notebooks, isHidden const noteBlockRegex = /([\s\S]*?)/g; let match; while ((match = noteBlockRegex.exec(markdown)) !== null) { // Unescape HTML entities in title const title = match[1].replace(/"/g, '"') || "Imported Note"; const noteId = match[2] || undefined; const notebooksStr = match[3] || ""; const isHiddenStr = match[4] || ""; const content = match[5].trim(); // Parse notebooks string into array (comma-separated) const notebooks = notebooksStr ? notebooksStr.split(",").map((s) => s.trim()).filter(Boolean) : undefined; // Parse isHidden (default to undefined if not specified for v1 compatibility) const isHidden = isHiddenStr === "true" ? true : isHiddenStr === "false" ? false : undefined; notes.push({ title, content, noteId, notebooks, isHidden }); } return notes; } // Parse markdown with HTML comment blocks into notebook structures (v2 format) function parseMarkdownToNotebooksPlain(markdown: string): ParsedNotebook[] { if (!markdown || markdown.trim() === "") return []; const notebooks: ParsedNotebook[] = []; // Regex to match COMMON_NOTEBOOK blocks const notebookBlockRegex = //g; let match; while ((match = notebookBlockRegex.exec(markdown)) !== null) { // Unescape HTML entities in title const title = match[1].replace(/"/g, '"') || "Imported Notebook"; const isHidden = match[2] === "true"; const noteIdsStr = match[3] || ""; const childNotebooksStr = match[4] || ""; // Parse noteIds (comma-separated, no spaces) const noteIds = noteIdsStr ? noteIdsStr.split(",").filter(Boolean) : []; // Parse child notebook titles (comma-separated, unescape , β ,) const childNotebookTitles = childNotebooksStr ? childNotebooksStr.split(",").map((t) => t.replace(/,/g, ",")) .filter(Boolean) : []; notebooks.push({ title, isHidden, noteIds, childNotebookTitles }); } return notebooks; } // Type for tracking detected duplicates during import type DetectedDuplicate = { title: string; noteId?: string; existingNotebook: string; }; // Type for tracking duplicates when adding notes to a notebook type NotebookDuplicate = { title: string; noteId: string; noteIndex: number; // Index in the notes array }; // Topological sort for notebooks: returns titles in order (leaves first, parents last) // Returns indices in topological order (leaves first, parents last) // Handles duplicate titles by using indices instead of titles function topologicalSortNotebooks(notebooks: ParsedNotebook[]): number[] { // Build map of title -> indices (handles duplicates) const titleToIndices = new Map(); notebooks.forEach((nb, idx) => { const indices = titleToIndices.get(nb.title) ?? []; indices.push(idx); titleToIndices.set(nb.title, indices); }); const visited = new Set(); const result: number[] = []; function visit(idx: number) { if (visited.has(idx)) return; visited.add(idx); const nb = notebooks[idx]; if (nb) { // Visit children first (leaves before parents) for (const childTitle of nb.childNotebookTitles) { // Find child indices - for duplicates, visit all matching const childIndices = titleToIndices.get(childTitle) ?? []; for (const childIdx of childIndices) { visit(childIdx); } } } result.push(idx); } // Visit all notebooks by index for (let i = 0; i < notebooks.length; i++) { visit(i); } return result; } // Helper to perform the actual import (used by both direct import and after duplicate confirmation) // Supports v1 format (notes with notebooks attr) and v2 format (hierarchical notebooks) // Uses multi-pass approach: // 1. Parse notes and notebooks from markdown // 2. Create all notes first (with isHidden preserved) // 3. Create notebooks in topological order (leaves first, then parents) // 4. Link notes to notebooks using noteIds // 5. Link child notebooks to parent notebooks // 6. Inject entity IDs into mention links function performImport( parsed: ParsedNote[], allCharms: Cell, notebooks: Cell, skipTitles: Set, // Titles to skip (duplicates user chose not to import) _importStatus?: Cell, // Unused - kept for API compatibility onComplete?: () => void, // Callback when import is done rawMarkdown?: string, // Original markdown for v2 notebook parsing ) { const notebooksList = notebooks.get(); // Build set of existing notebook names const existingNames = new Set(); notebooksList.forEach((nb: any) => { const rawName = nb?.[NAME] ?? ""; const cleanName = rawName.replace(/^π\s*/, "").replace(/\s*\(\d+\)$/, ""); if (cleanName) existingNames.add(cleanName); }); // Parse v2 notebook blocks if markdown provided const parsedNotebooks = rawMarkdown ? parseMarkdownToNotebooksPlain(rawMarkdown) : []; // Build noteId β notebook titles map from v2 format const noteIdToNotebookTitles = new Map(); for (const nb of parsedNotebooks) { for (const noteId of nb.noteIds) { if (!noteIdToNotebookTitles.has(noteId)) { noteIdToNotebookTitles.set(noteId, []); } noteIdToNotebookTitles.get(noteId)!.push(nb.title); } } // Collect unique notebook names needed (from v1 notes attr OR v2 notebook blocks) const notebooksNeeded = new Set(); for (const noteData of parsed) { if (!skipTitles.has(noteData.title)) { // v1 format: notebook names in note's notebooks attribute noteData.notebooks?.forEach((name) => notebooksNeeded.add(name)); // v2 format: notebook names from noteId mapping if (noteData.noteId) { const nbTitles = noteIdToNotebookTitles.get(noteData.noteId); nbTitles?.forEach((name) => notebooksNeeded.add(name)); } } } // Also add any notebooks from v2 that have no notes (empty notebooks or parent-only) for (const nb of parsedNotebooks) { notebooksNeeded.add(nb.title); } // === PHASE 1: Create all notes (batch - don't push yet) === const createdNotes: Array<{ title: string; noteId: string; index: number; contentCell: Cell; originalContent: string; }> = []; // Map noteId β created note charm for linking const noteIdToCharm = new Map(); // Map notebook name β notes to add (from v1 format or v2 noteId mapping) const notesByNotebook = new Map(); const startingIndex = allCharms.get().length; let currentIndex = startingIndex; const newItems: NoteCharm[] = []; parsed.forEach((noteData) => { if (skipTitles.has(noteData.title)) return; const contentCell = Cell.of(noteData.content); const noteIdToUse = noteData.noteId || generateId(); // Determine isHidden: // - v2 format: use explicit isHidden from parsed data // - v1 format: hidden if belongs to any notebook // - fallback: hidden if has notebook membership (v1 or v2 inferred) const belongsToNotebook = (noteData.notebooks && noteData.notebooks.length > 0) || noteIdToNotebookTitles.has(noteIdToUse); const isHidden = noteData.isHidden !== undefined ? noteData.isHidden : belongsToNotebook; const note = Note({ title: noteData.title, content: contentCell, noteId: noteIdToUse, isHidden, }); newItems.push(note as unknown as NoteCharm); noteIdToCharm.set(noteIdToUse, note); createdNotes.push({ title: noteData.title, noteId: noteIdToUse, index: currentIndex, contentCell, originalContent: noteData.content, }); currentIndex++; // Track which notebooks this note belongs to (v1 format) if (noteData.notebooks) { for (const notebookName of noteData.notebooks) { if (!notesByNotebook.has(notebookName)) { notesByNotebook.set(notebookName, []); } notesByNotebook.get(notebookName)!.push(note); } } }); // For v2 format, also populate notesByNotebook from noteId mapping for (const nb of parsedNotebooks) { if (!notesByNotebook.has(nb.title)) { notesByNotebook.set(nb.title, []); } for (const noteId of nb.noteIds) { const charm = noteIdToCharm.get(noteId); if (charm) { const existing = notesByNotebook.get(nb.title)!; // Avoid duplicates (in case v1 and v2 overlap) if (!existing.includes(charm)) { existing.push(charm); } } } } // === PHASE 2: Create notebooks in topological order === // For v2 format, we have hierarchy info. For v1, just create flat notebooks. // Track created notebooks by INDEX (not title) to handle duplicates const createdNotebookByIndex = new Map(); // Track which titles have been used (for deduplication) const usedTitles = new Set(existingNames); // Track notebooks separately so we can reorder them to match original export order const createdNotebooks: Array<{ originalIndex: number; notebook: unknown; }> = []; // Helper to generate unique title const getUniqueTitle = (baseTitle: string): string => { if (!usedTitles.has(baseTitle)) { usedTitles.add(baseTitle); return baseTitle; } let counter = 2; while (usedTitles.has(`${baseTitle} (${counter})`)) { counter++; } const uniqueTitle = `${baseTitle} (${counter})`; usedTitles.add(uniqueTitle); return uniqueTitle; }; if (parsedNotebooks.length > 0) { // v2 format: create in topological order (leaves first, for dependency resolution) // Returns INDICES, not titles, to handle duplicates const sortedIndices = topologicalSortNotebooks(parsedNotebooks); // Build map of original title -> child indices (for looking up children) const titleToChildIndices = new Map(); parsedNotebooks.forEach((nb, idx) => { // This notebook is a child of any parent that lists it in childNotebookTitles // We track by the notebook's own title for lookup const indices = titleToChildIndices.get(nb.title) ?? []; indices.push(idx); titleToChildIndices.set(nb.title, indices); }); for (const idx of sortedIndices) { const nbData = parsedNotebooks[idx]; if (!nbData) continue; // Generate unique title (handles duplicates) const actualName = getUniqueTitle(nbData.title); // Collect notes for this notebook (by original title) const notesForNotebook = notesByNotebook.get(nbData.title) ?? []; // Collect child notebooks by looking up their indices const childNotebooks: unknown[] = []; for (const childTitle of nbData.childNotebookTitles) { const childIndices = titleToChildIndices.get(childTitle) ?? []; for (const childIdx of childIndices) { const childCharm = createdNotebookByIndex.get(childIdx); if (childCharm) { childNotebooks.push(childCharm); } } } // Combine notes and child notebooks const allContents = [ ...notesForNotebook, ...childNotebooks, ] as unknown as NoteCharm[]; const newNb = Notebook({ title: actualName, notes: allContents, isHidden: nbData.isHidden ?? false, }); // Track by index for child lookup createdNotebookByIndex.set(idx, newNb); // Track for later reordering createdNotebooks.push({ originalIndex: idx, notebook: newNb }); } // Sort notebooks back to original export order and add to newItems createdNotebooks.sort((a, b) => a.originalIndex - b.originalIndex); for (const { notebook } of createdNotebooks) { newItems.push(notebook as unknown as NoteCharm); } } else { // v1 format: create flat notebooks (no hierarchy info) for (const nbName of notebooksNeeded) { // Use getUniqueTitle for consistency (handles duplicates) const actualName = getUniqueTitle(nbName); const notesForNotebook = notesByNotebook.get(nbName) ?? []; const newNb = Notebook({ title: actualName, notes: notesForNotebook as unknown as NoteCharm[], }); newItems.push(newNb as unknown as NoteCharm); } } // === BATCH PUSH: Single set() instead of N push() calls === if (newItems.length > 0) { allCharms.set([...allCharms.get(), ...newItems]); } // === PHASE 3: Build titleβID map and link mentions === const titleToId = new Map(); for (const { title, index } of createdNotes) { try { const noteCell = allCharms.key(index) as any; const resolved = noteCell.resolveAsCell(); const entityId = resolved?.entityId; if (entityId?.["/"] && title) { titleToId.set(title.toLowerCase(), entityId["/"]); } } catch (_e) { // Ignore errors getting entityId } } // Inject IDs into content for all created notes for ( const { title: _title, originalContent, contentCell } of createdNotes ) { try { const content = originalContent ?? ""; if (!content) continue; const updatedContent = content.replace( /\[\[([^\]]+)\]\]/g, (match: string, name: string) => { if (name.includes("(") && name.endsWith(")")) return match; const cleanName = name.trim().replace(/^(π|π)\s*/, "") .toLowerCase(); const id = titleToId.get(cleanName); if (id) { return `[[${name.trim()} (${id})]]`; } return match; }, ); if (updatedContent !== content) { contentCell.set(updatedContent); } } catch (_e) { // Ignore errors updating content } } onComplete?.(); } // Handler for file upload in import modal - reads file and triggers import directly const handleImportFileUpload = handler< { detail: { files: Array<{ url: string; name: string }> } }, { importMarkdown: Cell; notes: Cell; allCharms: Cell; notebooks: Cell; showDuplicateModal: Cell; detectedDuplicates: Cell; pendingImportData: Cell; showImportModal: Cell; showImportProgressModal: Cell; importProgressMessage: Cell; importComplete: Cell; showPasteSection?: Cell; } >(({ detail }, state) => { const files = detail?.files ?? []; if (files.length === 0) return; // data URL format: "data:text/plain;base64,..." or "data:text/markdown;base64,..." const dataUrl = files[0].url; const base64Part = dataUrl.split(",")[1]; if (!base64Part) return; // Decode base64 properly for UTF-8 (atob alone corrupts non-ASCII chars) const binaryString = atob(base64Part); const bytes = Uint8Array.from(binaryString, (char) => char.charCodeAt(0)); const content = new TextDecoder().decode(bytes); // Parse both notes AND notebooks from the file const parsedNotes = parseMarkdownToNotesPlain(content); const parsedNotebooks = parseMarkdownToNotebooksPlain(content); // If neither notes nor notebooks found, the file isn't in the expected format if (parsedNotes.length === 0 && parsedNotebooks.length === 0) { console.warn("Import: No notes or notebooks found in file"); return; } // Get existing notes for duplicate detection const existingNotes = state.notes.get(); const existingByTitle = new Map(); existingNotes.forEach((note: any) => { const title = note?.title; if (title) existingByTitle.set(title, note); }); // Detect duplicates (only for notes) const duplicates: DetectedDuplicate[] = []; for (const noteData of parsedNotes) { const existing = existingByTitle.get(noteData.title); if (existing) { duplicates.push({ title: noteData.title, noteId: noteData.noteId, existingNotebook: "this space", }); } } // Build progress message const itemCounts: string[] = []; if (parsedNotes.length > 0) { itemCounts.push( `${parsedNotes.length} note${parsedNotes.length !== 1 ? "s" : ""}`, ); } if (parsedNotebooks.length > 0) { itemCounts.push( `${parsedNotebooks.length} notebook${ parsedNotebooks.length !== 1 ? "s" : "" }`, ); } const importSummary = itemCounts.join(" and "); if (duplicates.length > 0) { // Store pending import and show duplicate modal state.pendingImportData.set(content); state.detectedDuplicates.set(duplicates); state.showDuplicateModal.set(true); state.showImportModal.set(false); state.showPasteSection?.set(true); // Reset for next time } else { // Set all state BEFORE showing modal to avoid default state flicker state.showImportModal.set(false); state.showPasteSection?.set(true); // Reset for next time state.importComplete.set(false); state.importProgressMessage.set(`Importing ${importSummary}...`); // NOW show the modal (after state is set) state.showImportProgressModal.set(true); // Run import synchronously (pass raw content for v2 notebook parsing) performImport( parsedNotes, state.allCharms, state.notebooks, new Set(), undefined, undefined, content, ); // Mark import as complete state.importProgressMessage.set(`Imported ${importSummary}!`); state.importComplete.set(true); } }); // Handler to analyze import and detect duplicates const analyzeImport = handler< Record, { importMarkdown: Cell; notes: Cell; allCharms: Cell; notebooks: Cell; showDuplicateModal: Cell; detectedDuplicates: Cell; pendingImportData: Cell; showImportModal?: Cell; importStatus?: Cell; showImportProgressModal?: Cell; importProgressMessage?: Cell; importComplete?: Cell; showPasteSection?: Cell; } >((_, state) => { const { importMarkdown, notes, allCharms, notebooks, showDuplicateModal, detectedDuplicates, pendingImportData, showImportModal, importStatus: _importStatus, showImportProgressModal, importProgressMessage, importComplete, showPasteSection, } = state; const markdown = importMarkdown.get(); // Parse both notes AND notebooks from the pasted content const parsedNotes = parseMarkdownToNotesPlain(markdown); const parsedNotebooks = parseMarkdownToNotebooksPlain(markdown); // If neither notes nor notebooks found, the content isn't in the expected format if (parsedNotes.length === 0 && parsedNotebooks.length === 0) return; // Get existing notes for duplicate detection const existingNotes = notes.get(); const existingByTitle = new Map(); existingNotes.forEach((note: any) => { const title = note?.title; if (title) existingByTitle.set(title, note); }); // Detect duplicates (same title exists in space) - only for notes const duplicates: DetectedDuplicate[] = []; for (const noteData of parsedNotes) { const existing = existingByTitle.get(noteData.title); if (existing) { duplicates.push({ title: noteData.title, noteId: noteData.noteId, existingNotebook: "this space", // We're checking space-level, not notebook-level }); } } // Build progress message const itemCounts: string[] = []; if (parsedNotes.length > 0) { itemCounts.push( `${parsedNotes.length} note${parsedNotes.length !== 1 ? "s" : ""}`, ); } if (parsedNotebooks.length > 0) { itemCounts.push( `${parsedNotebooks.length} notebook${ parsedNotebooks.length !== 1 ? "s" : "" }`, ); } const importSummary = itemCounts.join(" and "); if (duplicates.length > 0) { // Store pending import and show modal pendingImportData.set(markdown); detectedDuplicates.set(duplicates); showDuplicateModal.set(true); // Close import modal if open (duplicate modal will take over) showImportModal?.set(false); showPasteSection?.set(true); // Reset for next time } else { // Clear markdown and close import modal importMarkdown.set(""); showImportModal?.set(false); showPasteSection?.set(true); // Reset for next time // Set all state BEFORE showing modal to avoid default state flicker importComplete?.set(false); importProgressMessage?.set(`Importing ${importSummary}...`); // NOW show the modal showImportProgressModal?.set(true); // Run import synchronously (pass raw markdown for v2 notebook parsing) performImport( parsedNotes, allCharms, notebooks, new Set(), undefined, undefined, markdown, ); // Mark import as complete importProgressMessage?.set(`Imported ${importSummary}!`); importComplete?.set(true); } }); // Handler to import notes (skipping duplicates) const importSkipDuplicates = handler< Record, { pendingImportData: Cell; allCharms: Cell; notebooks: Cell; detectedDuplicates: Cell; showDuplicateModal: Cell; importMarkdown: Cell; importStatus?: Cell; showImportProgressModal?: Cell; importProgressMessage?: Cell; importComplete?: Cell; } >((_, state) => { const markdown = state.pendingImportData.get(); const parsed = parseMarkdownToNotesPlain(markdown); const duplicates = state.detectedDuplicates.get(); // Build skip set from duplicate titles const skipTitles = new Set(duplicates.map((d) => d.title)); const importCount = parsed.length - skipTitles.size; // Clear data and close duplicate modal state.pendingImportData.set(""); state.detectedDuplicates.set([]); state.importMarkdown.set(""); state.showDuplicateModal.set(false); // Set all state BEFORE showing modal to avoid default state flicker state.importComplete?.set(false); state.importProgressMessage?.set(`Importing ${importCount} notes...`); // NOW show the modal state.showImportProgressModal?.set(true); // Run import synchronously (pass raw markdown for v2 notebook parsing) performImport( parsed, state.allCharms, state.notebooks, skipTitles, undefined, undefined, markdown, ); // Mark import as complete state.importProgressMessage?.set(`Imported ${importCount} notes!`); state.importComplete?.set(true); }); // Handler to import all notes (including duplicates as copies) const importAllAsCopies = handler< Record, { pendingImportData: Cell; allCharms: Cell; notebooks: Cell; showDuplicateModal: Cell; detectedDuplicates: Cell; importMarkdown: Cell; importStatus?: Cell; showImportProgressModal?: Cell; importProgressMessage?: Cell; importComplete?: Cell; } >((_, state) => { const markdown = state.pendingImportData.get(); const parsed = parseMarkdownToNotesPlain(markdown); // Clear data and close duplicate modal state.pendingImportData.set(""); state.detectedDuplicates.set([]); state.importMarkdown.set(""); state.showDuplicateModal.set(false); // Set all state BEFORE showing modal to avoid default state flicker state.importComplete?.set(false); state.importProgressMessage?.set(`Importing ${parsed.length} notes...`); // NOW show the modal state.showImportProgressModal?.set(true); // Run import synchronously (pass raw markdown for v2 notebook parsing) performImport( parsed, state.allCharms, state.notebooks, new Set(), undefined, undefined, markdown, ); // Mark import as complete state.importProgressMessage?.set(`Imported ${parsed.length} notes!`); state.importComplete?.set(true); }); // Handler to cancel import const cancelImport = handler< Record, { showDuplicateModal: Cell; detectedDuplicates: Cell; pendingImportData: Cell; } >((_, state) => { state.showDuplicateModal.set(false); state.detectedDuplicates.set([]); state.pendingImportData.set(""); }); // Handler to hide paste section when Upload File button is clicked const hidePasteSection = handler< Record, { showPasteSection: Cell } >((_, { showPasteSection }) => { showPasteSection.set(false); }); // Legacy handler for direct import (no duplicate check) const _importNotes = handler< Record, { importMarkdown: Cell; allCharms: Cell; notebooks: Cell; importStatus?: Cell; } >((_, { importMarkdown, allCharms, notebooks, importStatus }) => { const markdown = importMarkdown.get(); const parsed = parseMarkdownToNotesPlain(markdown); if (parsed.length === 0) return; // Pass raw markdown for v2 notebook parsing performImport( parsed, allCharms, notebooks, new Set(), importStatus, undefined, markdown, ); importMarkdown.set(""); }); // Handler to toggle note visibility in default-app listing const toggleNoteVisibility = handler< Record, { note: Cell } >((_, { note }) => { const isHiddenCell = note.key("isHidden"); const current = isHiddenCell.get() ?? false; isHiddenCell.set(!current); }); // Handler to toggle all notes' visibility at once // If any are visible, hide all; if all hidden, show all const toggleAllNotesVisibility = handler< Record, { notes: Cell } >((_, { notes }) => { const notesList = notes.get(); if (notesList.length === 0) return; // Check if any notes are currently visible (not hidden) const anyVisible = notesList.some((n: any) => !n?.isHidden); // If any visible, hide all; otherwise show all const newHiddenState = anyVisible; // Update each note's isHidden state notesList.forEach((_n: any, idx: number) => { const noteCell = notes.key(idx); const isHiddenCell = (noteCell as any).key("isHidden"); isHiddenCell.set(newHiddenState); }); }); // Handler to toggle notebook visibility in default-app listing const toggleNotebookVisibility = handler< Record, { notebook: Cell } >((_, { notebook }) => { const isHiddenCell = notebook.key("isHidden"); const current = isHiddenCell.get() ?? false; isHiddenCell.set(!current); }); // Handler to toggle all notebooks' visibility at once // If any are visible, hide all; if all hidden, show all const toggleAllNotebooksVisibility = handler< Record, { notebooks: Cell } >((_, { notebooks }) => { const notebooksList = notebooks.get(); if (notebooksList.length === 0) return; // Check if any notebooks are currently visible (not hidden) const anyVisible = notebooksList.some((nb: any) => !nb?.isHidden); // If any visible, hide all; otherwise show all const newHiddenState = anyVisible; // Update each notebook's isHidden state notebooksList.forEach((_nb: any, idx: number) => { const notebookCell = notebooks.key(idx); const isHiddenCell = (notebookCell as any).key("isHidden"); isHiddenCell.set(newHiddenState); }); }); // Handler to toggle individual selection (with shift-click range support) const _toggleSelection = handler< { shiftKey?: boolean }, { index: number; selectedIndices: Cell; lastSelectedIndex: Cell; } >((event, { index, selectedIndices, lastSelectedIndex }) => { const current = selectedIndices.get(); const lastIdx = lastSelectedIndex.get(); if (event?.shiftKey && lastIdx >= 0 && lastIdx !== index) { // Range select: select all between lastIdx and index const start = Math.min(lastIdx, index); const end = Math.max(lastIdx, index); const range: number[] = []; for (let i = start; i <= end; i++) { range.push(i); } // Merge with existing selection (union) const merged = [...new Set([...current, ...range])]; selectedIndices.set(merged); } else { // Normal toggle const idx = current.indexOf(index); if (idx >= 0) { selectedIndices.set(current.filter((i) => i !== index)); } else { selectedIndices.set([...current, index]); } } lastSelectedIndex.set(index); }); // Handler to navigate to a notebook const goToNotebook = handler }>( (_, { notebook }) => navigateTo(notebook), ); // Handler to select all notes const selectAll = handler< Record, { notes: Cell; selectedIndices: Cell } >((_, { notes, selectedIndices }) => { const notesList = notes.get(); selectedIndices.set(notesList.map((_, i) => i)); }); // Handler to deselect all notes const deselectAll = handler< Record, { selectedIndices: Cell } >((_, { selectedIndices }) => { selectedIndices.set([]); }); // Handler to toggle visibility of all selected notes via switch const _toggleSelectedVisibility = handler< { detail: { checked: boolean } }, { notes: Cell; selectedIndices: Cell } >((event, { notes, selectedIndices }) => { const selected = selectedIndices.get(); const makeVisible = event.detail?.checked ?? false; for (const idx of selected) { const noteCell = notes.key(idx); if (noteCell) { noteCell.key("isHidden").set(!makeVisible); } } selectedIndices.set([]); }); // Handler to show the standalone "New Notebook" modal const showStandaloneNotebookModal = handler< void, { showStandaloneNotebookPrompt: Cell } >((_, { showStandaloneNotebookPrompt }) => showStandaloneNotebookPrompt.set(true) ); // Handler to create notebook with user-provided name and navigate to it const createStandaloneNotebookAndOpen = handler< void, { standaloneNotebookTitle: Cell; showStandaloneNotebookPrompt: Cell; allCharms: Cell; } >((_, { standaloneNotebookTitle, showStandaloneNotebookPrompt, allCharms }) => { const title = standaloneNotebookTitle.get().trim() || "New Notebook"; const nb = Notebook({ title }); allCharms.push(nb as unknown as NoteCharm); showStandaloneNotebookPrompt.set(false); standaloneNotebookTitle.set(""); return navigateTo(nb); }); // Handler to create notebook and stay in modal to create another const createStandaloneNotebookAndContinue = handler< void, { standaloneNotebookTitle: Cell; allCharms: Cell; } >((_, { standaloneNotebookTitle, allCharms }) => { const title = standaloneNotebookTitle.get().trim() || "New Notebook"; const nb = Notebook({ title }); allCharms.push(nb as unknown as NoteCharm); // Keep modal open, just clear the title for the next notebook standaloneNotebookTitle.set(""); }); // Handler to cancel the standalone notebook prompt const cancelStandaloneNotebookPrompt = handler< void, { showStandaloneNotebookPrompt: Cell; standaloneNotebookTitle: Cell; } >((_, { showStandaloneNotebookPrompt, standaloneNotebookTitle }) => { showStandaloneNotebookPrompt.set(false); standaloneNotebookTitle.set(""); }); // Handler to create a new notebook (without navigating) - kept for potential future use const _createNotebook = handler< Record, { allCharms: Cell } >((_, { allCharms }) => { const nb = Notebook({ title: "New Notebook" }); allCharms.push(nb as unknown as NoteCharm); }); // Handler to create a new note (without navigating) const createNote = handler< Record, { allCharms: Cell } >((_, { allCharms }) => { const note = Note({ title: "New Note", content: "", noteId: generateId(), }); allCharms.push(note as unknown as NoteCharm); }); // Helper to perform the actual add-to-notebook operation function performAddToNotebook( notesToAdd: { note: NoteCharm; idx: number }[], notebookCell: Cell, notes: Cell, selectedIndices: Cell, selectedNotebook: Cell, ) { const notebookNotes = notebookCell.key("notes"); for (const { note, idx } of notesToAdd) { // Add to notebook (notebookNotes as Cell).push(note); // Hide from main listing notes.key(idx).key("isHidden").set(true); } selectedIndices.set([]); selectedNotebook.set(""); } // Handler to add selected notes to a notebook (triggered by dropdown change) const addToNotebook = handler< { target?: { value: string }; detail?: { value: string } }, { notebooks: Cell; notes: Cell; selectedIndices: Cell; selectedNotebook: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; showNotebookDuplicateModal: Cell; notebookDuplicates: Cell; pendingAddNotebookIndex: Cell; nonDuplicateNotes: Cell<{ note: NoteCharm; idx: number }[]>; } >(( event, state, ) => { const { notebooks, notes, selectedIndices, selectedNotebook, showNewNotebookPrompt, pendingNotebookAction, showNotebookDuplicateModal, notebookDuplicates, pendingAddNotebookIndex, nonDuplicateNotes, } = state; // Handle both native select (target.value) and ct-select (detail.value) const value = event.target?.value ?? event.detail?.value ?? ""; if (!value) return; // Handle "new" - show prompt to get name from user if (value === "new") { pendingNotebookAction.set("add"); showNewNotebookPrompt.set(true); selectedNotebook.set(""); return; } // Add to existing notebook const nbIndex = parseInt(value, 10); if (nbIndex < 0) return; const selected = selectedIndices.get(); const notesList = notes.get(); const notebookCell = notebooks.key(nbIndex); const existingNotes = notebookCell.key("notes").get() ?? []; // Build set of existing noteIds in this notebook const existingNoteIds = new Set(); for (const n of existingNotes) { const noteId = (n as any)?.noteId; if (noteId) existingNoteIds.add(noteId); } // Check for duplicates const duplicates: NotebookDuplicate[] = []; const nonDuplicates: { note: NoteCharm; idx: number }[] = []; for (const idx of selected) { const note = notesList[idx]; if (!note) continue; const noteId = (note as any)?.noteId; if (noteId && existingNoteIds.has(noteId)) { duplicates.push({ title: note.title ?? "Untitled", noteId, noteIndex: idx, }); } else { nonDuplicates.push({ note, idx }); } } if (duplicates.length > 0) { // Store pending state and show modal pendingAddNotebookIndex.set(nbIndex); notebookDuplicates.set(duplicates); nonDuplicateNotes.set(nonDuplicates); showNotebookDuplicateModal.set(true); selectedNotebook.set(""); } else { // No duplicates, add directly performAddToNotebook( nonDuplicates, notebookCell, notes, selectedIndices, selectedNotebook, ); } }); // Handler to skip duplicates and add only non-duplicates to notebook const addSkipDuplicates = handler< Record, { notebooks: Cell; notes: Cell; selectedIndices: Cell; selectedNotebook: Cell; showNotebookDuplicateModal: Cell; notebookDuplicates: Cell; pendingAddNotebookIndex: Cell; nonDuplicateNotes: Cell<{ note: NoteCharm; idx: number }[]>; } >((_, state) => { const notebookCell = state.notebooks.key(state.pendingAddNotebookIndex.get()); const nonDuplicates = [...state.nonDuplicateNotes.get()]; performAddToNotebook( nonDuplicates, notebookCell, state.notes, state.selectedIndices, state.selectedNotebook, ); // Clean up state.showNotebookDuplicateModal.set(false); state.notebookDuplicates.set([]); state.nonDuplicateNotes.set([]); state.pendingAddNotebookIndex.set(-1); }); // Handler to add all notes including duplicates to notebook const addIncludingDuplicates = handler< Record, { notebooks: Cell; notes: Cell; selectedIndices: Cell; selectedNotebook: Cell; showNotebookDuplicateModal: Cell; notebookDuplicates: Cell; pendingAddNotebookIndex: Cell; nonDuplicateNotes: Cell<{ note: NoteCharm; idx: number }[]>; } >((_, state) => { const notebookCell = state.notebooks.key(state.pendingAddNotebookIndex.get()); const nonDuplicates = [...state.nonDuplicateNotes.get()]; const duplicates = [...state.notebookDuplicates.get()]; const notesList = state.notes.get(); // Combine non-duplicates and duplicates const allNotes: { note: NoteCharm; idx: number }[] = [ ...nonDuplicates, ...duplicates.map((d) => ({ note: notesList[d.noteIndex], idx: d.noteIndex, })), ]; performAddToNotebook( allNotes, notebookCell, state.notes, state.selectedIndices, state.selectedNotebook, ); // Clean up state.showNotebookDuplicateModal.set(false); state.notebookDuplicates.set([]); state.nonDuplicateNotes.set([]); state.pendingAddNotebookIndex.set(-1); }); // Handler to cancel adding to notebook const cancelAddToNotebook = handler< Record, { showNotebookDuplicateModal: Cell; notebookDuplicates: Cell; pendingAddNotebookIndex: Cell; nonDuplicateNotes: Cell<{ note: NoteCharm; idx: number }[]>; selectedIndices: Cell; selectedNotebook: Cell; } >((_, state) => { state.showNotebookDuplicateModal.set(false); state.notebookDuplicates.set([]); state.nonDuplicateNotes.set([]); state.pendingAddNotebookIndex.set(-1); state.selectedIndices.set([]); state.selectedNotebook.set(""); }); // Handler to duplicate selected notes const duplicateSelectedNotes = handler< Record, { notes: Cell; selectedIndices: Cell; allCharms: Cell; } >((_, { notes, selectedIndices, allCharms }) => { const selected = selectedIndices.get(); const notesList = notes.get(); // Collect copies first, then batch push (reduces N reactive cycles to 1) const copies: NoteCharm[] = []; for (const idx of selected) { const original = notesList[idx]; if (original) { copies.push(Note({ title: (original.title ?? "Note") + " (Copy)", content: original.content ?? "", noteId: generateId(), }) as unknown as NoteCharm); } } allCharms.push(...copies); selectedIndices.set([]); }); // Handler to permanently delete selected notes from the space const deleteSelectedNotes = handler< Record, { notes: Cell; selectedIndices: Cell; allCharms: Cell; notebooks: Cell; } >((_, { notes, selectedIndices, allCharms, notebooks }) => { const selected = selectedIndices.get(); const notesList = notes.get(); const allCharmsList = allCharms.get(); const notebooksList = notebooks.get(); // Collect noteIds and titles to delete (titles for notebooks which don't have noteId) const noteIdsToDelete: string[] = []; const titlesToDelete: string[] = []; for (const idx of selected) { const item = notesList[idx]; const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { noteIdsToDelete.push(noteId); } else if (title) { titlesToDelete.push(title); } } // Helper to check if item should be deleted const shouldDelete = (n: any) => { if (n?.noteId && noteIdsToDelete.includes(n.noteId)) return true; if (!n?.noteId && n?.title && titlesToDelete.includes(n.title)) return true; return false; }; // Remove from all notebooks first for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldDelete(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Remove from allCharms (permanent delete) const filteredCharms = allCharmsList.filter((charm: any) => !shouldDelete(charm) ); allCharms.set(filteredCharms); selectedIndices.set([]); }); // Handler to move selected notes to a notebook (removes from current notebooks first) const moveToNotebook = handler< { target?: { value: string }; detail?: { value: string } }, { notebooks: Cell; notes: Cell; selectedIndices: Cell; selectedMoveNotebook: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; } >(( event, { notebooks, notes, selectedIndices, selectedMoveNotebook, showNewNotebookPrompt, pendingNotebookAction, }, ) => { // Handle both native select (target.value) and ct-select (detail.value) const value = event.target?.value ?? event.detail?.value ?? ""; if (!value) return; // Handle "new" - show prompt to get name from user if (value === "new") { pendingNotebookAction.set("move"); showNewNotebookPrompt.set(true); selectedMoveNotebook.set(""); return; } // Move to existing notebook const nbIndex = parseInt(value, 10); if (nbIndex < 0) return; const selected = selectedIndices.get(); const notesList = notes.get(); const notebooksList = notebooks.get(); const targetNotebookCell = notebooks.key(nbIndex); const targetNotebookNotes = targetNotebookCell.key("notes"); // Collect items, noteIds and titles for removal const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; // For notebooks (no noteId) const itemsToMove: NoteCharm[] = []; for (const idx of selected) { const item = notesList[idx]; if (!item) continue; const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } itemsToMove.push(item); // Hide from main listing notes.key(idx).key("isHidden").set(true); } // Helper to check if item should be removed const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) return true; return false; }; // Remove from all notebooks (except target) for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { if (nbIdx === nbIndex) continue; // Don't remove from target const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } // Add all items to target notebook for (const item of itemsToMove) { (targetNotebookNotes as Cell).push(item); } selectedIndices.set([]); selectedMoveNotebook.set(""); }); // Handler to select all notebooks const selectAllNotebooks = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell } >((_, { notebooks, selectedNotebookIndices }) => { const nbList = notebooks.get(); selectedNotebookIndices.set(nbList.map((_, i) => i)); }); // Handler to deselect all notebooks const deselectAllNotebooks = handler< Record, { selectedNotebookIndices: Cell } >((_, { selectedNotebookIndices }) => { selectedNotebookIndices.set([]); }); // Handler to show delete notebooks confirmation modal const confirmDeleteNotebooks = handler< Record, { selectedNotebookIndices: Cell; showDeleteNotebookModal: Cell; } >((_, { selectedNotebookIndices, showDeleteNotebookModal }) => { if (selectedNotebookIndices.get().length > 0) { showDeleteNotebookModal.set(true); } }); // Handler to delete notebooks only (keep notes, make them visible) const deleteNotebooksOnly = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell; allCharms: Cell; showDeleteNotebookModal: Cell; } >(( _, { notebooks, selectedNotebookIndices, allCharms, showDeleteNotebookModal }, ) => { const selected = selectedNotebookIndices.get(); const notebooksList = notebooks.get(); const allCharmsList = allCharms.get(); // Guard: require explicit selection if (!selected || selected.length === 0) { showDeleteNotebookModal.set(false); return; } // Collect all notes from selected notebooks and make them visible for (const idx of selected) { const nb = notebooksList[idx]; const nbNotes = (nb as any)?.notes ?? []; for (const note of nbNotes) { const noteId = (note as any)?.noteId; if (noteId) { // Find the note in allCharms and set isHidden to false for (let i = 0; i < allCharmsList.length; i++) { if ((allCharmsList[i] as any)?.noteId === noteId) { allCharms.key(i).key("isHidden").set(false); break; } } } } } // Find which indices in allCharms are notebooks (to map selected -> allCharms indices) const notebookIndicesInAllCharms: number[] = []; for (let i = 0; i < allCharmsList.length; i++) { const name = (allCharmsList[i] as any)?.[NAME]; if (typeof name === "string" && name.startsWith("π")) { notebookIndicesInAllCharms.push(i); } } // Map selected notebook indices to allCharms indices const allCharmsIndicesToDelete: Set = new Set(); for (const selectedIdx of selected) { const allCharmsIdx = notebookIndicesInAllCharms[selectedIdx]; if (allCharmsIdx !== undefined) { allCharmsIndicesToDelete.add(allCharmsIdx); } } // Remove by allCharms index const filteredCharms = allCharmsList.filter((_, i) => { return !allCharmsIndicesToDelete.has(i); }); allCharms.set(filteredCharms); selectedNotebookIndices.set([]); showDeleteNotebookModal.set(false); }); // Handler to delete notebooks AND all their notes const deleteNotebooksAndNotes = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell; allCharms: Cell; showDeleteNotebookModal: Cell; } >(( _, { notebooks, selectedNotebookIndices, allCharms, showDeleteNotebookModal }, ) => { const selected = selectedNotebookIndices.get(); const notebooksList = notebooks.get(); const allCharmsList = allCharms.get(); // Guard: require explicit selection if (!selected || selected.length === 0) { showDeleteNotebookModal.set(false); return; } // Collect all noteIds from selected notebooks const noteIdsToDelete: string[] = []; for (const idx of selected) { const nb = notebooksList[idx]; const nbNotes = (nb as any)?.notes ?? []; for (const note of nbNotes) { const noteId = (note as any)?.noteId; if (noteId) noteIdsToDelete.push(noteId); } } // Find which indices in allCharms are notebooks (to map selected -> allCharms indices) const notebookIndicesInAllCharms: number[] = []; for (let i = 0; i < allCharmsList.length; i++) { const name = (allCharmsList[i] as any)?.[NAME]; if (typeof name === "string" && name.startsWith("π")) { notebookIndicesInAllCharms.push(i); } } // Map selected notebook indices to allCharms indices const allCharmsIndicesToDelete: Set = new Set(); for (const selectedIdx of selected) { const allCharmsIdx = notebookIndicesInAllCharms[selectedIdx]; if (allCharmsIdx !== undefined) { allCharmsIndicesToDelete.add(allCharmsIdx); } } // Remove notebooks by index AND notes by noteId const filteredCharms = allCharmsList.filter((charm: any, i) => { // Remove if it's a notebook to delete (by index) if (allCharmsIndicesToDelete.has(i)) return false; // Remove if it's a note to delete (by noteId) const noteId = charm?.noteId; if (noteId && noteIdsToDelete.includes(noteId)) return false; return true; }); allCharms.set(filteredCharms); selectedNotebookIndices.set([]); showDeleteNotebookModal.set(false); }); // Handler to cancel delete notebooks const cancelDeleteNotebooks = handler< Record, { showDeleteNotebookModal: Cell; } >((_, { showDeleteNotebookModal }) => { showDeleteNotebookModal.set(false); }); // Handler to clone selected notebooks (shallow copy - shares note references) const cloneSelectedNotebooks = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell; allCharms: Cell; } >((_, { notebooks, selectedNotebookIndices, allCharms }) => { const selected = selectedNotebookIndices.get(); const notebooksList = notebooks.get(); // Collect copies first, then batch push (reduces N reactive cycles to 1) const copies: NoteCharm[] = []; for (const idx of selected) { const original = notebooksList[idx]; if (original) { // Extract just the base title (strip emoji and count) const rawTitle = (original as any)?.[NAME] ?? original?.title ?? "Notebook"; const baseTitle = rawTitle.replace(/^π\s*/, "").replace( /\s*\(\d+\)$/, "", ); copies.push(Notebook({ title: baseTitle + " (Clone)", notes: [...(original?.notes ?? [])], // Shallow copy - reference same notes }) as unknown as NoteCharm); } } allCharms.push(...copies); selectedNotebookIndices.set([]); }); // Handler to duplicate selected notebooks (deep copy - new independent note instances) const duplicateSelectedNotebooks = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell; allCharms: Cell; } >((_, { notebooks, selectedNotebookIndices, allCharms }) => { const selected = selectedNotebookIndices.get(); const notebooksList = notebooks.get(); const newItems: NoteCharm[] = []; for (const idx of selected) { const original = notebooksList[idx]; if (original) { // Create NEW note instances for each note in the notebook const newNotes = (original.notes ?? []).map((note) => Note({ title: note.title ?? "Note", content: note.content ?? "", isHidden: true, noteId: generateId(), }) as unknown as NoteCharm ); // Add new notes to collection (visible in All Notes) newItems.push(...newNotes); // Extract just the base title (strip emoji and count) const rawTitle = (original as any)?.[NAME] ?? original?.title ?? "Notebook"; const baseTitle = rawTitle.replace(/^π\s*/, "").replace( /\s*\(\d+\)$/, "", ); // Create new notebook with the new independent notes newItems.push(Notebook({ title: baseTitle + " (Copy)", notes: newNotes, }) as unknown as NoteCharm); } } allCharms.push(...newItems); selectedNotebookIndices.set([]); }); // Handler to export selected notebooks' notes and show in modal const exportSelectedNotebooks = handler< Record, { notebooks: Cell; selectedNotebookIndices: Cell; showExportNotebooksModal: Cell; exportNotebooksMarkdown: Cell; } >(( _, { notebooks, selectedNotebookIndices, showExportNotebooksModal, exportNotebooksMarkdown, }, ) => { const selected = selectedNotebookIndices.get(); const notebooksList = notebooks.get(); // Collect all notes from selected notebooks with notebook info const allNotes: { title: string; content: string; notebookName: string }[] = []; for (const idx of selected) { const nb = notebooksList[idx]; const rawName = (nb as any)?.[NAME] ?? nb?.title ?? "Untitled"; const cleanName = rawName.replace(/^π\s*/, "").replace(/\s*\(\d+\)$/, ""); const nbNotes = nb?.notes ?? []; for (const note of nbNotes) { if (note?.title !== undefined && note?.content !== undefined) { allNotes.push({ title: note.title ?? "Untitled", content: note.content ?? "", notebookName: cleanName, }); } } } // Format as markdown with notebook info if (allNotes.length > 0) { const lines = allNotes.map((note) => { const escapedTitle = note.title.replace(/"/g, """); return `${NOTE_START_MARKER} title="${escapedTitle}" notebooks="${note.notebookName}" -->\n\n${note.content}\n\n${NOTE_END_MARKER}`; }); // Add timestamp header (ignored by import regex which only looks for COMMON_NOTE_START) const timestamp = new Date().toISOString(); const header = `\n\n\n`; exportNotebooksMarkdown.set(header + lines.join("\n\n")); } else { exportNotebooksMarkdown.set( "", ); } showExportNotebooksModal.set(true); }); // Handler to close export notebooks modal const closeExportNotebooksModal = handler< Record, { showExportNotebooksModal: Cell; exportNotebooksMarkdown: Cell; selectedNotebookIndices: Cell; } >(( _, { showExportNotebooksModal, exportNotebooksMarkdown, selectedNotebookIndices, }, ) => { showExportNotebooksModal.set(false); exportNotebooksMarkdown.set(""); selectedNotebookIndices.set([]); }); // Handler to toggle notebook checkbox selection with shift-click support const toggleNotebookCheckbox = handler< { shiftKey?: boolean }, { index: number; selectedNotebookIndices: Cell; lastSelectedNotebookIndex: Cell; } >((event, { index, selectedNotebookIndices, lastSelectedNotebookIndex }) => { const current = selectedNotebookIndices.get(); const lastIdx = lastSelectedNotebookIndex.get(); if (event?.shiftKey && lastIdx >= 0 && lastIdx !== index) { const start = Math.min(lastIdx, index); const end = Math.max(lastIdx, index); const range: number[] = []; for (let i = start; i <= end; i++) { range.push(i); } selectedNotebookIndices.set([...new Set([...current, ...range])]); } else { const idx = current.indexOf(index); if (idx >= 0) { selectedNotebookIndices.set(current.filter((i: number) => i !== index)); } else { selectedNotebookIndices.set([...current, index]); } } lastSelectedNotebookIndex.set(index); }); // Handler to toggle note checkbox selection with shift-click support const toggleNoteCheckbox = handler< { shiftKey?: boolean }, { index: number; selectedIndices: Cell; lastSelectedIndex: Cell; } >((event, { index, selectedIndices, lastSelectedIndex }) => { const current = selectedIndices.get(); const lastIdx = lastSelectedIndex.get(); if (event?.shiftKey && lastIdx >= 0 && lastIdx !== index) { const start = Math.min(lastIdx, index); const end = Math.max(lastIdx, index); const range: number[] = []; for (let i = start; i <= end; i++) { range.push(i); } selectedIndices.set([...new Set([...current, ...range])]); } else { const idx = current.indexOf(index); if (idx >= 0) { selectedIndices.set(current.filter((i: number) => i !== index)); } else { selectedIndices.set([...current, index]); } } lastSelectedIndex.set(index); }); // Handler to create notebook from prompt and add/move selected notes const createNotebookFromPrompt = handler< void, { newNotebookName: Cell; showNewNotebookPrompt: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; selectedIndices: Cell; notes: Cell; allCharms: Cell; notebooks: Cell; } >((_, state) => { const { newNotebookName, showNewNotebookPrompt, pendingNotebookAction, selectedIndices, notes, allCharms, notebooks, } = state; const name = newNotebookName.get().trim() || "New Notebook"; const action = pendingNotebookAction.get(); // Gather selected items and track by noteId (notes) or title (notebooks) const selected = selectedIndices.get(); const selectedItems: NoteCharm[] = []; const selectedNoteIds: string[] = []; const selectedTitles: string[] = []; // For notebooks (no noteId) for (const idx of selected) { const item = notes.key(idx).get(); if (item) { selectedItems.push(item); const noteId = (item as any)?.noteId; const title = (item as any)?.title; if (noteId) { selectedNoteIds.push(noteId); } else if (title) { selectedTitles.push(title); } } } // Helper to check if item should be removed const shouldRemove = (n: any) => { if (n?.noteId && selectedNoteIds.includes(n.noteId)) return true; if (!n?.noteId && n?.title && selectedTitles.includes(n.title)) return true; return false; }; // Create notebook with items directly (simpler approach) const newNotebook = Notebook({ title: name, notes: selectedItems }); allCharms.push(newNotebook as unknown as NoteCharm); // Mark selected items as hidden for (const idx of selected) { notes.key(idx).key("isHidden").set(true); } // For move: also remove from existing notebooks if (action === "move") { const notebooksList = notebooks.get(); for (let nbIdx = 0; nbIdx < notebooksList.length; nbIdx++) { const nbCell = notebooks.key(nbIdx); const nbNotesCell = nbCell.key("notes"); const nbNotes = nbNotesCell.get() ?? []; const filtered = nbNotes.filter((n: any) => !shouldRemove(n)); if (filtered.length !== nbNotes.length) { nbNotesCell.set(filtered); } } } // Clean up state selectedIndices.set([]); newNotebookName.set(""); pendingNotebookAction.set(""); showNewNotebookPrompt.set(false); }); // Handler to cancel new notebook prompt const cancelNewNotebookPrompt = handler< void, { showNewNotebookPrompt: Cell; newNotebookName: Cell; pendingNotebookAction: Cell<"add" | "move" | "">; selectedNotebook: Cell; selectedMoveNotebook: Cell; } >((_, state) => { state.showNewNotebookPrompt.set(false); state.newNotebookName.set(""); state.pendingNotebookAction.set(""); state.selectedNotebook.set(""); state.selectedMoveNotebook.set(""); }); // Handler to open Export All modal - computes export on-demand for performance const openExportAllModal = handler< void, { showExportAllModal: Cell; allCharms: Cell; notebooks: Cell; exportedMarkdown: Cell; } >((_, { showExportAllModal, allCharms, notebooks, exportedMarkdown }) => { // Compute export ONLY when modal opens (lazy evaluation) // Use plain function version (lift() doesn't work in handlers) // Pass allCharms as third param for reliable isHidden lookup const allCharmsArray = [...allCharms.get()]; const result = filterAndFormatNotesPlain( allCharmsArray, [...notebooks.get()], allCharmsArray, ); exportedMarkdown.set(result.markdown); showExportAllModal.set(true); }); // Handler to close Export All modal const closeExportAllModal = handler< void, { showExportAllModal: Cell } >((_, { showExportAllModal }) => { showExportAllModal.set(false); }); // Handler to open Import modal const openImportModal = handler< void, { showImportModal: Cell } >((_, { showImportModal }) => { showImportModal.set(true); }); // Handler to close Import modal const closeImportModal = handler< void, { showImportModal: Cell; importMarkdown: Cell; showPasteSection?: Cell; } >((_, { showImportModal, importMarkdown, showPasteSection }) => { showImportModal.set(false); importMarkdown.set(""); // Reset paste section visibility for next time modal opens showPasteSection?.set(true); }); // Plain function to get notebooks containing a note (with name and reference for navigation) // Using plain function instead of lift for more consistent proxy resolution function _getNoteNotebooksPlain( note: NoteCharm, notebooks: NotebookCharm[], ): { name: string; notebook: NotebookCharm }[] { // Use JSON.parse(JSON.stringify()) to fully resolve proxy values const noteId = resolveValue((note as any)?.noteId); if (!noteId) return []; const result: { name: string; notebook: NotebookCharm }[] = []; for (const nb of notebooks) { const nbNotes = (nb as any)?.notes ?? []; for (const n of nbNotes) { // Compare resolved string values if (resolveValue((n as any)?.noteId) === noteId) { const name = (nb as any)?.[NAME] ?? (nb as any)?.title ?? "Untitled"; // Strip the π prefix and note count suffix for cleaner display const cleanName = name.replace(/^π\s*/, "").replace( /\s*\(\d+\)$/, "", ); result.push({ name: cleanName, notebook: nb }); break; } } } return result; } const NotesImportExport = pattern(({ importMarkdown }) => { const { allCharms } = wish<{ allCharms: AllCharmsType }>("/"); // Filter to only notes (charms with title and content) const notes = computed(() => allCharms.filter( (charm) => charm?.title !== undefined && charm?.content !== undefined, ) ); // Filter to only notebooks using π marker in NAME // (NAME is the only property reliably accessible through proxy) const notebooks = computed(() => allCharms.filter((charm: any) => { const name = charm?.[NAME]; return typeof name === "string" && name.startsWith("π"); }) as unknown as NotebookCharm[] ); // Selection state for notes multi-select const selectedIndices = Cell.of([]); const selectedNotebook = Cell.of(""); const selectedMoveNotebook = Cell.of(""); const lastSelectedIndex = Cell.of(-1); // For shift-click range selection // Computed helper for notes selection count const selectedCount = computed(() => selectedIndices.get().length); const hasSelection = computed(() => selectedIndices.get().length > 0); // Selection state for notebooks multi-select const selectedNotebookIndices = Cell.of([]); const lastSelectedNotebookIndex = Cell.of(-1); // Computed helper for notebooks selection count const selectedNotebookCount = computed(() => selectedNotebookIndices.get().length ); const hasNotebookSelection = computed(() => selectedNotebookIndices.get().length > 0 ); // State for "New Notebook" prompt modal (for add/move to notebook flows) const showNewNotebookPrompt = Cell.of(false); const newNotebookName = Cell.of(""); const pendingNotebookAction = Cell.of<"add" | "move" | "">(""); // Track which action triggered the modal // State for standalone "New Notebook" modal (from New button) const showStandaloneNotebookPrompt = Cell.of(false); const standaloneNotebookTitle = Cell.of(""); // State for duplicate detection modal during import const showDuplicateModal = Cell.of(false); const detectedDuplicates = Cell.of([]); const pendingImportData = Cell.of(""); // State for duplicate detection modal when adding notes to notebook const showNotebookDuplicateModal = Cell.of(false); const notebookDuplicates = Cell.of([]); const pendingAddNotebookIndex = Cell.of(-1); const nonDuplicateNotes = Cell.of<{ note: NoteCharm; idx: number }[]>([]); // State for delete notebook confirmation modal const showDeleteNotebookModal = Cell.of(false); // State for export notebooks modal const showExportNotebooksModal = Cell.of(false); const exportNotebooksMarkdown = Cell.of(""); // State for Export All modal const showExportAllModal = Cell.of(false); // State for Import modal const showImportModal = Cell.of(false); const importStatus = Cell.of(""); // Status message during import // State for Import progress modal (super modal in front of others) const showImportProgressModal = Cell.of(false); const importProgressMessage = Cell.of("Importing notes..."); const importComplete = Cell.of(false); // State to hide paste section when Upload File button is clicked const showPasteSection = Cell.of(true); // Computed items for ct-select dropdowns (notebooks + "New Notebook...") // ct-select has proper bidirectional DOM sync, unlike native const notebookAddItems = computed(() => [ ...notebooks.map((nb: any, idx: number) => ({ label: nb?.[NAME] ?? nb?.title ?? "Untitled", value: String(idx), })), { label: "ββββββββββββ", value: "_divider", disabled: true }, { label: "New Notebook...", value: "new" }, ]); const notebookMoveItems = computed(() => [ ...notebooks.map((nb: any, idx: number) => ({ label: nb?.[NAME] ?? nb?.title ?? "Untitled", value: String(idx), })), { label: "ββββββββββββ", value: "_divider", disabled: true }, { label: "New Notebook...", value: "new" }, ]); // Helper to generate export filename with timestamp const getExportFilename = (prefix: string) => { const now = new Date(); const timestamp = now.toISOString().slice(0, 19).replace(/[T:]/g, "-"); return `${prefix}-${timestamp}.md`; }; // Computed filenames for exports (re-evaluate each time modal is shown) const notesExportFilename = computed(() => getExportFilename("notes-export")); const notebooksExportFilename = computed(() => getExportFilename("notebooks-export") ); // Pre-compute all note memberships in ONE central reactive expression // This ensures proper dependency tracking when notebooks are added/removed const noteMemberships = computed(() => { const result: Record< string, Array<{ name: string; notebook: NotebookCharm }> > = {}; for (const nb of notebooks) { const nbNotes = (nb as any)?.notes ?? []; const rawName = (nb as any)?.[NAME] ?? (nb as any)?.title ?? "Untitled"; const cleanName = rawName .replace(/^π\s*/, "") .replace(/\s*\(\d+\)$/, ""); for (const n of nbNotes) { const nId = resolveValue((n as any)?.noteId); if (nId) { if (!result[nId]) result[nId] = []; result[nId].push({ name: cleanName, notebook: nb }); } } } return result; }); // Combine notes with their membership data at pattern level // This ensures the map sees changes when either notes or memberships update type NoteWithMemberships = { note: NoteCharm; memberships: Array<{ name: string; notebook: NotebookCharm }>; }; const notesWithMemberships = computed((): NoteWithMemberships[] => { // Read noteMemberships to establish dependency (JSON to get plain object) const membershipMap = JSON.parse(JSON.stringify(noteMemberships)) as Record< string, Array<{ name: string; notebook: NotebookCharm }> >; return notes.map((note: NoteCharm) => { const noteId = resolveValue((note as any)?.noteId); const memberships = noteId ? (membershipMap[noteId] ?? []) : []; return { note, memberships }; }); }); // Compute parent notebook memberships for each notebook // A notebook's "memberships" are notebooks that contain it in their notes or childNotebooks arrays const notebookMemberships = computed(() => { const result: Record< string, Array<{ name: string; notebook: NotebookCharm }> > = {}; for (const nb of notebooks) { const rawName = (nb as any)?.[NAME] ?? (nb as any)?.title ?? "Untitled"; const cleanName = rawName .replace(/^π\s*/, "") .replace(/\s*\(\d+\)$/, ""); // Check notes array for nested notebooks (notebooks dropped into other notebooks) const nbNotes = (nb as any)?.notes ?? []; for (const item of nbNotes) { // Check if item is a notebook (has isNotebook property or NAME starts with π) const itemName = (item as any)?.[NAME] ?? (item as any)?.title ?? ""; const isChildNotebook = (item as any)?.isNotebook || (typeof itemName === "string" && itemName.startsWith("π")); if (isChildNotebook && itemName) { if (!result[itemName]) result[itemName] = []; // Avoid duplicate entries const alreadyAdded = result[itemName].some( (m) => m.name === cleanName, ); if (!alreadyAdded) { result[itemName].push({ name: cleanName, notebook: nb }); } } } // Also check childNotebooks array (v2 hierarchical format) const childNotebooks = (nb as any)?.childNotebooks ?? []; for (const child of childNotebooks) { const childName = (child as any)?.[NAME] ?? (child as any)?.title ?? ""; if (childName) { if (!result[childName]) result[childName] = []; // Avoid duplicate entries const alreadyAdded = result[childName].some( (m) => m.name === cleanName, ); if (!alreadyAdded) { result[childName].push({ name: cleanName, notebook: nb }); } } } } return result; }); // Combine notebooks with their membership data at pattern level type NotebookWithMemberships = { notebook: NotebookCharm; memberships: Array<{ name: string; notebook: NotebookCharm }>; }; const notebooksWithMemberships = computed((): NotebookWithMemberships[] => { // Read notebookMemberships directly - don't use JSON.parse/stringify which loses Cell references // Simply reading the computed value establishes the dependency const membershipMap = notebookMemberships as unknown as Record< string, Array<{ name: string; notebook: NotebookCharm }> >; return notebooks.map((notebook: NotebookCharm) => { const notebookName = (notebook as any)?.[NAME] ?? (notebook as any)?.title ?? ""; const memberships = notebookName ? (membershipMap[notebookName] ?? []) : []; return { notebook, memberships }; }); }); // noteCount derived from notes array for reactive UI display // Use lift() for proper reactive tracking (computed() doesn't track array.length correctly) const noteCount = lift((args: { n: NoteCharm[] }) => args.n.length)({ n: notes, }); const notebookCount = lift((args: { n: NotebookCharm[] }) => args.n.length)({ n: notebooks, }); // Boolean display helpers using lift() - needed because computed(() => array.length > 0) // doesn't properly track dependencies on computed arrays const notesDisplayStyle = lift((args: { n: NoteCharm[] }) => args.n.length > 0 ? "flex" : "none" )({ n: notes }); const notebooksDisplayStyle = lift((args: { n: NotebookCharm[] }) => args.n.length > 0 ? "flex" : "none" )({ n: notebooks }); // exportedMarkdown is computed on-demand when Export All modal opens (lazy for performance) const exportedMarkdown = Cell.of(""); return { [NAME]: computed(() => `All Notes (${noteCount} notes)`), [UI]: ( β Import πΎ Export All {/* Notes Index Section */} {/* Header */}