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:
Research Assistant 2026-05-24 19:49:11 +08:00
commit 6c096fd49c
23 changed files with 7394 additions and 5533 deletions

View file

@ -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_

File diff suppressed because it is too large Load diff

View 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

File diff suppressed because it is too large Load diff

View file

@ -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"
}
}

View 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;
}

View 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;
}

View 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);
}
}

View 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;
}

View 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;
}

File diff suppressed because it is too large Load diff

View file

@ -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,
};

View 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;
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View file

@ -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', () => {

View 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"]
}

View file

@ -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,
},
});