From bc051168d19dd49613973745d7a91ed2d42a826e Mon Sep 17 00:00:00 2001 From: mpstaton Date: Sun, 6 Jul 2025 18:41:26 +0300 Subject: [PATCH] feat(modal): milestone of modal feature displaying citations working On branch development Changes to be committed: modified: README.md modified: esbuild.config.mjs modified: main.ts modified: manifest.json new file: src/modals/CitationModal.ts new file: src/services/citationService.ts new file: src/styles/citations.css new file: src/types/obsidian.d.ts new file: src/utils/logger.ts --- README.md | 10 + esbuild.config.mjs | 12 +- main.ts | 680 +++----------------------------- manifest.json | 4 +- src/modals/CitationModal.ts | 163 ++++++++ src/services/citationService.ts | 86 ++++ src/styles/citations.css | 74 ++++ src/types/obsidian.d.ts | 24 ++ src/utils/logger.ts | 136 +++++++ styles.css | 70 +++- 10 files changed, 620 insertions(+), 639 deletions(-) create mode 100644 src/modals/CitationModal.ts create mode 100644 src/services/citationService.ts create mode 100644 src/styles/citations.css create mode 100644 src/types/obsidian.d.ts create mode 100644 src/utils/logger.ts diff --git a/README.md b/README.md index aa16cc7..540f314 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,13 @@ # Getting Started pnpm dev ``` +## Make it show up in Obsidian + +Create a symbolic link into the plugins directory: + +Here is my example, but you will need to use your own path structure: +```bash +ln -s /Users/mpstaton/code/lossless-monorepo/cite-wide /Users/mpstaton/content-md/lossless/.obsidian/plugins/cite-wide +``` + + diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 1feb7e9..2bdd883 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -27,13 +27,22 @@ const external = [ ...builtins ]; +// First, build the CSS file +await esbuild.build({ + entryPoints: ['src/styles/citations.css'], + bundle: true, + minify: isProduction, + outfile: 'styles.css', + loader: { '.css': 'css' }, +}); + const context = await esbuild.context({ banner: { js: banner, }, entryPoints: ['main.ts'], bundle: true, - external, + external: [...external, './styles.css'], format: 'cjs', platform: 'node', target: 'es2022', @@ -45,6 +54,7 @@ const context = await esbuild.context({ }, logLevel: 'info', outfile: 'main.js', + loader: { '.css': 'text' }, }); if (isProduction) { diff --git a/main.ts b/main.ts index 07ff100..8848936 100644 --- a/main.ts +++ b/main.ts @@ -1,673 +1,97 @@ -import { App, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, Editor } from 'obsidian'; -import * as crypto from 'crypto'; - -interface ContentFarmSettings { - mySetting: string; - localLLMPath: string; - requestBodyTemplate: string; -} - -const DEFAULT_SETTINGS: ContentFarmSettings = { - mySetting: 'default', - localLLMPath: 'http://localhost:3030/api/search', - requestBodyTemplate: `{ - "chatModel": { - "provider": "openai", - "name": "gpt-4o-mini" - }, - "embeddingModel": { - "provider": "openai", - "name": "text-embedding-3-large" - }, - "optimizationMode": "speed", - "focusMode": "webSearch", - "query": "What is Perplexicas architecture", - "history": [ - ["human", "Hi, how are you?"], - ["assistant", "I am doing well, how can I help you today?"] - ], - "systemInstructions": "Focus on providing technical details about Perplexicas architecture.", - "stream": false - }` -}; - -export default class ContentFarmPlugin extends Plugin { - public settings: ContentFarmSettings = DEFAULT_SETTINGS; - private statusBarItemEl: HTMLElement | null = null; - private ribbonIconEl: HTMLElement | null = null; +import { Notice, Plugin, Editor } from 'obsidian'; +import { citationService } from './src/services/citationService'; +import { CitationModal } from './src/modals/CitationModal'; +export default class CiteWidePlugin extends Plugin { async onload(): Promise { - await this.loadSettings(); - - this.registerRibbonIcon(); - this.setupStatusBar(); - this.registerCommands(); - this.addSettingTab(new ContentFarmSettingTab(this.app, this)); + // Load CSS + this.loadStyles(); + + // Register commands this.registerCitationCommands(); } - - onunload(): void { - this.statusBarItemEl?.remove(); - this.ribbonIconEl?.remove(); + + private async loadStyles() { + try { + const cssPath = this.manifest.dir + '/styles.css'; + const response = await fetch(cssPath); + if (!response.ok) throw new Error('Failed to load CSS'); + + const css = await response.text(); + const styleEl = document.createElement('style'); + styleEl.id = 'cite-wide-styles'; + styleEl.textContent = css; + document.head.appendChild(styleEl); + } catch (error) { + console.error('Error loading styles:', error); + } } - /** - * Register citation-related commands - */ private registerCitationCommands(): void { - console.log('Registering citation commands...'); - + // Command to show citations in current file + this.addCommand({ + id: 'show-citations', + name: 'Show Citations in Current File', + editorCallback: (editor: Editor) => { + new CitationModal(this.app, editor).open(); + } + }); + + // Command to convert all citations to hex format this.addCommand({ id: 'convert-all-citations', name: 'Convert All Citations to Hex Format', editorCallback: async (editor: Editor) => { - console.log('convert-all-citations command triggered'); try { const content = editor.getValue(); - console.log('Processing content length:', content.length); - const result = this.processCitations(content); + const result = citationService.convertCitations(content); if (result.changed) { - console.log('Citations changed, updating editor'); editor.setValue(result.updatedContent); new Notice(`Updated ${result.stats.citationsConverted} citations`); } else { - console.log('No citations needed conversion'); new Notice('No citations needed conversion'); } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error); - console.error('Error in convert-all-citations:', error); new Notice('Error processing citations: ' + errorMsg); } } }); - - console.log('Citation commands registered'); - } - /** - * Process citations in the content - * @param content - The markdown content to process - * @returns Object with updated content and statistics - */ - private processCitations(content: string): { - updatedContent: string; - changed: boolean; - stats: { - citationsConverted: number; - }; - } { - // Extract code blocks to avoid processing them - const codeBlocks: { placeholder: string; original: string }[] = []; - let processableContent = content; - - // Extract fenced code blocks - processableContent = processableContent.replace(/```[\s\S]*?```/g, (match) => { - const placeholder = `__CODE_BLOCK_${crypto.randomBytes(4).toString('hex')}__`; - codeBlocks.push({ placeholder, original: match }); - return placeholder; - }); - - // Process citations - const result = this.convertCitations(processableContent); - - // Restore code blocks - for (const { placeholder, original } of codeBlocks) { - result.updatedContent = result.updatedContent.replace(placeholder, original); - } - - return result; - } - - /** - * Generate a random hex string of specified length - * @param length - Length of the hex string to generate - * @returns Random hex string - */ - private generateHexId(length: number = 6): string { - return crypto.randomBytes(Math.ceil(length / 2)) - .toString('hex') - .slice(0, length); - } - - /** - * Convert all citations to random hex format - * @param content - The markdown content to process - * @returns Object with updated content and statistics - */ - private convertCitations(content: string): { - updatedContent: string; - changed: boolean; - stats: { - citationsConverted: number; - }; - } { - let updatedContent = content; - let citationsConverted = 0; - const citationMap = new Map(); // Maps original ID to hex ID - - // First pass: collect all citations and generate hex IDs - const collectCitations = (match: string, prefix: string, id: string) => { - if (!citationMap.has(id)) { - citationMap.set(id, this.generateHexId()); - } - return match; // Don't modify yet - }; - - // Collect numeric citations [^123] - updatedContent = updatedContent.replace(/\[\^(\d+)\]/g, collectCitations); - - // Collect plain numeric citations [123] (only if not part of a link) - updatedContent = updatedContent.replace(/\[(\d+)\]/g, (match, id, offset) => { - // Only collect if it's not part of a link - if (!/\]\([^)]*$/.test(updatedContent.substring(0, offset))) { - return collectCitations(match, '', id); - } - return match; - }); - - if (citationMap.size === 0) { - return { updatedContent, changed: false, stats: { citationsConverted: 0 } }; - } - - // Second pass: replace all collected citations with their hex IDs - citationMap.forEach((hexId, originalId) => { - // Replace inline citations [^123] or [123] - updatedContent = updatedContent.replace( - new RegExp(`\\[\\^?${originalId}\\]`, 'g'), - ` [^${hexId}]` - ); - - // Replace footnote definitions [^123]: or [^hex]: - updatedContent = updatedContent.replace( - new RegExp(`\\[\\^${originalId}\\](:\\s*)`, 'g'), - `[^${hexId}]$1` - ); - - citationsConverted++; - }); - - // Ensure consistent formatting of all footnote definitions - updatedContent = updatedContent.replace(/\[\^([0-9a-f]{6})\]:\s*/gi, (match, hexId) => { - return `[^${hexId.toLowerCase()}]: `; // Ensure consistent case - }); - - return { - updatedContent, - changed: citationsConverted > 0, - stats: { citationsConverted } - }; - } - - private async loadSettings(): Promise { - this.settings = { - ...DEFAULT_SETTINGS, - ...(await this.loadData() ?? {}) - }; - } - - public async saveSettings(): Promise { - try { - await this.saveData(this.settings); - } catch (error) { - console.error('Failed to save settings:', error); - new Notice('Failed to save settings'); - } - } - - private async sendRequest(jsonString: string, editor?: Editor): Promise { - try { - const response = await fetch(this.settings.localLLMPath, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: jsonString, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - // Parse JSON to check if it's a streaming request - const requestData = JSON.parse(jsonString); - if (requestData.stream && editor) { - // Insert a new line and get the position for streaming - const cursor = editor.getCursor(); - const insertPos = { line: cursor.line + 1, ch: 0 }; - editor.replaceRange('\n', cursor); - - // Handle streaming response directly to editor - const reader = response.body?.getReader(); - if (!reader) throw new Error('No response body'); - - let result = ''; - let lastUpdate = Date.now(); - const updateThreshold = 50; // ms between updates - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - const chunk = new TextDecoder().decode(value); - result += chunk; - - // Only update the editor at a throttled rate for performance - const now = Date.now(); - if (now - lastUpdate >= updateThreshold) { - editor.replaceRange(chunk, insertPos); - lastUpdate = now; - } else { - // For the last chunk, make sure to update - if (done) { - editor.replaceRange(chunk, insertPos); - } - } - - // Move cursor to end of inserted text - const lines = result.split('\n'); - const lastLine = lines[lines.length - 1] || ''; // Handle empty last line - editor.setCursor({ - line: insertPos.line + Math.max(0, lines.length - 1), // Ensure non-negative line number - ch: lastLine.length - }); - } - - // Final update with any remaining content - editor.replaceRange(result, insertPos); - return result; - } else { - if (!editor) { - new Notice('Error: No active editor'); - return ''; - } - - // Simple fetch request with the raw JSON string - try { - const response = await fetch(this.settings.localLLMPath, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: jsonString - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.text(); - editor.replaceRange('\n' + data, editor.getCursor()); - return data; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Unknown error'; - new Notice(`Error: ${errorMessage}`); - console.error('Request failed:', error); - return ''; - } - } - } catch (error) { - console.error('Error sending request:', error); - const errorMessage = `Error: ${error instanceof Error ? error.message : String(error)}`; - if (editor) { - editor.replaceRange('\n' + errorMessage, editor.getCursor()); - } - throw new Error(errorMessage); - } - } - - private registerRibbonIcon(): void { - this.ribbonIconEl = this.addRibbonIcon( - 'dice', - 'Content Farm', - () => { - new Notice('This is Deep Lossless notice!'); - } - ); - this.ribbonIconEl?.addClass('content-farm-ribbon'); - } - - private setupStatusBar(): void { - this.statusBarItemEl = this.addStatusBarItem(); - if (this.statusBarItemEl) { - this.statusBarItemEl.setText('Content Farm Active'); - } - } - - private registerCommands(): void { - this.addCommand({ - id: 'open-content-farm-modal', - name: 'Open Content Farm', - callback: () => { - new ContentFarmModal(this.app).open(); - } - }); - - this.addCommand({ - id: 'insert-sample-text', - name: 'Insert Sample Text', - editorCallback: (editor: Editor) => { - editor.replaceSelection('Sample text from Content Farm'); - } - }); - - // Command to insert a citation reference and its footnote definition + // Command to insert a new citation this.addCommand({ id: 'insert-hex-citation', - name: 'Insert Random Hex Citation Footnote', + name: 'Insert Hex Citation', editorCallback: (editor: Editor) => { - console.log('insert-hex-citation command triggered'); try { const cursor = editor.getCursor(); - console.log('Cursor position:', cursor); + const hexId = citationService.generateHexId(); - const citationId = this.generateHexId(); - console.log('Generated citation ID:', citationId); + // Insert the citation reference at cursor + editor.replaceRange(`[^${hexId}]`, cursor); - // Get current line content - const lineContent = editor.getLine(cursor.line); - console.log('Current line content:', lineContent); - - // Insert the citation reference at cursor position - const insertPosition = { line: cursor.line, ch: cursor.ch }; - editor.replaceRange(`[^${citationId}]`, insertPosition); - - // Add a new line for the footnote definition - const nextLine = cursor.line + 1; - const nextLineContent = editor.getLine(nextLine) || ''; - - // Insert a newline if next line is not empty - if (nextLineContent.trim() !== '') { - editor.replaceRange('\n', { line: nextLine, ch: 0 }); - } - - // Add the footnote definition - const footnotePosition = { - line: nextLineContent.trim() === '' ? nextLine : nextLine + 1, + // Add the footnote definition at the end + const content = editor.getValue(); + const footnotePosition = { + line: content.split('\n').length, ch: 0 }; - editor.replaceRange(`[^${citationId}]: `, footnotePosition); + editor.replaceRange(`\n\n[^${hexId}]: `, footnotePosition); - // Position cursor at the end of the footnote definition - const newCursorPos = { - line: footnotePosition.line, - ch: `[^${citationId}]: `.length + // Position cursor after the inserted citation + const newPos = { + line: footnotePosition.line + 2, // +2 for the two newlines + ch: `[^${hexId}]: `.length }; - editor.setCursor(newCursorPos); + editor.setCursor(newPos); - console.log('Footnote insertion complete'); } catch (error) { - console.error('Error in insert-hex-citation:', error); - new Notice('Error inserting citation: ' + (error instanceof Error ? error.message : String(error))); - } - } - }); - - this.addCommand({ - id: 'open-sample-modal-complex', - name: 'Open sample modal (complex)', - checkCallback: (checking: boolean) => { - const markdownView = this.app.workspace.getActiveViewOfType(MarkdownView); - if (markdownView) { - if (!checking) { - new ContentFarmModal(this.app).open(); - } - return true; - } - return false; - } - }); - - this.addCommand({ - id: 'send-perplexica-request', - name: 'Send Perplexica Request', - editorCallback: (editor) => { - editor.replaceSelection( - '```requestjson--perplexica\n' + - this.settings.requestBodyTemplate + '\n' + - '```' - ); - } - }); - - // This adds an editor command that can perform some operation on the current editor instanceAdd commentMore actions - this.addCommand({ - id: 'add-hex-citation', - name: 'Add Inline Hex Citation', - editorCallback: (editor: Editor) => { - console.log('add-hex-citation command triggered'); - try { - const cursor = editor.getCursor(); - console.log('Cursor position:', cursor); - - const hexId = this.generateHexId(); - console.log('Generated hex ID:', hexId); - - const selection = editor.getSelection(); - console.log('Current selection:', selection); - - if (selection) { - // If text is selected, wrap it with the citation - console.log('Wrapping selection with citation'); - editor.replaceSelection(`[^${hexId}] ${selection}`); - - // Position cursor after the inserted content - const newPos = editor.posToOffset(cursor) + hexId.length + 4 + selection.length; - editor.setCursor(editor.offsetToPos(newPos)); - } else { - // If no text selected, just insert the citation - console.log('Inserting citation at cursor'); - editor.replaceRange(`[^${hexId}]`, cursor); - - // Position cursor after the inserted citation - const newPos = editor.posToOffset(cursor) + hexId.length + 3; - editor.setCursor(editor.offsetToPos(newPos)); - } - - console.log('Inline citation insertion complete'); - } catch (error) { - console.error('Error in add-hex-citation:', error); - new Notice('Error adding citation: ' + (error instanceof Error ? error.message : String(error))); - } - } - }); - - // Simple curl command with NDJSON streaming support - this.addCommand({ - id: 'curl-request', - name: 'Curl Request', - editorCallback: (editor: Editor) => { - const sel = editor.getSelection() || ''; - const json = sel.match(/```[\s\S]*?```/)?.[0].replace(/```[\s\S]*?\n|```/g, '') || ''; - if (!json) { - new Notice('No request found'); - return; - } - - // Save current content and add a new line for the response - const currentContent = editor.getValue(); - editor.setValue(currentContent + '\n'); // Add new line for response - - // Use spawn instead of exec to handle streaming - const { spawn } = require('child_process'); - const echo = spawn('echo', [json]); - const curl = spawn('curl', [ - '-s', - '-X', 'POST', - '-H', 'Content-Type: application/json', - '--no-buffer', - '--data-binary', '@-', - 'http://localhost:3030/api/search' - ]); - - let responseText = ''; - - // Pipe echo to curl - echo.stdout.pipe(curl.stdin); - - // Handle curl output - curl.stdout.on('data', (data: Buffer) => { - const lines = data.toString().split('\n').filter(line => line.trim()); - - lines.forEach(line => { - try { - const message = JSON.parse(line); - if (message.type === 'response' && message.data) { - responseText += message.data; - - // Update the editor with the current response - const currentContent = editor.getValue(); - const newContent = currentContent + message.data; - editor.setValue(newContent); - - // Move cursor to end of content - const pos = editor.offsetToPos(newContent.length); - editor.setCursor(pos); - } - } catch (e) { - // Ignore JSON parse errors for partial lines - } - }); - }); - - curl.stderr.on('data', (data: Buffer) => { - new Notice(`Error: ${data.toString()}`); - }); - - curl.on('close', (code: number) => { - if (code !== 0) { - new Notice(`Process exited with code ${code}`); - } - }); - } - }); - - this.addCommand({ - id: 'send-request-from-selection', - name: 'Send Request from Selection', - editorCallback: async (editor) => { - const selection = editor.getSelection(); - if (!selection) { - new Notice('Please select a code block with the request JSON'); - return; - } - - try { - // Extract JSON from code block - const jsonMatch = selection.match(/```(?:requestjson--perplexica)?\n?([\s\S]*?)\n?```/); - if (!jsonMatch || !jsonMatch[1]) { - throw new Error('No valid JSON found in selection'); - } - - const jsonString = jsonMatch[1].trim(); - // Log the extracted JSON for debugging - console.log('Extracted JSON:', jsonString); - - // Validate JSON syntax - try { - JSON.parse(jsonString); // Just validate, we'll use the string as-is - await this.sendRequest(jsonString, editor); - } catch (e: unknown) { - const error = e as Error; - console.error('JSON parse error:', error); - throw new Error(`Invalid JSON in code block: ${error.message}`); - } - } catch (error) { - console.error('Error processing request:', error); - new Notice(`Error: ${error instanceof Error ? error.message : String(error)}`); + const errorMsg = error instanceof Error ? error.message : String(error); + new Notice('Error inserting citation: ' + errorMsg); } } }); } -} - -class ContentFarmModal extends Modal { - constructor(app: App) { - super(app); - } - - onOpen(): void { - const { contentEl } = this; - contentEl.createEl('h2', { text: 'Content Farm' }); - contentEl.createEl('p', { text: 'Welcome to Content Farm Plugin!' }); - } - - onClose(): void { - const { contentEl } = this; - contentEl.empty(); - } -} - -class ContentFarmSettingTab extends PluginSettingTab { - plugin: ContentFarmPlugin; - - constructor(app: App, plugin: ContentFarmPlugin) { - super(app, plugin); - this.plugin = plugin; - } - - display(): void { - const { containerEl } = this; - containerEl.empty(); - - containerEl.createEl('h2', { text: 'Content Farm Settings' }); - - new Setting(containerEl) - .setName('Custom Setting') - .setDesc('Configure your content farm settings') - .addText(text => text - .setPlaceholder('Enter your setting') - .setValue(this.plugin.settings.mySetting) - .onChange(async (value: string) => { - this.plugin.settings.mySetting = value; - await this.plugin.saveSettings(); - }) - ); - - new Setting(containerEl) - .setName('Local LLM Path or Port') - .setDesc('Configure your local LLM path or port') - .addText(text => text - .setPlaceholder('http://localhost:11434') - .setValue(this.plugin.settings.localLLMPath) - .onChange(async (value: string) => { - this.plugin.settings.localLLMPath = value; - await this.plugin.saveSettings(); - }) - ); - - // Create a textarea for JSON configuration - const jsonSetting = new Setting(containerEl) - .setName('Request Body Template') - .setDesc('Enter your request body template as JSON'); - - // Create a textarea element - const textArea = document.createElement('textarea'); - textArea.rows = 10; - textArea.cols = 50; - textArea.style.width = '100%'; - textArea.style.minHeight = '300px'; - textArea.style.fontFamily = 'monospace'; - textArea.placeholder = '{\n "chatModel": {\n "provider": "openai",\n "name": "gpt-4o-mini"\n },\n "embeddingModel": {\n "provider": "openai",\n "name": "text-embedding-3-large"\n },\n "optimizationMode": "speed",\n "focusMode": "webSearch",\n "query": "What is Perplexica",\n "history": [\n ["human", "Hi, how are you?"],\n ["assistant", "I am doing well, how can I help you today?"]\n ],\n "systemInstructions": "Focus on providing technical details about Perplexica\\\'s architecture.",\n "stream": false\n}'; - - // Set initial value if it exists - if (this.plugin.settings.requestBodyTemplate) { - try { - const config = JSON.parse(this.plugin.settings.requestBodyTemplate); - textArea.value = JSON.stringify(config, null, 2); - } catch (e) { - // If not valid JSON, use as is - textArea.value = this.plugin.settings.requestBodyTemplate; - } - } - - // Add input event listener - textArea.addEventListener('input', async () => { - this.plugin.settings.localLLMPath = textArea.value; - await this.plugin.saveSettings(); - }); - - // Add the textarea to the setting - jsonSetting.settingEl.appendChild(textArea); - } -} +} \ No newline at end of file diff --git a/manifest.json b/manifest.json index 83eab60..24ebcbf 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { - "id": "content-farm", - "name": "Content Farm Obsidian Plugin", + "id": "cite-wide", + "name": "Cite Wide Obsidian Plugin", "version": "0.0.0.1", "minAppVersion": "0.0.0.1", "description": "A plugin for Obsidian that allows you to generate content using AI.", diff --git a/src/modals/CitationModal.ts b/src/modals/CitationModal.ts new file mode 100644 index 0000000..43521af --- /dev/null +++ b/src/modals/CitationModal.ts @@ -0,0 +1,163 @@ +import { App, Modal, Notice, Editor } from 'obsidian'; + +export interface CitationMatch { + type: 'footnote' | 'reference'; + original: string; + number: string; + position: number; + line: number; + lineContent: string; +} + +export class CitationModal extends Modal { + private matches: CitationMatch[] = []; + private content: string = ''; + + constructor(app: App, private editor: Editor) { + super(app); + } + + async onOpen() { + const { contentEl } = this; + contentEl.empty(); + contentEl.addClass('cite-wide-modal'); + + // Get current editor content + this.content = this.editor.getValue(); + + // Find all citations in the content + this.findCitations(); + + if (this.matches.length === 0) { + contentEl.createEl('h3', { text: 'No Citations Found' }); + contentEl.createEl('p', { + text: 'No footnote references [^1] or citations [1] were found in the current note.' + }); + return; + } + + // Create header + contentEl.createEl('h3', { text: 'Citations in Current Note' }); + + // Create table + const table = contentEl.createEl('table', { cls: 'cite-wide-table' }); + const thead = table.createEl('thead'); + const headerRow = thead.createEl('tr'); + headerRow.createEl('th', { text: 'Type' }); + headerRow.createEl('th', { text: 'Number' }); + headerRow.createEl('th', { text: 'Context' }); + headerRow.createEl('th', { text: 'Actions' }); + + const tbody = table.createEl('tbody'); + + // Add each citation to the table + this.matches.forEach((match) => { + const row = tbody.createEl('tr'); + + // Type + row.createEl('td', { + text: match.type === 'footnote' ? 'Footnote' : 'Citation', + cls: 'cite-type-cell' + }); + + // Number + row.createEl('td', { + text: match.number, + cls: 'cite-number-cell' + }); + + // Context (first 30 chars of line) + const context = match.lineContent?.trim() || ''; + row.createEl('td', { + text: context.length > 30 ? context.substring(0, 30) + '...' : context, + cls: 'cite-context-cell', + title: context // Show full line on hover + }); + + // Actions + const actionsCell = row.createEl('td', { cls: 'cite-actions-cell' }); + const viewBtn = actionsCell.createEl('button', { + text: 'View', + cls: 'mod-cta' + }); + + viewBtn.onclick = () => { + // Move cursor to the line of this citation + this.editor.setCursor({ line: match.line, ch: 0 }); + this.editor.focus(); + this.close(); + }; + }); + + // Add convert all button if we have citations + if (this.matches.length > 0) { + const buttonContainer = contentEl.createDiv({ cls: 'modal-button-container' }); + const convertAllBtn = buttonContainer.createEl('button', { + text: 'Convert All to Hex', + cls: 'mod-cta' + }); + + convertAllBtn.onclick = async () => { + await this.convertAllCitations(); + this.close(); + }; + } + } + + private findCitations() { + const lines = this.content.split('\n'); + + lines.forEach((line, lineIndex) => { + if (!line) return; + + // Find footnote references [^1] + const footnoteRegex = /\[\^(\d+)\]/g; + let match; + while ((match = footnoteRegex.exec(line)) !== null) { + if (match[0] && match[1] !== undefined) { + this.matches.push({ + type: 'footnote', + original: match[0], + number: match[1], + position: match.index, + line: lineIndex, + lineContent: line + }); + } + } + + // Find citations [1] (but not links [text](url)) + const citationRegex = /\[(\d+)\]/g; + while ((match = citationRegex.exec(line)) !== null) { + // Skip if it's part of a markdown link + if (match[0] && match[1] !== undefined && !/\]\([^)]*$/.test(line.substring(0, match.index || 0))) { + this.matches.push({ + type: 'reference', + original: match[0], + number: match[1], + position: match.index || 0, + line: lineIndex, + lineContent: line + }); + } + } + }); + + // Sort by line number and then by position in line + this.matches.sort((a, b) => { + if (a.line !== b.line) return a.line - b.line; + return a.position - b.position; + }); + } + + private async convertAllCitations() { + // This will be implemented to convert all citations to hex format + new Notice('Converting citations to hex format...'); + // Implementation will be added after we confirm the modal works + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/services/citationService.ts b/src/services/citationService.ts new file mode 100644 index 0000000..5cc4ed1 --- /dev/null +++ b/src/services/citationService.ts @@ -0,0 +1,86 @@ +import * as crypto from 'crypto'; + +export interface CitationConversionResult { + updatedContent: string; + changed: boolean; + stats: { + citationsConverted: number; + }; +} + +export class CitationService { + /** + * Generate a random hex ID of specified length + * @param length - Length of the hex ID to generate (default: 6) + * @returns Random hex string + */ + public generateHexId(length: number = 6): string { + return crypto.randomBytes(Math.ceil(length / 2)) + .toString('hex') + .slice(0, length); + } + + /** + * Convert all citations to random hex format + * @param content - The markdown content to process + * @returns Object with updated content and statistics + */ + public convertCitations(content: string): CitationConversionResult { + let updatedContent = content; + let citationsConverted = 0; + const citationMap = new Map(); // Maps original ID to hex ID + + // First pass: collect all citations and generate hex IDs + const collectCitations = (match: string, _prefix: string, id: string) => { + // Only process if it's a numeric ID and we haven't seen it before + if (/^\d+$/.test(id) && !citationMap.has(id)) { + citationMap.set(id, this.generateHexId()); + } + return match; // Don't modify yet + }; + + // Collect numeric citations [^1] + updatedContent = updatedContent.replace(/\[\^(\d+)\]/g, collectCitations); + + // Collect plain numeric citations [1] (only if not part of a link) + updatedContent = updatedContent.replace(/\[(\d+)\]/g, (match, id, offset) => { + // Only collect if it's a single digit and not part of a link + if (/^\d+$/.test(id) && !/\]\([^)]*$/.test(updatedContent.substring(0, offset))) { + return collectCitations(match, '', id); + } + return match; + }); + + // Second pass: replace all collected citations with their hex equivalents + citationMap.forEach((hexId, originalId) => { + try { + // Replace [^1] style citations + updatedContent = updatedContent.replace( + new RegExp(`\\[\\^${originalId}\\]`, 'g'), + `[^${hexId}]` + ); + + // Replace [1] style citations (only if not part of a link) + updatedContent = updatedContent.replace( + new RegExp(`(^|[^\\])\\[${originalId}\\]`, 'g'), + `$1[^${hexId}]` + ); + + citationsConverted++; + } catch (error) { + console.error(`Error processing citation ${originalId}:`, error); + } + }); + + return { + updatedContent, + changed: citationsConverted > 0, + stats: { + citationsConverted + } + }; + } +} + +// Export a singleton instance +export const citationService = new CitationService(); diff --git a/src/styles/citations.css b/src/styles/citations.css new file mode 100644 index 0000000..3883718 --- /dev/null +++ b/src/styles/citations.css @@ -0,0 +1,74 @@ +/* Citations Modal Styles */ +.cite-wide-modal { + padding: 20px; +} + +.cite-wide-modal h3 { + margin-top: 0; + color: var(--text-normal); + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 10px; +} + +.cite-wide-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + font-size: 0.9em; +} + +.cite-wide-table th, +.cite-wide-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--background-modifier-border); +} + +.cite-wide-table th { + font-weight: 600; + color: var(--text-muted); + background-color: var(--background-primary-alt); +} + +.cite-type-cell { + font-weight: 600; + color: var(--text-accent); + width: 100px; +} + +.cite-number-cell { + width: 80px; + text-align: center !important; +} + +.cite-context-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cite-actions-cell { + width: 100px; + text-align: right !important; +} + +.modal-button-container { + display: flex; + justify-content: flex-end; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid var(--background-modifier-border); +} + +/* Make the modal responsive */ +@media (max-width: 600px) { + .cite-wide-table { + display: block; + overflow-x: auto; + } + + .cite-context-cell { + max-width: 150px; + } +} diff --git a/src/types/obsidian.d.ts b/src/types/obsidian.d.ts new file mode 100644 index 0000000..ee47d10 --- /dev/null +++ b/src/types/obsidian.d.ts @@ -0,0 +1,24 @@ +import { App, Editor, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian'; + +declare module 'obsidian' { + interface App { + commands: any; + } + + interface Editor { + getSelection(): string; + replaceSelection(text: string): void; + getCursor(): { line: number, ch: number }; + setCursor(line: number, ch: number): void; + lastLine(): number; + } + + interface MarkdownView { + file: TFile; + editor: Editor; + } + + interface PluginManifest { + dir: string; + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..d70b4ad --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,136 @@ +import { Notice } from 'obsidian'; +import { TFile, Vault } from 'obsidian'; + +interface LogEntry { + timestamp: string; + level: 'error' | 'warn' | 'info' | 'debug'; + message: string; + details?: any; + stack?: string; +} + +export class FileLogger { + private static instance: FileLogger; + private logFile: string = 'freepik-errors.json'; + private vault: Vault | null = null; + private logEntries: LogEntry[] = []; + private isSaving = false; + private saveQueue: (() => Promise)[] = []; + + private constructor() {} + + static getInstance(): FileLogger { + if (!FileLogger.instance) { + FileLogger.instance = new FileLogger(); + } + return FileLogger.instance; + } + + initialize(vault: Vault): void { + this.vault = vault; + this.loadLogs().catch(error => { + console.error('Failed to load logs:', error); + }); + } + + private async loadLogs(): Promise { + if (!this.vault) return; + + try { + const file = this.vault.getAbstractFileByPath(this.logFile); + if (file instanceof TFile) { + const content = await this.vault.read(file); + this.logEntries = JSON.parse(content); + } + } catch (error) { + // File doesn't exist or is corrupted, start with empty logs + this.logEntries = []; + } + } + + private async saveLogs(): Promise { + if (!this.vault || this.isSaving) { + // If already saving, queue this save for later + if (!this.isSaving) { + this.saveQueue.push(() => this.saveLogs()); + } + return; + } + + this.isSaving = true; + + try { + await this.vault.adapter.write( + this.logFile, + JSON.stringify(this.logEntries, null, 2) + ); + } catch (error) { + console.error('Failed to save logs:', error); + new Notice('Failed to save error logs. Check console for details.'); + } finally { + this.isSaving = false; + + // Process any queued saves + const nextSave = this.saveQueue.shift(); + if (nextSave) { + await nextSave(); + } + } + } + + private addEntry(level: LogEntry['level'], message: string, details?: any): void { + const entry: LogEntry = { + timestamp: new Date().toISOString(), + level, + message, + details: details instanceof Error ? { + message: details.message, + name: details.name, + stack: details.stack + } : details + }; + + this.logEntries.push(entry); + + // Keep only the last 1000 entries + if (this.logEntries.length > 1000) { + this.logEntries = this.logEntries.slice(-1000); + } + + this.saveLogs().catch(error => { + console.error('Failed to save log entry:', error); + }); + + // Also log to console + const logMethod = console[level] || console.log; + logMethod(`[${entry.timestamp}] [${level.toUpperCase()}] ${message}`, details || ''); + } + + error(message: string, details?: any): void { + this.addEntry('error', message, details); + } + + warn(message: string, details?: any): void { + this.addEntry('warn', message, details); + } + + info(message: string, details?: any): void { + this.addEntry('info', message, details); + } + + debug(message: string, details?: any): void { + this.addEntry('debug', message, details); + } + + getLogs(limit: number = 100): LogEntry[] { + return [...this.logEntries].reverse().slice(0, limit); + } + + async clearLogs(): Promise { + this.logEntries = []; + await this.saveLogs(); + } +} + +// Export a singleton instance +export const logger = FileLogger.getInstance(); diff --git a/styles.css b/styles.css index 71cc60f..0d598f7 100644 --- a/styles.css +++ b/styles.css @@ -1,8 +1,62 @@ -/* - -This CSS file will be included with your plugin, and -available in the app when your plugin is enabled. - -If your plugin does not need CSS, delete this file. - -*/ +/* src/styles/citations.css */ +.cite-wide-modal { + padding: 20px; +} +.cite-wide-modal h3 { + margin-top: 0; + color: var(--text-normal); + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 10px; +} +.cite-wide-table { + width: 100%; + border-collapse: collapse; + margin: 15px 0; + font-size: 0.9em; +} +.cite-wide-table th, +.cite-wide-table td { + padding: 8px 12px; + text-align: left; + border-bottom: 1px solid var(--background-modifier-border); +} +.cite-wide-table th { + font-weight: 600; + color: var(--text-muted); + background-color: var(--background-primary-alt); +} +.cite-type-cell { + font-weight: 600; + color: var(--text-accent); + width: 100px; +} +.cite-number-cell { + width: 80px; + text-align: center !important; +} +.cite-context-cell { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.cite-actions-cell { + width: 100px; + text-align: right !important; +} +.modal-button-container { + display: flex; + justify-content: flex-end; + margin-top: 20px; + padding-top: 15px; + border-top: 1px solid var(--background-modifier-border); +} +@media (max-width: 600px) { + .cite-wide-table { + display: block; + overflow-x: auto; + } + .cite-context-cell { + max-width: 150px; + } +}