jacobinwwey_obsidian-NotEMD/src/ui/NotemdSidebarView.ts
2026-07-09 19:14:23 +08:00

1945 lines
83 KiB
TypeScript

import { Editor, ItemView, MarkdownView, Notice, TFile, TFolder, WorkspaceLeaf } from 'obsidian';
import NotemdPlugin from '../main';
import { ApiLivenessEvent, ApiLivenessPhase, NotemdSettings, ProgressReporter } from '../types';
import { NOTEMD_SIDEBAR_ICON, NOTEMD_SIDEBAR_VIEW_TYPE } from '../constants';
import { findDuplicates } from '../fileUtils';
import { FFMPEG_INSTALL_HINTS, type EnvironmentReport, type ProbeResult } from '../slideExport/types';
import type { RenderTarget } from '../diagram/types';
import { NOTEMD_SLIDEV_FORK_RELEASE_URL, NOTEMD_SLIDEV_INSTALL_COMMAND } from '../slideExport/slidevDistribution';
import {
ActionCategory,
CustomWorkflowButton,
getLocalizedWorkflowButtonName,
getSidebarActionLabel,
getSidebarActionTooltip,
normalizeSidebarActionId,
resolveCustomWorkflowButtons,
SIDEBAR_ACTION_DEFINITIONS,
SidebarActionId
} from '../workflowButtons';
import { formatI18n, getCurrentUiLocale, getI18nStrings } from '../i18n';
import { formatTimeForLocale } from '../i18n/localeFormat';
import { isSupportedInputFileForTask, readSupportedInputFile } from '../inputFileSupport';
import { isDirectPreviewableDiagramExtension } from '../operations/diagramCommandHostAdapter';
interface WorkflowExecutionContext {
preferredFolderPath: string | null;
lastGeneratedCompleteFolderPath: string | null;
}
type ApiLivenessVisualPhase = 'idle' | 'waiting' | 'accepted' | 'receiving' | 'received' | 'error';
type ApiActivityTerminalState = 'received' | 'interrupted';
interface ApiActivityHistoryEntry {
timestamp: string;
phase: ApiLivenessPhase;
requestAttempt?: number;
transport?: string;
statusCode?: number;
retrying?: boolean;
}
interface ApiActivityRequestRecord {
requestId: string;
providerName: string;
accepted: boolean;
receiving: boolean;
lastPhase: ApiLivenessPhase;
requestAttempt?: number;
transport?: string;
statusCode?: number;
retrying?: boolean;
startedAt: string;
updatedAt: string;
completedAt?: string;
terminalState?: ApiActivityTerminalState;
history: ApiActivityHistoryEntry[];
}
const API_ACTIVITY_RECENT_LIMIT = 6;
const API_ACTIVITY_HISTORY_LIMIT = 20;
const API_ACTIVITY_VISIBLE_HISTORY_LIMIT = 6;
const SLIDE_EXPORT_FORMATS: Array<NotemdSettings['slideExportDefaultFormat']> = ['html', 'pdf', 'png', 'pptx', 'mp4'];
const ACTION_CATEGORY_CONFIG: Record<ActionCategory, { openByDefault: boolean }> = {
core: { openByDefault: true },
generation: { openByDefault: true },
knowledge: { openByDefault: false },
translation: { openByDefault: false },
utilities: { openByDefault: false },
export: { openByDefault: false }
};
function quotePosixShellPath(path: string): string {
return `'${path.replace(/'/g, `'\\''`)}'`;
}
function quoteWindowsCommandPath(path: string): string {
return `"${path.replace(/"/g, '""')}"`;
}
const SINGLE_FILE_ACTION_IDS = new Set<SidebarActionId>([
'process-current-add-links',
'generate-from-title',
'research-and-summarize',
'summarize-as-mermaid',
'generate-diagram',
'preview-diagram',
'translate-current-file',
'extract-concepts-current',
'extract-original-text',
'fix-formula-current',
'check-duplicates-current',
'export-slides'
]);
function isSingleFileAction(actionId: SidebarActionId): boolean {
return SINGLE_FILE_ACTION_IDS.has(actionId);
}
function isSingleFileWorkflow(button: CustomWorkflowButton): boolean {
return button.actions.length > 0 && button.actions.every(actionId => isSingleFileAction(actionId));
}
export class NotemdSidebarView extends ItemView implements ProgressReporter {
plugin: NotemdPlugin;
private statusEl: HTMLElement | null = null;
private progressAreaEl: HTMLElement | null = null;
private progressEl: HTMLElement | null = null;
private progressBarContainerEl: HTMLElement | null = null;
private progressValueEl: HTMLElement | null = null;
private timeRemainingEl: HTMLElement | null = null;
private apiLivenessRowEl: HTMLElement | null = null;
private apiLivenessTextEl: HTMLElement | null = null;
private apiActivityEl: HTMLElement | null = null;
private apiActivityContentEl: HTMLElement | null = null;
private apiActivityEmptyEl: HTMLElement | null = null;
private apiActivityActiveSectionEl: HTMLElement | null = null;
private apiActivityActiveListEl: HTMLElement | null = null;
private apiActivityRecentSectionEl: HTMLElement | null = null;
private apiActivityRecentListEl: HTMLElement | null = null;
private logEl: HTMLElement | null = null;
private cancelButton: HTMLButtonElement | null = null;
private languageSelector: HTMLSelectElement | null = null;
private slideExportFormatSelector: HTMLSelectElement | null = null;
private slideExportOutlineToggleButton: HTMLButtonElement | null = null;
private slideExportDirectActionsEl: HTMLElement | null = null;
private slideExportOutlineActionsEl: HTMLElement | null = null;
private slideExportControlsSectionEl: (HTMLElement & { open?: boolean }) | null = null;
private slideExportEnvironmentPanelEl: HTMLElement | null = null;
private apiLivenessPhase: ApiLivenessVisualPhase = 'idle';
private apiLivenessTimer: ReturnType<typeof setTimeout> | null = null;
private apiLivenessRequests = new Map<string, ApiActivityRequestRecord>();
private apiLivenessRecentRequests: ApiActivityRequestRecord[] = [];
private expandedApiActivityRequestIds = new Set<string>();
private logContent: string[] = [];
private startTime = 0;
private isProcessing = false;
private isCancelled = false;
private currentAbortController: AbortController | null = null;
private slideExportOutlineMode = false;
activeTasks = 0;
private actionButtons = new Map<string, HTMLButtonElement>();
private workflowButtons: HTMLButtonElement[] = [];
private getStrings() {
return getI18nStrings({ uiLocale: this.plugin.settings.uiLocale });
}
private getResolvedUiLocale() {
return getCurrentUiLocale({ uiLocale: this.plugin.settings.uiLocale });
}
constructor(leaf: WorkspaceLeaf, plugin: NotemdPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType() {
return NOTEMD_SIDEBAR_VIEW_TYPE;
}
getDisplayText() {
return this.getStrings().plugin.viewName;
}
getIcon() {
return NOTEMD_SIDEBAR_ICON;
}
startProcessing(initialStatus: string) {
this.clearDisplay();
this.currentAbortController = new AbortController();
this.isProcessing = true;
this.isCancelled = false;
this.startTime = Date.now();
this.updateStatus(initialStatus, 0);
this.updateButtonStates();
}
finishProcessing() {
this.isProcessing = false;
this.updateButtonStates();
}
clearDisplay() {
const i18n = this.getStrings();
this.logContent = [];
if (this.logEl) this.logEl.empty();
if (this.statusEl) this.statusEl.setText(i18n.common.ready);
if (this.progressAreaEl) this.progressAreaEl.addClass('is-idle');
if (this.progressEl) {
this.progressEl.dataset.progress = '0';
this.progressEl.setText('');
this.progressEl.removeClass('is-error');
this.progressEl.style.width = '0%';
}
if (this.progressBarContainerEl) {
this.progressBarContainerEl.removeClass('is-hidden');
this.progressBarContainerEl.addClass('is-idle');
}
if (this.progressValueEl) {
this.progressValueEl.setText(i18n.common.ready);
this.progressValueEl.addClass('is-idle');
this.progressValueEl.removeClass('is-error');
}
if (this.timeRemainingEl) this.timeRemainingEl.setText(i18n.common.standby);
this.resetApiLiveness();
if (this.cancelButton) {
this.cancelButton.disabled = true;
this.cancelButton.removeClass('is-active');
}
this.isProcessing = false;
this.isCancelled = false;
this.startTime = 0;
this.currentAbortController = null;
this.updateButtonStates();
}
updateStatus(text: string, percent?: number) {
const i18n = this.getStrings();
if (this.statusEl) this.statusEl.setText(text);
if (percent !== undefined && (percent < 0 || percent >= 100)) {
this.isProcessing = false;
}
this.updateButtonStates();
if (percent !== undefined && this.progressEl && this.progressBarContainerEl) {
this.progressBarContainerEl.removeClass('is-hidden');
this.progressBarContainerEl.removeClass('is-idle');
if (this.progressAreaEl) this.progressAreaEl.removeClass('is-idle');
if (percent >= 0) {
const clampedPercent = Math.min(100, Math.max(0, percent));
this.progressEl.dataset.progress = String(clampedPercent);
this.progressEl.setText('');
this.progressEl.removeClass('is-error');
this.progressEl.style.width = `${clampedPercent}%`;
if (this.progressValueEl) {
this.progressValueEl.setText(`${Math.round(clampedPercent)}%`);
this.progressValueEl.removeClass('is-idle');
this.progressValueEl.removeClass('is-error');
}
if (percent > 0 && this.startTime > 0) {
const elapsed = (Date.now() - this.startTime) / 1000;
const estimatedTotal = elapsed / (percent / 100);
const remaining = Math.max(0, estimatedTotal - elapsed);
if (this.timeRemainingEl) {
this.timeRemainingEl.setText(
formatI18n(i18n.sidebar.status.timeRemaining, { time: this.formatTime(remaining) })
);
}
} else if (this.timeRemainingEl) {
this.timeRemainingEl.setText(i18n.sidebar.status.timeRemainingCalculating);
}
} else {
this.progressEl.dataset.progress = '100';
this.progressEl.addClass('is-error');
this.progressEl.setText('');
this.progressEl.style.width = '100%';
if (this.progressValueEl) {
this.progressValueEl.setText(i18n.sidebar.status.stopped);
this.progressValueEl.removeClass('is-idle');
this.progressValueEl.addClass('is-error');
}
if (this.timeRemainingEl) this.timeRemainingEl.setText(i18n.sidebar.status.processingStopped);
}
}
}
log(message: string) {
if (!this.logEl) {
return;
}
const timestamp = `[${formatTimeForLocale(new Date(), this.getResolvedUiLocale())}]`;
const fullMessage = `${timestamp} ${message}`;
this.logContent.push(fullMessage);
const entry = this.logEl.createEl('div', { cls: 'notemd-log-entry' });
entry.createEl('span', { text: timestamp, cls: 'notemd-log-time' });
const messageEl = entry.createEl('span', { cls: 'notemd-log-message' });
messageEl.setText(` ${message}`);
this.logEl.scrollTop = this.logEl.scrollHeight;
}
getLogs(): string {
return this.logContent.join('\n');
}
private formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}m ${secs}s`;
}
get cancelled() {
return this.isCancelled;
}
requestCancel() {
if (this.isProcessing && !this.isCancelled) {
const i18n = this.getStrings();
this.isCancelled = true;
this.updateStatus(i18n.sidebar.status.cancelling, -1);
this.log(i18n.sidebar.status.userRequestedCancellation);
this.currentAbortController?.abort();
this.updateButtonStates();
}
}
get abortController(): AbortController | null | undefined {
return this.currentAbortController;
}
set abortController(controller: AbortController | null | undefined) {
this.currentAbortController = controller ?? null;
}
updateActiveTasks(delta: number): void {
const i18n = this.getStrings();
this.activeTasks += delta;
this.updateStatus(formatI18n(i18n.sidebar.status.processingActive, { count: this.activeTasks }));
}
updateApiLiveness(event: ApiLivenessEvent): void {
const i18n = this.getStrings();
const requestRecord = this.recordApiLivenessEvent(event);
switch (event.phase) {
case 'request-start':
requestRecord.accepted = false;
requestRecord.receiving = false;
this.syncApiLivenessWhileActive(i18n);
this.renderApiActivity(i18n);
break;
case 'response-headers':
requestRecord.accepted = true;
this.syncApiLivenessWhileActive(i18n);
this.renderApiActivity(i18n);
break;
case 'response-chunk':
requestRecord.accepted = true;
requestRecord.receiving = true;
this.syncApiLivenessWhileActive(i18n);
this.renderApiActivity(i18n);
break;
case 'request-complete':
requestRecord.accepted = true;
requestRecord.receiving = false;
this.archiveApiLivenessRequest(event.requestId, 'received', i18n);
if (!this.syncApiLivenessWhileActive(i18n)) {
this.setApiLivenessState('received', i18n.sidebar.status.apiResponseReceived);
}
break;
case 'request-error':
if (event.retrying) {
requestRecord.accepted = false;
requestRecord.receiving = false;
if (!this.syncApiLivenessWhileActive(i18n)) {
this.setApiLivenessState('waiting', i18n.sidebar.status.awaitingApiOutput);
}
this.renderApiActivity(i18n);
break;
}
requestRecord.receiving = false;
this.archiveApiLivenessRequest(event.requestId, 'interrupted', i18n);
if (!this.syncApiLivenessWhileActive(i18n)) {
this.setApiLivenessState('error', i18n.sidebar.status.apiOutputInterrupted);
}
break;
}
}
private ensureApiLivenessRequest(requestId: string, providerName?: string) {
const existingState = this.apiLivenessRequests.get(requestId);
if (existingState) {
if (providerName) {
existingState.providerName = providerName;
}
return existingState;
}
this.apiLivenessRecentRequests = this.apiLivenessRecentRequests.filter(record => record.requestId !== requestId);
this.expandedApiActivityRequestIds.delete(requestId);
const now = new Date().toISOString();
const createdState: ApiActivityRequestRecord = {
requestId,
providerName: providerName || 'Unknown',
accepted: false,
receiving: false,
lastPhase: 'request-start',
startedAt: now,
updatedAt: now,
history: []
};
this.apiLivenessRequests.set(requestId, createdState);
return createdState;
}
private recordApiLivenessEvent(event: ApiLivenessEvent): ApiActivityRequestRecord {
const requestRecord = this.ensureApiLivenessRequest(event.requestId, event.providerName);
const timestamp = new Date().toISOString();
requestRecord.providerName = event.providerName;
requestRecord.lastPhase = event.phase;
requestRecord.requestAttempt = event.requestAttempt ?? requestRecord.requestAttempt;
requestRecord.transport = event.transport ?? requestRecord.transport;
requestRecord.statusCode = event.statusCode ?? requestRecord.statusCode;
requestRecord.retrying = event.retrying;
requestRecord.updatedAt = timestamp;
requestRecord.terminalState = undefined;
requestRecord.completedAt = undefined;
requestRecord.history.push({
timestamp,
phase: event.phase,
requestAttempt: event.requestAttempt,
transport: event.transport,
statusCode: event.statusCode,
retrying: event.retrying
});
if (requestRecord.history.length > API_ACTIVITY_HISTORY_LIMIT) {
requestRecord.history.splice(0, requestRecord.history.length - API_ACTIVITY_HISTORY_LIMIT);
}
return requestRecord;
}
private archiveApiLivenessRequest(
requestId: string,
terminalState: ApiActivityTerminalState,
i18n = this.getStrings()
) {
const existingState = this.apiLivenessRequests.get(requestId);
if (!existingState) {
this.renderApiActivity(i18n);
return;
}
const archivedAt = new Date().toISOString();
const archivedState: ApiActivityRequestRecord = {
...existingState,
receiving: false,
retrying: false,
terminalState,
completedAt: archivedAt,
updatedAt: archivedAt,
history: existingState.history.map(entry => ({ ...entry }))
};
this.apiLivenessRequests.delete(requestId);
this.apiLivenessRecentRequests = [
archivedState,
...this.apiLivenessRecentRequests.filter(record => record.requestId !== requestId)
].slice(0, API_ACTIVITY_RECENT_LIMIT);
this.renderApiActivity(i18n);
}
private syncApiLivenessWhileActive(i18n = this.getStrings()): boolean {
if (this.apiLivenessRequests.size === 0) {
return false;
}
const requestStates = Array.from(this.apiLivenessRequests.values());
if (requestStates.some(state => state.receiving)) {
if (this.apiLivenessPhase !== 'receiving') {
this.setApiLivenessState('receiving', i18n.sidebar.status.receivingApiOutput, true);
}
return true;
}
if (requestStates.some(state => state.accepted)) {
this.setApiLivenessState('accepted', i18n.sidebar.status.apiRequestAcceptedAwaitingBody);
return true;
}
this.setApiLivenessState('waiting', i18n.sidebar.status.awaitingApiOutput);
return true;
}
private clearApiLivenessTimer() {
if (this.apiLivenessTimer !== null) {
clearTimeout(this.apiLivenessTimer);
this.apiLivenessTimer = null;
}
}
private resetApiLiveness() {
const i18n = this.getStrings();
this.apiLivenessRequests.clear();
this.apiLivenessRecentRequests = [];
this.expandedApiActivityRequestIds.clear();
this.setApiLivenessState('idle', i18n.common.standby);
this.renderApiActivity(i18n);
}
private setApiLivenessState(
phase: ApiLivenessVisualPhase,
text: string,
scheduleHealthyReminder = false
) {
this.apiLivenessPhase = phase;
this.clearApiLivenessTimer();
if (this.apiLivenessTextEl) {
this.apiLivenessTextEl.setText(text);
}
if (this.apiLivenessRowEl) {
this.apiLivenessRowEl.removeClass('is-idle');
this.apiLivenessRowEl.removeClass('is-waiting');
this.apiLivenessRowEl.removeClass('is-accepted');
this.apiLivenessRowEl.removeClass('is-active');
this.apiLivenessRowEl.removeClass('is-error');
const className = phase === 'idle'
? 'is-idle'
: phase === 'waiting'
? 'is-waiting'
: phase === 'accepted'
? 'is-accepted'
: phase === 'error'
? 'is-error'
: 'is-active';
this.apiLivenessRowEl.addClass(className);
}
if (scheduleHealthyReminder) {
this.apiLivenessTimer = setTimeout(() => {
if (this.apiLivenessPhase === 'receiving') {
this.setApiLivenessState('receiving', this.getStrings().sidebar.status.apiTaskHealthyWaiting);
}
}, 30000);
}
}
private renderApiActivity(i18n = this.getStrings()) {
if (
!this.apiActivityEmptyEl
|| !this.apiActivityContentEl
|| !this.apiActivityActiveSectionEl
|| !this.apiActivityActiveListEl
|| !this.apiActivityRecentSectionEl
|| !this.apiActivityRecentListEl
) {
return;
}
const activeRecords = Array.from(this.apiLivenessRequests.values());
const recentRecords = this.apiLivenessRecentRequests;
this.apiActivityActiveListEl.empty();
this.apiActivityRecentListEl.empty();
if (activeRecords.length === 0 && recentRecords.length === 0) {
this.apiActivityEmptyEl.setText(i18n.sidebar.apiActivityEmpty);
this.apiActivityEmptyEl.removeClass('is-hidden');
this.apiActivityActiveSectionEl.addClass('is-hidden');
this.apiActivityRecentSectionEl.addClass('is-hidden');
return;
}
this.apiActivityEmptyEl.setText('');
this.apiActivityEmptyEl.addClass('is-hidden');
this.renderApiActivitySection(
this.apiActivityActiveSectionEl,
this.apiActivityActiveListEl,
i18n.sidebar.apiActivityActiveSection,
activeRecords,
i18n
);
this.renderApiActivitySection(
this.apiActivityRecentSectionEl,
this.apiActivityRecentListEl,
i18n.sidebar.apiActivityRecentSection,
recentRecords,
i18n
);
}
private renderApiActivitySection(
sectionEl: HTMLElement,
listEl: HTMLElement,
title: string,
records: ApiActivityRequestRecord[],
i18n = this.getStrings()
) {
if (records.length === 0) {
sectionEl.addClass('is-hidden');
return;
}
sectionEl.removeClass('is-hidden');
const titleEl = sectionEl.querySelector?.('.notemd-api-activity-section-title') as HTMLElement | null;
if (titleEl) {
titleEl.setText(title);
}
records.forEach(record => {
const item = listEl.createDiv({ cls: 'notemd-api-activity-item' });
const headerEl = item.createDiv({ cls: 'notemd-api-activity-item-header' });
headerEl.createEl('div', { text: record.providerName, cls: 'notemd-api-activity-item-title' });
const toggleButtonEl = headerEl.createEl('button', {
text: this.expandedApiActivityRequestIds.has(record.requestId)
? i18n.sidebar.apiActivityHideHistory
: i18n.sidebar.apiActivityShowHistory,
cls: 'notemd-api-activity-toggle-button'
});
toggleButtonEl.onclick = () => {
this.toggleApiActivityHistory(record.requestId, i18n);
};
item.createEl('div', {
text: this.buildApiActivitySummary(record, i18n),
cls: 'notemd-api-activity-item-meta'
});
if (!this.expandedApiActivityRequestIds.has(record.requestId)) {
return;
}
const historyEl = item.createDiv({ cls: 'notemd-api-activity-history' });
record.history.slice(-API_ACTIVITY_VISIBLE_HISTORY_LIMIT).forEach(entry => {
historyEl.createEl('div', {
text: this.buildApiActivityHistorySummary(entry),
cls: 'notemd-api-activity-history-entry'
});
});
});
}
private toggleApiActivityHistory(requestId: string, i18n = this.getStrings()) {
if (this.expandedApiActivityRequestIds.has(requestId)) {
this.expandedApiActivityRequestIds.delete(requestId);
} else {
this.expandedApiActivityRequestIds.add(requestId);
}
this.renderApiActivity(i18n);
}
private buildApiActivityHistorySummary(entry: ApiActivityHistoryEntry): string {
const parts: string[] = [entry.phase];
if (entry.requestAttempt !== undefined) {
parts.push(`attempt=${entry.requestAttempt}`);
}
if (entry.transport) {
parts.push(`transport=${entry.transport}`);
}
if (entry.statusCode !== undefined) {
parts.push(`status=${entry.statusCode}`);
}
if (entry.retrying !== undefined) {
parts.push(`retrying=${entry.retrying}`);
}
return parts.join(' | ');
}
private buildApiActivitySummary(record: ApiActivityRequestRecord, i18n = this.getStrings()): string {
const parts = [formatI18n(i18n.sidebar.apiActivityRequestLabel, { id: record.requestId })];
if (record.requestAttempt !== undefined) {
parts.push(formatI18n(i18n.sidebar.apiActivityAttemptLabel, { attempt: record.requestAttempt }));
}
parts.push(this.getApiActivityStatusLabel(record, i18n));
if (record.transport) {
parts.push(record.transport);
}
if (record.statusCode !== undefined) {
parts.push(`HTTP ${record.statusCode}`);
}
return parts.join(' | ');
}
private getApiActivityStatusLabel(record: ApiActivityRequestRecord, i18n = this.getStrings()): string {
if (record.terminalState === 'received') {
return i18n.sidebar.apiActivityStatusReceived;
}
if (record.terminalState === 'interrupted') {
return i18n.sidebar.apiActivityStatusInterrupted;
}
if (record.retrying) {
return i18n.sidebar.apiActivityStatusRetrying;
}
if (record.receiving) {
return i18n.sidebar.apiActivityStatusReceiving;
}
if (record.accepted) {
return i18n.sidebar.apiActivityStatusAccepted;
}
return i18n.sidebar.apiActivityStatusWaiting;
}
private buildApiActivityReport(i18n = this.getStrings()): string | null {
const activeRequests = Array.from(this.apiLivenessRequests.values());
const recentRequests = this.apiLivenessRecentRequests;
if (activeRequests.length === 0 && recentRequests.length === 0) {
return null;
}
const lines = [
i18n.sidebar.apiActivityReportTitle,
formatI18n(i18n.sidebar.apiActivityReportGeneratedAt, { timestamp: new Date().toISOString() }),
'',
i18n.sidebar.apiActivityReportActiveSection
];
if (activeRequests.length === 0) {
lines.push(i18n.sidebar.apiActivityReportNone);
} else {
activeRequests.forEach(record => lines.push(...this.buildApiActivityReportLines(record, i18n)));
}
lines.push('', i18n.sidebar.apiActivityReportRecentSection);
if (recentRequests.length === 0) {
lines.push(i18n.sidebar.apiActivityReportNone);
} else {
recentRequests.forEach(record => lines.push(...this.buildApiActivityReportLines(record, i18n)));
}
return lines.join('\n');
}
private buildApiActivityReportLines(record: ApiActivityRequestRecord, i18n = this.getStrings()): string[] {
const lines = [
formatI18n(i18n.sidebar.apiActivityReportRequestLabel, { id: record.requestId }),
formatI18n(i18n.sidebar.apiActivityReportProviderLabel, { provider: record.providerName }),
formatI18n(i18n.sidebar.apiActivityReportSummaryLabel, { summary: this.buildApiActivitySummary(record, i18n) }),
formatI18n(i18n.sidebar.apiActivityReportStartedAt, { timestamp: record.startedAt })
];
if (record.completedAt) {
lines.push(formatI18n(i18n.sidebar.apiActivityReportCompletedAt, { timestamp: record.completedAt }));
}
lines.push(i18n.sidebar.apiActivityReportHistoryLabel);
record.history.forEach(entry => {
const parts = [entry.timestamp, entry.phase];
if (entry.requestAttempt !== undefined) {
parts.push(`attempt=${entry.requestAttempt}`);
}
if (entry.transport) {
parts.push(`transport=${entry.transport}`);
}
if (entry.statusCode !== undefined) {
parts.push(`status=${entry.statusCode}`);
}
if (entry.retrying !== undefined) {
parts.push(`retrying=${entry.retrying}`);
}
lines.push(`- ${parts.join(' | ')}`);
});
lines.push('');
return lines;
}
private updateButtonStates() {
const processing = this.isProcessing;
this.actionButtons.forEach(button => {
button.disabled = processing;
});
this.workflowButtons.forEach(button => {
button.disabled = processing;
});
if (this.languageSelector) {
this.languageSelector.disabled = processing;
}
if (this.slideExportFormatSelector) {
this.slideExportFormatSelector.disabled = processing;
}
if (this.slideExportOutlineToggleButton) {
this.slideExportOutlineToggleButton.disabled = processing;
}
if (this.cancelButton) {
this.cancelButton.disabled = !processing || this.isCancelled;
if (this.cancelButton.disabled) this.cancelButton.removeClass('is-active');
else this.cancelButton.addClass('is-active');
}
}
private createReporterProxy(clearEnabled = false): ProgressReporter {
const view = this;
return {
log(message: string) {
view.log(message);
},
updateStatus(text: string, percent?: number) {
view.updateStatus(text, percent);
},
requestCancel() {
view.requestCancel();
},
clearDisplay() {
if (clearEnabled) {
view.clearDisplay();
}
},
get cancelled() {
return view.cancelled;
},
get abortController() {
return view.abortController;
},
set abortController(controller: AbortController | null | undefined) {
view.abortController = controller;
},
get activeTasks() {
return view.activeTasks;
},
set activeTasks(value: number) {
view.activeTasks = value;
},
updateActiveTasks(delta: number) {
view.updateActiveTasks(delta);
},
updateApiLiveness(event: ApiLivenessEvent) {
view.updateApiLiveness(event);
},
getLogs() {
return view.getLogs();
}
};
}
private createSection(
parent: HTMLElement,
title: string,
description: string,
openByDefault: boolean
): HTMLElement {
const details = parent.createEl('details', { cls: 'notemd-section-card' });
details.open = openByDefault;
const summary = details.createEl('summary', { cls: 'notemd-section-summary' });
summary.createEl('span', { text: title });
details.createEl('p', { text: description, cls: 'notemd-section-description' });
return details.createDiv({ cls: 'notemd-button-grid' });
}
private createActionButton(
parent: HTMLElement,
actionId: SidebarActionId,
category: ActionCategory
) {
const i18n = this.getStrings();
const label = getSidebarActionLabel(i18n, actionId);
const classes = ['notemd-action-button'];
if (isSingleFileAction(actionId)) {
classes.push('mod-cta');
classes.push('is-primary');
}
const button = parent.createEl('button', {
text: label,
cls: classes.join(' ')
});
button.dataset.category = category;
button.title = getSidebarActionTooltip(i18n, actionId);
button.onclick = async () => {
await this.runSingleAction(actionId);
};
this.actionButtons.set(actionId, button);
}
private getConfiguredConceptFolderPath(): string | null {
if (this.plugin.settings.useCustomConceptNoteFolder && this.plugin.settings.conceptNoteFolder.trim()) {
return this.plugin.settings.conceptNoteFolder.trim();
}
return null;
}
private async runSingleAction(actionId: SidebarActionId): Promise<void> {
const i18n = this.getStrings();
if (this.isProcessing || this.plugin.getIsBusy()) {
new Notice(i18n.notices.processingAlreadyRunning);
return;
}
const actionLabel = getSidebarActionLabel(i18n, actionId);
this.startProcessing(formatI18n(i18n.sidebar.status.runningAction, { label: actionLabel }));
const reporter = this.createReporterProxy();
try {
await this.executeAction(actionId, reporter);
if (!this.cancelled) {
this.updateStatus(formatI18n(i18n.sidebar.status.actionComplete, { label: actionLabel }), 100);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const failureMessage = formatI18n(i18n.sidebar.status.actionFailed, { message });
this.log(failureMessage);
this.updateStatus(failureMessage, -1);
} finally {
this.finishProcessing();
}
}
private async runCustomWorkflow(button: CustomWorkflowButton): Promise<void> {
const i18n = this.getStrings();
if (this.isProcessing || this.plugin.getIsBusy()) {
new Notice(i18n.notices.processingAlreadyRunning);
return;
}
const workflowName = getLocalizedWorkflowButtonName(i18n, button.name);
this.startProcessing(formatI18n(i18n.sidebar.status.workflowStart, { name: workflowName }));
const reporter = this.createReporterProxy();
const context: WorkflowExecutionContext = {
preferredFolderPath: this.getConfiguredConceptFolderPath(),
lastGeneratedCompleteFolderPath: null
};
const continueOnError = this.plugin.settings.customWorkflowErrorStrategy === 'continue_on_error';
let failedSteps = 0;
this.log(formatI18n(i18n.sidebar.status.workflowStart, { name: workflowName }));
try {
for (let i = 0; i < button.actions.length; i++) {
if (this.cancelled) {
break;
}
const actionId = button.actions[i];
const actionLabel = getSidebarActionLabel(i18n, actionId);
const progress = Math.floor((i / button.actions.length) * 100);
this.updateStatus(
formatI18n(i18n.sidebar.status.stepLabel, {
current: i + 1,
total: button.actions.length,
label: actionLabel
}),
progress
);
this.log(
formatI18n(i18n.sidebar.status.stepLog, {
current: i + 1,
total: button.actions.length,
label: actionLabel
})
);
try {
await this.executeAction(actionId, reporter, context);
} catch (error: unknown) {
failedSteps++;
const message = error instanceof Error ? error.message : String(error);
this.log(formatI18n(i18n.sidebar.status.stepFailed, { message }));
if (!continueOnError) {
throw error;
}
}
}
if (!this.cancelled) {
if (failedSteps > 0) {
const failureSummary = formatI18n(i18n.sidebar.status.workflowFinishedWithErrors, {
name: workflowName,
count: failedSteps
});
this.updateStatus(failureSummary, -1);
new Notice(failureSummary);
} else {
const completeMessage = formatI18n(i18n.sidebar.status.workflowComplete, { name: workflowName });
this.updateStatus(completeMessage, 100);
new Notice(completeMessage);
}
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.log(formatI18n(i18n.sidebar.status.workflowFailedLog, { message }));
this.updateStatus(i18n.sidebar.status.workflowFailed, -1);
new Notice(formatI18n(i18n.sidebar.status.actionFailed, { message: `${workflowName}: ${message}` }));
} finally {
this.finishProcessing();
}
}
private async executeAction(
actionId: SidebarActionId,
reporter: ProgressReporter,
context?: WorkflowExecutionContext
): Promise<void> {
switch (normalizeSidebarActionId(actionId)) {
case 'process-current-add-links': {
await this.plugin.processWithNotemdCommand(reporter);
if (context && !context.preferredFolderPath) {
context.preferredFolderPath = this.getConfiguredConceptFolderPath();
}
break;
}
case 'process-folder-add-links': {
await this.plugin.processFolderWithNotemdCommand(reporter, context?.preferredFolderPath || undefined);
break;
}
case 'generate-from-title': {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
throw new Error('No active Markdown file selected.');
}
await this.plugin.generateContentForTitleCommand(activeFile, reporter);
break;
}
case 'batch-generate-from-titles': {
const folderOverride = context?.preferredFolderPath || this.getConfiguredConceptFolderPath() || undefined;
const result = await this.plugin.batchGenerateContentForTitlesCommand(reporter, folderOverride);
if (result && context) {
context.preferredFolderPath = result.sourceFolderPath;
context.lastGeneratedCompleteFolderPath = result.completeFolderPath;
}
break;
}
case 'research-and-summarize': {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
throw new Error('No active Markdown file selected.');
}
let targetView: MarkdownView | null = null;
let targetEditor: Editor | null = null;
this.app.workspace.iterateAllLeaves(leaf => {
if (leaf.view instanceof MarkdownView && leaf.view.file?.path === activeFile.path) {
targetView = leaf.view;
targetEditor = leaf.view.editor;
}
});
if (!targetView || !targetEditor) {
throw new Error('Could not find active Markdown editor view.');
}
await this.plugin.researchAndSummarizeCommand(targetEditor, targetView, reporter);
break;
}
case 'summarize-as-mermaid': {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || !isSupportedInputFileForTask(this.plugin.settings, 'summarize-as-mermaid', activeFile)) {
throw new Error('No supported diagram input file selected.');
}
await this.plugin.summarizeToMermaidCommand(activeFile, reporter);
break;
}
case 'generate-diagram': {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || !isSupportedInputFileForTask(this.plugin.settings, 'generate-diagram', activeFile)) {
throw new Error('No supported diagram input file selected.');
}
await this.plugin.generateDiagramCommand(activeFile, reporter, { executionMode: 'save-artifact' });
break;
}
case 'preview-diagram': {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || !isDirectPreviewableDiagramExtension(activeFile.extension)) {
throw new Error('No supported diagram source or artifact file selected.');
}
await this.plugin.previewDiagramCommand(activeFile, reporter);
break;
}
case 'translate-current-file': {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile) {
throw new Error('No active file selected.');
}
await this.plugin.translateFileCommand(activeFile, reporter.abortController?.signal, reporter);
break;
}
case 'batch-translate-folder': {
const targetFolderPath = context?.preferredFolderPath;
if (targetFolderPath) {
const abstract = this.app.vault.getAbstractFileByPath(targetFolderPath);
if (abstract instanceof TFolder) {
await this.plugin.batchTranslateFolderCommand(abstract, reporter);
break;
}
}
await this.plugin.batchTranslateFolderCommand(undefined, reporter);
break;
}
case 'extract-concepts-current': {
await this.plugin.extractConceptsCommand(reporter);
break;
}
case 'extract-concepts-folder': {
const folderOverride = context?.preferredFolderPath || undefined;
if (folderOverride) {
await this.plugin.batchExtractConceptsForFolderCommand(reporter, {
folderPathOverride: folderOverride
});
} else {
await this.plugin.batchExtractConceptsForFolderCommand(reporter);
}
break;
}
case 'extract-original-text': {
await this.plugin.extractOriginalTextCommand(reporter);
break;
}
case 'batch-extract-original-text': {
const folderOverride = context?.preferredFolderPath || undefined;
if (folderOverride) {
await this.plugin.batchExtractOriginalTextCommand(reporter, {
folderPathOverride: folderOverride
});
} else {
await this.plugin.batchExtractOriginalTextCommand(reporter);
}
break;
}
case 'split-note-by-chapters': {
await this.plugin.splitNoteByChaptersCommand(reporter);
break;
}
case 'batch-mermaid-fix': {
const folderOverride = context?.lastGeneratedCompleteFolderPath || context?.preferredFolderPath || undefined;
await this.plugin.batchMermaidFixCommand(reporter, folderOverride);
break;
}
case 'fix-formula-current': {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || (activeFile.extension !== 'md' && activeFile.extension !== 'txt')) {
throw new Error('No active .md/.txt file selected.');
}
await this.plugin.fixFormulaFormatsCommand(activeFile, reporter);
break;
}
case 'batch-fix-formula': {
const folderOverride = context?.lastGeneratedCompleteFolderPath || context?.preferredFolderPath || undefined;
if (folderOverride) {
await this.plugin.batchFixFormulaFormatsCommand(reporter, folderOverride);
} else {
await this.plugin.batchFixFormulaFormatsCommand(reporter);
}
break;
}
case 'check-duplicates-current': {
const activeFile = this.plugin.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || !isSupportedInputFileForTask(this.plugin.settings, 'check-duplicates-current', activeFile)) {
throw new Error(this.getStrings().notices.noActiveTextFileSelected);
}
const content = await readSupportedInputFile(this.plugin.app, activeFile, this.plugin.settings);
const duplicates = findDuplicates(content);
const message = formatI18n(this.getStrings().notices.duplicateTermsFound, { count: duplicates.size });
reporter.log(message);
new Notice(formatI18n(this.getStrings().notices.duplicateTermsCheckLogConsole, { count: duplicates.size }));
if (duplicates.size > 0) {
reporter.log(`Potential duplicates: ${Array.from(duplicates).join(', ')}`);
console.log(`Potential duplicates in ${activeFile.name}:`, Array.from(duplicates));
}
break;
}
case 'check-remove-duplicate-concepts': {
await this.plugin.checkAndRemoveDuplicateConceptNotesCommand(reporter);
break;
}
case 'test-llm-connection': {
await this.plugin.testLlmConnectionCommand(reporter);
break;
}
case 'probe-slide-export-env': {
await this.runSlideExportEnvironmentProbe();
break;
}
case 'export-slides': {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
throw new Error('No active Markdown file selected.');
}
await this.plugin.exportSlidesCommand(activeFile, reporter);
break;
}
default: {
throw new Error(`Unsupported action: ${actionId}`);
}
}
}
private buildLanguageSelector(parent: HTMLElement) {
const i18n = this.getStrings();
const row = parent.createDiv({ cls: 'notemd-inline-control' });
row.createEl('label', { text: i18n.common.language, cls: 'notemd-inline-label' });
this.languageSelector = row.createEl('select', { cls: 'notemd-language-select' });
const selector = this.languageSelector;
this.plugin.settings.availableLanguages.forEach(lang => {
selector.add(new Option(lang.name, lang.code));
});
selector.value = this.plugin.settings.language;
selector.onchange = async () => {
this.plugin.settings.language = selector.value;
await this.plugin.saveSettings();
new Notice(formatI18n(i18n.sidebar.languageChangedNotice, { language: selector.value }));
};
}
private buildSlideExportFormatSelector(parent: HTMLElement) {
const i18n = this.getStrings();
const row = parent.createDiv({ cls: 'notemd-inline-control notemd-slide-export-format-control' });
row.createEl('label', {
text: i18n.slideExport.defaultFormatName,
cls: 'notemd-inline-label'
});
const selector = row.createEl('select', { cls: 'notemd-slide-export-format-select' }) as HTMLSelectElement;
selector.title = i18n.slideExport.defaultFormatDesc;
SLIDE_EXPORT_FORMATS.forEach(format => {
const option = selector.createEl('option', { text: format.toUpperCase() }) as HTMLOptionElement;
option.value = format;
});
const currentFormat = this.plugin.settings.slideExportDefaultFormat;
selector.value = SLIDE_EXPORT_FORMATS.includes(currentFormat) ? currentFormat : 'html';
selector.onchange = async () => {
const selectedFormat = selector.value as NotemdSettings['slideExportDefaultFormat'];
if (!SLIDE_EXPORT_FORMATS.includes(selectedFormat)) {
return;
}
this.plugin.settings.slideExportDefaultFormat = selectedFormat;
await this.plugin.saveSettings();
};
this.slideExportFormatSelector = selector;
}
private buildSlideExportControls(parent: HTMLElement) {
const i18n = this.getStrings();
this.slideExportControlsSectionEl = this.resolveContainingDetails(parent);
const probeButton = this.createSecondarySlideExportActionButton(
parent,
'notemd-slide-export-probe-button',
getSidebarActionLabel(i18n, 'probe-slide-export-env'),
getSidebarActionTooltip(i18n, 'probe-slide-export-env'),
() => this.runSlideExportEnvironmentProbe()
);
this.actionButtons.set('probe-slide-export-env', probeButton);
this.buildSlideExportFormatSelector(parent);
const toggleButton = parent.createEl('button', { cls: 'notemd-slide-export-outline-toggle' }) as HTMLButtonElement;
toggleButton.type = 'button';
toggleButton.title = i18n.slideExport.outlineBeforeExportTooltip;
toggleButton.setAttr('role', 'switch');
toggleButton.onclick = () => {
this.slideExportOutlineMode = !this.slideExportOutlineMode;
this.syncSlideExportOutlineControls();
};
toggleButton.createEl('span', { cls: 'notemd-slide-export-outline-toggle-track' });
toggleButton.createEl('span', { text: i18n.slideExport.outlineBeforeExportToggle, cls: 'notemd-slide-export-outline-toggle-label' });
this.slideExportOutlineToggleButton = toggleButton;
this.slideExportDirectActionsEl = parent.createDiv({ cls: 'notemd-slide-export-direct-actions' });
const directButton = this.createPrimarySlideExportActionButton(
this.slideExportDirectActionsEl,
'notemd-slide-export-direct-button',
i18n.slideExport.oneShotExportButton,
i18n.slideExport.oneShotExportTooltip,
() => this.runSlideExportFileAction(
i18n.slideExport.oneShotExportButton,
(file, reporter) => this.plugin.exportSlidesCommand(file, reporter)
)
);
this.actionButtons.set('slide-export-one-shot', directButton);
this.slideExportOutlineActionsEl = parent.createDiv({ cls: 'notemd-slide-export-outline-actions is-hidden' });
const outlineButton = this.createSlideExportStepButton(
this.slideExportOutlineActionsEl,
'1',
i18n.slideExport.generateOutlineButton,
i18n.slideExport.generateOutlineTooltip,
() => this.runSlideExportFileAction(
i18n.slideExport.generateOutlineButton,
(file, reporter) => this.plugin.generateSlidevExportOutlineCommand(file, reporter)
)
);
const continueButton = this.createSlideExportStepButton(
this.slideExportOutlineActionsEl,
'2',
i18n.slideExport.continueFromOutlineButton,
i18n.slideExport.continueFromOutlineTooltip,
() => this.runSlideExportFileAction(
i18n.slideExport.continueFromOutlineButton,
(file, reporter) => this.plugin.exportSlidesFromOutlineCommand(file, reporter)
)
);
this.actionButtons.set('slide-export-outline-generate', outlineButton);
this.actionButtons.set('slide-export-outline-continue', continueButton);
this.slideExportEnvironmentPanelEl = parent.createDiv({ cls: 'notemd-slide-export-env-panel is-hidden' });
this.syncSlideExportOutlineControls();
}
private createPrimarySlideExportActionButton(
parent: HTMLElement,
className: string,
label: string,
tooltip: string,
onClick: () => Promise<void>
): HTMLButtonElement {
return this.appendSlideExportActionButton(parent, [className, 'mod-cta', 'is-primary'], label, tooltip, onClick);
}
private createSecondarySlideExportActionButton(
parent: HTMLElement,
className: string,
label: string,
tooltip: string,
onClick: () => Promise<void>
): HTMLButtonElement {
return this.appendSlideExportActionButton(parent, [className, 'notemd-slide-export-secondary-button'], label, tooltip, onClick);
}
private appendSlideExportActionButton(
parent: HTMLElement,
classNames: string[],
label: string,
tooltip: string,
onClick: () => Promise<void>
): HTMLButtonElement {
const classes = ['notemd-action-button', ...classNames];
const button = parent.createEl('button', {
text: label,
cls: classes.join(' ')
}) as HTMLButtonElement;
button.title = tooltip;
button.onclick = onClick;
return button;
}
private resolveContainingDetails(element: HTMLElement): (HTMLElement & { open?: boolean }) | null {
const parentElement = element.parentElement ?? (element as any).parent ?? null;
if (!parentElement || !('open' in parentElement)) {
return null;
}
return parentElement as HTMLElement & { open?: boolean };
}
private createSlideExportStepButton(
parent: HTMLElement,
stepNumber: string,
label: string,
tooltip: string,
onClick: () => Promise<void>
): HTMLButtonElement {
const button = parent.createEl('button', {
cls: 'notemd-action-button notemd-slide-export-step-button'
}) as HTMLButtonElement;
button.title = tooltip;
button.onclick = onClick;
button.createEl('span', { text: stepNumber, cls: 'notemd-slide-export-step-index' });
button.createEl('span', { text: label, cls: 'notemd-slide-export-step-label' });
return button;
}
private syncSlideExportOutlineControls() {
this.slideExportOutlineToggleButton?.setAttr('aria-checked', this.slideExportOutlineMode ? 'true' : 'false');
if (this.slideExportOutlineMode) {
this.slideExportOutlineToggleButton?.addClass('is-enabled');
this.slideExportDirectActionsEl?.addClass('is-hidden');
this.slideExportDirectActionsEl?.setAttr('aria-hidden', 'true');
this.slideExportOutlineActionsEl?.removeClass('is-hidden');
this.slideExportOutlineActionsEl?.setAttr('aria-hidden', 'false');
return;
}
this.slideExportOutlineToggleButton?.removeClass('is-enabled');
this.slideExportDirectActionsEl?.removeClass('is-hidden');
this.slideExportDirectActionsEl?.setAttr('aria-hidden', 'false');
this.slideExportOutlineActionsEl?.addClass('is-hidden');
this.slideExportOutlineActionsEl?.setAttr('aria-hidden', 'true');
}
private getActiveMarkdownFileForSlideExport(): TFile {
const activeFile = this.app.workspace.getActiveFile();
if (!activeFile || !(activeFile instanceof TFile) || activeFile.extension !== 'md') {
throw new Error(this.getStrings().notices.noActiveMarkdownFileSelected);
}
return activeFile;
}
private async runSlideExportFileAction(
label: string,
operation: (file: TFile, reporter: ProgressReporter) => Promise<unknown>
): Promise<void> {
const i18n = this.getStrings();
if (this.isProcessing || this.plugin.getIsBusy()) {
new Notice(i18n.notices.processingAlreadyRunning);
return;
}
this.openSlideExportControlsSection();
this.startProcessing(formatI18n(i18n.sidebar.status.runningAction, { label }));
const reporter = this.createReporterProxy();
try {
const file = this.getActiveMarkdownFileForSlideExport();
await operation(file, reporter);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const failureMessage = formatI18n(i18n.sidebar.status.actionFailed, { message });
this.log(failureMessage);
this.updateStatus(failureMessage, -1);
} finally {
this.finishProcessing();
}
}
async runSlideExportEnvironmentProbe(): Promise<void> {
const i18n = this.getStrings();
if (this.isProcessing || this.plugin.getIsBusy()) {
new Notice(i18n.notices.processingAlreadyRunning);
return;
}
this.openSlideExportControlsSection();
this.startProcessing(i18n.slideExport.probingEnvironment);
this.renderSlideExportEnvironmentLoading();
const reporter = this.createReporterProxy();
try {
reporter.log(i18n.slideExport.probingEnvironment);
const { probeEnvironment, getVaultBasePath } = await import('../slideExport');
const vaultRoot = getVaultBasePath(this.app);
const report = await probeEnvironment(vaultRoot ? [vaultRoot] : []);
this.renderSlideExportEnvironmentReport(report);
this.logSlideExportEnvironmentSummary(report);
this.updateStatus(i18n.slideExport.environmentCheckComplete, 100);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.renderSlideExportEnvironmentFailure(message);
this.log(formatI18n(i18n.slideExport.environmentCheckFailed, { message }));
this.updateStatus(formatI18n(i18n.slideExport.environmentCheckFailed, { message }), -1);
} finally {
this.finishProcessing();
}
}
private openSlideExportControlsSection() {
if (this.slideExportControlsSectionEl) {
this.slideExportControlsSectionEl.open = true;
}
}
private renderSlideExportEnvironmentLoading() {
const i18n = this.getStrings();
const panel = this.ensureSlideExportEnvironmentPanel();
panel.empty();
panel.removeClass('is-hidden');
panel.createEl('div', { text: i18n.slideExport.environmentCheckingTitle, cls: 'notemd-slide-export-env-title' });
panel.createEl('p', { text: i18n.slideExport.environmentCheckingDesc, cls: 'notemd-slide-export-env-desc' });
}
private renderSlideExportEnvironmentFailure(message: string) {
const i18n = this.getStrings();
const panel = this.ensureSlideExportEnvironmentPanel();
panel.empty();
panel.removeClass('is-hidden');
panel.createEl('div', { text: i18n.slideExport.environmentCheckFailedTitle, cls: 'notemd-slide-export-env-title' });
panel.createEl('p', {
text: formatI18n(i18n.slideExport.environmentCheckFailed, { message }),
cls: 'notemd-slide-export-env-desc is-error'
});
}
private renderSlideExportEnvironmentReport(report: EnvironmentReport) {
const i18n = this.getStrings();
const panel = this.ensureSlideExportEnvironmentPanel();
panel.empty();
panel.removeClass('is-hidden');
const availableCount = SLIDE_EXPORT_FORMATS.filter(format => report.capabilities[format]).length;
const titleRow = panel.createDiv({ cls: 'notemd-slide-export-env-heading' });
titleRow.createEl('div', { text: i18n.slideExport.environmentReportTitle, cls: 'notemd-slide-export-env-title' });
titleRow.createEl('div', {
text: formatI18n(i18n.slideExport.environmentCapabilitySummary, { count: availableCount, total: SLIDE_EXPORT_FORMATS.length }),
cls: 'notemd-slide-export-env-badge'
});
if (!report.isDesktop) {
panel.createEl('p', {
text: i18n.slideExport.mobileUnsupportedError,
cls: 'notemd-slide-export-env-desc is-error'
});
return;
}
const toolGrid = panel.createDiv({ cls: 'notemd-slide-export-env-tool-grid' });
this.renderSlideExportToolRow(toolGrid, 'node', 'Node.js', report.node, report);
this.renderSlideExportToolRow(toolGrid, 'slidev', 'Slidev CLI', report.slidev, report);
this.renderSlideExportToolRow(toolGrid, 'playwright', 'Playwright Chromium', report.playwright, report);
this.renderSlideExportToolRow(toolGrid, 'ffmpeg', 'ffmpeg', report.ffmpeg, report);
const capabilityList = panel.createDiv({ cls: 'notemd-slide-export-env-capabilities' });
const capabilityLabels: Record<NotemdSettings['slideExportDefaultFormat'], string> = {
html: 'HTML',
pdf: 'PDF',
png: 'PNG',
pptx: 'PPTX',
mp4: 'MP4'
};
for (const format of SLIDE_EXPORT_FORMATS) {
const item = capabilityList.createDiv({
cls: report.capabilities[format]
? 'notemd-slide-export-env-capability is-available'
: 'notemd-slide-export-env-capability is-unavailable'
});
item.createEl('span', {
text: report.capabilities[format] ? i18n.slideExport.availableShort : i18n.slideExport.unavailableShort,
cls: 'notemd-slide-export-env-capability-state'
});
item.createEl('span', { text: capabilityLabels[format] });
}
}
private renderSlideExportToolRow(
parent: HTMLElement,
tool: ProbeResult['tool'],
label: string,
result: ProbeResult,
report: EnvironmentReport
) {
const i18n = this.getStrings();
const guidance = this.getSlideExportToolGuidance(tool, report);
const row = parent.createDiv({
cls: result.installed
? 'notemd-slide-export-env-tool is-installed'
: 'notemd-slide-export-env-tool is-missing'
});
const header = row.createDiv({ cls: 'notemd-slide-export-env-tool-header' });
header.createEl('span', { text: label, cls: 'notemd-slide-export-env-tool-name' });
header.createEl('span', {
text: result.installed ? i18n.slideExport.installedStatus : i18n.slideExport.missingStatus,
cls: 'notemd-slide-export-env-tool-status'
});
row.createEl('div', {
text: result.installed
? (result.version || i18n.slideExport.availableShort)
: (result.error || i18n.slideExport.missingStatus),
cls: 'notemd-slide-export-env-tool-detail'
});
if (!result.installed) {
const command = row.createEl('code', {
text: guidance.command,
cls: 'notemd-slide-export-env-command'
});
command.setAttr('data-command', guidance.command);
const footer = row.createDiv({ cls: 'notemd-slide-export-env-tool-footer' });
const copyButton = footer.createEl('button', {
text: i18n.slideExport.copyInstallCommand,
cls: 'notemd-slide-export-env-copy-button'
}) as HTMLButtonElement;
copyButton.onclick = () => this.copySlideExportInstallCommand(guidance.command);
const link = footer.createEl('a', {
text: guidance.websiteLabel,
cls: 'notemd-slide-export-env-link'
});
link.setAttr('href', guidance.websiteUrl);
link.setAttr('target', '_blank');
link.setAttr('rel', 'noopener noreferrer');
if (tool === 'slidev' || tool === 'playwright') {
const installButton = footer.createEl('button', {
text: i18n.slideExport.installToolButton,
cls: 'notemd-slide-export-env-install-button'
}) as HTMLButtonElement;
installButton.onclick = () => this.installSlideExportTool(tool);
}
}
}
private getSlideExportToolGuidance(tool: ProbeResult['tool'], report: EnvironmentReport): { command: string; websiteUrl: string; websiteLabel: string } {
const i18n = this.getStrings();
switch (tool) {
case 'node':
return {
command: 'node --version',
websiteUrl: 'https://nodejs.org/en/download',
websiteLabel: i18n.slideExport.nodeWebsiteLabel
};
case 'slidev':
return {
command: this.getSlidevForkInstallCommand(report.platform),
websiteUrl: NOTEMD_SLIDEV_FORK_RELEASE_URL,
websiteLabel: i18n.slideExport.slidevWebsiteLabel
};
case 'playwright':
return {
command: 'npx playwright install chromium',
websiteUrl: 'https://playwright.dev/docs/intro',
websiteLabel: i18n.slideExport.playwrightWebsiteLabel
};
case 'ffmpeg':
return {
command: FFMPEG_INSTALL_HINTS[report.platform] || FFMPEG_INSTALL_HINTS.unknown,
websiteUrl: 'https://ffmpeg.org/download.html',
websiteLabel: i18n.slideExport.ffmpegWebsiteLabel
};
}
}
private copySlideExportInstallCommand(command: string): void {
const i18n = this.getStrings();
navigator.clipboard
.writeText(command)
.then(
() => new Notice(i18n.slideExport.copyInstallCommandSuccess),
() => new Notice(i18n.slideExport.copyInstallCommandFailed)
);
}
private async installSlideExportTool(tool: 'slidev' | 'playwright'): Promise<void> {
const i18n = this.getStrings();
if (this.isProcessing || this.plugin.getIsBusy()) {
new Notice(i18n.notices.processingAlreadyRunning);
return;
}
this.startProcessing(formatI18n(i18n.slideExport.installingTool, {
tool: tool === 'slidev' ? 'Slidev CLI' : 'Playwright Chromium'
}));
const reporter = this.createReporterProxy();
try {
const { installSlidevForVault, autoInstallPlaywright, probeEnvironment, getVaultBasePath } = await import('../slideExport');
const vaultRoot = getVaultBasePath(this.app);
let result;
if (tool === 'slidev') {
if (!vaultRoot) {
throw new Error('Vault filesystem path is unavailable; open a local vault before installing the NoteMD Slidev fork.');
}
result = await installSlidevForVault(vaultRoot, (phase, detail) => reporter.log(detail ? `${phase}: ${detail}` : phase));
} else {
result = await autoInstallPlaywright((phase, detail) => reporter.log(detail ? `${phase}: ${detail}` : phase));
}
if (result.exitCode !== 0) {
throw new Error(result.stderr || result.error?.message || i18n.common.unknownError);
}
reporter.log(i18n.slideExport.installComplete);
const report = await probeEnvironment(vaultRoot ? [vaultRoot] : []);
this.renderSlideExportEnvironmentReport(report);
this.logSlideExportEnvironmentSummary(report);
this.updateStatus(i18n.slideExport.environmentCheckComplete, 100);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
reporter.log(formatI18n(i18n.slideExport.installFailed, { message }));
this.updateStatus(formatI18n(i18n.slideExport.installFailed, { message }), -1);
} finally {
this.finishProcessing();
}
}
private getSlidevForkInstallCommand(platform: EnvironmentReport['platform']): string {
const vaultRoot = this.getVaultRootForInstallCommand();
if (!vaultRoot) {
return NOTEMD_SLIDEV_INSTALL_COMMAND;
}
const cdCommand = platform === 'win32'
? `cd /d ${quoteWindowsCommandPath(vaultRoot)}`
: `cd ${quotePosixShellPath(vaultRoot)}`;
return `${cdCommand}\n${NOTEMD_SLIDEV_INSTALL_COMMAND}`;
}
private getVaultRootForInstallCommand(): string | null {
const adapter = this.app?.vault?.adapter as any;
if (typeof adapter?.getBasePath === 'function') {
return adapter.getBasePath();
}
return typeof adapter?.basePath === 'string' ? adapter.basePath : null;
}
private ensureSlideExportEnvironmentPanel(): HTMLElement {
if (this.slideExportEnvironmentPanelEl) {
return this.slideExportEnvironmentPanelEl;
}
throw new Error('Slide export environment panel is not mounted.');
}
private logSlideExportEnvironmentSummary(report: EnvironmentReport) {
const i18n = this.getStrings();
const missingTools = [report.node, report.slidev, report.playwright, report.ffmpeg]
.filter(result => !result.installed)
.map(result => result.tool);
const availableFormats = SLIDE_EXPORT_FORMATS.filter(format => report.capabilities[format]).join(', ') || i18n.slideExport.noneShort;
this.log(formatI18n(i18n.slideExport.environmentAvailableFormats, { formats: availableFormats }));
if (missingTools.length > 0) {
this.log(formatI18n(i18n.slideExport.environmentMissingTools, { tools: missingTools.join(', ') }));
}
}
private buildDiagramIntentSelector(parent: HTMLElement) {
const i18n = this.getStrings();
const row = parent.createDiv({ cls: 'notemd-inline-control' });
row.createEl('label', { text: i18n.settings.developer.experimentalDiagramPipeline.intentName, cls: 'notemd-inline-label' });
const selector = row.createEl('select', { cls: 'notemd-language-select' });
const intents = [
{ value: 'auto', label: i18n.settings.developer.experimentalDiagramPipeline.intentAuto },
{ value: 'flowchart', label: i18n.settings.developer.experimentalDiagramPipeline.intentFlowchart },
{ value: 'sequence', label: i18n.settings.developer.experimentalDiagramPipeline.intentSequence },
{ value: 'classDiagram', label: i18n.settings.developer.experimentalDiagramPipeline.intentClassDiagram },
{ value: 'erDiagram', label: i18n.settings.developer.experimentalDiagramPipeline.intentErDiagram },
{ value: 'stateDiagram', label: i18n.settings.developer.experimentalDiagramPipeline.intentStateDiagram },
{ value: 'circuit', label: i18n.settings.developer.experimentalDiagramPipeline.intentCircuit },
{ value: 'dataChart', label: i18n.settings.developer.experimentalDiagramPipeline.intentDataChart },
];
intents.forEach(item => {
selector.add(new Option(item.label, item.value));
});
selector.value = this.plugin.settings.preferredDiagramIntent || 'auto';
selector.onchange = async () => {
const newValue = selector.value === 'auto' ? undefined : selector.value;
this.plugin.settings.preferredDiagramIntent = newValue;
await this.plugin.saveSettings();
};
const targetRow = parent.createDiv({ cls: 'notemd-inline-control' });
targetRow.createEl('label', {
text: i18n.settings.developer.experimentalDiagramPipeline.renderTargetName,
cls: 'notemd-inline-label'
});
const targetSelector = targetRow.createEl('select', { cls: 'notemd-language-select' });
const renderTargets = [
{ value: 'auto', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetAuto },
{ value: 'mermaid', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetMermaid },
{ value: 'json-canvas', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetJsonCanvas },
{ value: 'vega-lite', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetVegaLite },
{ value: 'html', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetHtml },
{ value: 'editable-html-svg', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetEditableHtmlSvg },
{ value: 'drawio', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetDrawio },
{ value: 'drawnix', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetDrawnix },
{ value: 'circuitikz', label: i18n.settings.developer.experimentalDiagramPipeline.renderTargetCircuitikz },
];
renderTargets.forEach(item => {
targetSelector.add(new Option(item.label, item.value));
});
targetSelector.value = this.plugin.settings.preferredDiagramRenderTarget || 'auto';
targetSelector.onchange = async () => {
this.plugin.settings.preferredDiagramRenderTarget = targetSelector.value === 'auto'
? undefined
: targetSelector.value as RenderTarget;
await this.plugin.saveSettings();
};
}
async onOpen() {
const i18n = this.getStrings();
const container = this.containerEl.children[1] as HTMLElement;
container.empty();
container.addClass('notemd-sidebar-container');
const shell = container.createDiv({ cls: 'notemd-sidebar-shell' });
const scrollArea = shell.createDiv({ cls: 'notemd-sidebar-scroll' });
const footer = shell.createDiv({ cls: 'notemd-sidebar-footer mod-docked' });
const footerScroll = footer.createDiv({ cls: 'notemd-sidebar-footer-scroll' });
const hero = scrollArea.createDiv({ cls: 'notemd-hero-card' });
hero.createEl('h3', { text: i18n.sidebar.heroTitle });
hero.createEl('p', { text: i18n.sidebar.heroDesc });
const workflowResolution = resolveCustomWorkflowButtons(this.plugin.settings.customWorkflowButtonsDsl);
const quickBody = this.createSection(
scrollArea,
i18n.sidebar.quickWorkflowTitle,
i18n.sidebar.quickWorkflowDesc,
true
);
workflowResolution.buttons.forEach(buttonConfig => {
const workflowName = getLocalizedWorkflowButtonName(i18n, buttonConfig.name);
const workflowClasses = ['notemd-action-button', 'notemd-workflow-button'];
if (isSingleFileWorkflow(buttonConfig)) {
workflowClasses.push('mod-cta', 'is-primary');
}
const workflowButton = quickBody.createEl('button', {
text: workflowName,
cls: workflowClasses.join(' ')
});
workflowButton.dataset.category = 'workflow';
workflowButton.title = buttonConfig.actions.map(actionId => getSidebarActionLabel(i18n, actionId)).join(' > ');
workflowButton.onclick = async () => {
await this.runCustomWorkflow(buttonConfig);
};
this.workflowButtons.push(workflowButton);
});
if (workflowResolution.usedFallback && workflowResolution.errors.length > 0) {
const warn = quickBody.createDiv({ cls: 'notemd-workflow-warning' });
warn.setText(formatI18n(i18n.sidebar.workflowFallbackWarning, { count: workflowResolution.errors.length }));
}
const actionsByCategory = new Map<ActionCategory, Array<typeof SIDEBAR_ACTION_DEFINITIONS[number]>>();
SIDEBAR_ACTION_DEFINITIONS.forEach(def => {
const existing = actionsByCategory.get(def.category) || [];
existing.push(def);
actionsByCategory.set(def.category, existing);
});
(Object.keys(ACTION_CATEGORY_CONFIG) as ActionCategory[]).forEach(category => {
const defs = actionsByCategory.get(category) || [];
if (defs.length === 0) {
return;
}
const categoryTitle = i18n.sidebar.sectionTitles[category];
const body = this.createSection(
scrollArea,
categoryTitle,
formatI18n(i18n.sidebar.builtInActionsPrefix, { category: categoryTitle }),
ACTION_CATEGORY_CONFIG[category].openByDefault
);
if (category === 'export') {
this.buildSlideExportControls(body);
} else {
defs.forEach(def => {
this.createActionButton(body, def.id, def.category);
});
}
if (category === 'generation') {
this.buildDiagramIntentSelector(body);
}
if (category === 'translation') {
this.buildLanguageSelector(body);
}
});
const progressArea = footerScroll.createDiv({ cls: 'notemd-progress-area is-idle' });
this.progressAreaEl = progressArea;
const progressMeta = progressArea.createDiv({ cls: 'notemd-progress-meta' });
this.statusEl = progressMeta.createEl('p', { text: i18n.common.ready, cls: 'notemd-status-text' });
this.progressValueEl = progressMeta.createEl('span', { text: i18n.common.ready, cls: 'notemd-progress-value is-idle' });
this.apiLivenessRowEl = progressArea.createDiv({ cls: 'notemd-api-liveness is-idle' });
this.apiLivenessRowEl.createEl('span', { cls: 'notemd-api-liveness-dot' });
this.apiLivenessTextEl = this.apiLivenessRowEl.createEl('span', {
text: i18n.common.standby,
cls: 'notemd-api-liveness-text'
});
this.apiActivityEl = progressArea.createDiv({ cls: 'notemd-api-activity' });
const apiActivityHeader = this.apiActivityEl.createDiv({ cls: 'notemd-api-activity-header' });
apiActivityHeader.createEl('span', { text: i18n.sidebar.apiActivityTitle, cls: 'notemd-api-activity-title' });
const copyApiActivityButton = apiActivityHeader.createEl('button', {
text: i18n.sidebar.copyApiActivity,
cls: 'notemd-copy-api-activity-button'
});
copyApiActivityButton.onclick = () => {
const report = this.buildApiActivityReport(i18n);
if (!report) {
new Notice(i18n.sidebar.apiActivityEmpty);
return;
}
navigator.clipboard
.writeText(report)
.then(
() => new Notice(i18n.sidebar.copyApiActivitySuccess),
() => new Notice(i18n.sidebar.copyApiActivityFailed)
);
};
this.apiActivityContentEl = this.apiActivityEl.createDiv({ cls: 'notemd-api-activity-content' });
this.apiActivityEmptyEl = this.apiActivityContentEl.createEl('p', {
text: i18n.sidebar.apiActivityEmpty,
cls: 'notemd-api-activity-empty'
});
this.apiActivityActiveSectionEl = this.apiActivityContentEl.createDiv({ cls: 'notemd-api-activity-section is-hidden' });
this.apiActivityActiveSectionEl.createEl('div', {
text: i18n.sidebar.apiActivityActiveSection,
cls: 'notemd-api-activity-section-title'
});
this.apiActivityActiveListEl = this.apiActivityActiveSectionEl.createDiv({ cls: 'notemd-api-activity-list' });
this.apiActivityRecentSectionEl = this.apiActivityContentEl.createDiv({ cls: 'notemd-api-activity-section is-hidden' });
this.apiActivityRecentSectionEl.createEl('div', {
text: i18n.sidebar.apiActivityRecentSection,
cls: 'notemd-api-activity-section-title'
});
this.apiActivityRecentListEl = this.apiActivityRecentSectionEl.createDiv({ cls: 'notemd-api-activity-list' });
this.progressBarContainerEl = progressArea.createEl('div', { cls: 'notemd-progress-bar-container mod-sidebar is-idle' });
this.progressEl = this.progressBarContainerEl.createEl('div', { cls: 'notemd-progress-bar-fill' });
this.timeRemainingEl = progressArea.createEl('p', { text: i18n.common.standby, cls: 'notemd-time-remaining' });
this.cancelButton = progressArea.createEl('button', { text: i18n.sidebar.cancelProcessing, cls: 'notemd-cancel-button' });
this.cancelButton.onclick = () => this.requestCancel();
const logCard = footerScroll.createDiv({ cls: 'notemd-log-card mod-persistent' });
const logHeader = logCard.createDiv({ cls: 'notemd-log-header' });
logHeader.createEl('h5', { text: i18n.sidebar.logOutputTitle });
const logHeaderActions = logHeader.createDiv({ cls: 'notemd-log-header-actions' });
const debugToggleLabel = logHeaderActions.createEl('label', { cls: 'notemd-debug-toggle' });
const debugToggleInput = debugToggleLabel.createEl('input', {
cls: 'notemd-debug-toggle-input',
type: 'checkbox'
}) as HTMLInputElement;
debugToggleInput.checked = this.plugin.settings.enableApiErrorDebugMode;
debugToggleInput.onchange = async () => {
this.plugin.settings.enableApiErrorDebugMode = debugToggleInput.checked;
await this.plugin.saveSettings();
};
debugToggleLabel.createEl('span', { text: i18n.sidebar.quickDeepDebugToggle });
const copyLogButton = logHeaderActions.createEl('button', { text: i18n.sidebar.copyLog, cls: 'notemd-copy-log-button' });
copyLogButton.onclick = () => {
if (this.logContent.length > 0) {
navigator.clipboard
.writeText(this.logContent.join('\n'))
.then(() => new Notice(i18n.sidebar.copyLogSuccess), () => new Notice(i18n.sidebar.copyLogFailed));
} else {
new Notice(i18n.sidebar.logEmpty);
}
};
this.logEl = logCard.createEl('div', { cls: 'notemd-log-output is-selectable mod-sidebar' });
this.clearDisplay();
}
async onClose() {
this.statusEl = null;
this.progressAreaEl = null;
this.progressEl = null;
this.progressBarContainerEl = null;
this.progressValueEl = null;
this.timeRemainingEl = null;
this.apiLivenessRowEl = null;
this.apiLivenessTextEl = null;
this.apiActivityEl = null;
this.apiActivityContentEl = null;
this.apiActivityEmptyEl = null;
this.apiActivityActiveSectionEl = null;
this.apiActivityActiveListEl = null;
this.apiActivityRecentSectionEl = null;
this.apiActivityRecentListEl = null;
this.logEl = null;
this.cancelButton = null;
this.languageSelector = null;
this.clearApiLivenessTimer();
this.slideExportFormatSelector = null;
this.slideExportOutlineToggleButton = null;
this.slideExportDirectActionsEl = null;
this.slideExportOutlineActionsEl = null;
this.slideExportControlsSectionEl = null;
this.slideExportEnvironmentPanelEl = null;
this.expandedApiActivityRequestIds.clear();
this.actionButtons.clear();
this.workflowButtons = [];
}
}