From 9f6cbfd4c0fe24e14bf3f388b85dfbbb2aa5b561 Mon Sep 17 00:00:00 2001 From: mpstaton Date: Tue, 8 Jul 2025 02:12:40 +0300 Subject: [PATCH] progress(modal): progress on citations modal, now isolates single citations On branch development Changes to be committed: modified: src/modals/CitationModal.ts modified: src/services/citationService.ts modified: src/styles/citations.css --- src/modals/CitationModal.ts | 63 ++++++++++- src/services/citationService.ts | 185 ++++++++++++++++---------------- src/styles/citations.css | 31 +++++- 3 files changed, 179 insertions(+), 100 deletions(-) diff --git a/src/modals/CitationModal.ts b/src/modals/CitationModal.ts index a9ff9fa..da7866c 100644 --- a/src/modals/CitationModal.ts +++ b/src/modals/CitationModal.ts @@ -93,7 +93,7 @@ export class CitationModal extends Modal { }); // Add each citation instance - group.matches.forEach((match) => { + group.matches.forEach((match, matchIndex) => { const instanceEl = content.createDiv('cite-wide-instance'); // Show line number and preview @@ -114,15 +114,28 @@ export class CitationModal extends Modal { cls: 'cite-wide-line-preview' }); - // Add view button - const viewBtn = instanceEl.createEl('button', { + // Add view and convert buttons + const btnContainer = instanceEl.createDiv('cite-wide-instance-actions'); + + const viewBtn = btnContainer.createEl('button', { text: 'View', cls: 'mod-cta-outline cite-wide-view-btn' }); - viewBtn.addEventListener('click', () => { + viewBtn.addEventListener('click', (e) => { + e.stopPropagation(); this.scrollToLine(match.lineNumber); }); + + const convertBtn = btnContainer.createEl('button', { + text: 'Convert', + cls: 'mod-cta cite-wide-convert-btn' + }); + + convertBtn.addEventListener('click', async (e) => { + e.stopPropagation(); + await this.convertCitationInstance(group, matchIndex); + }); }); } @@ -146,6 +159,48 @@ export class CitationModal extends Modal { } } + private async convertCitationInstance(group: CitationGroup, matchIndex: number) { + try { + const match = group.matches[matchIndex]; + if (!match) return; + + // Get current cursor position + const cursor = this.editor.getCursor(); + + // Convert just this specific instance + const result = citationService.convertCitation( + this.content, + group.number, + undefined, // Let it generate a new hex ID + matchIndex // Convert only this specific instance + ); + + if (result.changed) { + // Update the content + await this.saveChanges(result.content); + + // Calculate new cursor position + const offsetChange = result.content.length - this.content.length; + const newCursor = { + line: cursor.line, + ch: cursor.ch + (match.index <= cursor.ch ? offsetChange : 0) + }; + + // Update content and restore cursor + this.content = result.content; + this.editor.setCursor(newCursor); + + new Notice(`Converted citation [${group.number}] to hex format`); + this.close(); + } else { + new Notice('No changes were made to the document'); + } + } catch (error) { + console.error('Error converting citation instance:', error); + new Notice('Error converting citation. See console for details.'); + } + } + private async convertAllCitations() { try { let updatedContent = this.content; diff --git a/src/services/citationService.ts b/src/services/citationService.ts index 10571b2..12f7c70 100644 --- a/src/services/citationService.ts +++ b/src/services/citationService.ts @@ -26,82 +26,68 @@ export interface ConversionResult { } export class CitationService { - private hexCache: Map = new Map(); // Cache URL to hex ID mapping - /** * Find all citations in the content */ public findCitations(content: string): CitationGroup[] { - const lines = content.split('\n'); const matches: CitationMatch[] = []; - const urlMap = new Map(); // Map of number to URL - - // First pass: Find all footnote definitions to map numbers to URLs - lines.forEach((line, _lineIndex) => { - // Match Perplexity-style footnotes: "1. [url](url)" - const footnoteMatch = line.match(/^(\d+)\.\s+(\[([^\]]+)\]\(([^)]+)\)|(https?:\/\/\S+))/); - if (footnoteMatch) { - const number = footnoteMatch[1]; - const url = footnoteMatch[4] || footnoteMatch[2]; - if (number && url) { - urlMap.set(number, url); - } - } - }); - - // Second pass: Find all citations in the content - lines.forEach((line, lineIndex) => { - // Match Perplexity-style citations: [1](url) - const citationRegex = /\[(\d+)\]\(([^)]+)\)/g; - let citationMatch; - - while ((citationMatch = citationRegex.exec(line)) !== null) { - const [fullMatch, number, url] = citationMatch; - - // Skip if number is undefined - if (!number) continue; - - const match: CitationMatch = { + const lines = content.split('\n'); + let currentPosition = 0; + + for (const line of lines) { + // 1. Check for Perplexity-style footnotes (e.g., "1. [https://...]") + const perplexityMatch = line.match(/^(\d+)\.\s+\[(https?:\/\/[^\]]+)\]/); + if (perplexityMatch && perplexityMatch[1] && perplexityMatch[0]) { + matches.push({ type: 'perplexity', - number, - original: fullMatch, - index: citationMatch.index, + number: perplexityMatch[1], + original: perplexityMatch[0], + url: undefined, // URL will be set in the group + index: currentPosition + line.indexOf(perplexityMatch[0]), lineContent: line, - lineNumber: lineIndex, - url: url || undefined - }; - - matches.push(match); + lineNumber: 0 // Will be set in the group + }); } - - // Match standard markdown links that might be citations - const linkRegex = /\[(\d+)\]/g; - let linkMatch; - while ((linkMatch = linkRegex.exec(line)) !== null) { - const [fullMatch, number] = linkMatch; - - // Skip if number is undefined or if it's part of a markdown link - if (!number || line.substring(0, linkMatch.index).endsWith('](')) { - continue; + // 2. Find standard footnote references [^1] + const footnoteRegex = /\[\^(\d+)\]/g; + let footnoteMatch; + while ((footnoteMatch = footnoteRegex.exec(line)) !== null) { + if (footnoteMatch[1] && footnoteMatch[0]) { + matches.push({ + type: 'footnote', + number: footnoteMatch[1], + original: footnoteMatch[0], + url: undefined, + index: currentPosition + (footnoteMatch.index || 0), + lineContent: line, + lineNumber: 0 // Will be set in the group + }); } - - const url = urlMap.get(number); - - // Create the match object with all required properties - const match: CitationMatch = { - type: 'reference', - number, - original: fullMatch, - index: linkMatch.index, - lineContent: line, - lineNumber: lineIndex, - url: url || undefined - }; - - matches.push(match); } - }); + + // 3. Find standard citations [1] (but not links [text](url)) + const citationRegex = /\[(\d+)\]/g; + let citationMatch; + while ((citationMatch = citationRegex.exec(line)) !== null) { + // Skip if it's part of a markdown link + if (citationMatch[1] && citationMatch[0] && + !/\]\([^)]*$/.test(line.substring(0, citationMatch.index || 0))) { + matches.push({ + type: 'reference', + number: citationMatch[1], + original: citationMatch[0], + url: undefined, + index: currentPosition + (citationMatch.index || 0), + lineContent: line, + lineNumber: 0 // Will be set in the group + }); + } + } + + // Update position for the next line + currentPosition += line.length + 1; // +1 for the newline character + } // Group matches by number const groups = new Map(); @@ -127,7 +113,8 @@ export class CitationService { public convertCitation( content: string, citationNumber: string, - hexId?: string + hexId?: string, + matchIndex: number = -1 ): ConversionResult { const groups = this.findCitations(content); const group = groups.find(g => g.number === citationNumber); @@ -138,44 +125,54 @@ export class CitationService { // Generate or use provided hex ID const targetHexId = hexId || this.generateHexId(); - const url = group.url || group.matches[0]?.url; - - if (!url) { - return { content, changed: false, stats: { citationsConverted: 0 } }; - } - - // Cache the URL to hex ID mapping - this.hexCache.set(url, targetHexId); - let updatedContent = content; let citationsConverted = 0; + const url = group.matches[0]?.url; - // Process in reverse to avoid position shifting - const sortedMatches = [...group.matches].sort((a, b) => b.index - a.index); + // If a specific match index is provided, only convert that one + const matchesToProcess = matchIndex >= 0 && matchIndex < group.matches.length + ? [group.matches[matchIndex]] + : [...group.matches].sort((a, b) => b.index - a.index); - for (const match of sortedMatches) { + // Process matches in reverse order to avoid position shifting + for (const match of matchesToProcess) { + if (!match) continue; + const before = updatedContent.substring(0, match.index); const after = updatedContent.substring(match.index + match.original.length); - // Ensure proper spacing - const needsLeadingSpace = !before.endsWith(' ') && !before.endsWith('\n') && before.length > 0; - const needsTrailingSpace = !after.startsWith(' ') && !after.startsWith('\n') && after.length > 0; - - const replacement = `${needsLeadingSpace ? ' ' : ''}[^${targetHexId}]${needsTrailingSpace ? ' ' : ''}`; - - updatedContent = before + replacement + after; + // Replace with hex reference + updatedContent = `${before}[^${targetHexId}]${after}`; citationsConverted++; + + // If this was a Perplexity-style citation, clean up the URL part + if (match.type === 'perplexity' && match.url) { + // Match the URL pattern that might follow the citation + const urlPattern = new RegExp( + `\\[${citationNumber}\\]\\s*\\[([^\]]+)\\]\([^)]+\)` + ); + const urlMatch = updatedContent.match(urlPattern); + + if (urlMatch) { + const urlStart = updatedContent.indexOf(urlMatch[0]); + const urlEnd = urlStart + urlMatch[0].length; + updatedContent = updatedContent.substring(0, urlStart) + + updatedContent.substring(urlEnd); + } + } } - - // Add or update the footnote definition - const footnoteDef = `[^${targetHexId}]: ${url}`; - const footnoteSection = this.ensureFootnoteSection(updatedContent); - if (!footnoteSection.content.includes(`[^${targetHexId}]:`)) { - updatedContent = updatedContent.replace( - footnoteSection.marker, - `${footnoteSection.marker}\n${footnoteDef}` - ); + // Add footnote definition if we have a URL and converted citations + if (citationsConverted > 0 && url) { + const footnoteDef = `[^${targetHexId}]: ${url}`; + const footnoteSection = this.ensureFootnoteSection(updatedContent); + + if (!footnoteSection.content.includes(`[^${targetHexId}]:`)) { + updatedContent = updatedContent.replace( + footnoteSection.marker, + `${footnoteSection.marker}\n${footnoteDef}` + ); + } } return { diff --git a/src/styles/citations.css b/src/styles/citations.css index ccc93ad..2777607 100644 --- a/src/styles/citations.css +++ b/src/styles/citations.css @@ -91,12 +91,39 @@ opacity: 0.9; } +.cite-wide-instance-actions { + display: flex; + gap: 0.5rem; + margin-left: 0.75rem; +} + +.cite-wide-convert-btn, +.cite-wide-view-btn { + white-space: nowrap; + padding: 0.25rem 0.75rem; + font-size: 0.85em; + line-height: 1.4; + border-radius: 4px; + transition: all 0.1s ease; +} + .cite-wide-convert-btn { - margin-left: 0.5rem; + background-color: var(--interactive-accent); + color: var(--text-on-accent); + border: 1px solid var(--interactive-accent-hover); +} + +.cite-wide-convert-btn:hover { + background-color: var(--interactive-accent-hover); } .cite-wide-view-btn { - margin-left: 0.5rem; + background-color: var(--background-secondary); + border: 1px solid var(--background-modifier-border); +} + +.cite-wide-view-btn:hover { + background-color: var(--background-modifier-hover); } .cite-wide-footer {