mirror of
https://github.com/lossless-group/perplexed-plugin.git
synced 2026-07-22 06:49:50 +00:00
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:
parent
220876d2a5
commit
bc051168d1
10 changed files with 620 additions and 639 deletions
10
README.md
10
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
|
||||
```
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
680
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<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
163
src/modals/CitationModal.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
86
src/services/citationService.ts
Normal file
86
src/services/citationService.ts
Normal 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
74
src/styles/citations.css
Normal 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
24
src/types/obsidian.d.ts
vendored
Normal 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
136
src/utils/logger.ts
Normal 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();
|
||||
70
styles.css
70
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue