qf3l3k_obsidian-data-fetcher/main.ts

1839 lines
58 KiB
TypeScript

import { App, Editor, EventRef, MarkdownRenderer, MarkdownView, Modal, Notice, Plugin, PluginSettingTab, Setting, TFile } from 'obsidian';
import { DataFetcherSettings, DEFAULT_SETTINGS, EndpointConfig } from './src/settings';
import { parseDataQuery, executeQuery, QueryParams, QueryResult } from './src/queryEngine';
import { CacheManager } from './src/cacheManager';
export default class DataFetcherPlugin extends Plugin {
settings: DataFetcherSettings;
cacheManager: CacheManager;
// Store query data associated with DOM elements
private queryButtonMap: WeakMap<HTMLElement, QueryParams> = new WeakMap();
private cacheRibbonEl: HTMLElement | null = null;
async onload() {
await this.loadSettings();
this.cacheManager = new CacheManager(this.app, this);
// Register the data fetcher processor for codeblocks
this.registerMarkdownCodeBlockProcessor('data-query', async (source, el, ctx) => {
try {
const query = parseDataQuery(source, this.settings);
const cachedResult = await this.cacheManager.getFromCache(query);
if (cachedResult) {
this.renderResult(cachedResult, el, query, ctx);
} else {
el.createEl('div', { text: 'Fetching data...', cls: 'data-fetcher-loading' });
const result = await executeQuery(query);
await this.cacheManager.saveToCache(query, result);
await this.applyOutputTargetSafely(query, result, ctx);
el.empty();
this.renderResult(result, el, query, ctx);
}
} catch (error) {
el.createEl('div', { text: `Error: ${error.message}`, cls: 'data-fetcher-error' });
}
});
// Add the command to manually refresh data
this.addCommand({
id: 'refresh-data-query',
name: 'Refresh data query', // Changed to sentence case
editorCallback: (editor: Editor, view: MarkdownView) => {
new Notice('Refreshing data queries in the current note...');
this.app.workspace.trigger('data-fetcher:refresh-query');
}
});
this.addCommand({
id: 'open-cache-browser',
name: 'Open cache browser',
callback: () => {
new CacheBrowserModal(this.app, this.cacheManager).open();
}
});
this.updateCacheRibbonIcon();
// Handle refresh events triggered by command or other plugin actions.
const workspaceEvents = this.app.workspace as unknown as {
on(name: string, callback: (...args: unknown[]) => unknown, ctx?: unknown): EventRef;
};
this.registerEvent(workspaceEvents.on('data-fetcher:refresh-query', async () => {
await this.refreshQueriesInActiveNote();
}));
// Add settings tab
this.addSettingTab(new DataFetcherSettingTab(this.app, this));
}
public updateCacheRibbonIcon(): void {
if (this.cacheRibbonEl) {
this.cacheRibbonEl.remove();
this.cacheRibbonEl = null;
}
if (!this.settings.showCacheRibbonIcon) {
return;
}
this.cacheRibbonEl = this.addRibbonIcon('database', 'Open cache browser', () => {
new CacheBrowserModal(this.app, this.cacheManager).open();
});
}
private extractDataQueryBlocks(markdown: string): string[] {
const queryBlocks: string[] = [];
const dataQueryRegex = /```data-query[^\n]*\n([\s\S]*?)```/g;
let match: RegExpExecArray | null;
while ((match = dataQueryRegex.exec(markdown)) !== null) {
queryBlocks.push(match[1].trim());
}
return queryBlocks;
}
private rerenderActiveView(view: MarkdownView): void {
const previewMode = (view as any).previewMode;
if (previewMode && typeof previewMode.rerender === 'function') {
previewMode.rerender(true);
return;
}
// Fallback that still refreshes markdown render output.
this.app.workspace.trigger('layout-change');
}
private async refreshQueriesInActiveNote(): Promise<void> {
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
const activeFile = activeView?.file;
if (!activeView || !activeFile) {
new Notice('No active markdown file to refresh');
return;
}
const content = await this.app.vault.cachedRead(activeFile);
const queryBlocks = this.extractDataQueryBlocks(content);
if (queryBlocks.length === 0) {
new Notice('No data-query blocks found in the current note');
return;
}
let refreshedCount = 0;
let failedCount = 0;
for (const querySource of queryBlocks) {
try {
const query = parseDataQuery(querySource, this.settings);
const result = await executeQuery(query);
await this.cacheManager.saveToCache(query, result);
await this.applyOutputTargetSafely(query, result, { sourcePath: activeFile.path });
if (result.error) {
failedCount++;
} else {
refreshedCount++;
}
} catch (error) {
failedCount++;
console.error('Failed to refresh query block:', error);
}
}
this.rerenderActiveView(activeView);
if (failedCount === 0) {
new Notice(`Refreshed ${refreshedCount} data quer${refreshedCount === 1 ? 'y' : 'ies'}`);
return;
}
new Notice(`Refreshed ${refreshedCount}; ${failedCount} failed`);
}
private valuesEqual(a: any, b: any): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
private setNestedPropertyValue(target: Record<string, any>, propertyPath: string, value: any): boolean {
const segments = propertyPath.split('.').map(segment => segment.trim()).filter(Boolean);
if (segments.length === 0) {
throw new Error('Property path cannot be empty');
}
let current: Record<string, any> = target;
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const next = current[segment];
if (next === null || next === undefined) {
current[segment] = {};
} else if (typeof next !== 'object' || Array.isArray(next)) {
throw new Error(`Property path conflict at "${segment}"`);
}
current = current[segment] as Record<string, any>;
}
const finalSegment = segments[segments.length - 1];
if (this.valuesEqual(current[finalSegment], value)) {
return false;
}
current[finalSegment] = value;
return true;
}
private async writeToFrontmatter(sourcePath: string, propertyPath: string, value: any): Promise<boolean> {
const file = this.app.vault.getAbstractFileByPath(sourcePath);
if (!(file instanceof TFile)) {
throw new Error(`Source file not found: ${sourcePath}`);
}
let changed = false;
await this.app.fileManager.processFrontMatter(file, frontmatter => {
changed = this.setNestedPropertyValue(frontmatter, propertyPath, value);
});
return changed;
}
private async applyOutputTarget(query: QueryParams, result: QueryResult, ctx: any): Promise<void> {
if (query.output !== 'frontmatter') {
return;
}
if (result.error) {
return;
}
if (!query.property) {
throw new Error('`property` is required when `output: frontmatter` is used');
}
if (!ctx || !ctx.sourcePath) {
throw new Error('Frontmatter output requires a note source path');
}
const selectedData = this.selectDataByPath(result.data, query.path);
await this.writeToFrontmatter(ctx.sourcePath, query.property, selectedData);
}
private async applyOutputTargetSafely(query: QueryParams, result: QueryResult, ctx: any): Promise<void> {
try {
await this.applyOutputTarget(query, result, ctx);
} catch (error) {
console.error('Failed to apply output target:', error);
new Notice(`Frontmatter output failed: ${error.message}`);
}
}
private selectDataByPath(data: any, path?: string): any {
if (!path || path.trim() === '') {
return data;
}
const resolvePath = (root: any, targetPath: string): any => {
const segments = targetPath.split('.').map(segment => segment.trim()).filter(Boolean);
let current: any = root;
for (const segment of segments) {
if (current === null || current === undefined) {
throw new Error(`Path "${targetPath}" not found`);
}
if (Array.isArray(current)) {
const index = Number(segment);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
throw new Error(`Invalid array index "${segment}" in path "${targetPath}"`);
}
current = current[index];
continue;
}
if (typeof current === 'object' && segment in current) {
current = current[segment];
continue;
}
throw new Error(`Path "${targetPath}" not found`);
}
return current;
};
try {
return resolvePath(data, path);
} catch (error) {
// Common GraphQL envelope fallback: { data: ... }
if (
data &&
typeof data === 'object' &&
'data' in data &&
(data as any).data &&
typeof (data as any).data === 'object'
) {
return resolvePath((data as any).data, path);
}
throw error;
}
}
private buildTableData(data: any): { headers: string[]; rows: Record<string, any>[] } | null {
if (!Array.isArray(data) || data.length === 0) {
return null;
}
const rows: Record<string, any>[] = [];
const headers: string[] = [];
const seenHeaders = new Set<string>();
for (const item of data) {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null;
}
rows.push(item as Record<string, any>);
for (const key of Object.keys(item)) {
if (!seenHeaders.has(key)) {
seenHeaders.add(key);
headers.push(key);
}
}
}
return { headers, rows };
}
private tryResolveTableInput(data: any): any {
if (Array.isArray(data)) {
return data;
}
if (!data || typeof data !== 'object') {
return data;
}
// Common GraphQL envelope: { data: ... }
if ('data' in data && data.data && typeof data.data === 'object') {
const unwrapped = this.tryResolveTableInput(data.data);
if (Array.isArray(unwrapped)) {
return unwrapped;
}
}
// If object has exactly one property, keep drilling into it.
const keys = Object.keys(data);
if (keys.length === 1) {
const singleValue = data[keys[0]];
const drilled = this.tryResolveTableInput(singleValue);
if (Array.isArray(drilled)) {
return drilled;
}
}
// GraphQL connections: { edges: [...] }
if (Array.isArray((data as any).edges)) {
return (data as any).edges;
}
// Generic object containing any array field.
for (const key of keys) {
if (Array.isArray((data as any)[key])) {
return (data as any)[key];
}
}
return data;
}
private findFirstArrayOfObjects(data: any, depth: number = 0): Record<string, any>[] | null {
if (depth > 8 || data === null || data === undefined) {
return null;
}
if (Array.isArray(data)) {
if (data.length > 0 && data.every(item =>
item &&
typeof item === 'object' &&
!Array.isArray(item)
)) {
return data as Record<string, any>[];
}
for (const item of data) {
const nested = this.findFirstArrayOfObjects(item, depth + 1);
if (nested) {
return nested;
}
}
return null;
}
if (typeof data !== 'object') {
return null;
}
const objectData = data as Record<string, any>;
const priorityKeys = ['edges', 'nodes', 'items', 'results', 'data'];
for (const key of priorityKeys) {
if (key in objectData) {
const nested = this.findFirstArrayOfObjects(objectData[key], depth + 1);
if (nested) {
return nested;
}
}
}
for (const value of Object.values(objectData)) {
const nested = this.findFirstArrayOfObjects(value, depth + 1);
if (nested) {
return nested;
}
}
return null;
}
private normalizeTableRows(rows: Record<string, any>[]): Record<string, any>[] {
// Common GraphQL edge shape: [{ node: {...} }]
const canUnwrapNode = rows.length > 0 && rows.every(row =>
row &&
typeof row === 'object' &&
!Array.isArray(row) &&
Object.keys(row).length === 1 &&
row.node &&
typeof row.node === 'object' &&
!Array.isArray(row.node)
);
if (canUnwrapNode) {
return rows.map(row => row.node as Record<string, any>);
}
return rows;
}
private tableCellValue(value: any): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
private tableCellDisplayValue(value: any): string {
const raw = this.tableCellValue(value).replace(/\s+/g, ' ').trim();
const maxLength = 120;
if (raw.length <= maxLength) {
return raw;
}
return `${raw.substring(0, maxLength - 3)}...`;
}
private toMarkdownTable(headers: string[], rows: Record<string, any>[]): string {
const headerLine = `| ${headers.join(' | ')} |`;
const dividerLine = `| ${headers.map(() => '---').join(' | ')} |`;
const rowLines = rows.map(row => {
const cells = headers.map(header => this.tableCellValue(row[header]).replace(/\|/g, '\\|'));
return `| ${cells.join(' | ')} |`;
});
return [headerLine, dividerLine, ...rowLines].join('\n');
}
private resolveTemplatePath(data: any, path: string): any {
const normalizedPath = path.trim();
if (!normalizedPath) {
return '';
}
if (normalizedPath === 'value' && (data === null || typeof data !== 'object')) {
return data;
}
const segments = normalizedPath.split('.').map(segment => segment.trim()).filter(Boolean);
let current = data;
for (const segment of segments) {
if (current === null || current === undefined) {
return '';
}
if (Array.isArray(current)) {
const index = Number(segment);
if (!Number.isInteger(index) || index < 0 || index >= current.length) {
return '';
}
current = current[index];
continue;
}
if (typeof current === 'object' && segment in current) {
current = current[segment];
continue;
}
return '';
}
return current;
}
private templateValueToString(value: any): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'object') {
return JSON.stringify(value);
}
return String(value);
}
private renderTemplate(template: string, data: any): string {
return template.replace(/{{\s*([^}]+?)\s*}}/g, (_match, path) => {
return this.templateValueToString(this.resolveTemplatePath(data, String(path)));
});
}
private renderTemplateOutput(template: string, data: any): string {
if (Array.isArray(data)) {
return data.map(item => this.renderTemplate(template, item)).join('\n');
}
return this.renderTemplate(template, data);
}
renderResult(result: QueryResult, container: HTMLElement, query?: QueryParams, ctx?: any) {
// First clear the container
container.empty();
// Check if we have actual data to display
if (!result || result.error) {
container.createEl('div', {
text: result.error || 'No data returned from query',
cls: 'data-fetcher-error'
});
console.error("Query error:", result.error);
return;
}
const resultContainer = container.createEl('div', { cls: 'data-fetcher-result' });
// Store source information from context if available
if (ctx && ctx.sourcePath && ctx.getSectionInfo) {
try {
// Store section info for this code block in the container's dataset
const sectionInfo = ctx.getSectionInfo(container);
if (sectionInfo) {
container.dataset.sourcePath = ctx.sourcePath;
container.dataset.lineStart = String(sectionInfo.lineStart);
container.dataset.lineEnd = String(sectionInfo.lineEnd);
}
} catch (e) {
console.warn("Failed to get section info:", e);
}
}
let outputText = '';
// Create header with timestamp and refresh button
const header = resultContainer.createEl('div', { cls: 'data-fetcher-header' });
header.createEl('span', {
text: `Last updated: ${new Date(result.timestamp).toLocaleString()}`,
cls: 'data-fetcher-timestamp'
});
// Create action buttons container
const actionButtons = header.createEl('div', { cls: 'data-fetcher-actions' });
// Add copy button
const copyBtn = actionButtons.createEl('button', {
text: 'Copy',
cls: 'data-fetcher-copy'
});
// Add save to note button
const saveToNoteBtn = actionButtons.createEl('button', {
text: 'Save to Note',
cls: 'data-fetcher-save-note'
});
// Add refresh button
const refreshBtn = actionButtons.createEl('button', {
text: 'Refresh',
cls: 'data-fetcher-refresh'
});
refreshBtn.addEventListener('click', async () => {
// Existing refresh logic
try {
container.empty();
container.createEl('div', { text: 'Refreshing data...', cls: 'data-fetcher-loading' });
const storedQuery = this.queryButtonMap.get(refreshBtn);
if (!storedQuery) {
throw new Error('Query data not found');
}
const result = await executeQuery(storedQuery);
await this.cacheManager.saveToCache(storedQuery, result);
await this.applyOutputTargetSafely(storedQuery, result, ctx);
container.empty();
this.renderResult(result, container, storedQuery, ctx);
new Notice('Data refreshed successfully');
} catch (error) {
container.empty();
container.createEl('div', { text: `Error: ${error.message}`, cls: 'data-fetcher-error' });
}
});
// Store the query with the button using our WeakMap
if (query) {
this.queryButtonMap.set(refreshBtn, query);
}
// Add event listener for save to note button with proper data
saveToNoteBtn.addEventListener('click', () => {
this.saveResultToNote(outputText, container);
});
// Create the content container
const content = resultContainer.createEl('div', { cls: 'data-fetcher-content' });
try {
const selectedData = this.selectDataByPath(result.data, query?.path);
const format = query?.format || 'json';
if (selectedData === null || selectedData === undefined) {
outputText = 'No data returned';
content.setText(outputText);
} else if (query?.template?.trim()) {
outputText = this.renderTemplateOutput(query.template, selectedData);
void MarkdownRenderer.render(this.app, outputText, content, ctx?.sourcePath || '', this);
} else if (format === 'table') {
const tableInput = this.tryResolveTableInput(selectedData);
const resolvedTableInput = this.buildTableData(tableInput)
? tableInput
: (this.findFirstArrayOfObjects(tableInput) || tableInput);
const initialTable = this.buildTableData(resolvedTableInput);
const tableData = initialTable
? this.buildTableData(this.normalizeTableRows(initialTable.rows))
: null;
if (tableData) {
const tableEl = content.createEl('table', { cls: 'data-fetcher-table' });
const thead = tableEl.createEl('thead');
const headerRow = thead.createEl('tr');
for (const headerName of tableData.headers) {
headerRow.createEl('th', { text: headerName });
}
const tbody = tableEl.createEl('tbody');
for (const row of tableData.rows) {
const tr = tbody.createEl('tr');
for (const headerName of tableData.headers) {
const fullValue = this.tableCellValue(row[headerName]);
const cell = tr.createEl('td');
cell.createEl('span', {
text: this.tableCellDisplayValue(row[headerName]),
cls: 'data-fetcher-table-cell',
attr: { title: fullValue }
});
}
}
outputText = this.toMarkdownTable(tableData.headers, tableData.rows);
} else {
outputText = JSON.stringify(selectedData, null, 2);
content.createEl('div', {
text: 'Table format requires an array of objects. Showing JSON output instead.',
cls: 'data-fetcher-format-note'
});
const pre = content.createEl('pre');
pre.createEl('code', { text: outputText });
}
} else if (typeof selectedData === 'object') {
outputText = JSON.stringify(selectedData, null, 2);
const pre = content.createEl('pre');
pre.createEl('code', { text: outputText });
} else {
outputText = String(selectedData);
content.setText(outputText);
}
} catch (e) {
const errorMessage = `Error displaying data: ${e.message}`;
outputText = errorMessage;
content.createEl('div', { text: errorMessage, cls: 'data-fetcher-error' });
}
// Setup copy to clipboard functionality
copyBtn.addEventListener('click', () => {
navigator.clipboard.writeText(outputText).then(() => {
new Notice('Copied to clipboard');
}).catch(err => {
console.error("Error copying to clipboard:", err);
new Notice('Failed to copy: ' + err.message);
});
});
}
saveResultToNote(dataString: string, container: HTMLElement): void {
try {
// Get the active view
const activeView = this.app.workspace.getActiveViewOfType(MarkdownView);
if (!activeView) {
new Notice('No active markdown view - please open a markdown file first');
return;
}
const editor = activeView.editor;
// Format the data if it's not already formatted
let formattedData: string;
try {
// Check if the data looks like JSON
JSON.parse(dataString);
formattedData = '```json\n' + dataString + '\n```';
} catch (e) {
// If it's not valid JSON, treat as plain text
formattedData = dataString;
}
// Add a comment with timestamp
const timestamp = new Date().toLocaleString();
const commentedData = `<!-- Data saved on ${timestamp} -->\n${formattedData}`;
// Try to locate the code block position from container's dataset
if (container.dataset.sourcePath &&
container.dataset.lineStart &&
container.dataset.lineEnd) {
// Check if the source path matches the current file
const currentPath = activeView.file?.path;
if (currentPath === container.dataset.sourcePath) {
const start = {
line: parseInt(container.dataset.lineStart),
ch: 0
};
const end = {
line: parseInt(container.dataset.lineEnd),
ch: editor.getLine(parseInt(container.dataset.lineEnd)).length
};
// Use editor transaction for safer text replacement
editor.transaction({
changes: [
{
from: start,
to: end,
text: commentedData
}
]
});
new Notice('Data block replaced with static content');
return;
}
}
// If exact block location is unavailable, avoid replacing the wrong data-query block.
const cursor = editor.getCursor();
editor.transaction({
changes: [
{
from: cursor,
to: cursor,
text: `\n${commentedData}\n`
}
]
});
new Notice('Block location unavailable, so data was inserted at the cursor position');
} catch (error) {
console.error("Error saving data to note:", error);
new Notice(`Error saving data: ${error.message}`);
}
}
onunload() {
// WeakMap will be garbage collected automatically when plugin is unloaded
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
}
class DataFetcherSettingTab extends PluginSettingTab {
plugin: DataFetcherPlugin;
private endpointFilter = '';
constructor(app: App, plugin: DataFetcherPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const {containerEl} = this;
containerEl.empty();
// Cache Management section
new Setting(containerEl)
.setName('Cache management')
.setHeading();
const cacheInfoEl = containerEl.createEl('div', {
cls: 'data-fetcher-cache-info'
});
// Get and display cache info
this.updateCacheInfo(cacheInfoEl);
// Clear cache button
new Setting(containerEl)
.setName('Clear cache')
.setDesc('Remove all cached API responses')
.addButton(button => button
.setButtonText('Clear cache')
.setClass('data-fetcher-clear-cache')
.onClick(async () => {
try {
await this.plugin.cacheManager.clearAllCache();
new Notice('Cache cleared successfully');
this.updateCacheInfo(cacheInfoEl);
} catch (error) {
new Notice('Failed to clear cache: ' + error.message);
}
}));
new Setting(containerEl)
.setName('Open cache browser')
.setDesc('Browse, preview, and delete individual cache entries')
.addButton(button => button
.setButtonText('Open browser')
.onClick(() => {
new CacheBrowserModal(this.app, this.plugin.cacheManager).open();
}));
// General settings
new Setting(containerEl)
.setName('Cache duration')
.setDesc('How long to cache results (in minutes)')
.addText(text => text
.setPlaceholder('60')
.setValue(this.plugin.settings.cacheDuration.toString())
.onChange(async (value) => {
this.plugin.settings.cacheDuration = parseInt(value) || 60;
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Show cache browser ribbon icon')
.setDesc('Add a ribbon icon for quick access to cache browser')
.addToggle(toggle => toggle
.setValue(this.plugin.settings.showCacheRibbonIcon)
.onChange(async (value) => {
this.plugin.settings.showCacheRibbonIcon = value;
await this.plugin.saveSettings();
this.plugin.updateCacheRibbonIcon();
}));
new Setting(containerEl)
.setName('Endpoint import/export')
.setDesc('Move endpoint aliases between devices using a JSON file')
.addButton(button => button
.setButtonText('Export endpoints')
.onClick(() => {
new EndpointExportModal(this.app, this.plugin.settings.endpoints, includeHeaders => {
this.exportEndpoints(includeHeaders);
}).open();
}))
.addButton(button => button
.setButtonText('Import endpoints')
.onClick(() => {
void this.importEndpoints();
}));
// Endpoint aliases section
new Setting(containerEl)
.setName('Endpoint aliases')
.setHeading();
const filterEl = containerEl.createEl('input', {
cls: 'data-fetcher-endpoint-filter',
attr: {
type: 'text',
placeholder: 'Filter endpoints by alias, type, or URL...'
}
});
filterEl.value = this.endpointFilter;
filterEl.addEventListener('input', () => {
this.endpointFilter = filterEl.value;
this.renderEndpointTable(containerEl);
});
this.renderEndpointTable(containerEl);
new Setting(containerEl)
.addButton(button => button
.setButtonText('Add endpoint')
.setCta()
.onClick(() => {
const newEndpoint = this.buildDefaultEndpoint();
new EndpointEditorModal(this.app, newEndpoint, this.collectExistingAliases(), async (savedEndpoint) => {
this.plugin.settings.endpoints.push(savedEndpoint);
await this.plugin.saveSettings();
this.display();
}).open();
}));
}
private buildDefaultEndpoint(): EndpointConfig {
return {
alias: '',
url: '',
method: 'GET',
type: 'rest',
headers: {}
};
}
private collectExistingAliases(excludeAlias?: string): Set<string> {
const aliases = new Set<string>();
for (const endpoint of this.plugin.settings.endpoints) {
const alias = endpoint.alias?.trim();
if (!alias) {
continue;
}
if (excludeAlias && alias === excludeAlias) {
continue;
}
aliases.add(alias);
}
return aliases;
}
private endpointTypeLabel(type: EndpointConfig['type']): string {
if (type === 'rest') return 'REST';
if (type === 'graphql') return 'GraphQL';
if (type === 'grpc') return 'gRPC';
return 'RPC';
}
private truncateUrl(url: string, maxLen = 72): string {
if (!url) return '-';
if (url.length <= maxLen) return url;
return `${url.substring(0, maxLen - 1)}...`;
}
private renderEndpointTable(containerEl: HTMLElement): void {
containerEl.querySelector('.data-fetcher-endpoint-table')?.remove();
const table = containerEl.createEl('div', { cls: 'data-fetcher-endpoint-table' });
const header = table.createEl('div', { cls: 'data-fetcher-endpoint-row data-fetcher-endpoint-row-header' });
header.createEl('div', { text: 'Name', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-alias' });
header.createEl('div', { text: 'Type', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-type' });
header.createEl('div', { text: 'URL', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-url' });
header.createEl('div', { text: 'Headers', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-headers' });
header.createEl('div', { text: 'Actions', cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-actions' });
if (this.plugin.settings.endpoints.length === 0) {
const empty = table.createEl('div', { cls: 'data-fetcher-endpoint-empty' });
empty.setText('No endpoints configured yet.');
return;
}
const normalizedFilter = this.endpointFilter.trim().toLowerCase();
const visibleEndpoints = this.plugin.settings.endpoints
.map((endpoint, index) => ({ endpoint, index }))
.filter(({ endpoint }) => {
if (!normalizedFilter) {
return true;
}
return [
endpoint.alias || '',
endpoint.type || '',
endpoint.url || ''
].join(' ').toLowerCase().includes(normalizedFilter);
});
if (visibleEndpoints.length === 0) {
const empty = table.createEl('div', { cls: 'data-fetcher-endpoint-empty' });
empty.setText('No endpoints match current filter.');
return;
}
visibleEndpoints.forEach(({ endpoint, index }) => {
const row = table.createEl('div', { cls: 'data-fetcher-endpoint-row' });
row.createEl('div', {
text: endpoint.alias?.trim() || '(unnamed)',
cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-alias'
});
row.createEl('div', {
text: this.endpointTypeLabel(endpoint.type),
cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-type'
});
row.createEl('div', {
text: this.truncateUrl(endpoint.url),
cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-url'
});
row.createEl('div', {
text: String(Object.keys(endpoint.headers || {}).length),
cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-headers'
});
const actions = row.createEl('div', {
cls: 'data-fetcher-endpoint-col data-fetcher-endpoint-col-actions data-fetcher-endpoint-actions'
});
actions.createEl('button', { text: 'Edit' }).addEventListener('click', () => {
const draft = { ...endpoint, headers: { ...(endpoint.headers || {}) } };
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(endpoint.alias), async (savedEndpoint) => {
this.plugin.settings.endpoints[index] = savedEndpoint;
await this.plugin.saveSettings();
this.display();
}).open();
});
actions.createEl('button', { text: 'Duplicate' }).addEventListener('click', () => {
const duplicateAlias = endpoint.alias ? `${endpoint.alias}-copy` : '';
const draft: EndpointConfig = {
...endpoint,
alias: duplicateAlias,
headers: { ...(endpoint.headers || {}) }
};
new EndpointEditorModal(this.app, draft, this.collectExistingAliases(), async (savedEndpoint) => {
this.plugin.settings.endpoints.push(savedEndpoint);
await this.plugin.saveSettings();
this.display();
}).open();
});
actions.createEl('button', { text: 'Delete', cls: 'mod-warning' }).addEventListener('click', async () => {
const alias = endpoint.alias?.trim() || 'this endpoint';
const shouldDelete = window.confirm(`Delete ${alias}?`);
if (!shouldDelete) {
return;
}
this.plugin.settings.endpoints.splice(index, 1);
await this.plugin.saveSettings();
this.display();
});
});
}
private endpointForExport(endpoint: EndpointConfig, includeHeaders: boolean): EndpointConfig {
return {
...endpoint,
headers: includeHeaders ? { ...(endpoint.headers || {}) } : {}
};
}
private exportEndpoints(includeHeaders: boolean): void {
const payload = {
version: 1,
exportedAt: new Date().toISOString(),
includesHeaders: includeHeaders,
endpoints: this.plugin.settings.endpoints.map(endpoint => this.endpointForExport(endpoint, includeHeaders))
};
const json = JSON.stringify(payload, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
anchor.href = url;
anchor.download = `data-fetcher-endpoints-${timestamp}.json`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
const headerMode = includeHeaders ? 'with headers' : 'without headers';
new Notice(`Exported ${this.plugin.settings.endpoints.length} endpoint(s) ${headerMode}`);
}
private readFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
reader.readAsText(file);
});
}
private normalizeHeaders(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
const normalized: Record<string, string> = {};
for (const [key, headerValue] of Object.entries(value as Record<string, unknown>)) {
if (!key || typeof key !== 'string') {
continue;
}
if (headerValue === null || headerValue === undefined) {
continue;
}
normalized[key] = String(headerValue);
}
return normalized;
}
private parseEndpointImport(rawEndpoint: unknown): EndpointConfig | null {
if (!rawEndpoint || typeof rawEndpoint !== 'object' || Array.isArray(rawEndpoint)) {
return null;
}
const endpoint = rawEndpoint as Record<string, unknown>;
const alias = typeof endpoint.alias === 'string' ? endpoint.alias.trim() : '';
const url = typeof endpoint.url === 'string' ? endpoint.url.trim() : '';
const typeValue = typeof endpoint.type === 'string' ? endpoint.type : '';
const validTypes: EndpointConfig['type'][] = ['rest', 'graphql', 'grpc', 'rpc'];
if (!alias || !url || !validTypes.includes(typeValue as EndpointConfig['type'])) {
return null;
}
const type = typeValue as EndpointConfig['type'];
let method = typeof endpoint.method === 'string' ? endpoint.method.toUpperCase() : 'GET';
if (type === 'graphql' || type === 'grpc' || type === 'rpc') {
method = 'POST';
}
const normalized: EndpointConfig = {
alias,
url,
method,
type,
headers: this.normalizeHeaders(endpoint.headers)
};
if (typeof endpoint.body === 'string') {
normalized.body = endpoint.body;
}
if (typeof endpoint.query === 'string') {
normalized.query = endpoint.query;
}
return normalized;
}
private extractImportedEndpoints(payload: unknown): unknown[] {
if (Array.isArray(payload)) {
return payload;
}
if (payload && typeof payload === 'object' && Array.isArray((payload as { endpoints?: unknown[] }).endpoints)) {
return (payload as { endpoints: unknown[] }).endpoints;
}
return [];
}
private dedupeImportedEndpoints(endpoints: EndpointConfig[]): { endpoints: EndpointConfig[]; duplicateAliases: number } {
const deduped = new Map<string, EndpointConfig>();
let duplicateAliases = 0;
for (const endpoint of endpoints) {
if (deduped.has(endpoint.alias)) {
duplicateAliases++;
}
deduped.set(endpoint.alias, endpoint);
}
return {
endpoints: Array.from(deduped.values()),
duplicateAliases
};
}
private async importEndpoints(): Promise<void> {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,application/json';
input.addEventListener('change', async () => {
const selectedFile = input.files?.item(0);
if (!selectedFile) {
return;
}
try {
const text = await this.readFileAsText(selectedFile);
const parsed = JSON.parse(text) as unknown;
const rawEndpoints = this.extractImportedEndpoints(parsed);
if (rawEndpoints.length === 0) {
new Notice('No endpoints found in import file');
return;
}
const validEndpoints: EndpointConfig[] = [];
let skipped = 0;
for (const rawEndpoint of rawEndpoints) {
const endpoint = this.parseEndpointImport(rawEndpoint);
if (!endpoint) {
skipped++;
continue;
}
validEndpoints.push(endpoint);
}
if (validEndpoints.length === 0) {
new Notice('Import failed: no valid endpoints found');
return;
}
const dedupedImport = this.dedupeImportedEndpoints(validEndpoints);
const skippedTotal = skipped + dedupedImport.duplicateAliases;
new EndpointImportModeModal(this.app, dedupedImport.endpoints.length, skippedTotal, async (mode) => {
if (mode === 'replace') {
this.plugin.settings.endpoints = dedupedImport.endpoints;
} else {
const merged = [...this.plugin.settings.endpoints];
for (const endpoint of dedupedImport.endpoints) {
const existingIndex = merged.findIndex(item => item.alias === endpoint.alias);
if (existingIndex >= 0) {
merged[existingIndex] = endpoint;
} else {
merged.push(endpoint);
}
}
this.plugin.settings.endpoints = merged;
}
await this.plugin.saveSettings();
this.display();
const modeLabel = mode === 'replace' ? 'replaced' : 'merged';
new Notice(`Imported ${dedupedImport.endpoints.length} endpoint(s), ${skippedTotal} skipped (${modeLabel})`);
}).open();
} catch (error) {
new Notice(`Import failed: ${error.message}`);
}
}, { once: true });
input.click();
}
async updateCacheInfo(containerEl: HTMLElement) {
const cacheInfo = await this.plugin.cacheManager.getCacheInfo();
containerEl.empty();
const formatSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' bytes';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
};
containerEl.createEl('div', {
text: `Cache contains ${cacheInfo.count} items (${formatSize(cacheInfo.size)})`
});
}
}
class EndpointImportModeModal extends Modal {
private validCount: number;
private skippedCount: number;
private onSubmit: (mode: 'merge' | 'replace') => Promise<void>;
constructor(app: App, validCount: number, skippedCount: number, onSubmit: (mode: 'merge' | 'replace') => Promise<void>) {
super(app);
this.validCount = validCount;
this.skippedCount = skippedCount;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl)
.setName('Import endpoints')
.setHeading();
contentEl.createEl('p', {
text: `Valid endpoints: ${this.validCount}. Skipped entries: ${this.skippedCount}.`
});
contentEl.createEl('p', {
text: 'Merge updates existing aliases and adds new ones. Replace overwrites all current endpoints.'
});
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Merge', cls: 'mod-cta' }).addEventListener('click', async () => {
await this.onSubmit('merge');
this.close();
});
actions.createEl('button', { text: 'Replace', cls: 'mod-warning' }).addEventListener('click', async () => {
await this.onSubmit('replace');
this.close();
});
}
onClose() {
this.contentEl.empty();
}
}
class EndpointExportModal extends Modal {
private endpoints: EndpointConfig[];
private onSubmit: (includeHeaders: boolean) => void;
private includeHeaders = false;
constructor(app: App, endpoints: EndpointConfig[], onSubmit: (includeHeaders: boolean) => void) {
super(app);
this.endpoints = endpoints;
this.onSubmit = onSubmit;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
new Setting(contentEl)
.setName('Export endpoints')
.setHeading();
contentEl.createEl('p', {
text: `Export ${this.endpoints.length} endpoint(s) to JSON. Headers may contain API keys or tokens.`
});
new Setting(contentEl)
.setName('Include headers')
.setDesc('Disabled by default to avoid exporting secrets such as Authorization headers.')
.addToggle(toggle => toggle
.setValue(this.includeHeaders)
.onChange(value => {
this.includeHeaders = value;
}));
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Export', cls: 'mod-cta' }).addEventListener('click', () => {
this.onSubmit(this.includeHeaders);
this.close();
});
}
onClose() {
this.contentEl.empty();
}
}
class EndpointTestResultModal extends Modal {
private query: QueryParams;
private result: QueryResult;
private durationMs: number;
constructor(app: App, query: QueryParams, result: QueryResult, durationMs: number) {
super(app);
this.query = query;
this.result = result;
this.durationMs = durationMs;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.addClass('data-fetcher-endpoint-test-modal');
new Setting(contentEl)
.setName('Endpoint test result')
.setHeading();
const statusText = this.result.error ? 'Failed' : 'Success';
contentEl.createEl('div', {
text: statusText,
cls: this.result.error ? 'data-fetcher-endpoint-test-status-error' : 'data-fetcher-endpoint-test-status-success'
});
const meta = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-test-meta' });
meta.createEl('div', { text: `Type: ${this.query.type}` });
meta.createEl('div', { text: `URL: ${this.query.url || ''}` });
meta.createEl('div', { text: `Duration: ${Math.round(this.durationMs)} ms` });
if (this.result.error) {
contentEl.createEl('div', { text: this.result.error, cls: 'data-fetcher-error' });
}
const previewValue = this.result.error ? this.result.data : this.result.data;
const previewText = typeof previewValue === 'string'
? previewValue
: JSON.stringify(previewValue, null, 2);
const pre = contentEl.createEl('pre', { cls: 'data-fetcher-endpoint-test-preview' });
pre.createEl('code', { text: previewText || '(empty response)' });
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Close', cls: 'mod-cta' }).addEventListener('click', () => this.close());
}
onClose() {
this.modalEl.removeClass('data-fetcher-endpoint-test-modal');
this.contentEl.empty();
}
}
class EndpointEditorModal extends Modal {
private endpoint: EndpointConfig;
private existingAliases: Set<string>;
private onSubmit: (endpoint: EndpointConfig) => void;
private methodSettingEl: HTMLElement | null = null;
private headersSummaryEl: HTMLElement | null = null;
private rpcMethodNoticeEl: HTMLElement | null = null;
constructor(app: App, endpoint: EndpointConfig, existingAliases: Set<string>, onSubmit: (endpoint: EndpointConfig) => void) {
super(app);
this.endpoint = {
...endpoint,
headers: { ...(endpoint.headers || {}) },
method: endpoint.method || 'GET'
};
this.existingAliases = existingAliases;
this.onSubmit = onSubmit;
}
private updateHeadersSummary(): void {
if (!this.headersSummaryEl) {
return;
}
const count = Object.keys(this.endpoint.headers || {}).length;
this.headersSummaryEl.setText(`${count} header${count === 1 ? '' : 's'} configured`);
}
private renderMethodSetting(containerEl: HTMLElement): void {
if (this.methodSettingEl) {
this.methodSettingEl.remove();
this.methodSettingEl = null;
}
if (this.rpcMethodNoticeEl) {
this.rpcMethodNoticeEl.remove();
this.rpcMethodNoticeEl = null;
}
if (this.endpoint.type === 'rpc') {
this.endpoint.method = 'POST';
this.rpcMethodNoticeEl = containerEl.createDiv();
new Setting(this.rpcMethodNoticeEl)
.setName('Method')
.setDesc('JSON-RPC requests always use POST for compatibility across Obsidian desktop and mobile.');
return;
}
if (this.endpoint.type !== 'rest') {
return;
}
this.methodSettingEl = containerEl.createDiv();
new Setting(this.methodSettingEl)
.setName('Method')
.setDesc('HTTP method for REST/RPC requests')
.addDropdown(dropdown => dropdown
.addOption('GET', 'GET')
.addOption('POST', 'POST')
.addOption('PUT', 'PUT')
.addOption('DELETE', 'DELETE')
.setValue(this.endpoint.method || 'GET')
.onChange(value => {
this.endpoint.method = value;
}));
}
private buildTestQuery(): QueryParams {
const url = this.endpoint.url?.trim();
if (!url) {
throw new Error('URL is required before testing.');
}
const alias = this.endpoint.alias?.trim() || 'endpoint-test';
const method = this.endpoint.type === 'rest'
? (this.endpoint.method || 'GET')
: 'POST';
return {
endpoint: alias,
type: this.endpoint.type,
url,
method,
headers: { ...(this.endpoint.headers || {}) },
body: this.endpoint.body,
query: this.endpoint.query
};
}
private async testEndpoint(button: HTMLButtonElement): Promise<void> {
let query: QueryParams;
try {
query = this.buildTestQuery();
} catch (error) {
new Notice(error.message);
return;
}
button.disabled = true;
const originalText = button.textContent || 'Test endpoint';
button.setText('Testing...');
const startedAt = performance.now();
try {
const result = await executeQuery(query);
const durationMs = performance.now() - startedAt;
new EndpointTestResultModal(this.app, query, result, durationMs).open();
} finally {
button.disabled = false;
button.setText(originalText);
}
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
this.modalEl.addClass('data-fetcher-endpoint-editor-modal');
contentEl.addClass('data-fetcher-endpoint-editor');
new Setting(contentEl)
.setName('Endpoint editor')
.setHeading();
new Setting(contentEl)
.setName('Alias')
.setDesc('Endpoint reference name used in notes, e.g. @my-api')
.addText(text => text
.setPlaceholder('my-api')
.setValue(this.endpoint.alias || '')
.onChange(value => {
this.endpoint.alias = value.trim();
}));
new Setting(contentEl)
.setName('Type')
.addDropdown(dropdown => dropdown
.addOption('rest', 'REST')
.addOption('graphql', 'GraphQL')
.addOption('grpc', 'gRPC')
.addOption('rpc', 'RPC')
.setValue(this.endpoint.type)
.onChange(value => {
const validTypes: EndpointConfig['type'][] = ['rest', 'graphql', 'grpc', 'rpc'];
this.endpoint.type = validTypes.includes(value as EndpointConfig['type'])
? (value as EndpointConfig['type'])
: 'rest';
this.renderMethodSetting(contentEl);
}));
const urlSetting = new Setting(contentEl)
.setName('URL')
.setDesc('Endpoint URL')
.addText(text => text
.setPlaceholder('https://api.example.com')
.setValue(this.endpoint.url || '')
.onChange(value => {
this.endpoint.url = value.trim();
}));
urlSetting.settingEl.addClass('data-fetcher-endpoint-url-setting');
this.renderMethodSetting(contentEl);
new Setting(contentEl)
.setName('Headers')
.setDesc('Authentication and custom request headers')
.addButton(button => button
.setButtonText('Manage headers')
.onClick(() => {
new HeadersModal(this.app, this.endpoint.headers || {}, (headers) => {
this.endpoint.headers = headers;
this.updateHeadersSummary();
}).open();
}));
this.headersSummaryEl = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-header-summary' });
this.updateHeadersSummary();
const actions = contentEl.createEl('div', { cls: 'data-fetcher-endpoint-editor-actions' });
actions.createEl('button', { text: 'Cancel' }).addEventListener('click', () => this.close());
actions.createEl('button', { text: 'Test endpoint' }).addEventListener('click', async (event) => {
await this.testEndpoint(event.currentTarget as HTMLButtonElement);
});
actions.createEl('button', { text: 'Save', cls: 'mod-cta' }).addEventListener('click', () => {
const normalizedAlias = this.endpoint.alias?.trim();
if (!normalizedAlias) {
new Notice('Alias is required.');
return;
}
if (!this.endpoint.url?.trim()) {
new Notice('URL is required.');
return;
}
if (this.existingAliases.has(normalizedAlias)) {
new Notice(`Alias "${normalizedAlias}" already exists.`);
return;
}
if (this.endpoint.type === 'graphql' || this.endpoint.type === 'grpc' || this.endpoint.type === 'rpc') {
this.endpoint.method = 'POST';
}
this.onSubmit({
alias: normalizedAlias,
type: this.endpoint.type,
url: this.endpoint.url.trim(),
method: this.endpoint.method || 'GET',
headers: { ...(this.endpoint.headers || {}) }
});
this.close();
});
}
onClose() {
this.modalEl.removeClass('data-fetcher-endpoint-editor-modal');
this.contentEl.empty();
}
}
class CacheBrowserModal extends Modal {
private cacheManager: CacheManager;
private entriesContainer: HTMLElement;
private previewContainer: HTMLElement;
private summaryContainer: HTMLElement;
private filterInput: HTMLInputElement;
private selectedCacheKey: string | null = null;
private entries: Array<{key: string; path: string; size: number; mtime: number; endpoint?: string; type?: QueryParams['type']; url?: string}> = [];
constructor(app: App, cacheManager: CacheManager) {
super(app);
this.cacheManager = cacheManager;
}
private formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
private formatDate(timestamp: number): string {
if (!timestamp) return 'Unknown';
return new Date(timestamp).toLocaleString();
}
private setPreviewText(text: string): void {
this.previewContainer.empty();
const pre = this.previewContainer.createEl('pre', { cls: 'data-fetcher-cache-preview-pre' });
pre.createEl('code', { text });
}
private entryLabel(entry: { key: string; endpoint?: string }): string {
const endpointLabel = entry.endpoint && entry.endpoint !== 'direct' ? entry.endpoint : 'direct';
return `${endpointLabel} - ${entry.key}`;
}
private renderEntries(): void {
const filter = this.filterInput?.value?.trim().toLowerCase() || '';
const visibleEntries = this.entries.filter(entry => {
const searchable = [
entry.key,
entry.endpoint || '',
entry.type || '',
entry.url || ''
].join(' ').toLowerCase();
return searchable.includes(filter);
});
const totalSize = visibleEntries.reduce((sum, entry) => sum + entry.size, 0);
this.summaryContainer.empty();
this.summaryContainer.setText(`Entries: ${visibleEntries.length} (${this.formatBytes(totalSize)})`);
this.entriesContainer.empty();
if (visibleEntries.length === 0) {
this.entriesContainer.createEl('div', {
text: this.entries.length === 0 ? 'No cache entries found.' : 'No entries match current filter.',
cls: 'data-fetcher-cache-empty'
});
return;
}
for (const entry of visibleEntries) {
const row = this.entriesContainer.createEl('div', { cls: 'data-fetcher-cache-row' });
const meta = row.createEl('div', { cls: 'data-fetcher-cache-row-meta' });
meta.createEl('div', { text: this.entryLabel(entry), cls: 'data-fetcher-cache-key' });
meta.createEl('div', {
text: `${entry.type ? `${entry.type.toUpperCase()} | ` : ''}${this.formatDate(entry.mtime)} | ${this.formatBytes(entry.size)}`,
cls: 'data-fetcher-cache-meta'
});
if (entry.url) {
meta.createEl('div', {
text: entry.url,
cls: 'data-fetcher-cache-meta data-fetcher-cache-url'
});
}
const actions = row.createEl('div', { cls: 'data-fetcher-cache-row-actions' });
actions.createEl('button', { text: 'Preview' }).addEventListener('click', async () => {
const payload = await this.cacheManager.readCacheEntry(entry.key);
this.selectedCacheKey = entry.key;
if (!payload) {
this.setPreviewText(`Entry ${entry.key} not found.`);
return;
}
this.setPreviewText(JSON.stringify(payload, null, 2));
});
actions.createEl('button', { text: 'Delete' }).addEventListener('click', async () => {
try {
await this.cacheManager.deleteCacheEntry(entry.key);
if (this.selectedCacheKey === entry.key) {
this.selectedCacheKey = null;
this.setPreviewText('Select an entry to preview');
}
await this.refreshEntries();
} catch (error) {
new Notice(`Failed to delete entry: ${error.message}`);
}
});
}
}
private async refreshEntries(): Promise<void> {
this.entries = await this.cacheManager.listCacheEntries();
this.renderEntries();
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('data-fetcher-cache-browser');
this.modalEl.addClass('data-fetcher-cache-browser-modal');
new Setting(contentEl)
.setName('Cache browser')
.setHeading();
const toolbar = contentEl.createEl('div', { cls: 'data-fetcher-cache-toolbar' });
toolbar.createEl('button', { text: 'Refresh list' }).addEventListener('click', async () => {
await this.refreshEntries();
});
this.filterInput = toolbar.createEl('input', {
cls: 'data-fetcher-cache-filter',
attr: {
type: 'text',
placeholder: 'Filter by alias, key, type, or URL...'
}
});
this.filterInput.addEventListener('input', () => this.renderEntries());
toolbar.createEl('button', { text: 'Clear all', cls: 'mod-warning' }).addEventListener('click', async () => {
try {
await this.cacheManager.clearAllCache();
this.selectedCacheKey = null;
this.setPreviewText('Select an entry to preview');
await this.refreshEntries();
new Notice('Cache cleared successfully');
} catch (error) {
new Notice(`Failed to clear cache: ${error.message}`);
}
});
this.summaryContainer = contentEl.createEl('div', { cls: 'data-fetcher-cache-summary' });
const split = contentEl.createEl('div', { cls: 'data-fetcher-cache-split' });
this.entriesContainer = split.createEl('div', { cls: 'data-fetcher-cache-list' });
this.previewContainer = split.createEl('div', { cls: 'data-fetcher-cache-preview' });
this.setPreviewText('Select an entry to preview');
void this.refreshEntries();
}
onClose() {
this.modalEl.removeClass('data-fetcher-cache-browser-modal');
this.contentEl.empty();
}
}
class HeadersModal extends Modal {
headers: Record<string, string>;
onSubmit: (headers: Record<string, string>) => void;
constructor(app: App, headers: Record<string, string>, onSubmit: (headers: Record<string, string>) => void) {
super(app);
this.headers = {...headers};
this.onSubmit = onSubmit;
}
onOpen() {
const {contentEl} = this;
// Use Setting for heading to maintain consistency
new Setting(contentEl)
.setName('Manage headers')
.setHeading();
// Display existing headers
Object.entries(this.headers).forEach(([key, value]) => {
this.createHeaderRow(contentEl, key, value);
});
// Add new header button
const addBtn = contentEl.createEl('button', {text: 'Add header'});
addBtn.addEventListener('click', () => {
this.createHeaderRow(contentEl, '', '');
});
// Save button
const saveBtn = contentEl.createEl('button', {text: 'Save', cls: 'mod-cta'});
saveBtn.addEventListener('click', () => {
this.onSubmit(this.headers);
this.close();
});
}
createHeaderRow(container: HTMLElement, key: string, value: string) {
const row = container.createEl('div', {cls: 'header-row'});
const keyInput = row.createEl('input', {
attr: {
type: 'text',
placeholder: 'Header name',
value: key
}
});
const valueInput = row.createEl('input', {
attr: {
type: 'text',
placeholder: 'Value',
value: value
}
});
const deleteBtn = row.createEl('button', {text: 'X'});
// Event listeners
const oldKey = key;
keyInput.addEventListener('change', () => {
if (oldKey) {
delete this.headers[oldKey];
}
this.headers[keyInput.value] = valueInput.value;
});
valueInput.addEventListener('change', () => {
this.headers[keyInput.value] = valueInput.value;
});
deleteBtn.addEventListener('click', () => {
if (keyInput.value) {
delete this.headers[keyInput.value];
}
row.remove();
});
}
onClose() {
const {contentEl} = this;
contentEl.empty();
}
}