diff --git a/src/utils/noteLinks.ts b/src/utils/noteLinks.ts new file mode 100644 index 0000000..a556c2d --- /dev/null +++ b/src/utils/noteLinks.ts @@ -0,0 +1,187 @@ +import { Note } from "@/services/NotesService"; + +/** + * Pattern to match wiki-style links: [[Note Title]] + */ +const WIKI_LINK_PATTERN = /\[\[([^\]]+)\]\]/g; + +/** + * Extract all note links from content + */ +export function extractNoteLinks(content: string): string[] { + if (!content) return []; + + const matches = content.matchAll(WIKI_LINK_PATTERN); + const links: string[] = []; + + for (const match of matches) { + if (match[1]) { + links.push(match[1].trim()); + } + } + + return [...new Set(links)]; // Remove duplicates +} + +/** + * Find notes that match the given link text + */ +export function findLinkedNotes(linkText: string, allNotes: Note[]): Note[] { + const searchText = linkText.toLowerCase().trim(); + + return allNotes.filter(note => { + if (!note.title) return false; + return note.title.toLowerCase().includes(searchText); + }); +} + +/** + * Convert wiki-style links to HTML clickable links + */ +export function convertLinksToHTML( + content: string, + allNotes: Note[], + onLinkClick: (noteId: number) => void +): string { + if (!content) return ''; + + return content.replace(WIKI_LINK_PATTERN, (match, linkText) => { + const trimmedText = linkText.trim(); + const matchedNotes = findLinkedNotes(trimmedText, allNotes); + + if (matchedNotes.length > 0) { + // Found matching note - create clickable link + const note = matchedNotes[0]; + return `${trimmedText}`; + } else { + // No matching note - show as grayed out + return `[[${trimmedText}]]`; + } + }); +} + +/** + * Get backlinks for a note (notes that link to this one) + */ +export function getBacklinks(note: Note, allNotes: Note[]): Note[] { + if (!note.title) return []; + + const backlinks: Note[] = []; + const noteTitle = note.title.toLowerCase(); + + for (const otherNote of allNotes) { + if (otherNote.id === note.id || !otherNote.content) continue; + + const links = extractNoteLinks(otherNote.content); + const hasLink = links.some(link => + link.toLowerCase().includes(noteTitle) || + noteTitle.includes(link.toLowerCase()) + ); + + if (hasLink) { + backlinks.push(otherNote); + } + } + + return backlinks; +} + +/** + * Suggest note titles for autocomplete when typing [[ + */ +export function suggestNoteTitles( + partialText: string, + allNotes: Note[], + limit = 10 +): Note[] { + if (!partialText) { + // Return recent notes when no search text + return allNotes + .filter(note => note.title && !note.archived) + .sort((a, b) => + new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime() + ) + .slice(0, limit); + } + + const searchText = partialText.toLowerCase().trim(); + + // Find notes matching the partial text + const matches = allNotes + .filter(note => { + if (!note.title || note.archived) return false; + return note.title.toLowerCase().includes(searchText); + }) + .sort((a, b) => { + // Prioritize exact matches and more recent notes + const aTitle = a.title?.toLowerCase() || ''; + const bTitle = b.title?.toLowerCase() || ''; + + const aExact = aTitle === searchText ? 1 : 0; + const bExact = bTitle === searchText ? 1 : 0; + + if (aExact !== bExact) return bExact - aExact; + + const aStarts = aTitle.startsWith(searchText) ? 1 : 0; + const bStarts = bTitle.startsWith(searchText) ? 1 : 0; + + if (aStarts !== bStarts) return bStarts - aStarts; + + // Fall back to recency + return new Date(b.updated_at || 0).getTime() - new Date(a.updated_at || 0).getTime(); + }); + + return matches.slice(0, limit); +} + +/** + * Parse content and detect cursor position for link autocomplete + * Returns the partial link text if cursor is inside a [[...]] pattern + */ +export function detectLinkAtCursor(content: string, cursorPosition: number): { + isInLink: boolean; + linkText: string; + startPos: number; + endPos: number; +} | null { + // Find all [[ positions before cursor + const beforeCursor = content.substring(0, cursorPosition); + const lastOpenBracket = beforeCursor.lastIndexOf('[['); + + if (lastOpenBracket === -1) { + return null; // Not in a link + } + + // Check if there's a closing ]] between lastOpenBracket and cursor + const betweenBrackets = content.substring(lastOpenBracket, cursorPosition); + if (betweenBrackets.includes(']]')) { + return null; // Link is already closed + } + + // Find the closing ]] after cursor (if any) + const afterCursor = content.substring(cursorPosition); + const nextCloseBracket = afterCursor.indexOf(']]'); + const endPos = nextCloseBracket !== -1 + ? cursorPosition + nextCloseBracket + 2 + : cursorPosition; + + // Extract the link text + const linkText = content.substring(lastOpenBracket + 2, cursorPosition).trim(); + + return { + isInLink: true, + linkText, + startPos: lastOpenBracket, + endPos + }; +}