iterate(modal): rebuild of modal code

- Fixed method name mismatch between convertCitation and convertCitations
- Made generateHexId private and added public getNewHexId method
- Improved type safety for CitationMatch interface with explicit URL handling
- Cleaned up unused imports and variables in CitationModal
- Fixed file handling to use correct Obsidian API types
- Updated citation conversion logic to handle multiple groups
- Added proper error handling for file operations
- Improved code documentation and type annotations
- Formatted styles.css for better readability
This commit is contained in:
mpstaton 2025-07-08 01:51:47 +03:00
parent 08631c7264
commit ad3303c0e8
5 changed files with 543 additions and 645 deletions

22
main.ts
View file

@ -44,11 +44,23 @@ export default class CiteWidePlugin extends Plugin {
editorCallback: async (editor: Editor) => {
try {
const content = editor.getValue();
const result = citationService.convertCitations(content);
// Get all citation groups
const groups = citationService.findCitations(content);
let totalConverted = 0;
let updatedContent = content;
if (result.changed) {
editor.setValue(result.updatedContent);
new Notice(`Updated ${result.stats.citationsConverted} citations`);
// Convert each citation group
for (const group of groups) {
const result = citationService.convertCitation(updatedContent, group.number);
if (result.changed) {
updatedContent = result.content;
totalConverted += result.stats.citationsConverted;
}
}
if (totalConverted > 0) {
editor.setValue(updatedContent);
new Notice(`Updated ${totalConverted} citations`);
} else {
new Notice('No citations needed conversion');
}
@ -66,7 +78,7 @@ export default class CiteWidePlugin extends Plugin {
editorCallback: (editor: Editor) => {
try {
const cursor = editor.getCursor();
const hexId = citationService.generateHexId();
const hexId = citationService.getNewHexId();
// Insert the citation reference at cursor
editor.replaceRange(`[^${hexId}]`, cursor);

View file

@ -1,38 +1,11 @@
import { App, Modal, Notice, Editor, TFile } from 'obsidian';
import { citationService, type CitationConversionResult } from '../services/citationService';
// cite-wide/src/modals/CitationModal.ts
import { App, Modal, Notice, Editor } from 'obsidian';
import { citationService, type CitationGroup } from '../services/citationService';
// Extend the Obsidian Modal interface to include contentEl
declare module 'obsidian' {
interface Modal {
contentEl: HTMLElement;
}
}
/**
* Represents a single citation match in the document
*/
interface CitationMatch {
type: 'footnote' | 'reference' | 'perplexity';
original: string;
number: string;
position: number;
line: number;
lineContent: string;
}
interface CitationGroup {
type: string;
number: string;
matches: CitationMatch[];
}
/**
* Modal for displaying and managing citations in the current document
*/
export class CitationModal extends Modal {
private matches: CitationMatch[] = [];
private editor: Editor;
private content: string;
private citationGroups: CitationGroup[] = [];
constructor(app: App, editor: Editor) {
super(app);
@ -40,185 +13,160 @@ export class CitationModal extends Modal {
this.content = editor.getValue();
}
async onOpen(): Promise<void> {
async onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('cite-wide-modal');
// Find all citations in the document
this.findCitations();
// Find all citation groups
this.citationGroups = citationService.findCitations(this.content);
// If no citations found, show a message
if (this.matches.length === 0) {
contentEl.createEl('p', { text: 'No citations found in the current document.' });
if (this.citationGroups.length === 0) {
contentEl.createEl('p', {
text: 'No citations found in the current document.'
});
return;
}
// Group citations by type and number
const groupedCitations = this.groupCitations();
// Create a container for the citation groups
const container = contentEl.createDiv({ cls: 'citation-groups' });
// Render each citation group
Object.entries(groupedCitations).forEach(([key, group]) => {
const [type, number] = key.split(':');
const groupEl = container.createDiv({ cls: 'citation-group' });
// Create header with toggle functionality and actions
const header = groupEl.createDiv({ cls: 'citation-group-header' });
// Header content with integrated To Hex button
const headerContent = header.createDiv({ cls: 'citation-group-header-content' });
const headerTitle = headerContent.createEl('h3');
// Add the title text
headerTitle.createSpan({
text: `${type === 'footnote' ? 'Footnote' : 'Citation'} [${number}] (${group.matches.length} occurrences)`
});
// Add Convert to Hex button next to the title
const convertButton = headerTitle.createEl('button', {
text: 'To Hex',
cls: 'mod-cta',
attr: { 'data-action': 'convert-all-group' }
});
convertButton.addEventListener('click', (e) => {
e.stopPropagation();
const firstMatch = group.matches[0];
if (firstMatch) {
this.convertCitationToHex(firstMatch);
}
});
// Create content container (initially hidden)
const content = groupEl.createDiv({ cls: 'citation-group-content' });
// Add toggle functionality to header
header.onclick = () => {
content.style.display = content.style.display === 'none' ? 'block' : 'none';
};
// Add each citation context
group.matches.forEach((match: CitationMatch) => {
const contextEl = content.createDiv({ cls: 'citation-context' });
contextEl.createEl('div', {
text: `Line ${match.line + 1}: ${match.lineContent}`,
cls: 'citation-line'
});
// Add action buttons
const actions = contextEl.createDiv({ cls: 'citation-actions' });
// View button
actions.createEl('button', {
text: 'View',
cls: 'mod-cta',
attr: { 'data-action': 'view' }
}).addEventListener('click', () => this.scrollToCitation(match));
});
// Create a container for citation groups
const container = contentEl.createDiv('cite-wide-container');
// Add a title
container.createEl('h2', {
text: 'Citations in Document',
cls: 'cite-wide-title'
});
// Add a button to convert all citations
const footer = contentEl.createDiv({ cls: 'modal-button-container' });
footer.createEl('button', {
// Add each citation group
for (const group of this.citationGroups) {
this.renderCitationGroup(container, group);
}
// Add convert all button
const footer = contentEl.createDiv('cite-wide-footer');
const convertAllBtn = footer.createEl('button', {
text: 'Convert All to Hex',
cls: 'mod-cta',
attr: { 'data-action': 'convert-all' }
}).addEventListener('click', () => this.convertAllCitations());
}
/**
* Groups citations by their type and number
*/
private groupCitations(): Record<string, CitationGroup> {
const groups: Record<string, CitationGroup> = {};
this.matches.forEach((match: CitationMatch) => {
const key = `${match.type}:${match.number}`;
if (!groups[key]) {
groups[key] = {
type: match.type,
number: match.number,
matches: []
};
}
groups[key]?.matches.push(match);
cls: 'mod-cta'
});
return groups;
convertAllBtn.addEventListener('click', () => this.convertAllCitations());
}
/**
* Scrolls the editor to a specific citation
*/
private scrollToCitation(match: CitationMatch): void {
const line = Math.max(0, match.line - 2); // Show a couple lines before the match
this.editor.setCursor({ line, ch: 0 });
this.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line: line + 5, ch: 0 } });
this.close();
}
private renderCitationGroup(container: HTMLElement, group: CitationGroup) {
const groupEl = container.createDiv('cite-wide-group');
const header = groupEl.createDiv('cite-wide-group-header');
// Create a collapsible header
const headerContent = header.createDiv('cite-wide-group-header-content');
headerContent.createEl('h3', {
text: `Citation [${group.number}] (${group.matches.length} instances)`,
cls: 'cite-wide-group-title'
});
/**
* Finds all citations in the current document
*/
private findCitations(): void {
const lines = this.content.split('\n');
if (group.url) {
headerContent.createEl('a', {
href: group.url,
text: 'Source',
cls: 'cite-wide-source-link',
attr: { target: '_blank' }
});
}
lines.forEach((line: string, lineIndex: number) => {
if (!line) return;
// Add convert button
const convertBtn = header.createEl('button', {
text: 'Convert to Hex',
cls: 'mod-cta cite-wide-convert-btn'
});
// Find footnote references [^1]
const footnoteRegex = /\[\^(\d+)\]/g;
let footnoteMatch;
while ((footnoteMatch = footnoteRegex.exec(line)) !== null) {
this.matches.push({
type: 'footnote',
number: footnoteMatch[1] || '',
original: footnoteMatch[0] || '',
position: footnoteMatch.index || 0,
line: lineIndex,
lineContent: line
});
}
convertBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.convertCitationGroup(group);
});
// 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 (!/\]\([^)]*$/.test(line.substring(0, citationMatch.index || 0))) {
this.matches.push({
type: 'reference',
number: citationMatch[1] || '',
original: citationMatch[0] || '',
position: citationMatch.index || 0,
line: lineIndex,
lineContent: line
});
}
}
// Create collapsible content
const content = groupEl.createDiv('cite-wide-group-content');
content.style.display = 'none'; // Start collapsed
// Toggle content on header click
header.addEventListener('click', () => {
content.style.display = content.style.display === 'none' ? 'block' : 'none';
});
// Add each citation instance
group.matches.forEach((match) => {
const instanceEl = content.createDiv('cite-wide-instance');
// Show line number and preview
const lineInfo = instanceEl.createDiv('cite-wide-line-info');
lineInfo.createEl('span', {
text: `Line ${match.lineNumber + 1}: `,
cls: 'cite-wide-line-number'
});
// Create a preview of the line content
const preview = match.lineContent.trim();
const previewText = preview.length > 100
? `${preview.substring(0, 100)}...`
: preview;
lineInfo.createEl('span', {
text: previewText,
cls: 'cite-wide-line-preview'
});
// Add view button
const viewBtn = instanceEl.createEl('button', {
text: 'View',
cls: 'mod-cta-outline cite-wide-view-btn'
});
viewBtn.addEventListener('click', () => {
this.scrollToLine(match.lineNumber);
});
});
}
/**
* Converts all citations in the document to hex format
*/
private async convertAllCitations(): Promise<void> {
new Notice('Converting all citations to hex format...');
private async convertCitationGroup(group: CitationGroup) {
try {
const content = this.editor.getValue();
const result = citationService.convertCitations(content);
const result = citationService.convertCitation(
this.content,
group.number
);
if (result?.changed) {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('No active file found');
return;
if (result.changed) {
await this.saveChanges(result.content);
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:', error);
new Notice('Error converting citation. See console for details.');
}
}
private async convertAllCitations() {
try {
let updatedContent = this.content;
let totalConverted = 0;
// Process each group
for (const group of this.citationGroups) {
const result = citationService.convertCitation(
updatedContent,
group.number
);
if (result.changed) {
updatedContent = result.content;
totalConverted += result.stats.citationsConverted;
}
}
await this.app.vault.modify(activeFile as TFile, result.updatedContent);
new Notice('Successfully converted all citations to hex format');
if (totalConverted > 0) {
await this.saveChanges(updatedContent);
new Notice(`Converted ${totalConverted} citations to hex format`);
this.close();
} else {
new Notice('No citations were converted');
@ -229,41 +177,26 @@ export class CitationModal extends Modal {
}
}
/**
* Converts a specific citation to hex format
*/
private async convertCitationToHex(match: CitationMatch): Promise<void> {
try {
const content = this.editor.getValue();
// Use the citation service to handle the conversion
const result = citationService.convertCitations(
content,
match.type === 'footnote' ? `[^${match.number}]` : `[${match.number}]`
);
if (result?.changed) {
// Get the current file
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
new Notice('No active file found');
return;
}
await this.app.vault.modify(activeFile as TFile, result.updatedContent);
new Notice(`Successfully converted ${match.type} [${match.number}] to hex format`);
this.close();
} else {
new Notice(`No changes were made to ${match.type} [${match.number}]`);
}
} catch (error) {
console.error(`Error converting citation ${match.number}:`, error);
new Notice(`Error converting citation ${match.number}. See console for details.`);
private async saveChanges(newContent: string) {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file');
}
await this.app.vault.modify(activeFile, newContent);
}
private scrollToLine(lineNumber: number) {
this.editor.setCursor({ line: lineNumber, ch: 0 });
this.editor.scrollIntoView({
from: { line: Math.max(0, lineNumber - 2), ch: 0 },
to: { line: lineNumber + 2, ch: 0 }
}, true);
this.close();
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
}

View file

@ -1,7 +1,24 @@
import * as crypto from 'crypto';
// cite-wide/src/services/citationService.ts
import crypto from 'crypto';
export interface CitationConversionResult {
updatedContent: string;
export interface CitationMatch {
type: 'perplexity' | 'reference' | 'footnote';
number: string;
original: string;
url: string | undefined; // Explicitly allow undefined
index: number;
lineContent: string;
lineNumber: number;
}
export interface CitationGroup {
number: string;
matches: CitationMatch[];
url: string | undefined; // Explicitly allow undefined
}
export interface ConversionResult {
content: string;
changed: boolean;
stats: {
citationsConverted: number;
@ -9,287 +26,205 @@ export interface CitationConversionResult {
}
export class CitationService {
private hexCache: Map<string, string> = new Map(); // Cache URL to hex ID mapping
/**
* Generate a random hex ID of specified length
* @param length - Length of the hex ID to generate (default: 6)
* @returns Random hex string
* Find all citations in the content
*/
public generateHexId(length: number = 6): string {
return crypto.randomBytes(Math.ceil(length / 2))
public findCitations(content: string): CitationGroup[] {
const lines = content.split('\n');
const matches: CitationMatch[] = [];
const urlMap = new Map<string, string>(); // 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 = {
type: 'perplexity',
number,
original: fullMatch,
index: citationMatch.index,
lineContent: line,
lineNumber: lineIndex,
url: url || undefined
};
matches.push(match);
}
// 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;
}
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);
}
});
// Group matches by number
const groups = new Map<string, CitationGroup>();
matches.forEach(match => {
if (!groups.has(match.number)) {
const newGroup: CitationGroup = {
number: match.number,
matches: [],
url: match.url
};
groups.set(match.number, newGroup);
}
groups.get(match.number)?.matches.push(match);
});
return Array.from(groups.values());
}
/**
* Convert a specific citation to hex format
*/
public convertCitation(
content: string,
citationNumber: string,
hexId?: string
): ConversionResult {
const groups = this.findCitations(content);
const group = groups.find(g => g.number === citationNumber);
if (!group) {
return { content, changed: false, stats: { citationsConverted: 0 } };
}
// 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;
// Process in reverse to avoid position shifting
const sortedMatches = [...group.matches].sort((a, b) => b.index - a.index);
for (const match of sortedMatches) {
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;
citationsConverted++;
}
// 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}`
);
}
return {
content: updatedContent,
changed: citationsConverted > 0,
stats: { citationsConverted }
};
}
/**
* Ensure the document has a footnotes section
*/
private ensureFootnoteSection(content: string): { content: string; marker: string } {
const footnoteMarker = '\n\n# Footnotes\n';
if (content.includes(footnoteMarker)) {
return { content, marker: footnoteMarker };
}
const altMarker = '\n## Footnotes\n';
if (content.includes(altMarker)) {
return { content, marker: altMarker };
}
// Add a new footnotes section at the end
return {
content: content + footnoteMarker,
marker: footnoteMarker
};
}
/**
* Generate a consistent hex ID for a given URL
*/
private generateHexId(length: number = 6): string {
return crypto
.randomBytes(Math.ceil(length / 2))
.toString('hex')
.slice(0, length);
}
/**
* Convert citations to random hex format
* @param content - The markdown content to process
* @param targetCitation - Optional specific citation to convert (e.g., '[1]' or '[^1]')
* @returns Object with updated content and statistics
* Generate a new hex ID for citations
* @returns A new unique hex ID
*/
/**
* Enhanced citation matching function that handles multiple citation formats
*/
private basicCitationMatch(content: string): Array<{
type: 'footnote' | 'reference' | 'perplexity';
number: string;
index: number;
original: string;
lineContent: string;
}> {
const matches: Array<{
type: 'footnote' | 'reference' | 'perplexity';
number: string;
index: number;
original: string;
lineContent: string;
}> = [];
// Split content into lines to handle Perplexity-style footnotes
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: perplexityMatch[1],
original: perplexityMatch[0],
index: currentPosition + line.indexOf(perplexityMatch[0]),
lineContent: line
});
}
// 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],
index: currentPosition + (footnoteMatch.index || 0),
lineContent: line
});
}
}
// 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],
index: currentPosition + (citationMatch.index || 0),
lineContent: line
});
}
}
// Update position for the next line
currentPosition += line.length + 1; // +1 for the newline character
}
return matches;
}
/**
* Enhanced citation replacement function
*/
private basicMatchAndReplace(
content: string,
targetNumber: string,
hexId: string,
matchType: 'footnote' | 'reference' | 'perplexity' = 'reference',
lineContent: string = ''
): string {
if (matchType === 'perplexity' && lineContent) {
// For Perplexity-style footnotes, replace the entire line with a footnote reference
const url = lineContent.match(/^(\d+)\.\s+\[(https?:\/\/[^\]]+)\]/)?.[2] || '';
const footnoteDef = `[^${hexId}]: ${url}`;
// Replace the line with just the footnote reference
let updatedContent = content.replace(
lineContent,
`[^${hexId}]`
);
// Add footnotes section if it doesn't exist
if (!updatedContent.includes('# Footnotes')) {
updatedContent += '\n\n# Footnotes\n';
}
// Add the footnote definition if it doesn't exist
if (!updatedContent.includes(`[^${hexId}]:`)) {
updatedContent += `\n${footnoteDef}`;
}
return updatedContent;
} else {
// For standard footnotes and references, replace only the specific instance
const matches = this.basicCitationMatch(content);
const targetMatch = matches.find(m =>
m.type === matchType &&
m.number === targetNumber &&
(matchType === 'perplexity' ? m.lineContent === lineContent : true)
);
if (!targetMatch) {
return content;
}
// For reference type with URL, convert to footnote format and add URL to footnotes
const citationWithUrlPattern = new RegExp(`\\[${targetNumber}\\]\\(([^)]+)\\)`);
if (matchType === 'reference' && citationWithUrlPattern.test(targetMatch.original)) {
// Extract the URL from the original citation
const urlMatch = targetMatch.original.match(citationWithUrlPattern);
const url = urlMatch ? urlMatch[1] : '';
// Replace the entire [number](url) with [^hex]
const before = content.substring(0, targetMatch.index);
const after = content.substring(targetMatch.index + targetMatch.original.length);
// Add the URL to the footnotes section if it doesn't exist
let updatedContent = `${before}[^${hexId}]${after}`;
// Ensure footnotes section exists
if (!updatedContent.includes('# Footnotes')) {
updatedContent += '\n\n# Footnotes\n';
}
// Add the footnote definition if it doesn't exist
if (!updatedContent.includes(`[^${hexId}]:`)) {
updatedContent += `\n[^${hexId}]: ${url}`;
}
return updatedContent;
} else {
// For other types, just replace the number inside the brackets
const before = content.substring(0, targetMatch.index + (matchType === 'footnote' ? 2 : 1));
const after = content.substring(targetMatch.index + targetMatch.original.length - 1);
return `${before}${hexId}${after}`;
}
}
}
public convertCitations(content: string, targetCitation?: string): CitationConversionResult {
if (!content) {
return { updatedContent: content || '', changed: false, stats: { citationsConverted: 0 } };
}
const matches = this.basicCitationMatch(content);
if (matches.length === 0) {
return { updatedContent: content, changed: false, stats: { citationsConverted: 0 } };
}
let updatedContent = content;
const processedOriginals = new Set<string>();
let citationsConverted = 0;
// If we have a target citation, only process that one
const matchesToProcess = targetCitation
? matches.filter(m => m.original === targetCitation)
: matches;
// Process each match in reverse order to avoid position shifting issues
for (let i = matchesToProcess.length - 1; i >= 0; i--) {
const match = matchesToProcess[i];
if (!match || processedOriginals.has(match.original)) {
continue;
}
try {
// Generate a hex ID for this citation
const hexId = this.generateHexId();
// Replace the citation
updatedContent = this.basicMatchAndReplace(
updatedContent,
match.number,
hexId,
match.type,
match.lineContent
);
citationsConverted++;
processedOriginals.add(match.original);
} catch (error) {
console.error(`Error processing citation ${match.number}:`, error);
}
}
try {
// Map to store URL to hex ID mappings
const urlToHexMap = new Map<string, string>();
const hexToUrlMap = new Map<string, string>();
// First pass: Find all [number](url) patterns and replace with [^hex]
// This handles citations that are adjacent to text or punctuation
updatedContent = updatedContent.replace(/([^\s\[]|^)\[(\d+)\]\(([^)]+)\)/g, (_match, prefix, _number, url) => {
let hexId: string;
// If we've seen this URL before, use the existing hex ID
if (urlToHexMap.has(url)) {
hexId = urlToHexMap.get(url)!;
} else {
// Generate a new hex ID for this URL
hexId = this.generateHexId();
urlToHexMap.set(url, hexId);
hexToUrlMap.set(hexId, url);
}
citationsConverted++;
// Add a space before the citation if it's not at the start of the line
const space = prefix === '' ? '' : ' ';
return `${prefix}${space}[^${hexId}]`;
});
// If we found any citations, add the footnotes section
if (citationsConverted > 0) {
// Check if footnotes section already exists
const hasFootnotesSection = /\n#+\s*Footnotes\s*\n/.test(updatedContent);
// If no footnotes section exists, add one
if (!hasFootnotesSection) {
updatedContent += '\n\n# Footnotes\n';
} else {
updatedContent += '\n';
}
// Add all footnote definitions
hexToUrlMap.forEach((url, hexId) => {
// Check if this footnote is already defined
const footnoteRegex = new RegExp(`\\[\\^${hexId}\\]:.*`, 'g');
if (!footnoteRegex.test(updatedContent)) {
updatedContent += `\n[^${hexId}]: ${url}`;
}
});
}
return {
updatedContent,
changed: citationsConverted > 0,
stats: {
citationsConverted
}
};
} catch (error) {
console.error('Error processing citations:', error);
return {
updatedContent: content, // Return original content on error
changed: false,
stats: { citationsConverted: 0 }
};
}
public getNewHexId(): string {
return this.generateHexId();
}
}
// Export a singleton instance
export const citationService = new CitationService();
export const citationService = new CitationService();

