mirror of
https://github.com/lllin000/PaperForge.git
synced 2026-07-22 06:50:53 +00:00
feat(plugin): migrate monolithic main.js to modular TypeScript source tree
Extract 4977-line main.js into 9 focused TypeScript modules under src/: - src/main.ts (thin lifecycle entry, ~324 lines) - src/services/memory-state.ts, python-bridge.ts - src/views/dashboard.ts, modals.ts - src/settings.ts, constants.ts, i18n.ts - src/utils/disclosure.ts Build with esbuild → main.js (CJS). All 48 tests passing.
This commit is contained in:
commit
6c096fd49c
23 changed files with 7394 additions and 5533 deletions
|
|
@ -1,157 +1,56 @@
|
|||
---
|
||||
phase: 02-code-review
|
||||
reviewed: 2026-05-14T12:00:00Z
|
||||
depth: deep
|
||||
phase: 02-code-review-command
|
||||
reviewed: 2026-05-24T09:10:00Z
|
||||
depth: standard
|
||||
files_reviewed: 1
|
||||
files_reviewed_list:
|
||||
- paperforge/memory/schema.py
|
||||
- paperforge/plugin/package.json
|
||||
findings:
|
||||
critical: 0
|
||||
warning: 3
|
||||
info: 2
|
||||
total: 5
|
||||
status: issues_found
|
||||
warning: 0
|
||||
info: 0
|
||||
total: 0
|
||||
status: clean
|
||||
---
|
||||
|
||||
# Phase 02: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-05-14T12:00:00Z
|
||||
**Depth:** deep
|
||||
**Reviewed:** 2026-05-24T09:10:00Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 1
|
||||
**Status:** issues_found
|
||||
**Status:** clean
|
||||
|
||||
## Summary
|
||||
|
||||
Reviewed Task 1 implementation: addition of `CREATE_READING_LOG` and `CREATE_PROJECT_LOG` SQL table definitions to `paperforge/memory/schema.py`. The implementation faithfully follows the plan specification with correct SQL syntax, proper registration in `ensure_schema()`, correct `ALL_TABLES` inclusion, and `CURRENT_SCHEMA_VERSION` bumped from 1 to 2. All 5 existing schema unit tests pass. No critical defects were introduced.
|
||||
Reviewed Task 1 implementation (commit `8982c67`) — installation of TypeScript + esbuild build system dependencies in `paperforge/plugin/package.json`, as specified in [plugin-ts-migration plan](docs/superpowers/plans/2026-05-24-plugin-ts-migration.md).
|
||||
|
||||
However, three warnings were identified: (1) the foreign key from `reading_log` to `papers` will create a deletion-ordering conflict in `builder.py` once the table is populated (future Task 6 concern), (2) the column name `date` in `project_log` shadows a SQLite built-in function name, and (3) the foreign key column `reading_log.paper_id` lacks an index, which will cause full table scans on lookup queries.
|
||||
**Result: All reviewed files meet quality standards. No issues found.**
|
||||
|
||||
## Warnings
|
||||
### What was verified
|
||||
|
||||
### WR-01: Foreign Key Will Block `DELETE FROM papers` During Rebuild
|
||||
| Check | Result |
|
||||
|-------|--------|
|
||||
| **Plan alignment** | EXACT match — every version, script, and dependency matches the plan verbatim |
|
||||
| **Scripts added** | `dev` and `build` — exactly as specified |
|
||||
| **DevDeps added** | `typescript ^5.4.0`, `esbuild ^0.25.0`, `builtin-modules ^3.3.0`, `@types/node ^20.0.0` |
|
||||
| **Existing deps preserved** | `vitest`, `obsidian-test-mocks`, `jsdom`, `obsidian` — untouched |
|
||||
| **`package-lock.json`** | Committed alongside package.json (659 insertions, 120 deletions) |
|
||||
| **`npm install`** | Clean — all 4 packages installed, `npm ci --dry-run` reports "up to date" |
|
||||
| **`npm audit`** | 0 vulnerabilities (dev-only deps are expected security-wise) |
|
||||
| **Installed versions** | TypeScript 5.9.3, esbuild 0.25.12, builtin-modules 3.3.0, @types/node 20.19.41 |
|
||||
| **Commit message** | `"chore(plugin): add esbuild, typescript, @types/node devDeps"` — matches plan |
|
||||
| **No extra files** | Only `package.json` and `package-lock.json` were modified |
|
||||
| **Version reasonableness** | All ranges are stable and compatible with the planned tech stack |
|
||||
|
||||
**File:** `paperforge/memory/schema.py:153` (FK definition), `paperforge/memory/builder.py:84` (DELETE caller)
|
||||
### Specific concerns checked and cleared
|
||||
|
||||
**Issue:** The `reading_log` table defines `FOREIGN KEY (paper_id) REFERENCES papers(zotero_key)` without `ON DELETE CASCADE`. During non-schema-change rebuilds in `builder.py`, the code executes `DELETE FROM papers` (line 84) WITHOUT first clearing `reading_log`. Once Task 6 populates `reading_log` from JSONL, this DELETE will fail with an `IntegrityError` because SQLite enforces foreign key constraints on DML operations when `PRAGMA foreign_keys=ON` (which `db.py:34` explicitly enables).
|
||||
|
||||
Currently latent because `reading_log` is never populated (Task 6 has not been implemented). Will surface during the Task 6 rebuild flow.
|
||||
|
||||
**Fix:**
|
||||
|
||||
In `builder.py`, clear `reading_log` (and `project_log`) before deleting `papers`:
|
||||
|
||||
```python
|
||||
# In builder.py, before the DELETE FROM papers (line 84), add:
|
||||
conn.execute("DELETE FROM reading_log;")
|
||||
conn.execute("DELETE FROM project_log;")
|
||||
```
|
||||
|
||||
Alternatively, add `ON DELETE CASCADE` to the foreign key definition in `schema.py`:
|
||||
```python
|
||||
FOREIGN KEY (paper_id) REFERENCES papers(zotero_key) ON DELETE CASCADE
|
||||
```
|
||||
|
||||
The first approach (explicit DELETE) is preferred because it makes the rebuild flow explicit and doesn't silently cascade deletions that could surprise maintainers.
|
||||
- **`esbuild.config.mjs` not yet existing**: Expected — it will be added in Task 3. The scripts reference it preemptively, which is by design.
|
||||
- **Single-dash `-noEmit` / `-skipLibCheck`**: Intentional — matches the official Obsidian sample plugin convention, as specified in the plan.
|
||||
- **`builtin-modules` necessity**: Required by the esbuild config (Task 3) to extern Node.js built-ins at bundle time. Correct inclusion.
|
||||
- **`@types/node` scope**: `^20.0.0` correctly restricts to Node 20.x LTS types. No version creep risk.
|
||||
|
||||
---
|
||||
|
||||
### WR-02: Column Name `date` Shadows SQLite Built-in Function
|
||||
|
||||
**File:** `paperforge/memory/schema.py:161`
|
||||
|
||||
**Issue:** The `project_log` table uses `date` as a column name (line 161: `date TEXT NOT NULL`). While syntactically valid in SQLite (which allows function names as unquoted identifiers), `date` is a built-in SQL function. This creates ambiguity when reading queries — `SELECT date FROM project_log` works but `SELECT date(created_at) FROM project_log` is ambiguous. More importantly, if this schema is ever ported to another SQL dialect (PostgreSQL, MySQL), `date` is a reserved word and will require quoting.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Rename the column to `log_date`, `entry_date`, or `recorded_date`:
|
||||
|
||||
```sql
|
||||
-- In CREATE_PROJECT_LOG:
|
||||
log_date TEXT NOT NULL,
|
||||
```
|
||||
|
||||
Note: This would also require updating the plan's Task 2 (`permanent.py`) and Task 6 (`builder.py`) where the column is referenced. If changing the schema column name is too invasive for this phase, consider adding a comment noting the potential ambiguity.
|
||||
|
||||
---
|
||||
|
||||
### WR-03: Missing Index on Foreign Key Column `reading_log.paper_id`
|
||||
|
||||
**File:** `paperforge/memory/schema.py:142`
|
||||
|
||||
**Issue:** The `reading_log` table has a foreign key on `paper_id` (line 142), but no index is created for this column. All queries filtering by `paper_id` (e.g., `get_reading_notes_for_paper()` in `permanent.py`, paper-context lookup) will require full table scans. As the reading log grows, this will degrade query performance.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Add an index alongside the existing `EVENT_INDEX_SQL` block (following the established pattern in the file):
|
||||
|
||||
```python
|
||||
# After EVENT_INDEX_SQL (line 137), add:
|
||||
READING_LOG_INDEX_SQL = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_reading_log_paper ON reading_log(paper_id);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reading_log_project ON reading_log(project);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_reading_log_created ON reading_log(created_at);",
|
||||
]
|
||||
```
|
||||
|
||||
And register in `ensure_schema()` after the `EVENT_INDEX_SQL` loop:
|
||||
|
||||
```python
|
||||
for idx_sql in READING_LOG_INDEX_SQL:
|
||||
conn.execute(idx_sql)
|
||||
```
|
||||
|
||||
Also add a project-level index for `project_log`:
|
||||
|
||||
```python
|
||||
PROJECT_LOG_INDEX_SQL = [
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_log_project ON project_log(project);",
|
||||
"CREATE INDEX IF NOT EXISTS idx_project_log_created ON project_log(created_at);",
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: Test Hardcodes Stale Schema Version
|
||||
|
||||
**File:** `tests/unit/memory/test_schema.py:73`
|
||||
|
||||
**Issue:** The test `test_get_schema_version_returns_stored_value` inserts `schema_version = '1'` and asserts the returned value equals 1. Since `CURRENT_SCHEMA_VERSION` was bumped to 2, the hardcoded '1' no longer matches the current version. While the test is verifying read-back behavior (not version equality), the stale value could confuse future maintainers who assume the test reflects the current schema version.
|
||||
|
||||
**Fix:**
|
||||
|
||||
Either update to a version-independent test or clarify with a comment:
|
||||
|
||||
```python
|
||||
# Test uses arbitrary version '1' for read-back verification (not tied to CURRENT_SCHEMA_VERSION)
|
||||
conn.execute(
|
||||
"INSERT INTO meta (key, value) VALUES ('schema_version', '1')"
|
||||
)
|
||||
conn.commit()
|
||||
assert get_schema_version(conn) == 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### IN-02: `ALL_TABLES` Ordering — Child Tables After Parent (Latent DDL Risk)
|
||||
|
||||
**File:** `paperforge/memory/schema.py:175`
|
||||
|
||||
**Issue:** The `ALL_TABLES` list orders `papers` (index 1) before its child tables `paper_assets`, `paper_aliases`, `paper_events`, and now `reading_log` (indices 2-7). In `drop_all_tables()`, SQLite with `PRAGMA foreign_keys=ON` will reject `DROP TABLE papers` if any child table contains rows referencing papers. This is currently benign because `drop_all_tables()` is only called when schema versions mismatch — at which point the new `reading_log` table hasn't been populated yet. However, if `drop_all_tables` is ever called on a populated database, the ordering would cause a failure.
|
||||
|
||||
**Fix (if desired — pre-existing, not introduced by this change):**
|
||||
|
||||
Reorder `ALL_TABLES` so child tables (FK-referencing) appear before parent tables (FK-referenced):
|
||||
|
||||
```python
|
||||
ALL_TABLES = ["paper_fts", "reading_log", "project_log", "paper_events", "paper_aliases", "paper_assets", "papers", "meta"]
|
||||
```
|
||||
|
||||
Note: `paper_fts` is a virtual table and cannot have FK constraints, so its position is flexible.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-05-14T12:00:00Z_
|
||||
_Reviewer: the agent (gsd-code-reviewer)_
|
||||
_Depth: deep_
|
||||
_Reviewed: 2026-05-24T09:10:00Z_
|
||||
_Reviewer: VT-OS/OPENCODE (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
|
|
|||
1514
docs/superpowers/plans/2026-05-24-plugin-ts-migration.md
Normal file
1514
docs/superpowers/plans/2026-05-24-plugin-ts-migration.md
Normal file
File diff suppressed because it is too large
Load diff
32
paperforge/plugin/esbuild.config.mjs
Normal file
32
paperforge/plugin/esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import esbuild from "esbuild";
|
||||
import builtins from "builtin-modules";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins,
|
||||
],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
771
paperforge/plugin/package-lock.json
generated
771
paperforge/plugin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,8 @@
|
|||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
|
@ -11,6 +13,10 @@
|
|||
"vitest": "^2.1.0",
|
||||
"obsidian-test-mocks": "^2.0.0",
|
||||
"jsdom": "^25.0.0",
|
||||
"obsidian": "^1.12.0"
|
||||
"obsidian": "^1.12.0",
|
||||
"typescript": "^5.4.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"builtin-modules": "^3.3.0",
|
||||
"@types/node": "^20.0.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
132
paperforge/plugin/src/constants.ts
Normal file
132
paperforge/plugin/src/constants.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// ── View type, icon, SVG ──
|
||||
|
||||
export const VIEW_TYPE_PAPERFORGE = "paperforge-status";
|
||||
export const PF_ICON_ID = "paperforge";
|
||||
export const PF_RIBBON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path><line x1="8" y1="7" x2="16" y2="7"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>`;
|
||||
|
||||
// ── Action definitions ──
|
||||
|
||||
export interface ActionDef {
|
||||
id: string;
|
||||
title: string;
|
||||
desc?: string;
|
||||
icon?: string;
|
||||
cmd: string;
|
||||
args?: string[];
|
||||
needsKey?: boolean;
|
||||
needsFilter?: boolean;
|
||||
okMsg?: string;
|
||||
disabled?: boolean;
|
||||
disabledMsg?: string;
|
||||
}
|
||||
|
||||
export const ACTIONS: ActionDef[] = [
|
||||
{
|
||||
id: "paperforge-sync",
|
||||
title: "Sync Library",
|
||||
desc: "Pull new references from Zotero and generate literature notes",
|
||||
icon: "\u21BB",
|
||||
cmd: "sync",
|
||||
okMsg: "Sync complete",
|
||||
},
|
||||
{
|
||||
id: "paperforge-ocr",
|
||||
title: "Run OCR",
|
||||
desc: "Extract full text and figures from PDFs via PaddleOCR",
|
||||
icon: "\u229E",
|
||||
cmd: "ocr",
|
||||
okMsg: "OCR started",
|
||||
},
|
||||
{
|
||||
id: "paperforge-doctor",
|
||||
title: "Run Doctor",
|
||||
desc: "Verify PaperForge setup \u2014 check configs, Zotero, paths, and index health",
|
||||
icon: "\u2695",
|
||||
cmd: "doctor",
|
||||
okMsg: "Doctor complete",
|
||||
},
|
||||
{
|
||||
id: "paperforge-repair",
|
||||
title: "Repair Issues",
|
||||
desc: "Fix three-way state divergence, path errors, and rebuild index",
|
||||
icon: "\u21BA",
|
||||
cmd: "repair",
|
||||
args: ["--fix", "--fix-paths"],
|
||||
okMsg: "Repair complete",
|
||||
},
|
||||
];
|
||||
|
||||
// ── Settings ──
|
||||
|
||||
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>;
|
||||
system_dir: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: PaperForgeSettings = {
|
||||
vault_path: "",
|
||||
setup_complete: false,
|
||||
auto_update: true,
|
||||
auto_update_on_startup: true,
|
||||
agent_platform: "opencode",
|
||||
language: "",
|
||||
paddleocr_api_key: "",
|
||||
zotero_data_dir: "",
|
||||
python_path: "",
|
||||
features: {
|
||||
memory_layer: true,
|
||||
vector_db: false,
|
||||
},
|
||||
selected_skill_platform: "opencode",
|
||||
vector_db_api_key: "",
|
||||
vector_db_api_base: "",
|
||||
vector_db_api_model: "text-embedding-3-small",
|
||||
frozen_skills: {},
|
||||
system_dir: "",
|
||||
resources_dir: "",
|
||||
literature_dir: "",
|
||||
base_dir: "",
|
||||
};
|
||||
|
||||
// ── Workflow state helpers ──
|
||||
|
||||
export interface WorkflowState {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function overlayEntryWorkflowState(app: any, entry: any): WorkflowState {
|
||||
if (!entry || !entry.note_path) return entry;
|
||||
const noteFile = app.vault.getAbstractFileByPath(entry.note_path);
|
||||
if (!noteFile) return entry;
|
||||
const cache = app.metadataCache.getFileCache(noteFile);
|
||||
const fm = cache && cache.frontmatter;
|
||||
if (!fm) return entry;
|
||||
const merged = { ...entry };
|
||||
for (const key of ["do_ocr", "analyze", "ocr_status", "deep_reading_status"]) {
|
||||
if (Object.prototype.hasOwnProperty.call(fm, key)) merged[key] = fm[key];
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function patchEntryWorkflowState(entry: any, patch: Partial<WorkflowState>): any {
|
||||
return entry ? { ...entry, ...patch } : entry;
|
||||
}
|
||||
424
paperforge/plugin/src/i18n.ts
Normal file
424
paperforge/plugin/src/i18n.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
import { App } from "obsidian";
|
||||
|
||||
const LANG: Record<string, Record<string, string>> = {
|
||||
en: {
|
||||
action_running: 'Running ',
|
||||
api_key_missing: 'Missing',
|
||||
api_key_set: 'Entered',
|
||||
btn_install: 'Open Setup Wizard',
|
||||
btn_install_desc: 'Check whether the environment is ready, then open the step-by-step setup wizard',
|
||||
btn_reconfig: 'Reconfigure',
|
||||
btn_reconfig_desc: 'Open the setup wizard again to change directories, platform, or API keys',
|
||||
btn_validate: 'Validate',
|
||||
check_bbt_fail: 'Not detected',
|
||||
check_bbt_ok: 'Installed',
|
||||
check_python_fail: 'Not found',
|
||||
check_python_ok: 'Ready',
|
||||
check_zotero_fail: 'Not detected',
|
||||
check_zotero_ok: 'Found',
|
||||
complete_export_path: 'Save Better BibTeX JSON exports into:',
|
||||
complete_next: 'Recommended next steps',
|
||||
complete_step1: 'Open Dashboard',
|
||||
complete_step1_desc: 'Press Ctrl+P and run "PaperForge: Open Main Panel", or click the PaperForge icon in the left sidebar.',
|
||||
complete_step2: 'Sync Literature',
|
||||
complete_step2_desc: 'In the main panel, click Sync Library to bring papers from Zotero into Obsidian and generate notes.',
|
||||
complete_step3: 'Run OCR',
|
||||
complete_step3_desc: 'In the Obsidian Base view, mark do_ocr:true on papers, then run OCR in the main panel.',
|
||||
complete_step4: 'Configure Better BibTeX Auto-export',
|
||||
complete_step4_desc: 'In Zotero, right-click the library or collection you want to sync -> Export -> Better BibTeX JSON -> enable "Keep updated".',
|
||||
complete_summary: 'Saved Configuration',
|
||||
complete_title: 'Setup Complete',
|
||||
copied: 'Copied!',
|
||||
copy_pf_deep_cmd: 'Copy /pf-deep Command',
|
||||
dashboard_drift_warning: 'PaperForge CLI (v{0}) differs from plugin (v{1}). Open Settings → Runtime Health to sync.',
|
||||
deep_reading_not_found: 'Deep reading file not found',
|
||||
desc: 'Obsidian + Zotero literature pipeline. Sync papers, generate notes, run OCR, and read deeply in one place.',
|
||||
dir_base: 'Base Dir',
|
||||
dir_index: 'Index Dir',
|
||||
dir_notes: 'Notes Dir',
|
||||
dir_resources: 'Resource Dir',
|
||||
dir_system: 'System Dir',
|
||||
dir_vault: 'Vault Path',
|
||||
error_copied: 'Copied!',
|
||||
error_copy_diagnostic: 'Copy diagnostic',
|
||||
feat_agent_platform: 'Agent Platform',
|
||||
feat_agent_platform_desc: 'Select which agent platform to manage skills for.',
|
||||
feat_api_base_url: 'API Base URL',
|
||||
feat_api_base_url_desc: 'Custom OpenAI-compatible API endpoint. Leave empty for default.',
|
||||
feat_api_model: 'API Model',
|
||||
feat_api_model_desc: 'Embedding model name for this endpoint.',
|
||||
feat_build_btn: 'Build',
|
||||
feat_build_complete: 'Vector build complete.',
|
||||
feat_build_failed: 'Build failed. See terminal output.',
|
||||
feat_building: 'Building...',
|
||||
feat_cache_remove_failed: 'Failed: {0}',
|
||||
feat_cache_removed: 'Model cache removed.',
|
||||
feat_checking: 'Checking...',
|
||||
feat_checking_btn: 'Checking...',
|
||||
feat_deps_checking: 'Checking dependencies...',
|
||||
feat_deps_missing: 'Dependencies not installed. Required: chromadb, openai.',
|
||||
feat_enter_key: 'Enter a valid OpenAI API key.',
|
||||
feat_install_btn: 'Install',
|
||||
feat_install_deps: 'Install Dependencies',
|
||||
feat_install_deps_desc: 'pip install chromadb openai (~35MB).',
|
||||
feat_install_done: 'Dependencies installed. Building vectors...',
|
||||
feat_install_failed: 'Install failed: ',
|
||||
feat_installing: 'Installing...',
|
||||
feat_installing_pkgs: 'Installing {pkgs}...',
|
||||
feat_key_rejected: 'API key rejected.',
|
||||
feat_memory_desc: 'The Memory Layer is the core data engine of PaperForge, powered by SQLite. It integrates all literature metadata (papers, assets, aliases, reading events), provides FTS5 full-text search across titles/abstracts/authors/collections, and enables the agent-context and paper-status commands. Always active — no toggle needed.',
|
||||
feat_memory_rebuild_btn: 'Rebuild',
|
||||
feat_memory_rebuild_done: 'Memory DB rebuilt.',
|
||||
feat_memory_rebuild_failed: 'Rebuild failed.',
|
||||
feat_memory_rebuilding: 'Rebuilding...',
|
||||
feat_model_changed_warn: 'Model changed ({0} -> {1}). Existing vectors are incompatible — rebuild required.',
|
||||
feat_network_error: 'Network error: ',
|
||||
feat_no_python: 'No Python found. Check Installation tab.',
|
||||
feat_not_cached: 'Not cached',
|
||||
feat_openai_key: 'OpenAI API Key',
|
||||
feat_openai_key_desc: 'Used for API embedding calls. Model is defined below.',
|
||||
feat_output_copied: 'Output copied to clipboard.',
|
||||
feat_rebuild_btn: 'Rebuild',
|
||||
feat_rebuild_vectors: 'Rebuild Vectors',
|
||||
feat_rebuild_vectors_changed: 'Model changed — rebuild to update all vectors.',
|
||||
feat_rebuild_vectors_desc: 'Rebuild all OCR fulltext vectors. Required after model or mode change.',
|
||||
feat_removing: 'Removing...',
|
||||
feat_retry_btn: 'Retry',
|
||||
feat_skills_desc: 'Manage and enable/disable agent skills installed in your vault. Each row corresponds to a SKILL.md file — toggle off to prevent the agent from auto-invoking that skill.',
|
||||
feat_skills_system: 'System Skills ship with PaperForge and are updated alongside PaperForge.',
|
||||
feat_skills_user: 'User Skills are custom skills you install from community or create yourself.',
|
||||
feat_uninstall_btn: 'Uninstall',
|
||||
feat_valid_key: 'API key valid.',
|
||||
feat_vector_config_label: 'Vector Settings',
|
||||
feat_vector_corrupted: 'Vector index corrupted — needs force rebuild.',
|
||||
feat_vector_desc: 'Vector Database enables semantic search across OCR-extracted fulltext via API embedding. Documents are split into chunks, embedded via OpenAI-compatible API, and stored in ChromaDB.',
|
||||
feat_vector_enable: 'Enable Vector Retrieval',
|
||||
feat_vector_enable_desc: 'Semantic search across OCR fulltext. Requires: pip install openai chromadb (~35MB).',
|
||||
feat_vector_rebuild_force_btn: 'Force Rebuild',
|
||||
feat_verify: 'Verify',
|
||||
feat_verify_btn: 'Verify',
|
||||
field_paddleocr: 'PaddleOCR API Key',
|
||||
field_python_custom: 'Custom Path',
|
||||
field_python_interp: 'Python Interpreter',
|
||||
field_zotero_data: 'Zotero Data Dir',
|
||||
field_zotero_placeholder: 'Required. Path to Zotero data directory for PDF attachment resolution.',
|
||||
guide_ocr: 'Run OCR',
|
||||
guide_ocr_desc: 'In the main panel, click Run OCR to extract full text and figures from PDFs for later reading and analysis.',
|
||||
guide_open: 'Open Main Panel',
|
||||
guide_open_desc: 'Press Ctrl+P and run "PaperForge: Open Main Panel", or click the PaperForge icon in the left sidebar.',
|
||||
guide_sync: 'Sync Literature',
|
||||
guide_sync_desc: 'After Better BibTeX JSON export is configured, click Sync Library to import papers from Zotero into Obsidian and generate notes automatically.',
|
||||
header_title: 'PaperForge',
|
||||
install_bootstrapping: 'PaperForge Python package not found. Installing automatically...',
|
||||
install_btn: 'Start Install',
|
||||
install_btn_retry: 'Retry',
|
||||
install_btn_running: 'Installing...',
|
||||
install_complete: 'Installation complete!',
|
||||
install_failed: 'Installation failed: ',
|
||||
install_validating: 'Validating setup...',
|
||||
jump_to_deep_reading: 'Open Deep Reading',
|
||||
label_agent: 'Agent Platform',
|
||||
nav_close: 'Close',
|
||||
nav_next: 'Next',
|
||||
nav_prev: 'Back',
|
||||
not_set: 'Not entered',
|
||||
notice_check_fail: 'Missing: ',
|
||||
notice_python_missing: 'Python was not detected. Install Python 3.10+ and add it to PATH.',
|
||||
ocr_privacy_title: 'OCR Privacy Notice',
|
||||
ocr_privacy_warning: 'OCR will upload PDFs to the PaddleOCR API. Do not upload sensitive or confidential documents.',
|
||||
ocr_queue_add: 'Add to OCR Queue',
|
||||
ocr_queue_added: 'Added to OCR queue',
|
||||
ocr_queue_remove: 'Remove from OCR Queue',
|
||||
ocr_queue_removed: 'Removed from OCR queue',
|
||||
ocr_understand: 'I understand, continue',
|
||||
optional_later: '(can be set later in Settings)',
|
||||
orphan_delete_failed: 'Prune failed',
|
||||
orphan_delete_selected: 'Delete {count} selected',
|
||||
orphan_deleted: 'Deleted {count} orphan workspace(s)',
|
||||
orphan_desc: 'These papers are no longer in your Zotero library.',
|
||||
orphan_deselect_all: 'Deselect all',
|
||||
orphan_explain: 'Removed from Zotero. Workspace files remain on disk.',
|
||||
orphan_keep_all: 'Keep all',
|
||||
orphan_none_selected: 'No papers selected for deletion',
|
||||
orphan_select_all: 'Select all',
|
||||
orphan_title: 'Found {count} orphan paper(s)',
|
||||
panel_actions: 'Quick Actions',
|
||||
prep_bbt: 'Better BibTeX',
|
||||
prep_bbt_desc: 'In Zotero: Tools -> Add-ons -> install Better BibTeX.',
|
||||
prep_export: 'Better BibTeX Auto-export',
|
||||
prep_export_desc: 'In Zotero, right-click the collection you want to sync -> Export Collection -> BetterBibTeX JSON -> enable "Keep updated" -> save the JSON file into the exports folder shown below. Obsidian Base views will use the JSON filename as the Base name:',
|
||||
prep_export_path_label: 'Save the exported JSON file into this folder:',
|
||||
prep_key: 'PaddleOCR Key',
|
||||
prep_key_desc: 'Get your API key from https://aistudio.baidu.com/paddleocr',
|
||||
prep_python: 'Python 3.10+',
|
||||
prep_python_desc: 'Python must be available from the command line. If you are not sure, click below to auto-detect.',
|
||||
prep_zotero: 'Zotero Desktop',
|
||||
prep_zotero_desc: 'Install Zotero from https://www.zotero.org',
|
||||
run_in_agent: 'Run in {0}',
|
||||
runtime_health: 'Runtime Health',
|
||||
runtime_health_checking: 'Checking...',
|
||||
runtime_health_desc: 'Check whether the installed paperforge Python package matches the plugin version',
|
||||
runtime_health_match: 'Match',
|
||||
runtime_health_mismatch: 'Mismatch',
|
||||
runtime_health_package_ver: 'Python package v{0}',
|
||||
runtime_health_plugin_ver: 'Plugin v{0}',
|
||||
runtime_health_sync: 'Sync Runtime',
|
||||
runtime_health_sync_done: 'Runtime synced to v{0}',
|
||||
runtime_health_sync_fail: 'Sync failed: {0}',
|
||||
runtime_health_syncing: 'Syncing...',
|
||||
section_config: 'Current Configuration',
|
||||
section_guide: 'How To Use',
|
||||
section_prep: 'Preparation',
|
||||
section_prep_desc: 'Before first use, finish these 4 preparation items. Better BibTeX auto-export is configured after setup:',
|
||||
setup_done: 'PaperForge environment is ready',
|
||||
setup_pending: 'Not installed yet. Finish the preparation items below, then open the wizard.',
|
||||
tab_features: 'Features',
|
||||
tab_setup: 'Installation',
|
||||
validate_base: 'Base directory is required',
|
||||
validate_fail: 'Please complete the required fields below',
|
||||
validate_index: 'Index directory is required',
|
||||
validate_key: 'PaddleOCR API key (optional, needed for OCR)',
|
||||
validate_notes: 'Notes directory is required',
|
||||
validate_resources: 'Resources directory is required',
|
||||
validate_system: 'System directory is required',
|
||||
validate_vault: 'Vault path is required',
|
||||
validate_zotero: 'Zotero data directory (optional, needed for PDF linking)',
|
||||
wizard_agent_hint: 'Choose the AI agent platform you use most often. PaperForge will place the matching command and skill files in the correct location.',
|
||||
wizard_dir_hint: 'PaperForge stores user-facing literature data under the resources directory. These folders will live there:',
|
||||
wizard_dir_sub_hint: 'Resolved folder preview based on the names below:',
|
||||
wizard_intro: 'This wizard walks you through the full setup. In most cases, the default values are fine to keep.',
|
||||
wizard_keys_hint: 'Enter your PaddleOCR API key below. If you want PaperForge to auto-locate Zotero PDFs, you can also fill in the Zotero data directory.',
|
||||
wizard_preview: 'After installation, system files stay at the vault root while literature data stays under the resources directory.',
|
||||
wizard_safety: 'Safety: if the selected folders already contain files, setup preserves existing files and only creates missing PaperForge folders and files.',
|
||||
wizard_step1: 'Overview',
|
||||
wizard_step2: 'Directory Setup',
|
||||
wizard_step3: 'Platform & Keys',
|
||||
wizard_step4: 'Install',
|
||||
wizard_step5: 'Done',
|
||||
wizard_sys_hint: 'These folders live at the vault root, outside the resources directory:',
|
||||
wizard_title: 'PaperForge Setup Wizard',
|
||||
},
|
||||
|
||||
zh: {
|
||||
action_running: '正在执行 ',
|
||||
api_key_missing: '未配置 ✗',
|
||||
api_key_set: '已配置 ✓',
|
||||
btn_install: '打开安装向导',
|
||||
btn_install_desc: '自动检测 Python + 前置环境,通过后打开分步安装向导',
|
||||
btn_reconfig: '重新配置',
|
||||
btn_reconfig_desc: '重新运行安装向导,修改目录或密钥配置',
|
||||
btn_validate: '验证',
|
||||
check_bbt_fail: '未检测到',
|
||||
check_bbt_ok: '已安装',
|
||||
check_python_fail: '未安装',
|
||||
check_python_ok: '已就绪',
|
||||
check_zotero_fail: '未检测到',
|
||||
check_zotero_ok: '已安装',
|
||||
complete_next: '下一步操作',
|
||||
complete_step1: '打开 PaperForge Dashboard',
|
||||
complete_step1_desc: 'Ctrl+P → 输入 PaperForge: Open Dashboard,或点左侧书本图标',
|
||||
complete_step2: '同步文献',
|
||||
complete_step2_desc: 'Dashboard 中点 Sync Library,从 Zotero 拉取文献生成笔记',
|
||||
complete_step3: '运行 OCR',
|
||||
complete_step3_desc: 'Dashboard 中点 Run OCR,提取 PDF 全文与图表',
|
||||
complete_step4: '配置 BBT 自动导出',
|
||||
complete_summary: '当前完整配置',
|
||||
complete_title: '✓ PaperForge 安装完成',
|
||||
copied: '已复制!',
|
||||
copy_pf_deep_cmd: '复制 /pf-deep 命令',
|
||||
dashboard_drift_warning: '插件版本与 Python 运行时版本不匹配。请在设置中点击"同步运行时"。',
|
||||
deep_reading_not_found: '精读文件未找到',
|
||||
desc: 'Obsidian + Zotero 文献管理流水线。自动同步文献、生成笔记、OCR 提取全文,一站式文献精读工作流。',
|
||||
dir_base: 'Base 目录',
|
||||
dir_index: '索引目录',
|
||||
dir_notes: '正文目录',
|
||||
dir_resources: '资源目录',
|
||||
dir_system: '系统目录',
|
||||
dir_vault: 'Vault 路径',
|
||||
error_copied: '已复制!',
|
||||
error_copy_diagnostic: '复制诊断信息',
|
||||
feat_agent_platform: 'Agent 平台',
|
||||
feat_agent_platform_desc: '选择要管理的 Agent 平台。',
|
||||
feat_api_base_url: 'API 地址',
|
||||
feat_api_base_url_desc: '自定义 OpenAI 兼容 API 端点。留空使用默认地址。',
|
||||
feat_api_model: 'API 模型',
|
||||
feat_api_model_desc: '该端点使用的嵌入模型名称。',
|
||||
feat_build_btn: '构建',
|
||||
feat_build_complete: '向量构建完成。',
|
||||
feat_build_failed: '构建失败。请查看终端输出。',
|
||||
feat_building: '构建中…',
|
||||
feat_cache_remove_failed: '失败:{0}',
|
||||
feat_cache_removed: '模型缓存已清除。',
|
||||
feat_checking: '检测中…',
|
||||
feat_checking_btn: '检测中…',
|
||||
feat_deps_checking: '正在检测依赖…',
|
||||
feat_deps_missing: '依赖未安装。需要:chromadb, openai。',
|
||||
feat_enter_key: '请输入有效的 OpenAI API Key。',
|
||||
feat_install_btn: '安装',
|
||||
feat_install_deps: '安装依赖',
|
||||
feat_install_done: '依赖已安装。正在构建向量…',
|
||||
feat_install_failed: '安装失败:',
|
||||
feat_installing: '安装中…',
|
||||
feat_installing_pkgs: '正在安装 {pkgs}...',
|
||||
feat_key_rejected: 'API Key 被拒绝。',
|
||||
feat_memory_desc: '记忆层是 PaperForge 的核心数据引擎,基于 SQLite 构建。它整合了所有文献元数据(论文、资源文件、别名、阅读事件),支持 FTS5 全文检索(可搜索标题、摘要、作者、分类),并为 agent-context 和 paper-status 命令提供数据支撑。始终运行,无需手动开启。',
|
||||
feat_memory_rebuild_btn: '重建数据库',
|
||||
feat_memory_rebuild_done: '记忆数据库重建完成。',
|
||||
feat_memory_rebuild_failed: '重建失败。',
|
||||
feat_memory_rebuilding: '重建中…',
|
||||
feat_model: '模型',
|
||||
feat_model_changed_warn: '模型已更换({0} -> {1})。已有向量不兼容——需要重建。',
|
||||
feat_network_error: '网络错误:',
|
||||
feat_no_python: '未找到 Python。请查看安装标签页。',
|
||||
feat_not_cached: '未缓存',
|
||||
feat_openai_key: 'OpenAI API Key',
|
||||
feat_openai_key_desc: '用于 API 嵌入调用,模型在下方定义。',
|
||||
feat_output_copied: '输出已复制到剪贴板。',
|
||||
feat_rebuild_btn: '重建',
|
||||
feat_rebuild_vectors: '重建向量',
|
||||
feat_rebuild_vectors_changed: '模型已更换 — 需要重建向量。',
|
||||
feat_rebuild_vectors_desc: '重建所有 OCR 全文向量。更换模型或模式后需要重建。',
|
||||
feat_removing: '删除中…',
|
||||
feat_retry_btn: '重试',
|
||||
feat_skills_desc: '管理 Vault 中已安装的 Agent 技能。每行对应一个 SKILL.md 文件,关闭开关可阻止 Agent 自动调用该技能。',
|
||||
feat_skills_system: '系统技能随 PaperForge 一同发布,会跟随 PaperForge 版本更新。',
|
||||
feat_skills_user: '用户技能是你自行安装或创建的自定义技能。',
|
||||
feat_uninstall_btn: '卸载',
|
||||
feat_valid_key: 'API Key 有效。',
|
||||
feat_vector_config_label: '向量库配置',
|
||||
feat_vector_corrupted: '向量索引已损坏 — 需要强制重建。',
|
||||
feat_vector_desc: '向量数据库通过嵌入模型实现 OCR 全文的语义搜索。文档被切分为文本块(chunk),编码为向量存入 ChromaDB。支持本地模型(免费,CPU 运行)或 OpenAI API(付费,更快速)。',
|
||||
feat_vector_enable: '启用向量检索',
|
||||
feat_vector_enable_desc: '对 OCR 全文进行语义搜索。需安装: pip install chromadb sentence-transformers openai (~500MB)。',
|
||||
feat_vector_rebuild_force_btn: '强制重建',
|
||||
feat_verify: '验证',
|
||||
feat_verify_btn: '验证',
|
||||
field_paddleocr: 'PaddleOCR API 密钥',
|
||||
field_python_custom: '自定义 Python 路径',
|
||||
field_python_interp: '当前 Python 解释器',
|
||||
field_zotero_data: 'Zotero 数据目录',
|
||||
field_zotero_placeholder: '可选,用于自动检测 PDF',
|
||||
guide_ocr: '运行 OCR',
|
||||
guide_ocr_desc: 'Dashboard 中点 Run OCR,提取 PDF 全文与图表',
|
||||
guide_open: '打开 Dashboard',
|
||||
guide_open_desc: 'Ctrl+P → 输入 PaperForge: Open Dashboard,或点左侧书本图标',
|
||||
guide_sync: '同步文献',
|
||||
guide_sync_desc: 'Dashboard 中点 Sync Library,从 Zotero 拉取文献生成笔记',
|
||||
header_title: 'PaperForge',
|
||||
install_bootstrapping: '未检测到 PaperForge Python 包,正在自动安装…',
|
||||
install_btn: '开始安装',
|
||||
install_btn_retry: '重试',
|
||||
install_btn_running: '正在安装...',
|
||||
install_complete: '✓ 安装完成!',
|
||||
install_failed: '✗ 安装失败:',
|
||||
install_validating: '正在校验安装环境…',
|
||||
jump_to_deep_reading: '跳转到精读',
|
||||
label_agent: 'Agent 平台',
|
||||
nav_close: '关闭',
|
||||
nav_next: '下一步 →',
|
||||
nav_prev: '← 上一步',
|
||||
no_pending_ocr: '所有 OCR 任务已完成',
|
||||
not_set: '未设置',
|
||||
notice_check_fail: '未通过: ',
|
||||
notice_python_missing: 'Python 未检测到,请先安装 Python 3.10+ 并加入 PATH',
|
||||
ocr_privacy_title: 'OCR 隐私提示',
|
||||
ocr_privacy_warning: 'OCR 会将 PDF 上传到 PaddleOCR API 进行处理。请不要上传包含敏感信息或无法外传的文献。',
|
||||
ocr_queue_add: '加入 OCR 队列',
|
||||
ocr_queue_added: '已加入 OCR 队列',
|
||||
ocr_queue_remove: '移出 OCR 队列',
|
||||
ocr_queue_removed: '已移出 OCR 队列',
|
||||
ocr_understand: '我了解,继续',
|
||||
optional_later: '(稍后可在设置中补充)',
|
||||
orphan_delete_failed: '清理失败',
|
||||
orphan_delete_selected: '删除 {count} 篇',
|
||||
orphan_deleted: '已删除 {count} 篇残留文献',
|
||||
orphan_desc: '这些文献已从 Zotero 中移除。',
|
||||
orphan_deselect_all: '取消全选',
|
||||
orphan_explain: '已从 Zotero 中移除。工作区文件仍保留在磁盘上。',
|
||||
orphan_keep_all: '保留全部',
|
||||
orphan_none_selected: '未选择任何文献',
|
||||
orphan_select_all: '全选',
|
||||
orphan_title: '发现 {count} 篇残留文献',
|
||||
panel_actions: '快捷操作',
|
||||
prep_bbt: 'Better BibTeX',
|
||||
prep_bbt_desc: 'Zotero → 工具 → 插件 → 安装 Better BibTeX',
|
||||
prep_export: 'BBT 自动导出',
|
||||
prep_export_desc: '右键文献子分类 → 导出分类 → BetterBibTeX JSON → 勾选保持更新 → 导出到(JSON 文件名即为 Base 名):',
|
||||
prep_key: 'PaddleOCR Key',
|
||||
prep_key_desc: '在 https://aistudio.baidu.com/paddleocr 获取 API Key',
|
||||
prep_python: 'Python 3.10+',
|
||||
prep_python_desc: '确保 Python 可命令行调用。点击下方按钮自动检测。',
|
||||
prep_zotero: 'Zotero 桌面版',
|
||||
prep_zotero_desc: '安装 Zotero (https://www.zotero.org)',
|
||||
run_in_agent: '在 {0} 中运行',
|
||||
runtime_health: '运行时状态',
|
||||
runtime_health_checking: '正在检测…',
|
||||
runtime_health_desc: '检查插件与 Python 运行时版本的匹配情况',
|
||||
runtime_health_match: '匹配',
|
||||
runtime_health_mismatch: '不匹配',
|
||||
runtime_health_package_ver: 'Python 包 v{0}',
|
||||
runtime_health_plugin_ver: '插件 v{0}',
|
||||
runtime_health_sync: '同步运行时',
|
||||
runtime_health_sync_done: '运行时已同步至 v{0}',
|
||||
runtime_health_sync_fail: '运行时同步失败:{0}',
|
||||
runtime_health_syncing: '正在同步…',
|
||||
section_config: '当前配置',
|
||||
section_guide: '操作方式',
|
||||
section_prep: '安装准备',
|
||||
section_prep_desc: '首次使用前,请依次完成以下准备:',
|
||||
setup_done: '✓ PaperForge 环境已配置完成',
|
||||
setup_pending: '尚未安装,完成安装准备后点击安装向导',
|
||||
tab_features: '功能',
|
||||
tab_setup: '安装',
|
||||
validate_base: 'Base 目录未填写',
|
||||
validate_fail: '配置验证失败',
|
||||
validate_index: '索引目录未填写',
|
||||
validate_key: 'PaddleOCR API 密钥未填写',
|
||||
validate_notes: '正文目录未填写',
|
||||
validate_resources: '资源目录未填写',
|
||||
validate_system: '系统目录未填写',
|
||||
validate_vault: 'Vault 路径未填写',
|
||||
validate_zotero: 'Zotero 数据目录为必填项',
|
||||
wizard_agent_hint: '选择你使用的 AI Agent 平台,安装时将按对应格式部署技能文件:',
|
||||
wizard_dir_hint: '资源目录是文献数据的统一根目录,以下子目录将创建在其内部:',
|
||||
wizard_dir_sub_hint: '资源目录内的两个子目录:',
|
||||
wizard_intro: '本向导将引导您完成 PaperForge 环境的完整配置。安装过程会自动创建所有目录结构,无需手动操作。',
|
||||
wizard_keys_hint: '以下为 API 密钥与 Zotero 配置:',
|
||||
wizard_preview: '系统文件和 Agent 配置位于 Vault 根目录下。文献数据(正文、索引)统一存放在资源目录内。安装后仍可在设置中修改。',
|
||||
wizard_safety: '安全说明:如果你选择的目录里已经有文件,安装向导会保留已有内容,只补充缺失的 PaperForge 文件和目录。',
|
||||
wizard_step1: '概览',
|
||||
wizard_step2: '目录',
|
||||
wizard_step3: 'Agent',
|
||||
wizard_step4: '安装',
|
||||
wizard_step5: '完成',
|
||||
wizard_sys_hint: '独立于资源目录的系统文件:',
|
||||
wizard_title: 'PaperForge 安装向导',
|
||||
},
|
||||
};
|
||||
|
||||
let T: Record<string, string> | null = null;
|
||||
|
||||
export function langFromApp(app: App): "zh" | "en" {
|
||||
try {
|
||||
const vault = app.vault as unknown as Record<string, unknown>;
|
||||
if (typeof vault.getConfig === 'function') {
|
||||
const l = vault.getConfig('language') as string;
|
||||
if (l && String(l).startsWith('zh')) return 'zh';
|
||||
}
|
||||
} catch {}
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const l = localStorage.getItem('language');
|
||||
if (l && String(l).startsWith('zh')) return 'zh';
|
||||
}
|
||||
} catch {}
|
||||
return 'en';
|
||||
}
|
||||
|
||||
export function setLanguage(app: App): void {
|
||||
T = langFromApp(app) === "zh" ? LANG.zh : LANG.en;
|
||||
}
|
||||
|
||||
export function t(key: string): string {
|
||||
return (T && T[key]) || (LANG.en[key]) || key;
|
||||
}
|
||||
324
paperforge/plugin/src/main.ts
Normal file
324
paperforge/plugin/src/main.ts
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import { Plugin, addIcon, Notice } from "obsidian";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { execFile, exec, spawn } from "child_process";
|
||||
import { VIEW_TYPE_PAPERFORGE, PF_ICON_ID, PF_RIBBON_SVG, ACTIONS, DEFAULT_SETTINGS, PaperForgeSettings } from "./constants";
|
||||
import { t, setLanguage } from "./i18n";
|
||||
import { PaperForgeSettingTab } from "./settings";
|
||||
import { PaperForgeStatusView } from "./views/dashboard";
|
||||
import { resolvePythonExecutable, paperforgeEnrichedEnv } from "./services/python-bridge";
|
||||
import { resolveVaultPaths } from "./services/memory-state";
|
||||
|
||||
export default class PaperForgePlugin extends Plugin {
|
||||
settings!: PaperForgeSettings;
|
||||
private _lastExportMtime = 0;
|
||||
private _lastOcrMtimes: Record<string, number> = {};
|
||||
private _autoSyncRunning = false;
|
||||
private _lastSyncTime: string | null = null;
|
||||
private _pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private _embedProcess: unknown = null;
|
||||
private _embedProgress = { current: 0, total: 0, key: "" };
|
||||
private _embedStderr = "";
|
||||
_memoryStatusText: string | null = null;
|
||||
|
||||
async onload() {
|
||||
await this.loadSettings();
|
||||
this.saveSettings();
|
||||
setLanguage(this.app);
|
||||
this.registerView(VIEW_TYPE_PAPERFORGE, (leaf) => new PaperForgeStatusView(leaf));
|
||||
|
||||
try { addIcon(PF_ICON_ID, PF_RIBBON_SVG); } catch (_) {}
|
||||
this.addRibbonIcon(PF_ICON_ID, "PaperForge Dashboard", () => PaperForgeStatusView.open(this as any));
|
||||
|
||||
this.addSettingTab(new PaperForgeSettingTab(this.app, this as any));
|
||||
|
||||
this.addCommand({
|
||||
id: "paperforge-status-panel",
|
||||
name: `PaperForge: ${t("guide_open")}`,
|
||||
callback: () => PaperForgeStatusView.open(this as any),
|
||||
});
|
||||
|
||||
for (const a of ACTIONS) {
|
||||
this.addCommand({
|
||||
id: a.id,
|
||||
name: `PaperForge: ${a.title}`,
|
||||
callback: () => {
|
||||
if (a.disabled) {
|
||||
new Notice(`[i] ${a.disabledMsg || 'This action is not yet available.'}`, 6000);
|
||||
return;
|
||||
}
|
||||
const vp = (this.app.vault.adapter as any).basePath as string;
|
||||
new Notice(`PaperForge: running ${a.cmd}...`);
|
||||
const { path: cmdPythonExe, extraArgs: cmdExtra = [] } = resolvePythonExecutable(vp, this.settings, undefined, undefined);
|
||||
execFile(cmdPythonExe, [...cmdExtra, "-m", "paperforge", a.cmd], { cwd: vp, timeout: 300000 }, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
new Notice(`[!!] ${a.cmd} failed: ${(stderr || err.message).slice(0, 120)}`, 8000);
|
||||
return;
|
||||
}
|
||||
new Notice(`[OK] ${a.okMsg || stdout.trim().split("\n")[0].slice(0, 80)}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (this.settings.auto_update_on_startup === true && this.settings.setup_complete) {
|
||||
setTimeout(() => this._autoUpdate(), 3000);
|
||||
}
|
||||
this._startFilePolling();
|
||||
this._firstLaunchSnapshotMigration();
|
||||
}
|
||||
|
||||
private _firstLaunchSnapshotMigration() {
|
||||
const vp = (this.app.vault.adapter as any).basePath as string;
|
||||
if (!vp) return;
|
||||
const runtimePaths = resolveVaultPaths(vp);
|
||||
const memSnap = runtimePaths.memoryStatePath;
|
||||
if (!fs.existsSync(memSnap)) {
|
||||
const py = resolvePythonExecutable(vp, this.settings, undefined, undefined);
|
||||
const commands: string[][] = [
|
||||
['runtime-health', '--json'],
|
||||
['memory', 'status', '--json'],
|
||||
['embed', 'status', '--json'],
|
||||
];
|
||||
commands.forEach((cmdArgs) => {
|
||||
const args = [...py.extraArgs, '-m', 'paperforge', '--vault', vp, ...cmdArgs];
|
||||
execFile(py.path, args, { cwd: vp, timeout: 60000, windowsHide: true }, () => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _autoUpdate() {
|
||||
const vp = (this.app.vault.adapter as any).basePath as string;
|
||||
const { path: pythonExe, extraArgs = [] } = resolvePythonExecutable(vp, this.settings, undefined, undefined);
|
||||
const ver = this.manifest.version;
|
||||
const pypiPkg = `paperforge==${ver}`;
|
||||
const gitUrl = `git+https://github.com/LLLin000/PaperForge.git@v${ver}`;
|
||||
|
||||
const doInstall = (pkg: string, onDone: (ok: boolean) => void) => {
|
||||
const child = spawn(pythonExe, [...extraArgs, '-m', 'pip', 'install', '--upgrade', pkg], { cwd: vp, timeout: 120000, env: paperforgeEnrichedEnv() });
|
||||
child.on('close', (code) => onDone(code === 0));
|
||||
};
|
||||
|
||||
execFile(pythonExe, [...extraArgs, '-c', 'import paperforge; print(paperforge.__version__)'], { cwd: vp, timeout: 10000 }, (err, stdout) => {
|
||||
const install = (label: string) => {
|
||||
console.log(`[PaperForge] Auto-update: trying PyPI (paperforge==${ver})`);
|
||||
doInstall(pypiPkg, (ok) => {
|
||||
if (ok) { console.log('[PaperForge] Auto-update: installed via PyPI'); new Notice(`[OK] PaperForge CLI ${label}`, 5000); return; }
|
||||
console.warn('[PaperForge] Auto-update: PyPI failed, falling back to git...');
|
||||
doInstall(gitUrl, (ok2) => {
|
||||
if (ok2) { console.log('[PaperForge] Auto-update: installed via git'); new Notice(`[OK] PaperForge CLI ${label} (via git)`, 5000); }
|
||||
});
|
||||
});
|
||||
};
|
||||
if (err) {
|
||||
install('installed');
|
||||
return;
|
||||
}
|
||||
const pyVer = stdout.trim();
|
||||
if (pyVer !== ver) {
|
||||
install(`${pyVer} -> ${ver}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _startFilePolling() {
|
||||
const vaultPath = (this.app.vault.adapter as any).basePath as string;
|
||||
|
||||
this._pollTimer = setInterval(() => {
|
||||
this._checkExports(vaultPath);
|
||||
this._checkOcr(vaultPath);
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
private _checkExports(vaultPath: string) {
|
||||
if (this._autoSyncRunning) return;
|
||||
const exportsDir = resolveVaultPaths(vaultPath).exportsDir;
|
||||
if (!fs.existsSync(exportsDir)) return;
|
||||
|
||||
let newestMtime = 0;
|
||||
try {
|
||||
fs.readdirSync(exportsDir).forEach(f => {
|
||||
if (!f.endsWith('.json')) return;
|
||||
const stat = fs.statSync(path.join(exportsDir, f));
|
||||
if (stat.mtimeMs > newestMtime) newestMtime = stat.mtimeMs;
|
||||
});
|
||||
} catch(e) { return; }
|
||||
|
||||
if (newestMtime > this._lastExportMtime) {
|
||||
this._lastExportMtime = newestMtime;
|
||||
this._autoSync(vaultPath);
|
||||
}
|
||||
}
|
||||
|
||||
private _autoSync(vaultPath: string) {
|
||||
if (this._autoSyncRunning) return;
|
||||
this._autoSyncRunning = true;
|
||||
|
||||
const pyResult = resolvePythonExecutable(vaultPath, this.settings, undefined, undefined);
|
||||
if (!pyResult.path) { this._autoSyncRunning = false; return; }
|
||||
|
||||
const cmd = `"${pyResult.path}" -m paperforge --vault "${vaultPath}" sync`;
|
||||
exec(cmd, { timeout: 120000, encoding: 'utf-8' }, (err, _stdout, _stderr) => {
|
||||
this._autoSyncRunning = false;
|
||||
this._memoryStatusText = null;
|
||||
if (!err) {
|
||||
this._lastSyncTime = new Date().toLocaleTimeString();
|
||||
}
|
||||
try {
|
||||
const exportsDir = resolveVaultPaths(vaultPath).exportsDir;
|
||||
let newest = 0;
|
||||
fs.readdirSync(exportsDir).forEach(f => {
|
||||
if (!f.endsWith('.json')) return;
|
||||
newest = Math.max(newest, fs.statSync(path.join(exportsDir, f)).mtimeMs);
|
||||
});
|
||||
this._lastExportMtime = newest;
|
||||
} catch(_e) {}
|
||||
});
|
||||
}
|
||||
|
||||
private _checkOcr(vaultPath: string) {
|
||||
if (this._autoSyncRunning) return;
|
||||
const ocrDir = resolveVaultPaths(vaultPath).ocrDir;
|
||||
if (!fs.existsSync(ocrDir)) return;
|
||||
|
||||
try {
|
||||
fs.readdirSync(ocrDir, { withFileTypes: true }).forEach(entry => {
|
||||
if (!entry.isDirectory()) return;
|
||||
const metaPath = path.join(ocrDir, entry.name, 'meta.json');
|
||||
if (!fs.existsSync(metaPath)) return;
|
||||
const stat = fs.statSync(metaPath);
|
||||
const prevMtime = this._lastOcrMtimes[entry.name] || 0;
|
||||
if (stat.mtimeMs <= prevMtime) return;
|
||||
|
||||
this._lastOcrMtimes[entry.name] = stat.mtimeMs;
|
||||
if (this._autoSyncRunning) return;
|
||||
this._autoSyncRunning = true;
|
||||
|
||||
const pyResult = resolvePythonExecutable(vaultPath, this.settings, undefined, undefined);
|
||||
if (!pyResult.path) { this._autoSyncRunning = false; return; }
|
||||
|
||||
const cmd = `"${pyResult.path}" -m paperforge --vault "${vaultPath}" sync`;
|
||||
exec(cmd, { timeout: 30000, encoding: 'utf-8' }, () => {
|
||||
this._autoSyncRunning = false;
|
||||
this._memoryStatusText = null;
|
||||
});
|
||||
});
|
||||
} catch(_e) {}
|
||||
}
|
||||
|
||||
readPaperforgeJson(): Record<string, string> {
|
||||
const vaultPath = (this.app.vault.adapter as any).basePath as string;
|
||||
const pfPath = path.join(vaultPath, 'paperforge.json');
|
||||
|
||||
const DEFAULTS: Record<string, string> = {
|
||||
system_dir: 'System',
|
||||
resources_dir: 'Resources',
|
||||
literature_dir: 'Literature',
|
||||
base_dir: 'Bases',
|
||||
};
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(pfPath)) {
|
||||
return DEFAULTS;
|
||||
}
|
||||
const raw = fs.readFileSync(pfPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
|
||||
const vc = data.vault_config || {};
|
||||
return {
|
||||
system_dir: vc.system_dir || data.system_dir || DEFAULTS.system_dir,
|
||||
resources_dir: vc.resources_dir || data.resources_dir || DEFAULTS.resources_dir,
|
||||
literature_dir: vc.literature_dir || data.literature_dir || DEFAULTS.literature_dir,
|
||||
base_dir: vc.base_dir || data.base_dir || DEFAULTS.base_dir,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('PaperForge: Failed to read paperforge.json, using defaults', e);
|
||||
return DEFAULTS;
|
||||
}
|
||||
}
|
||||
|
||||
savePaperforgeJson(pathConfig: Record<string, string | undefined>): void {
|
||||
const vaultPath = (this.app.vault.adapter as any).basePath as string;
|
||||
const pfPath = path.join(vaultPath, 'paperforge.json');
|
||||
|
||||
let data: Record<string, any> = {};
|
||||
try {
|
||||
if (fs.existsSync(pfPath)) {
|
||||
data = JSON.parse(fs.readFileSync(pfPath, 'utf-8'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('PaperForge: Failed to read paperforge.json for update', e);
|
||||
}
|
||||
|
||||
if (!data.vault_config || typeof data.vault_config !== 'object') {
|
||||
data.vault_config = {};
|
||||
}
|
||||
|
||||
const validPathKeys = ['system_dir', 'resources_dir', 'literature_dir', 'base_dir'];
|
||||
for (const key of validPathKeys) {
|
||||
if (pathConfig[key] !== undefined) {
|
||||
data.vault_config[key] = pathConfig[key];
|
||||
}
|
||||
}
|
||||
|
||||
if (!data.schema_version) {
|
||||
data.schema_version = '2';
|
||||
}
|
||||
|
||||
for (const key of validPathKeys) {
|
||||
delete data[key];
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(pfPath, JSON.stringify(data, null, 2), 'utf-8');
|
||||
if (this.settings) {
|
||||
const pfConfig = this.readPaperforgeJson();
|
||||
this.settings.system_dir = pfConfig.system_dir;
|
||||
this.settings.resources_dir = pfConfig.resources_dir;
|
||||
this.settings.literature_dir = pfConfig.literature_dir;
|
||||
this.settings.base_dir = pfConfig.base_dir;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('PaperForge: Failed to write paperforge.json', e);
|
||||
new Notice('PaperForge: Failed to save configuration to paperforge.json');
|
||||
}
|
||||
}
|
||||
|
||||
onunload() {
|
||||
if (this._pollTimer) clearInterval(this._pollTimer);
|
||||
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PAPERFORGE);
|
||||
}
|
||||
|
||||
async loadSettings() {
|
||||
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
|
||||
if (this.settings.features && DEFAULT_SETTINGS.features) {
|
||||
this.settings.features = Object.assign({}, DEFAULT_SETTINGS.features, this.settings.features || {});
|
||||
}
|
||||
if (!this.settings.frozen_skills) { this.settings.frozen_skills = {}; }
|
||||
const pfConfig = this.readPaperforgeJson();
|
||||
this.settings.system_dir = pfConfig.system_dir;
|
||||
this.settings.resources_dir = pfConfig.resources_dir;
|
||||
this.settings.literature_dir = pfConfig.literature_dir;
|
||||
this.settings.base_dir = pfConfig.base_dir;
|
||||
|
||||
if (this.settings.python_path && this.settings.python_path.trim()) {
|
||||
const pp = this.settings.python_path.trim();
|
||||
if (!fs.existsSync(pp)) {
|
||||
console.warn(`PaperForge: Saved python_path "${pp}" no longer exists - showing stale warning`);
|
||||
this.settings._python_path_stale = true;
|
||||
} else {
|
||||
this.settings._python_path_stale = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async saveSettings() {
|
||||
const dataToSave: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(DEFAULT_SETTINGS)) {
|
||||
if (key in this.settings) {
|
||||
dataToSave[key] = this.settings[key];
|
||||
}
|
||||
}
|
||||
await this.saveData(dataToSave);
|
||||
}
|
||||
}
|
||||
245
paperforge/plugin/src/services/memory-state.ts
Normal file
245
paperforge/plugin/src/services/memory-state.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { execFileSync } from "child_process";
|
||||
import type { PaperForgeSettings } from "../constants";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface PathConfig {
|
||||
system_dir: string;
|
||||
resources_dir: string;
|
||||
literature_dir: string;
|
||||
base_dir: string;
|
||||
_warning: string | null;
|
||||
}
|
||||
|
||||
export interface ResolvedPaths {
|
||||
vault: string;
|
||||
systemDir: string;
|
||||
indexesDir: string;
|
||||
logsDir: string;
|
||||
dbPath: string;
|
||||
memoryStatePath: string;
|
||||
vectorStatePath: string;
|
||||
healthStatePath: string;
|
||||
buildStatePath: string;
|
||||
orphanStatePath: string;
|
||||
exportsDir: string;
|
||||
ocrDir: string;
|
||||
pluginDataPath: string;
|
||||
pfJsonPath: string;
|
||||
configWarning: string | null;
|
||||
}
|
||||
|
||||
export interface MemoryRuntime {
|
||||
paper_count_db?: number;
|
||||
needs_rebuild?: boolean;
|
||||
fresh?: boolean;
|
||||
updated_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VectorRuntime {
|
||||
enabled?: boolean;
|
||||
deps_installed?: boolean;
|
||||
db_exists?: boolean;
|
||||
healthy?: boolean | null;
|
||||
chunk_count?: number;
|
||||
model?: string;
|
||||
mode?: string;
|
||||
updated_at?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PythonResult {
|
||||
path: string;
|
||||
source: "manual" | "auto-detected";
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
memory: MemoryRuntime | null;
|
||||
vector: VectorRuntime | null;
|
||||
health: Record<string, unknown> | null;
|
||||
updatedAt: string;
|
||||
summary: {
|
||||
status: string;
|
||||
memoryReady: boolean;
|
||||
vectorReady: boolean;
|
||||
healthOk: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ── Module-level closure state ──
|
||||
|
||||
let _cachedPython: PythonResult | null = null;
|
||||
|
||||
// ── Functions ──
|
||||
|
||||
export function readPathConfig(vaultPath: string, _fs?: any): PathConfig {
|
||||
const f = _fs || fs;
|
||||
const pfPath = path.join(vaultPath, 'paperforge.json');
|
||||
const defaults = {
|
||||
system_dir: 'System',
|
||||
resources_dir: 'Resources',
|
||||
literature_dir: 'Literature',
|
||||
base_dir: 'Bases',
|
||||
};
|
||||
|
||||
try {
|
||||
if (!f.existsSync(pfPath)) {
|
||||
return { ...defaults, _warning: 'paperforge.json not found; using defaults' };
|
||||
}
|
||||
const raw = f.readFileSync(pfPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const vc = data.vault_config || {};
|
||||
return {
|
||||
system_dir: vc.system_dir || data.system_dir || defaults.system_dir,
|
||||
resources_dir: vc.resources_dir || data.resources_dir || defaults.resources_dir,
|
||||
literature_dir: vc.literature_dir || data.literature_dir || defaults.literature_dir,
|
||||
base_dir: vc.base_dir || data.base_dir || defaults.base_dir,
|
||||
_warning: null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('PaperForge: Failed to read paperforge.json, using defaults', e);
|
||||
return { ...defaults, _warning: 'paperforge.json invalid; using defaults' };
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveVaultPaths(vaultPath: string, _fs?: any): ResolvedPaths {
|
||||
const cfg = readPathConfig(vaultPath, _fs);
|
||||
const systemDir = path.join(vaultPath, cfg.system_dir, 'PaperForge');
|
||||
return {
|
||||
vault: vaultPath,
|
||||
systemDir,
|
||||
indexesDir: path.join(systemDir, 'indexes'),
|
||||
logsDir: path.join(systemDir, 'logs'),
|
||||
dbPath: path.join(systemDir, 'indexes', 'paperforge.db'),
|
||||
memoryStatePath: path.join(systemDir, 'indexes', 'memory-runtime-state.json'),
|
||||
vectorStatePath: path.join(systemDir, 'indexes', 'vector-runtime-state.json'),
|
||||
healthStatePath: path.join(systemDir, 'indexes', 'runtime-health.json'),
|
||||
buildStatePath: path.join(systemDir, 'indexes', 'vector-build-state.json'),
|
||||
orphanStatePath: path.join(systemDir, 'indexes', 'sync-orphan-state.json'),
|
||||
exportsDir: path.join(systemDir, 'exports'),
|
||||
ocrDir: path.join(systemDir, 'ocr'),
|
||||
pluginDataPath: path.join(vaultPath, '.obsidian', 'plugins', 'paperforge', 'data.json'),
|
||||
pfJsonPath: path.join(vaultPath, 'paperforge.json'),
|
||||
configWarning: cfg._warning,
|
||||
};
|
||||
}
|
||||
|
||||
export function readJSONFile(filePath: string): Record<string, unknown> | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
export function getMemoryRuntime(vaultPath: string): MemoryRuntime | null {
|
||||
const paths = resolveVaultPaths(vaultPath);
|
||||
return readJSONFile(paths.memoryStatePath) as MemoryRuntime | null;
|
||||
}
|
||||
|
||||
export function getVectorRuntime(vaultPath: string): VectorRuntime | null {
|
||||
const paths = resolveVaultPaths(vaultPath);
|
||||
return readJSONFile(paths.vectorStatePath) as VectorRuntime | null;
|
||||
}
|
||||
|
||||
export function getRuntimeHealth(vaultPath: string): Record<string, unknown> | null {
|
||||
const paths = resolveVaultPaths(vaultPath);
|
||||
return readJSONFile(paths.healthStatePath);
|
||||
}
|
||||
|
||||
export function isMemoryReady(vaultPath: string): boolean {
|
||||
const s = getMemoryRuntime(vaultPath);
|
||||
return !!(s && (s.paper_count_db ?? 0) > 0 && !s.needs_rebuild);
|
||||
}
|
||||
|
||||
export function isVectorReady(vaultPath: string): boolean {
|
||||
const s = getVectorRuntime(vaultPath);
|
||||
if (!s) return false;
|
||||
if (!s.enabled) return false;
|
||||
if (!s.deps_installed) return false;
|
||||
if (!s.db_exists) return false;
|
||||
if (s.healthy === false) return false;
|
||||
if (s.chunk_count === 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function isHealthOk(vaultPath: string): boolean {
|
||||
const s = getRuntimeHealth(vaultPath);
|
||||
return !!(s && (s.summary as { status?: string })?.status === 'ok');
|
||||
}
|
||||
|
||||
export function getMemoryStatusText(vaultPath: string): string {
|
||||
const s = getMemoryRuntime(vaultPath);
|
||||
if (!s || s.paper_count_db === 0) return 'DB not found. Run paperforge memory build.';
|
||||
return 'Papers: ' + s.paper_count_db + ' | ' + (s.fresh ? 'fresh' : 'stale');
|
||||
}
|
||||
|
||||
export function getVectorStatusText(vaultPath: string): string {
|
||||
const s = getVectorRuntime(vaultPath);
|
||||
if (!s) return 'Status unavailable';
|
||||
if (s.healthy === false) return 'Vector index unreadable - rebuild required';
|
||||
return 'Chunks: ' + s.chunk_count + ' | ' + s.model + ' | ' + s.mode;
|
||||
}
|
||||
|
||||
export function getCachedPython(vaultPath: string, settings: PaperForgeSettings): PythonResult {
|
||||
if (_cachedPython) return _cachedPython;
|
||||
if (settings && settings.python_path && settings.python_path.trim()) {
|
||||
const p = settings.python_path.trim();
|
||||
if (fs.existsSync(p)) { _cachedPython = { path: p, source: 'manual', extraArgs: [] }; return _cachedPython; }
|
||||
}
|
||||
const venvCandidates = [
|
||||
path.join(vaultPath, '.paperforge-test-venv', 'Scripts', 'python.exe'),
|
||||
path.join(vaultPath, '.venv', 'Scripts', 'python.exe'),
|
||||
path.join(vaultPath, 'venv', 'Scripts', 'python.exe'),
|
||||
];
|
||||
for (let i = 0; i < venvCandidates.length; i++) {
|
||||
if (fs.existsSync(venvCandidates[i])) {
|
||||
_cachedPython = { path: venvCandidates[i], source: 'auto-detected', extraArgs: [] };
|
||||
return _cachedPython;
|
||||
}
|
||||
}
|
||||
const sysCandidates = [{path:'py',extraArgs:['-3']},{path:'python',extraArgs:[]},{path:'python3',extraArgs:[]}];
|
||||
for (let j = 0; j < sysCandidates.length; j++) {
|
||||
try {
|
||||
const c = sysCandidates[j];
|
||||
const out = execFileSync(c.path, c.extraArgs.concat(['--version']), {encoding:'utf-8',timeout:5000,windowsHide:true});
|
||||
if (out && out.toLowerCase().indexOf('python') !== -1) {
|
||||
_cachedPython = { path: c.path, source: 'auto-detected', extraArgs: c.extraArgs };
|
||||
return _cachedPython;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
_cachedPython = { path: 'python', source: 'auto-detected', extraArgs: [] };
|
||||
return _cachedPython;
|
||||
}
|
||||
|
||||
export function buildSnapshot(
|
||||
vaultPath: string,
|
||||
_readFn?: (filePath: string) => Record<string, unknown> | null,
|
||||
_resolvePaths?: (vaultPath: string) => ResolvedPaths,
|
||||
): Snapshot {
|
||||
const readFn = _readFn || readJSONFile;
|
||||
const resolvePaths = _resolvePaths || resolveVaultPaths;
|
||||
const paths = resolvePaths(vaultPath);
|
||||
const memory = readFn(paths.memoryStatePath) as MemoryRuntime | null;
|
||||
const vector = readFn(paths.vectorStatePath) as VectorRuntime | null;
|
||||
const health = readFn(paths.healthStatePath);
|
||||
const memoryOk = !!(memory && (memory.paper_count_db ?? 0) > 0 && !memory.needs_rebuild);
|
||||
const vectorOk = !!(vector && vector.enabled && vector.deps_installed && vector.db_exists && (vector.chunk_count ?? 0) > 0);
|
||||
return {
|
||||
memory: memory, vector: vector, health: health,
|
||||
updatedAt: (memory && memory.updated_at) || (vector && vector.updated_at) || '',
|
||||
summary: {
|
||||
status: memoryOk && vectorOk ? 'ready' : 'degraded',
|
||||
memoryReady: memoryOk, vectorReady: vectorOk,
|
||||
healthOk: !!(health && (health.summary as { status?: string })?.status === 'ok'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldRenderVectorReady(vectorDepsOk: boolean | null, embedStatusText: string): boolean {
|
||||
return vectorDepsOk === true;
|
||||
}
|
||||
380
paperforge/plugin/src/services/python-bridge.ts
Normal file
380
paperforge/plugin/src/services/python-bridge.ts
Normal file
|
|
@ -0,0 +1,380 @@
|
|||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
import { execFile, execFileSync, spawn, exec } from "child_process";
|
||||
import type { PaperForgeSettings } from "../constants";
|
||||
|
||||
// ── Types ──
|
||||
|
||||
export interface PythonResult {
|
||||
path: string;
|
||||
source: "manual" | "auto-detected";
|
||||
extraArgs: string[];
|
||||
}
|
||||
|
||||
export interface InstallCommand {
|
||||
cmd: string;
|
||||
url: string;
|
||||
args: string[];
|
||||
pypiArgs: string[];
|
||||
gitArgs: string[];
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
export interface SubprocessResult {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
export interface ErrorClassification {
|
||||
type: string;
|
||||
message: string;
|
||||
recoverable: boolean;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeStatus {
|
||||
status: string;
|
||||
version: string | null;
|
||||
type?: string;
|
||||
message?: string;
|
||||
recoverable?: boolean;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
// ── Cross-platform state ──
|
||||
|
||||
let _gitDir: string | null = null;
|
||||
let _gitDirResolved = false;
|
||||
|
||||
// ── Runtime helpers ──
|
||||
|
||||
export function resolvePythonExecutable(vaultPath: string, settings: PaperForgeSettings | null | undefined, _fs: any, _execFileSync: any): PythonResult {
|
||||
const f = _fs || fs;
|
||||
const execSync = _execFileSync || execFileSync;
|
||||
|
||||
if (settings && settings.python_path && settings.python_path.trim()) {
|
||||
const manualPath = settings.python_path.trim();
|
||||
if (f.existsSync(manualPath)) {
|
||||
return { path: manualPath, source: "manual", extraArgs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const venvCandidates = [
|
||||
path.join(vaultPath, ".paperforge-test-venv", "Scripts", "python.exe"),
|
||||
path.join(vaultPath, ".venv", "Scripts", "python.exe"),
|
||||
path.join(vaultPath, "venv", "Scripts", "python.exe"),
|
||||
];
|
||||
for (const candidate of venvCandidates) {
|
||||
try {
|
||||
if (f.existsSync(candidate)) {
|
||||
return { path: candidate, source: "auto-detected", extraArgs: [] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const systemCandidates = [
|
||||
{ path: "py", extraArgs: ["-3"] },
|
||||
{ path: "python", extraArgs: [] },
|
||||
{ path: "python3", extraArgs: [] },
|
||||
];
|
||||
for (const candidate of systemCandidates) {
|
||||
try {
|
||||
const verOut = execSync(candidate.path, [...candidate.extraArgs, "--version"], {
|
||||
encoding: "utf-8", timeout: 5000, windowsHide: true,
|
||||
});
|
||||
if (verOut && verOut.toLowerCase().includes("python")) {
|
||||
return { path: candidate.path, source: "auto-detected", extraArgs: candidate.extraArgs };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { path: "python", source: "auto-detected", extraArgs: [] };
|
||||
}
|
||||
|
||||
export function getPluginVersion(app: any): string | null {
|
||||
try {
|
||||
const manifest = app && app.plugins && app.plugins.plugins &&
|
||||
app.plugins.plugins["paperforge"] && app.plugins.plugins["paperforge"].manifest;
|
||||
return (manifest && manifest.version) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function checkRuntimeVersion(pythonExe: string, pluginVersion: string | null, cwd: string, timeout: number | undefined, _execFile: any): Promise<{ status: string; pyVersion: string | null; pluginVersion: string | null; error: string | null }> {
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
const exe = _execFile || execFile;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exe(pythonExe, ["-c", "import paperforge; print(paperforge.__version__)"],
|
||||
{ cwd, timeout },
|
||||
(err: any, stdout: any) => {
|
||||
if (err) {
|
||||
resolve({ status: "not-installed", pyVersion: null, pluginVersion, error: err.message });
|
||||
return;
|
||||
}
|
||||
const pyVer = (stdout && stdout.trim()) || null;
|
||||
if (pyVer === pluginVersion) {
|
||||
resolve({ status: "match", pyVersion: pyVer, pluginVersion, error: null });
|
||||
} else {
|
||||
resolve({ status: "mismatch", pyVersion: pyVer, pluginVersion, error: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Error helpers ──
|
||||
|
||||
export function classifyError(errorCode: string): ErrorClassification {
|
||||
const code = String(errorCode);
|
||||
const patterns: Record<string, ErrorClassification> = {
|
||||
ENOENT: { type: "python_missing", message: "Python executable not found", recoverable: true },
|
||||
"python-missing": { type: "python_missing", message: "Python executable not found", recoverable: true },
|
||||
MODULE_NOT_FOUND: { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
|
||||
"import-failed": { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
|
||||
"version-mismatch": { type: "version_mismatch", message: "Plugin and package versions differ", recoverable: true, action: "sync-runtime" },
|
||||
"pip-failed": { type: "pip_install_failure", message: "pip install command failed", recoverable: true },
|
||||
ETIMEDOUT: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
|
||||
timeout: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
|
||||
};
|
||||
const match = patterns[code];
|
||||
if (match) return { ...match };
|
||||
return { type: "unknown", message: String(errorCode), recoverable: false };
|
||||
}
|
||||
|
||||
export function buildRuntimeInstallCommand(pythonExe: string, version: string, extraArgs: string[]): InstallCommand {
|
||||
if (extraArgs === undefined) extraArgs = [];
|
||||
const pypiPkg = `paperforge==${version}`;
|
||||
const gitUrl = `git+https://github.com/LLLin000/PaperForge.git@v${version}`;
|
||||
const pypiArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", pypiPkg];
|
||||
const gitArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", gitUrl];
|
||||
return { cmd: pythonExe, url: gitUrl.replace('@v', '@'), args: gitArgs, pypiArgs, gitArgs, timeout: 120000 };
|
||||
}
|
||||
|
||||
export function parseRuntimeStatus(err: any, stdout: any, stderr: any): RuntimeStatus {
|
||||
if (!err && stdout) {
|
||||
return { status: "ok", version: stdout.trim() };
|
||||
}
|
||||
if (err && err.code === "ENOENT") {
|
||||
const classified = classifyError("ENOENT");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (stderr && stderr.includes("No module named paperforge")) {
|
||||
const classified = classifyError("import-failed");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (err && err.killed) {
|
||||
const classified = classifyError("timeout");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (stderr && stderr.includes("ModuleNotFoundError")) {
|
||||
const classified = classifyError("import-failed");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
return { status: "error", version: null, type: "unknown",
|
||||
message: err ? err.message : String(stderr), recoverable: false };
|
||||
}
|
||||
|
||||
// ── Action definitions ── (ACTIONS already in constants.ts)
|
||||
|
||||
export function buildCommandArgs(action: any, key: any, filter: any): string[] {
|
||||
const args = Array.isArray(action.args) ? [...action.args] : [];
|
||||
if (action.needsKey && key) args.push(key);
|
||||
if (action.needsFilter || filter) args.push("--all");
|
||||
return args;
|
||||
}
|
||||
|
||||
export function runSubprocess(pythonExe: string, args: string[], cwd: string, timeout: number, _spawn: any, env?: any): Promise<SubprocessResult> {
|
||||
const sp = _spawn || spawn;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
const opts: any = { cwd, timeout, windowsHide: true };
|
||||
if (env) opts.env = env;
|
||||
const child = sp(pythonExe, args, opts);
|
||||
const stdoutChunks: string[] = [];
|
||||
const stderrChunks: string[] = [];
|
||||
|
||||
child.stdout.on("data", (data: any) => { stdoutChunks.push(data.toString("utf-8")); });
|
||||
child.stderr.on("data", (data: any) => { stderrChunks.push(data.toString("utf-8")); });
|
||||
|
||||
child.on("close", (code: any) => {
|
||||
resolve({ stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""),
|
||||
exitCode: code, elapsed: Date.now() - startTime });
|
||||
});
|
||||
|
||||
child.on("error", (err: any) => {
|
||||
resolve({ stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join("") + "\n" + err.message,
|
||||
exitCode: -1, elapsed: Date.now() - startTime });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Cross-platform Python and BBT detection (macOS/Linux) ──
|
||||
|
||||
export function resolveGitDir(): string | null {
|
||||
if (_gitDirResolved) return _gitDir;
|
||||
_gitDirResolved = true;
|
||||
try {
|
||||
let out: string;
|
||||
if (process.platform === 'win32') {
|
||||
const cmdExe = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
|
||||
out = execFileSync(cmdExe, ['/c', 'where', 'git'], { timeout: 5000, windowsHide: true, encoding: 'utf-8' });
|
||||
} else {
|
||||
out = execFileSync('which', ['git'], { timeout: 5000, encoding: 'utf-8' });
|
||||
}
|
||||
if (out) {
|
||||
const line = out.split('\n')[0].trim();
|
||||
if (line) _gitDir = path.dirname(line);
|
||||
}
|
||||
} catch (_) {}
|
||||
return _gitDir;
|
||||
}
|
||||
|
||||
export function paperforgeEnrichedEnv(): Record<string, string | undefined> {
|
||||
const env: Record<string, string | undefined> = { ...process.env };
|
||||
const plat = process.platform;
|
||||
const home = os.homedir();
|
||||
const extras: string[] = [];
|
||||
const gitDir = resolveGitDir();
|
||||
if (gitDir) extras.push(gitDir);
|
||||
if (plat === 'darwin') {
|
||||
extras.push('/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', `${home}/.local/bin`);
|
||||
} else if (plat === 'linux') {
|
||||
extras.push('/usr/local/bin', '/usr/bin', `${home}/.local/bin`);
|
||||
}
|
||||
const cur = env.PATH || '';
|
||||
env.PATH = [...extras, cur].filter(Boolean).join(path.delimiter);
|
||||
return env;
|
||||
}
|
||||
|
||||
export function shellQuoteForExec(cmd: string): string {
|
||||
if (!cmd) return "''";
|
||||
if (/[\s'"\\]/.test(cmd)) return `'${cmd.replace(/'/g, "'\\''")}'`;
|
||||
return cmd;
|
||||
}
|
||||
|
||||
export function isLikelyAppleStubPython(resolvedAbsPath: string): boolean {
|
||||
const n = String(resolvedAbsPath).toLowerCase().replace(/\\/g, '/');
|
||||
return n.includes('commandlinetools') || n.includes('/library/developer/commandlinetools');
|
||||
}
|
||||
|
||||
export function collectDarwinPythonCandidates(home: string): string[] {
|
||||
return [
|
||||
'/opt/homebrew/bin/python3',
|
||||
'/usr/local/bin/python3',
|
||||
path.join(home, '.local', 'bin', 'python3'),
|
||||
path.join(home, '.pyenv', 'shims', 'python3'),
|
||||
'/usr/bin/python3',
|
||||
];
|
||||
}
|
||||
|
||||
export function getPaperforgePythonCmd(): string {
|
||||
const plat = process.platform;
|
||||
const home = os.homedir();
|
||||
if (plat === 'darwin') {
|
||||
let stubFallback: string | null = null;
|
||||
for (const p of collectDarwinPythonCandidates(home)) {
|
||||
try {
|
||||
if (!p || !fs.existsSync(p)) continue;
|
||||
let resolved = p;
|
||||
try { resolved = fs.realpathSync(p); } catch (_) {}
|
||||
if (isLikelyAppleStubPython(resolved)) {
|
||||
if (!stubFallback) stubFallback = p;
|
||||
continue;
|
||||
}
|
||||
return p;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (stubFallback) return stubFallback;
|
||||
return 'python3';
|
||||
}
|
||||
const candidates: string[] = [];
|
||||
if (plat === 'linux') {
|
||||
candidates.push('/usr/bin/python3', '/usr/local/bin/python3', path.join(home, '.local', 'bin', 'python3'), path.join(home, '.pyenv', 'shims', 'python3'));
|
||||
}
|
||||
for (const p of candidates) {
|
||||
try { if (p && fs.existsSync(p)) return p; } catch (_) {}
|
||||
}
|
||||
if (plat === 'win32') return 'python';
|
||||
return 'python3';
|
||||
}
|
||||
|
||||
export function paperforgePythonExecArgs(scriptTail: string): string {
|
||||
const py = shellQuoteForExec(getPaperforgePythonCmd());
|
||||
return `${py} ${scriptTail}`;
|
||||
}
|
||||
|
||||
export function tryExecPythonVersion(callback: any): void {
|
||||
const plat = process.platform;
|
||||
const home = os.homedir();
|
||||
const tried = new Set<string>();
|
||||
const list: string[] = [];
|
||||
if (plat === 'darwin') {
|
||||
const nonStub: string[] = [], stub: string[] = [];
|
||||
for (const p of collectDarwinPythonCandidates(home)) {
|
||||
try {
|
||||
if (!fs.existsSync(p)) continue;
|
||||
let resolved = p;
|
||||
try { resolved = fs.realpathSync(p); } catch (_) {}
|
||||
if (isLikelyAppleStubPython(resolved)) stub.push(p);
|
||||
else nonStub.push(p);
|
||||
} catch (_) {}
|
||||
}
|
||||
list.push(...nonStub, ...stub);
|
||||
} else if (plat === 'linux') {
|
||||
list.push('/usr/bin/python3', '/usr/local/bin/python3', path.join(home, '.local', 'bin', 'python3'), path.join(home, '.pyenv', 'shims', 'python3'));
|
||||
}
|
||||
list.push(plat === 'win32' ? 'python' : 'python3', 'python');
|
||||
const candidates = list.filter((c: string) => { if (!c || tried.has(c)) return false; tried.add(c); return true; });
|
||||
let i = 0;
|
||||
const next = () => {
|
||||
if (i >= candidates.length) { callback(new Error('Python not found'), '', null); return; }
|
||||
const py = candidates[i++];
|
||||
if (py.includes(path.sep) || py.startsWith('/')) {
|
||||
try { if (!fs.existsSync(py)) { next(); return; } } catch (_) { next(); return; }
|
||||
}
|
||||
exec(`${shellQuoteForExec(py)} --version`, { timeout: 8000, env: paperforgeEnrichedEnv() as any }, (err: any, stdout: any) => {
|
||||
if (!err && stdout) callback(null, stdout.trim(), py);
|
||||
else next();
|
||||
});
|
||||
};
|
||||
next();
|
||||
}
|
||||
|
||||
export function dirLooksLikeBetterBibtexFolder(entryName: string): boolean {
|
||||
const compact = String(entryName).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
return compact.includes('betterbibtex');
|
||||
}
|
||||
|
||||
export function scanBbtDirectChildren(dir: string): boolean {
|
||||
if (!dir) return false;
|
||||
try {
|
||||
if (!fs.existsSync(dir)) return false;
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
if (dirLooksLikeBetterBibtexFolder(entry)) return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function scanBbtUnderProfiles(profilesDir: string): boolean {
|
||||
if (!profilesDir) return false;
|
||||
try {
|
||||
if (!fs.existsSync(profilesDir)) return false;
|
||||
for (const prof of fs.readdirSync(profilesDir)) {
|
||||
const extDir = path.join(profilesDir, prof, 'extensions');
|
||||
try {
|
||||
if (!fs.existsSync(extDir)) continue;
|
||||
for (const entry of fs.readdirSync(extDir)) {
|
||||
if (dirLooksLikeBetterBibtexFolder(entry)) return true;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
return false;
|
||||
}
|
||||
1245
paperforge/plugin/src/settings.ts
Normal file
1245
paperforge/plugin/src/settings.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,293 +0,0 @@
|
|||
/**
|
||||
* Testable functions extracted from main.js for Vitest.
|
||||
* No obsidian dependency — safe to import in Node test environment.
|
||||
*/
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile, spawn } = require('node:child_process');
|
||||
|
||||
function readPathConfig(vaultPath, _fs) {
|
||||
const f = _fs || fs;
|
||||
const pfPath = path.join(vaultPath, 'paperforge.json');
|
||||
const defaults = {
|
||||
system_dir: 'System',
|
||||
resources_dir: 'Resources',
|
||||
literature_dir: 'Literature',
|
||||
base_dir: 'Bases',
|
||||
};
|
||||
|
||||
try {
|
||||
if (!f.existsSync(pfPath)) {
|
||||
return { ...defaults, _warning: 'paperforge.json not found; using defaults' };
|
||||
}
|
||||
const raw = f.readFileSync(pfPath, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const vc = data.vault_config || {};
|
||||
return {
|
||||
system_dir: vc.system_dir || data.system_dir || defaults.system_dir,
|
||||
resources_dir: vc.resources_dir || data.resources_dir || defaults.resources_dir,
|
||||
literature_dir: vc.literature_dir || data.literature_dir || defaults.literature_dir,
|
||||
base_dir: vc.base_dir || data.base_dir || defaults.base_dir,
|
||||
_warning: null,
|
||||
};
|
||||
} catch {
|
||||
return { ...defaults, _warning: 'paperforge.json invalid; using defaults' };
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRuntimePaths(vaultPath, _fs) {
|
||||
const cfg = readPathConfig(vaultPath, _fs);
|
||||
const systemRoot = path.join(vaultPath, cfg.system_dir, 'PaperForge');
|
||||
return {
|
||||
vault: vaultPath,
|
||||
systemDir: systemRoot,
|
||||
indexesDir: path.join(systemRoot, 'indexes'),
|
||||
logsDir: path.join(systemRoot, 'logs'),
|
||||
dbPath: path.join(systemRoot, 'indexes', 'paperforge.db'),
|
||||
memoryStatePath: path.join(systemRoot, 'indexes', 'memory-runtime-state.json'),
|
||||
vectorStatePath: path.join(systemRoot, 'indexes', 'vector-runtime-state.json'),
|
||||
healthStatePath: path.join(systemRoot, 'indexes', 'runtime-health.json'),
|
||||
buildStatePath: path.join(systemRoot, 'indexes', 'vector-build-state.json'),
|
||||
exportsDir: path.join(systemRoot, 'exports'),
|
||||
ocrDir: path.join(systemRoot, 'ocr'),
|
||||
pluginDataPath: path.join(vaultPath, '.obsidian', 'plugins', 'paperforge', 'data.json'),
|
||||
pfJsonPath: path.join(vaultPath, 'paperforge.json'),
|
||||
configWarning: cfg._warning,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Runtime helpers ──
|
||||
|
||||
function resolvePythonExecutable(vaultPath, settings, _fs, _execFileSync) {
|
||||
const f = _fs || fs;
|
||||
const execSync = _execFileSync || require("node:child_process").execFileSync;
|
||||
|
||||
if (settings && settings.python_path && settings.python_path.trim()) {
|
||||
const manualPath = settings.python_path.trim();
|
||||
if (f.existsSync(manualPath)) {
|
||||
return { path: manualPath, source: "manual", extraArgs: [] };
|
||||
}
|
||||
}
|
||||
|
||||
const venvCandidates = [
|
||||
path.join(vaultPath, ".paperforge-test-venv", "Scripts", "python.exe"),
|
||||
path.join(vaultPath, ".venv", "Scripts", "python.exe"),
|
||||
path.join(vaultPath, "venv", "Scripts", "python.exe"),
|
||||
];
|
||||
for (const candidate of venvCandidates) {
|
||||
try {
|
||||
if (f.existsSync(candidate)) {
|
||||
return { path: candidate, source: "auto-detected", extraArgs: [] };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const systemCandidates = [
|
||||
{ path: "py", extraArgs: ["-3"] },
|
||||
{ path: "python", extraArgs: [] },
|
||||
{ path: "python3", extraArgs: [] },
|
||||
];
|
||||
for (const candidate of systemCandidates) {
|
||||
try {
|
||||
const verOut = execSync(candidate.path, [...candidate.extraArgs, "--version"], {
|
||||
encoding: "utf-8", timeout: 5000, windowsHide: true,
|
||||
});
|
||||
if (verOut && verOut.toLowerCase().includes("python")) {
|
||||
return { path: candidate.path, source: "auto-detected", extraArgs: candidate.extraArgs };
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return { path: "python", source: "auto-detected", extraArgs: [] };
|
||||
}
|
||||
|
||||
function getPluginVersion(app) {
|
||||
try {
|
||||
const manifest = app && app.plugins && app.plugins.plugins &&
|
||||
app.plugins.plugins["paperforge"] && app.plugins.plugins["paperforge"].manifest;
|
||||
return (manifest && manifest.version) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function checkRuntimeVersion(pythonExe, pluginVersion, cwd, timeout, _execFile) {
|
||||
if (timeout === undefined) timeout = 10000;
|
||||
const exe = _execFile || execFile;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
exe(pythonExe, ["-c", "import paperforge; print(paperforge.__version__)"],
|
||||
{ cwd, timeout },
|
||||
(err, stdout) => {
|
||||
if (err) {
|
||||
resolve({ status: "not-installed", pyVersion: null, pluginVersion, error: err.message });
|
||||
return;
|
||||
}
|
||||
const pyVer = (stdout && stdout.trim()) || null;
|
||||
if (pyVer === pluginVersion) {
|
||||
resolve({ status: "match", pyVersion: pyVer, pluginVersion, error: null });
|
||||
} else {
|
||||
resolve({ status: "mismatch", pyVersion: pyVer, pluginVersion, error: null });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Error helpers ──
|
||||
|
||||
function classifyError(errorCode) {
|
||||
const code = String(errorCode);
|
||||
const patterns = {
|
||||
ENOENT: { type: "python_missing", message: "Python executable not found", recoverable: true },
|
||||
"python-missing": { type: "python_missing", message: "Python executable not found", recoverable: true },
|
||||
MODULE_NOT_FOUND: { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
|
||||
"import-failed": { type: "import_failed", message: "PaperForge package not installed", recoverable: true },
|
||||
"version-mismatch": { type: "version_mismatch", message: "Plugin and package versions differ", recoverable: true, action: "sync-runtime" },
|
||||
"pip-failed": { type: "pip_install_failure", message: "pip install command failed", recoverable: true },
|
||||
ETIMEDOUT: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
|
||||
timeout: { type: "timeout", message: "Subprocess timed out", recoverable: true, action: "retry" },
|
||||
};
|
||||
const match = patterns[code];
|
||||
if (match) return { ...match };
|
||||
return { type: "unknown", message: String(errorCode), recoverable: false };
|
||||
}
|
||||
|
||||
function buildRuntimeInstallCommand(pythonExe, version, extraArgs) {
|
||||
if (extraArgs === undefined) extraArgs = [];
|
||||
const pypiPkg = `paperforge==${version}`;
|
||||
const gitUrl = `git+https://github.com/LLLin000/PaperForge.git@v${version}`;
|
||||
const pypiArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", pypiPkg];
|
||||
const gitArgs = [...extraArgs, "-m", "pip", "install", "--upgrade", gitUrl];
|
||||
return { cmd: pythonExe, url: gitUrl.replace('@v', '@'), args: gitArgs, pypiArgs, gitArgs, timeout: 120000 };
|
||||
}
|
||||
|
||||
function parseRuntimeStatus(err, stdout, stderr) {
|
||||
if (!err && stdout) {
|
||||
return { status: "ok", version: stdout.trim() };
|
||||
}
|
||||
if (err && err.code === "ENOENT") {
|
||||
const classified = classifyError("ENOENT");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (stderr && stderr.includes("No module named paperforge")) {
|
||||
const classified = classifyError("import-failed");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (err && err.killed) {
|
||||
const classified = classifyError("timeout");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
if (stderr && stderr.includes("ModuleNotFoundError")) {
|
||||
const classified = classifyError("import-failed");
|
||||
return { status: "error", version: null, ...classified };
|
||||
}
|
||||
return { status: "error", version: null, type: "unknown",
|
||||
message: err ? err.message : String(stderr), recoverable: false };
|
||||
}
|
||||
|
||||
// ── Action definitions ──
|
||||
|
||||
const ACTIONS = [
|
||||
{
|
||||
id: "paperforge-sync",
|
||||
title: "Sync Library",
|
||||
desc: "Pull new references from Zotero and generate literature notes",
|
||||
icon: "\u21BB",
|
||||
cmd: "sync",
|
||||
okMsg: "Sync complete",
|
||||
},
|
||||
{
|
||||
id: "paperforge-ocr",
|
||||
title: "Run OCR",
|
||||
desc: "Extract full text and figures from PDFs via PaddleOCR",
|
||||
icon: "\u229E",
|
||||
cmd: "ocr",
|
||||
okMsg: "OCR started",
|
||||
},
|
||||
{
|
||||
id: "paperforge-doctor",
|
||||
title: "Run Doctor",
|
||||
desc: "Verify PaperForge setup \u2014 check configs, Zotero, paths, and index health",
|
||||
icon: "\u2695",
|
||||
cmd: "doctor",
|
||||
okMsg: "Doctor complete",
|
||||
},
|
||||
{
|
||||
id: "paperforge-repair",
|
||||
title: "Repair Issues",
|
||||
desc: "Fix three-way state divergence, path errors, and rebuild index",
|
||||
icon: "\u21BA",
|
||||
cmd: "repair",
|
||||
args: ["--fix", "--fix-paths"],
|
||||
okMsg: "Repair complete",
|
||||
},
|
||||
];
|
||||
|
||||
function buildCommandArgs(action, key, filter) {
|
||||
const args = Array.isArray(action.args) ? [...action.args] : [];
|
||||
if (action.needsKey && key) args.push(key);
|
||||
if (action.needsFilter || filter) args.push("--all");
|
||||
return args;
|
||||
}
|
||||
|
||||
function runSubprocess(pythonExe, args, cwd, timeout, _spawn, env) {
|
||||
const sp = _spawn || spawn;
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const startTime = Date.now();
|
||||
const opts = { cwd, timeout, windowsHide: true };
|
||||
if (env) opts.env = env;
|
||||
const child = sp(pythonExe, args, opts);
|
||||
const stdoutChunks = [];
|
||||
const stderrChunks = [];
|
||||
|
||||
child.stdout.on("data", (data) => { stdoutChunks.push(data.toString("utf-8")); });
|
||||
child.stderr.on("data", (data) => { stderrChunks.push(data.toString("utf-8")); });
|
||||
|
||||
child.on("close", (code) => {
|
||||
resolve({ stdout: stdoutChunks.join(""), stderr: stderrChunks.join(""),
|
||||
exitCode: code, elapsed: Date.now() - startTime });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
resolve({ stdout: stdoutChunks.join(""),
|
||||
stderr: stderrChunks.join("") + "\n" + err.message,
|
||||
exitCode: -1, elapsed: Date.now() - startTime });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function shouldRenderVectorReady(vectorDepsOk, embedStatusText) {
|
||||
return vectorDepsOk === true;
|
||||
}
|
||||
|
||||
function getDisclosureState(store, key, defaultCollapsed) {
|
||||
if (!store || typeof store !== 'object') return !!defaultCollapsed;
|
||||
if (!Object.prototype.hasOwnProperty.call(store, key)) return !!defaultCollapsed;
|
||||
return !!store[key];
|
||||
}
|
||||
|
||||
function toggleDisclosureState(store, key, defaultCollapsed) {
|
||||
const next = !getDisclosureState(store, key, defaultCollapsed);
|
||||
if (store && typeof store === 'object') {
|
||||
store[key] = next;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
readPathConfig,
|
||||
resolveRuntimePaths,
|
||||
resolvePythonExecutable,
|
||||
getPluginVersion,
|
||||
checkRuntimeVersion,
|
||||
classifyError,
|
||||
buildRuntimeInstallCommand,
|
||||
parseRuntimeStatus,
|
||||
ACTIONS,
|
||||
buildCommandArgs,
|
||||
runSubprocess,
|
||||
shouldRenderVectorReady,
|
||||
getDisclosureState,
|
||||
toggleDisclosureState,
|
||||
};
|
||||
21
paperforge/plugin/src/utils/disclosure.ts
Normal file
21
paperforge/plugin/src/utils/disclosure.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
export function getDisclosureState(
|
||||
store: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
defaultCollapsed: boolean,
|
||||
): boolean {
|
||||
if (!store || typeof store !== "object") return !!defaultCollapsed;
|
||||
if (!Object.prototype.hasOwnProperty.call(store, key)) return !!defaultCollapsed;
|
||||
return !!store[key];
|
||||
}
|
||||
|
||||
export function toggleDisclosureState(
|
||||
store: Record<string, unknown> | null | undefined,
|
||||
key: string,
|
||||
defaultCollapsed: boolean,
|
||||
): boolean {
|
||||
const next = !getDisclosureState(store, key, defaultCollapsed);
|
||||
if (store && typeof store === "object") {
|
||||
store[key] = next;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
1493
paperforge/plugin/src/views/dashboard.ts
Normal file
1493
paperforge/plugin/src/views/dashboard.ts
Normal file
File diff suppressed because it is too large
Load diff
831
paperforge/plugin/src/views/modals.ts
Normal file
831
paperforge/plugin/src/views/modals.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { ACTIONS, buildCommandArgs, runSubprocess } = await import('../src/testable.js');
|
||||
import { ACTIONS } from "../src/constants";
|
||||
import { buildCommandArgs, runSubprocess } from "../src/services/python-bridge";
|
||||
|
||||
describe('ACTIONS', () => {
|
||||
it('has exactly 4 entries', () => {
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const {
|
||||
import {
|
||||
classifyError,
|
||||
buildRuntimeInstallCommand,
|
||||
parseRuntimeStatus,
|
||||
} = await import('../src/testable.js');
|
||||
} from "../src/services/python-bridge";
|
||||
|
||||
describe('classifyError', () => {
|
||||
it('classifies ENOENT as python_missing', () => {
|
||||
|
|
@ -1,18 +1,14 @@
|
|||
/**
|
||||
* Vitest tests for runtime.js — resolvePythonExecutable, getPluginVersion, checkRuntimeVersion.
|
||||
* Vitest tests for runtime functions — readPathConfig, resolveVaultPaths,
|
||||
* resolvePythonExecutable, getPluginVersion, checkRuntimeVersion.
|
||||
*
|
||||
* Uses dependency injection (last parameter) instead of vi.mock to avoid
|
||||
* CJS/ESM module mocking limitations in vitest v2.1.x.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const {
|
||||
readPathConfig,
|
||||
resolveRuntimePaths,
|
||||
resolvePythonExecutable,
|
||||
getPluginVersion,
|
||||
checkRuntimeVersion,
|
||||
} = await import('../src/testable.js');
|
||||
import { readPathConfig, resolveVaultPaths as resolveRuntimePaths } from "../src/services/memory-state";
|
||||
import { resolvePythonExecutable, getPluginVersion, checkRuntimeVersion } from "../src/services/python-bridge";
|
||||
|
||||
describe('readPathConfig', () => {
|
||||
it('uses vault_config system_dir when present', () => {
|
||||
|
|
@ -1,10 +1,6 @@
|
|||
import { describe, expect, test } from 'vitest';
|
||||
import testable from '../src/testable.js';
|
||||
|
||||
const {
|
||||
getDisclosureState,
|
||||
toggleDisclosureState,
|
||||
} = testable;
|
||||
import { getDisclosureState, toggleDisclosureState } from "../src/utils/disclosure";
|
||||
|
||||
describe('feature panel disclosure state', () => {
|
||||
test('defaults vector config panel to expanded when no state exists', () => {
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { shouldRenderVectorReady } from '../src/testable.js';
|
||||
import { shouldRenderVectorReady } from "../src/services/memory-state";
|
||||
|
||||
describe('shouldRenderVectorReady', () => {
|
||||
it('keeps vector advanced UI visible while build status text is temporarily null', () => {
|
||||
22
paperforge/plugin/tsconfig.json
Normal file
22
paperforge/plugin/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"lib": ["ES2018", "DOM"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { defineConfig } from 'vitest/config';
|
|||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['tests/**/*.test.mjs'],
|
||||
include: ['tests/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue