feat(plugin): extract modals to src/views/modals.ts

This commit is contained in:
Research Assistant 2026-05-24 17:11:19 +08:00
parent 58543286aa
commit efc3cc126c
2 changed files with 841 additions and 0 deletions

View file

@ -60,7 +60,9 @@ export const ACTIONS: ActionDef[] = [
export interface PaperForgeSettings {
python_path: string;
vault_path: string;
setup_complete: boolean;
auto_update: boolean;
auto_update_on_startup: boolean;
features: Record<string, boolean>;
frozen_skills: Record<string, string>;
@ -68,6 +70,14 @@ export interface PaperForgeSettings {
resources_dir: string;
literature_dir: string;
base_dir: string;
agent_platform: string;
language: string;
paddleocr_api_key: string;
zotero_data_dir: string;
selected_skill_platform: string;
vector_db_api_key: string;
vector_db_api_base: string;
vector_db_api_model: string;
_python_path_stale?: boolean;
[key: string]: unknown;
}

View file

@ -0,0 +1,831 @@
import { Modal, App, Notice } from "obsidian";
import * as fs from "fs";
import * as path from "path";
import * as https from "https";
import { execFile, spawn } from "child_process";
import { t } from "../i18n";
import { PaperForgeSettings } from "../constants";
import { resolveVaultPaths, getCachedPython } from "../services/memory-state";
import { resolvePythonExecutable, resolveGitDir, paperforgeEnrichedEnv } from "../services/python-bridge";
import type { PythonResult } from "../services/python-bridge";
// ── Interfaces ──
export interface IPluginRef {
settings: PaperForgeSettings;
saveSettings(): Promise<void>;
loadSettings(): Promise<void>;
manifest: { version: string };
}
export interface ISettingTab {
display(): void;
}
interface OrphanItem {
citation_key?: string;
key: string;
has_pdf?: boolean;
collection_path?: string;
title?: string;
authors?: string;
year?: string;
}
/* ── OCR Privacy Modal ── */
export class PaperForgeOcrPrivacyModal extends Modal {
private _onConfirm: (() => void) | undefined;
constructor(app: App, onConfirm?: () => void) {
super(app);
this._onConfirm = onConfirm;
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('paperforge-modal');
contentEl.addClass('paperforge-ocr-privacy-modal');
// Title
contentEl.createEl('h2', { text: t('ocr_privacy_title') });
// Warning text
const warningEl = contentEl.createEl('div', { cls: 'paperforge-ocr-privacy-warning' });
warningEl.createEl('p', { text: t('ocr_privacy_warning') });
// "I Understand" button
const btnRow = contentEl.createEl('div', { cls: 'paperforge-ocr-privacy-actions' });
const confirmBtn = btnRow.createEl('button', {
cls: 'paperforge-step-btn mod-cta',
text: t('ocr_understand'),
});
confirmBtn.addEventListener('click', () => {
this.close();
if (this._onConfirm) this._onConfirm();
});
}
onClose() {
this.contentEl.empty();
}
}
/* ── Orphan Paper Cleanup Modal ── */
export class PaperForgeOrphanModal extends Modal {
private orphans: (OrphanItem & { _selected: boolean; _idx: number })[];
private vaultPath: string;
private py: PythonResult | null;
private _rowEls: HTMLElement[] = [];
private _countEl!: HTMLElement;
private _selectAllBtn!: HTMLElement;
constructor(app: App, orphans: OrphanItem[], vaultPath: string, py: PythonResult | null) {
super(app);
this.orphans = orphans.map((o, i) => ({ ...o, _selected: true, _idx: i }));
this.vaultPath = vaultPath;
this.py = py;
}
_updateUI() {
const sel = this.orphans.filter(o => o._selected);
this._countEl.setText(t('orphan_delete_selected').replace('{count}', String(sel.length)));
this._selectAllBtn.setText(sel.length === this.orphans.length ? t('orphan_deselect_all') : t('orphan_select_all'));
for (const o of this.orphans) {
const row = this._rowEls[o._idx];
if (!row) continue;
row.toggleClass('paperforge-orphan-dimmed', !o._selected);
}
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('paperforge-modal');
contentEl.createEl('h2', { text: t('orphan_title').replace('{count}', String(this.orphans.length)) });
contentEl.createEl('p', {
cls: 'paperforge-modal-desc',
text: t('orphan_desc')
});
this._rowEls = [];
const listEl = contentEl.createEl('div', { cls: 'paperforge-orphan-list' });
for (const o of this.orphans) {
const row = listEl.createEl('div', { cls: 'paperforge-orphan-row' + (o._selected ? '' : ' paperforge-orphan-dimmed') });
this._rowEls.push(row);
const left = row.createEl('div', { cls: 'paperforge-orphan-info' });
// Header: citation key + tags
const hdr = left.createEl('div', { cls: 'paperforge-orphan-header' });
hdr.createEl('span', { cls: 'paperforge-orphan-key', text: o.citation_key || o.key });
const tags = hdr.createEl('span', { cls: 'paperforge-orphan-tags' });
tags.createEl('span', { cls: 'paperforge-tag ' + (o.has_pdf ? 'tag-pdf' : 'tag-nopdf'), text: o.has_pdf ? 'PDF' : 'no PDF' });
if (o.collection_path) tags.createEl('span', { cls: 'paperforge-tag tag-collection', text: o.collection_path });
if (o.title) left.createEl('div', { cls: 'paperforge-orphan-title', text: o.title });
const meta = [];
if (o.authors) meta.push(o.authors);
if (o.year) meta.push(o.year);
if (meta.length > 0) left.createEl('div', { cls: 'paperforge-orphan-meta', text: meta.join(' \u00B7 ') });
left.createEl('div', { cls: 'paperforge-orphan-explain', text: t('orphan_explain') });
row.addEventListener('click', () => {
o._selected = !o._selected;
this._updateUI();
});
}
const btnRow = contentEl.createEl('div', { cls: 'paperforge-modal-actions' });
this._selectAllBtn = btnRow.createEl('button', { cls: 'paperforge-step-btn', text: 'Deselect all' });
this._selectAllBtn.addEventListener('click', () => {
const allSel = this.orphans.every(o => o._selected);
for (const o of this.orphans) o._selected = !allSel;
this._updateUI();
});
this._countEl = btnRow.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: 'Delete ' + this.orphans.length + ' selected' });
btnRow.createEl('button', { cls: 'paperforge-step-btn', text: 'Keep all' }).addEventListener('click', () => this.close());
this._countEl.addEventListener('click', () => {
const selected = this.orphans.filter(o => o._selected);
if (selected.length === 0) { new Notice(t('orphan_none_selected')); return; }
this._countEl.setText('Deleting...');
this._countEl.setAttr('disabled', '');
this._selectAllBtn.setAttr('disabled', '');
if (!this.py || !this.py.path) { new Notice('PaperForge: Python not found'); this.close(); return; }
const keys = selected.map(o => o.key);
execFile(this.py.path, [...this.py.extraArgs, '-m', 'paperforge', '--vault', this.vaultPath, 'prune', '--force', '--json', ...keys],
{ cwd: this.vaultPath, timeout: 60000 }, (err, stdout) => {
if (err) { new Notice('PaperForge: prune failed'); this.close(); return; }
try {
const r = JSON.parse(stdout);
const deleted = (r.data && r.data.deleted) || [];
new Notice('Deleted ' + deleted.length + ' orphan workspace(s)');
} catch (_) { new Notice('PaperForge: prune done'); }
this.close();
});
});
}
onClose() {
this.contentEl.empty();
}
}
/* ── Orphan state checker ── */
export function checkOrphanState(app: App, plugin: IPluginRef, vp: string) {
console.log('[PF] checkOrphanState called');
try {
const paths = resolveVaultPaths(vp);
const orphanPath = paths.orphanStatePath;
if (!fs.existsSync(orphanPath)) {
console.log('[PF] orphan file NOT FOUND');
return;
}
console.log('[PF] orphan file FOUND');
const raw = fs.readFileSync(orphanPath, 'utf-8');
const data = JSON.parse(raw);
const orphans = data.orphans || [];
console.log('[PF] orphans count:', orphans.length);
if (orphans.length === 0) return;
const py = getCachedPython(vp, plugin.settings);
console.log('[PF] py.path:', py ? py.path : 'null');
new PaperForgeOrphanModal(app, orphans, vp, py).open();
fs.unlinkSync(orphanPath);
console.log('[PF] orphan file cleaned');
} catch (e) {
console.log('[PF] checkOrphanState exception:', (e as Error).message || e);
}
}
/* ── Setup Wizard Modal ── */
export class PaperForgeSetupModal extends Modal {
private plugin: IPluginRef;
private _step: number;
private _installLog!: HTMLElement;
private _pendingSave: ReturnType<typeof setTimeout> | null = null;
private _apiKeyValidated!: boolean;
private _apiKeyStatus!: HTMLElement;
constructor(app: App, plugin: IPluginRef) {
super(app);
this.plugin = plugin;
this._step = 1;
}
onOpen() {
this._render();
}
onClose() {
this.contentEl.empty();
}
_render() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('paperforge-modal');
this._renderStepIndicator();
this._renderStepContent();
this._renderNavigation();
}
_renderStepIndicator() {
const steps = [t('wizard_step1'), t('wizard_step2'), t('wizard_step3'), t('wizard_step4'), t('wizard_step5')];
const bar = this.contentEl.createEl('div', { cls: 'paperforge-step-bar' });
steps.forEach((label, i) => {
const n = i + 1;
const dot = bar.createEl('div', {
cls: `paperforge-step-dot ${n === this._step ? 'active' : ''} ${n < this._step ? 'done' : ''}`
});
dot.createEl('span', { cls: 'paperforge-step-num', text: `${n}` });
dot.createEl('span', { cls: 'paperforge-step-label', text: label });
});
}
_renderStepContent() {
const el = this.contentEl.createEl('div', { cls: 'paperforge-step-content' });
switch (this._step) {
case 1: this._stepOverview(el); break;
case 2: this._stepDirectories(el); break;
case 3: this._stepKeys(el); break;
case 4: this._stepInstall(el); break;
case 5: this._stepComplete(el); break;
}
}
_renderNavigation() {
const nav = this.contentEl.createEl('div', { cls: 'paperforge-step-nav' });
if (this._step > 1) {
nav.createEl('button', { cls: 'paperforge-step-btn', text: t('nav_prev') })
.addEventListener('click', () => { this._step--; this._render(); });
}
if (this._step < 5) {
const nextBtn = nav.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: t('nav_next') });
nextBtn.addEventListener('click', () => {
if (this._step === 3 && !this._validateStep3()) return;
this._step++;
this._render();
});
} else {
nav.createEl('button', { cls: 'paperforge-step-btn', text: t('nav_close') })
.addEventListener('click', () => this.close());
}
}
_validateStep3() {
const s = this.plugin.settings;
// Check API key validated
if (!this._apiKeyValidated) {
new Notice('\u8BF7\u5148\u9A8C\u8BC1 PaddleOCR API \u5BC6\u94A5');
return false;
}
// Check Zotero data directory: required + exists + is directory + has storage/
if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) {
new Notice('Zotero \u6570\u636E\u76EE\u5F55\u4E3A\u5FC5\u586B\u9879\uFF0C\u8BF7\u586B\u5199\u8DEF\u5F84');
return false;
}
const zotPath = s.zotero_data_dir.trim();
if (!fs.existsSync(zotPath)) {
new Notice('Zotero \u6570\u636E\u76EE\u5F55\u8DEF\u5F84\u4E0D\u5B58\u5728');
return false;
}
if (!fs.statSync(zotPath).isDirectory()) {
new Notice('Zotero \u6570\u636E\u76EE\u5F55\u8DEF\u5F84\u4E0D\u662F\u4E00\u4E2A\u76EE\u5F55');
return false;
}
const storagePath = path.join(zotPath, 'storage');
if (!fs.existsSync(storagePath) || !fs.statSync(storagePath).isDirectory()) {
new Notice('Zotero \u6570\u636E\u76EE\u5F55\u4E2D\u672A\u627E\u5230 storage/ \u5B50\u76EE\u5F55');
return false;
}
return true;
}
/* ── Step 1: Overview ── */
_stepOverview(el: HTMLElement) {
el.createEl('h2', { text: t('wizard_title') });
el.createEl('p', { text: t('wizard_intro') });
const s = this.plugin.settings;
const vault = (this.app.vault.adapter as any).basePath;
const tree = el.createEl('div', { cls: 'paperforge-dir-tree' });
// HARDEN-05: Use createEl() DOM API instead of innerHTML to prevent XSS
// from user-configured directory names containing HTML/script tags.
const rootNode = tree.createEl('div', { cls: 'paperforge-dir-node root' });
rootNode.textContent = `\uD83D\uDCC1 Vault (${vault})`;
const children = tree.createEl('div', { cls: 'paperforge-dir-children' });
const resourcesFolder = children.createEl('div', { cls: 'paperforge-dir-node folder' });
resourcesFolder.textContent = `\uD83D\uDCC1 ${s.resources_dir || 'Resources'}/ \u2014 \u6587\u732E\u5361\u7247\u76EE\u5F55\uFF08Base \u6570\u636E\u6765\u6E90\uFF09`;
const resourcesChildren = resourcesFolder.createEl('div', { cls: 'paperforge-dir-children' });
resourcesChildren.createEl('div', { cls: 'paperforge-dir-node file',
text: `\uD83D\uDCC1 ${s.literature_dir || 'Literature'}/ \u2014 \u6587\u732E\u5361\u7247` });
children.createEl('div', { cls: 'paperforge-dir-node folder',
text: `\uD83D\uDCC1 ${s.base_dir || 'Bases'}/ \u2014 \u6570\u636E\u7BA1\u7406\u9762\u677F` });
children.createEl('div', { cls: 'paperforge-dir-node folder',
text: `\uD83D\uDCC1 ${s.system_dir || 'System'}/ \u2014 Zotero \u8F6F\u94FE\u63A5 + PaperForge \u7CFB\u7EDF\u6587\u4EF6\u5939` });
el.createEl('p', { text: t('wizard_preview'), cls: 'paperforge-modal-hint' });
el.createEl('p', { text: t('wizard_safety'), cls: 'paperforge-modal-hint' });
const summary = el.createEl('div', { cls: 'paperforge-summary' });
const overviewItems = [
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir || 'Resources'}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir || 'Resources'}/${s.literature_dir || 'Literature'}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir || 'Bases'}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir || 'System'}` },
];
for (const item of overviewItems) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
}
/* ── Step 2: Directory Config (editable) ── */
_stepDirectories(el: HTMLElement) {
el.createEl('h2', { text: t('wizard_step2') });
el.createEl('p', { text: t('wizard_intro') });
const s = this.plugin.settings;
const vault = (this.app.vault.adapter as any).basePath;
this._modalField(el, t('dir_vault'), vault, true);
el.createEl('p', { text: t('wizard_dir_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '\u8D44\u6E90\u76EE\u5F55\uFF08\u521B\u5EFA\u6587\u732E\u5361\u7247\u76EE\u5F55\u7684\u5730\u65B9\uFF09', 'resources_dir', s.resources_dir, 'Resources');
el.createEl('p', { text: t('wizard_dir_sub_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '\u6587\u732E\u5361\u7247\u76EE\u5F55\uFF08\u5B58\u653E\u6587\u732E\u5361\u7247\u7684\u5730\u65B9\uFF0CBase \u6570\u636E\u6765\u6E90\uFF09', 'literature_dir', s.literature_dir, 'Literature');
el.createEl('p', { text: t('wizard_sys_hint'), cls: 'paperforge-modal-hint' });
this._modalInput(el, '\u7CFB\u7EDF\u76EE\u5F55\uFF08\u5B58\u653E Zotero \u8F6F\u94FE\u63A5\u548C PaperForge \u7CFB\u7EDF\u6587\u4EF6\uFF09', 'system_dir', s.system_dir, 'System');
this._modalInput(el, 'Base \u76EE\u5F55\uFF08\u5B58\u653E\u6570\u636E\u7BA1\u7406\u9762\u677F\u7684\u5730\u65B9\uFF09', 'base_dir', s.base_dir, 'Bases');
el.createEl('p', { text: t('wizard_safety'), cls: 'paperforge-modal-hint' });
const preview = el.createEl('div', { cls: 'paperforge-summary' });
const previewItems = [
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir || ''}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir || ''}/${s.literature_dir || ''}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir || ''}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir || ''}` },
];
for (const item of previewItems) {
const row = preview.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
}
/* ── Step 3: Keys, Zotero & Agent ── */
_stepKeys(el: HTMLElement) {
el.createEl('h2', { text: t('wizard_step3') });
const s = this.plugin.settings;
el.createEl('p', { text: t('wizard_agent_hint'), cls: 'paperforge-modal-hint' });
const AGENTS = [
{ key: 'opencode', name: 'OpenCode' },
{ key: 'claude', name: 'Claude Code' },
{ key: 'cursor', name: 'Cursor' },
{ key: 'github_copilot', name: 'GitHub Copilot' },
{ key: 'windsurf', name: 'Windsurf' },
{ key: 'codex', name: 'Codex' },
{ key: 'cline', name: 'Cline' },
];
const agentRow = el.createEl('div', { cls: 'paperforge-modal-field' });
agentRow.createEl('label', { cls: 'paperforge-modal-label', text: t('label_agent') });
const select = agentRow.createEl('select', { cls: 'paperforge-modal-select' });
for (const a of AGENTS) {
const opt = select.createEl('option', { text: a.name, attr: { value: a.key } });
if (a.key === (s.agent_platform || 'opencode')) opt.selected = true;
}
select.addEventListener('change', () => {
s.agent_platform = select.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
});
el.createEl('p', { text: t('wizard_keys_hint'), cls: 'paperforge-modal-hint' });
// PaddleOCR API Key with validate button
const apiRow = el.createEl('div', { cls: 'paperforge-modal-field' });
apiRow.createEl('label', { cls: 'paperforge-modal-label', text: t('field_paddleocr') });
const apiInput = apiRow.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'password', placeholder: 'API Key' }
});
apiInput.value = s.paddleocr_api_key || '';
this._apiKeyValidated = false;
this._apiKeyStatus = apiRow.createEl('span', { cls: 'paperforge-apikey-status', text: '' });
const validateBtn = apiRow.createEl('button', { cls: 'paperforge-step-btn', text: '\u9A8C\u8BC1' });
validateBtn.addEventListener('click', () => this._validateApiKey(apiInput.value, validateBtn));
apiInput.addEventListener('input', () => {
s.paddleocr_api_key = apiInput.value;
this._apiKeyValidated = false;
this._apiKeyStatus.textContent = '';
this._apiKeyStatus.className = 'paperforge-apikey-status';
});
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
// Zotero data directory (now required)
const zotRow = el.createEl('div', { cls: 'paperforge-modal-field' });
zotRow.createEl('label', { cls: 'paperforge-modal-label', text: t('field_zotero_data') });
const zotInput = zotRow.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'text', placeholder: t('field_zotero_placeholder') }
});
zotInput.value = s.zotero_data_dir || '';
zotInput.addEventListener('input', () => {
s.zotero_data_dir = zotInput.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => { this.plugin.saveSettings(); this._pendingSave = null; }, 500);
});
}
_validateApiKey(key: string, btn: HTMLButtonElement) {
if (!key || key.length < 10) {
this._apiKeyStatus.textContent = '\u5BC6\u94A5\u683C\u5F0F\u4E0D\u6B63\u786E';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
return;
}
btn.disabled = true;
btn.textContent = '\u9A8C\u8BC1\u4E2D\u2026';
this._apiKeyStatus.textContent = '\u6B63\u5728\u9A8C\u8BC1\u2026';
this._apiKeyStatus.className = 'paperforge-apikey-status';
const postData = JSON.stringify({ model: 'PaddleOCR-VL-1.5' });
const options = {
hostname: 'paddleocr.aistudio-app.com',
path: '/api/v2/ocr/jobs',
method: 'POST',
headers: {
'Authorization': 'bearer ' + key,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: 10000,
} as https.RequestOptions;
const req = https.request(options, (res) => {
btn.disabled = false;
btn.textContent = '\u9A8C\u8BC1';
let body = '';
res.on('data', (chunk) => body += chunk);
res.on('end', () => {
try {
const json = JSON.parse(body);
if (res.statusCode === 400 && json.code === 10001) {
// 400 code=10001 = auth passed, file missing (expected)
this._apiKeyStatus.textContent = '\u2713 \u5BC6\u94A5\u6709\u6548';
this._apiKeyStatus.className = 'paperforge-apikey-status ok';
this._apiKeyValidated = true;
} else if (res.statusCode === 401) {
this._apiKeyStatus.textContent = '\u9A8C\u8BC1\u5931\u8D25\uFF1A\u5BC6\u94A5\u65E0\u6548';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
} else {
this._apiKeyStatus.textContent = '\u9A8C\u8BC1\u5931\u8D25\uFF1AAPI \u8FD4\u56DE ' + res.statusCode;
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
}
} catch (e) {
this._apiKeyStatus.textContent = '\u9A8C\u8BC1\u5931\u8D25\uFF1A\u65E0\u6CD5\u89E3\u6790\u54CD\u5E94';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
}
});
});
req.on('error', (e) => {
btn.disabled = false;
btn.textContent = '\u9A8C\u8BC1';
this._apiKeyStatus.textContent = '\u9A8C\u8BC1\u5931\u8D25\uFF1A\u65E0\u6CD5\u8FDE\u63A5 (' + e.message + ')';
this._apiKeyStatus.className = 'paperforge-apikey-status error';
this._apiKeyValidated = false;
});
req.write(postData);
req.end();
}
/* ── Modal form helpers ── */
_modalField(el: HTMLElement, label: string, value: string, disabled: boolean) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', { cls: 'paperforge-modal-input', attr: { type: 'text' } });
input.value = value;
input.disabled = !!disabled;
}
_modalInput(el: HTMLElement, label: string, key: string, value: string, placeholder: string) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'text', placeholder: placeholder || '' }
});
input.value = value;
const settings = this.plugin.settings;
input.addEventListener('input', () => {
settings[key] = input.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => {
this.plugin.saveSettings();
this._pendingSave = null;
}, 500);
});
}
_modalSecret(el: HTMLElement, label: string, key: string, value: string, placeholder: string) {
const row = el.createEl('div', { cls: 'paperforge-modal-field' });
row.createEl('label', { cls: 'paperforge-modal-label', text: label });
const input = row.createEl('input', {
cls: 'paperforge-modal-input',
attr: { type: 'password', placeholder: placeholder || '' }
});
input.value = value;
const settings = this.plugin.settings;
input.addEventListener('input', () => {
settings[key] = input.value;
if (this._pendingSave) clearTimeout(this._pendingSave);
this._pendingSave = setTimeout(() => {
this.plugin.saveSettings();
this._pendingSave = null;
}, 500);
});
}
/* ── Step 4: Install ── */
_stepInstall(el: HTMLElement) {
el.createEl('h2', { text: t('wizard_step4') });
this._installLog = el.createEl('div', { cls: 'paperforge-install-log' });
const startBtn = el.createEl('button', { cls: 'paperforge-step-btn mod-cta', text: t('install_btn') });
startBtn.addEventListener('click', () => this._runInstall(startBtn));
}
async _runInstall(btn: HTMLButtonElement) {
btn.disabled = true;
btn.textContent = t('install_btn_running');
this._installLog.setText(t('install_validating') + '\n');
this._log(t('install_validating'));
const s = this.plugin.settings;
const errors = this._validate();
if (errors.length > 0) {
this._log(t('validate_fail') + ':');
errors.forEach(e => this._log(' \u2717 ' + e));
btn.disabled = false;
btn.textContent = t('install_btn_retry');
return;
}
const runPython = (args: string[], options: any = {}) => new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
const { path: pyExe, extraArgs: pyExtra = [] } = resolvePythonExecutable(s.vault_path.trim(), this.plugin.settings, undefined, undefined);
const child = spawn(pyExe, [...pyExtra, ...args], {
cwd: s.vault_path.trim(),
env: paperforgeEnrichedEnv(),
timeout: 120000,
...options,
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
const text = data.toString('utf-8');
stdout += text;
if (options.logStdout) this._processSetupOutput(text);
});
child.stderr.on('data', (data) => {
const text = data.toString('utf-8');
stderr += text;
this._log('[stderr] ' + text.trim());
});
child.on('close', (code) => {
code === 0 ? resolve({ stdout, stderr }) : reject(new Error(stderr.trim() || stdout.trim() || `exit code ${code}`));
});
child.on('error', (err) => reject(err));
});
const setupArgs = [
'-m', 'paperforge',
'--vault', s.vault_path.trim(),
'setup', '--headless',
'--system-dir', s.system_dir.trim(),
'--resources-dir', s.resources_dir.trim(),
'--literature-dir', s.literature_dir.trim(),
'--base-dir', s.base_dir.trim(),
'--agent', s.agent_platform || 'opencode',
];
if (s.zotero_data_dir && s.zotero_data_dir.trim()) setupArgs.push('--zotero-data', s.zotero_data_dir.trim());
if (s.paddleocr_api_key && s.paddleocr_api_key.trim()) setupArgs.push('--paddleocr-key', s.paddleocr_api_key.trim());
try {
let hasPaperforge = true;
try {
await runPython(['-c', 'import paperforge']);
} catch {
hasPaperforge = false;
}
if (!hasPaperforge) {
this._log(t('install_bootstrapping'));
const ver = this.plugin.manifest.version;
this._log(`[install] Trying PyPI: pip install paperforge==${ver}`);
const pypiArgs = ['-m', 'pip', 'install', '--upgrade'];
if (process.platform !== 'win32') pypiArgs.push('--user');
pypiArgs.push(`paperforge==${ver}`);
try {
await runPython(pypiArgs, { logStdout: true });
} catch (pypiErr) {
this._log(`[install] PyPI failed, falling back to git: git+https://...@v${ver}`);
console.warn('[PaperForge] PyPI install failed, falling back to git:', (pypiErr as Error).message?.slice(0, 200));
const gitArgs = ['-m', 'pip', 'install', '--upgrade'];
if (process.platform !== 'win32') gitArgs.push('--user');
gitArgs.push(`git+https://github.com/LLLin000/PaperForge.git@v${ver}`);
await runPython(gitArgs, { logStdout: true });
}
}
await runPython(setupArgs, {
logStdout: true,
env: paperforgeEnrichedEnv(),
});
this._log(t('install_complete'));
s.setup_complete = true;
await this.plugin.saveSettings();
setTimeout(() => { this._step = 5; this._render(); }, 800);
} catch (err) {
console.error('PaperForge setup failed:', (err as Error).message);
const errorMsg = this._formatSetupError((err as Error).message);
this._log(t('install_failed') + errorMsg);
// Add "Copy diagnostic" button
const diagBtn = this._installLog.parentElement?.createEl('button', {
cls: 'paperforge-copy-diag-btn',
text: t('error_copy_diagnostic') || 'Copy diagnostic',
});
if (diagBtn) {
const rawError = (err as Error).message;
const pyInfo = this.plugin?.settings?.python_path || 'auto';
const pluginVer = this.plugin?.manifest?.version || '?';
const osInfo = process.platform + ' ' + process.arch;
let gitDir, resolvedPy;
try { gitDir = resolveGitDir() || '(not found)'; } catch (_) { gitDir = '(error)'; }
try { resolvedPy = resolvePythonExecutable(s.vault_path.trim(), this.plugin.settings, undefined, undefined); } catch (_) { resolvedPy = null; }
const pathLen = (process.env.PATH || '').length;
const pathHasGit = (process.env.PATH || '').toLowerCase().includes('git');
const diagnostic = [
'[PaperForge Diagnostic]',
'Category: ' + errorMsg,
'Plugin version: ' + pluginVer,
'Python: ' + pyInfo,
'Resolved Python: ' + ((resolvedPy as any)?.path || '?'),
'OS: ' + osInfo,
'Vault path: ' + (s.vault_path || '?'),
'--- Git ---',
'Git dir (resolved): ' + gitDir,
'PATH length: ' + pathLen + ' chars',
'PATH contains git: ' + pathHasGit,
'--- Raw error ---',
rawError.slice(0, 2000),
].join('\n');
diagBtn.addEventListener('click', () => {
navigator.clipboard.writeText(diagnostic).then(() => {
diagBtn.setText(t('error_copied') || 'Copied!');
setTimeout(() => {
diagBtn.setText(t('error_copy_diagnostic') || 'Copy diagnostic');
}, 3000);
}).catch(() => {
new Notice('[!!] Clipboard write failed', 6000);
});
});
}
btn.disabled = false;
btn.textContent = t('install_btn_retry');
}
}
_log(msg: string) {
if (this._installLog) {
this._installLog.setText(this._installLog.textContent + msg + '\n');
}
}
_validate() {
const errors: string[] = [];
const s = this.plugin.settings;
if (!s.vault_path || !s.vault_path.trim()) errors.push(t('validate_vault'));
if (!s.resources_dir || !s.resources_dir.trim()) errors.push(t('validate_resources'));
if (!s.literature_dir || !s.literature_dir.trim()) errors.push(t('validate_notes'));
if (!s.base_dir || !s.base_dir.trim()) errors.push(t('validate_base'));
if (!s.paddleocr_api_key || !s.paddleocr_api_key.trim()) this._log(' ! ' + t('validate_key') + ' ' + t('optional_later'));
if (!s.zotero_data_dir || !s.zotero_data_dir.trim()) this._log(' ! ' + t('validate_zotero') + ' ' + t('optional_later'));
return errors;
}
_processSetupOutput(text: string) {
const lines = text.split('\n').filter(Boolean);
for (const line of lines) {
if (line.includes('[*]') || line.includes('[OK]') || line.includes('[FAIL]')) {
const clean = line.replace(/^\[\*\].*\d+:?\s*/, '').replace(/^\[OK\]\s*/, '').replace(/^\[FAIL\]\s*/, '');
this._log(' ' + clean);
}
}
}
_formatSetupError(raw: string) {
if (process.platform === 'darwin' && /No module named ['"]?paperforge/i.test(raw)) {
return 'PaperForge not installed \u2014 install Python from Homebrew or python.org (Apple CLT /Library/Developer/CommandLineTools python often fails); then: python3 -m pip install --user git+https://github.com/LLLin000/PaperForge.git';
}
const patterns = [
// New: pip not found (before generic command not found)
{ match: /pip.*not found|No module named.*pip|command not found.*pip/i, msg: 'pip not found' },
// Existing: Python not found (keep broad)
{ match: /command not found|No such file|not recognized/i, msg: 'Python not found' },
// New: Network error (DNS resolution, connection refused, etc.)
{ match: /resolve host|getaddrinfo.*nodename|connect ETIMEDOUT|connect ECONNREFUSED|fetch failed|Network error|ENOTFOUND|ECONNREFUSED|ECONNRESET/i, msg: 'Network error' },
// New: SSL certificate
{ match: /certificate verify failed|SSL.*certificate|self.signed.cert|CERTIFICATE_VERIFY_FAILED/i, msg: 'SSL certificate error' },
// New: Disk full
{ match: /No space left on device|disk full|ENOSPC/i, msg: 'Disk full' },
// Existing: PaperForge not installed
{ match: /paperforge.*not found|cannot import|ModuleNotFoundError|No module named/i, msg: 'PaperForge not installed' },
// Existing + New: Permission denied (expand pattern)
{ match: /permission denied|EACCES|EPERM/i, msg: 'Permission denied' },
// Existing: Path not found
{ match: /ENOENT/i, msg: 'Path not found' },
// Existing: Timeout
{ match: /timeout|timed out/i, msg: 'Timeout' },
];
for (const p of patterns) { if (p.match.test(raw)) return p.msg; }
const fallback = raw.split('\n').filter(Boolean).slice(0, 3).join(' | ');
return fallback.slice(0, 200) || 'Unknown error';
}
/* ── Step 5: Complete ── */
_stepComplete(el: HTMLElement) {
el.createEl('h2', { text: t('complete_title') });
const summary = el.createEl('div', { cls: 'paperforge-summary' });
summary.createEl('div', { cls: 'paperforge-summary-title', text: t('complete_summary') });
const s = this.plugin.settings;
const vault = (this.app.vault.adapter as any).basePath;
const items = [
{ label: t('dir_vault'), val: vault },
{ label: t('dir_resources'), val: `${vault}/${s.resources_dir}` },
{ label: t('dir_notes'), val: `${vault}/${s.resources_dir}/${s.literature_dir}` },
{ label: t('dir_base'), val: `${vault}/${s.base_dir}` },
{ label: t('dir_system'), val: `${vault}/${s.system_dir}` },
{ label: 'API Key', val: s.paddleocr_api_key ? t('api_key_set') : t('api_key_missing') },
{ label: t('field_zotero_data'), val: s.zotero_data_dir || t('not_set') },
];
for (const item of items) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
// PaperForge version (fetched async)
const verRow = summary.createEl('div', { cls: 'paperforge-summary-row' });
verRow.createEl('span', { cls: 'paperforge-summary-label', text: 'PaperForge' });
const verVal = verRow.createEl('span', { cls: 'paperforge-summary-value', text: '\u2014' });
{
const vp = vault;
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.plugin.settings, undefined, undefined);
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
if (!err && stdout) verVal.textContent = 'v' + stdout.trim();
});
}
for (const item of items) {
const row = summary.createEl('div', { cls: 'paperforge-summary-row' });
row.createEl('span', { cls: 'paperforge-summary-label', text: item.label });
row.createEl('span', { cls: 'paperforge-summary-value', text: item.val });
}
el.createEl('h3', { text: t('complete_next') });
const nextList = el.createEl('div', { cls: 'paperforge-nextsteps' });
const steps: [string, string][] = [
[t('complete_step4'), t('complete_step4_desc')],
['', `${t('complete_export_path')} ${vault}/${s.system_dir}/PaperForge/exports/`],
[t('complete_step1'), t('complete_step1_desc')],
[t('complete_step2'), t('complete_step2_desc')],
[t('complete_step3'), t('complete_step3_desc')],
];
for (const [title, desc] of steps) {
const item = nextList.createEl('div', { cls: 'paperforge-nextstep-item' });
if (title) item.createEl('strong', { text: title });
item.createEl('span', { text: desc });
}
}
}