View file

@ -1,98 +1,108 @@
/* Citations Modal Styles */
/* cite-wide/src/styles/citations.css */
.cite-wide-modal {
padding: 20px;
padding: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.cite-wide-modal h3 {
.cite-wide-title {
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;
padding-bottom: 0.75rem;
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-wide-container {
margin-top: 1rem;
}
.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;
.cite-wide-group {
margin-bottom: 1.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cite-actions-cell {
width: 150px;
text-align: right !important;
white-space: nowrap;
}
.cite-actions-cell button {
margin-left: 4px;
padding: 4px 8px;
font-size: 0.8em;
}
.cite-actions-cell button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
cursor: pointer;
}
.cite-actions-cell button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
}
.button-spacer {
display: inline-block;
width: 6px;
}
.modal-button-container {
.cite-wide-group-header {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: var(--background-secondary);
cursor: pointer;
transition: background-color 0.2s;
}
.cite-wide-group-header:hover {
background-color: var(--background-modifier-hover);
}
.cite-wide-group-header-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cite-wide-group-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.cite-wide-source-link {
font-size: 0.85rem;
opacity: 0.8;
text-decoration: none;
}
.cite-wide-group-content {
padding: 0.75rem 1rem;
background-color: var(--background-primary);
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;
}
.cite-wide-instance {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.cite-wide-instance:last-child {
border-bottom: none;
}
.cite-wide-line-info {
flex: 1;
font-family: var(--font-monospace);
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cite-wide-line-number {
color: var(--text-muted);
margin-right: 0.5rem;
}
.cite-wide-line-preview {
opacity: 0.9;
}
.cite-wide-convert-btn {
margin-left: 0.5rem;
}
.cite-wide-view-btn {
margin-left: 0.5rem;
}
.cite-wide-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-modifier-border);
display: flex;
justify-content: flex-end;
}

View file

@ -1,82 +1,90 @@
/* src/styles/citations.css */
.cite-wide-modal {
padding: 20px;
padding: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.cite-wide-modal h3 {
.cite-wide-title {
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;
padding-bottom: 0.75rem;
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-wide-container {
margin-top: 1rem;
}
.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;
.cite-wide-group {
margin-bottom: 1.5rem;
border: 1px solid var(--background-modifier-border);
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cite-actions-cell {
width: 150px;
text-align: right !important;
white-space: nowrap;
}
.cite-actions-cell button {
margin-left: 4px;
padding: 4px 8px;
font-size: 0.8em;
}
.cite-actions-cell button.mod-cta {
background-color: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: 4px;
cursor: pointer;
}
.cite-actions-cell button.mod-cta:hover {
background-color: var(--interactive-accent-hover);
}
.button-spacer {
display: inline-block;
width: 6px;
}
.modal-button-container {
.cite-wide-group-header {
display: flex;
justify-content: flex-end;
margin-top: 20px;
padding-top: 15px;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background-color: var(--background-secondary);
cursor: pointer;
transition: background-color 0.2s;
}
.cite-wide-group-header:hover {
background-color: var(--background-modifier-hover);
}
.cite-wide-group-header-content {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cite-wide-group-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.cite-wide-source-link {
font-size: 0.85rem;
opacity: 0.8;
text-decoration: none;
}
.cite-wide-group-content {
padding: 0.75rem 1rem;
background-color: var(--background-primary);
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;
}
.cite-wide-instance {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid var(--background-modifier-border);
}
.cite-wide-instance:last-child {
border-bottom: none;
}
.cite-wide-line-info {
flex: 1;
font-family: var(--font-monospace);
font-size: 0.9em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cite-wide-line-number {
color: var(--text-muted);
margin-right: 0.5rem;
}
.cite-wide-line-preview {
opacity: 0.9;
}
.cite-wide-convert-btn {
margin-left: 0.5rem;
}
.cite-wide-view-btn {
margin-left: 0.5rem;
}
.cite-wide-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--background-modifier-border);
display: flex;
justify-content: flex-end;
}