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
This commit is contained in:
mpstaton 2025-07-06 18:41:26 +03:00
parent 220876d2a5
commit bc051168d1
10 changed files with 620 additions and 639 deletions

View file

@ -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
```

View file

@ -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) {

680
main.ts
View file

@ -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<void> {
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<string, string>(); // 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<void> {
this.settings = {
...DEFAULT_SETTINGS,
...(await this.loadData() ?? {})
};
}
public async saveSettings(): Promise<void> {
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<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}`);
}
// 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);
}
}
}

View file

@ -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.",

163
src/modals/CitationModal.ts Normal file
View file

@ -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();
}
}

View file

@ -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<string, string>(); // 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();

74
src/styles/citations.css Normal file
View file

@ -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;
}
}

24
src/types/obsidian.d.ts vendored Normal file
View file

@ -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;
}
}

136
src/utils/logger.ts Normal file
View file

@ -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<void>)[] = [];
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<void> {
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<void> {
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<void> {
this.logEntries = [];
await this.saveLogs();
}
}
// Export a singleton instance
export const logger = FileLogger.getInstance();

View file

@ -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;
}
}