from __future__ import annotations import json import logging import re import shutil from json import JSONDecodeError from pathlib import Path from paperforge.config import paperforge_paths logger = logging.getLogger(__name__) # --- Constants --- STANDARD_VIEW_NAMES = frozenset( ["控制面板", "推荐分析", "待 OCR", "OCR 完成", "待深度阅读", "深度阅读完成", "正式卡片", "全记录"] ) # --- Journal Database --- from paperforge.core.io import read_json, write_json # noqa: F401 _JOURNAL_DB: dict[str, dict] | None = None def _extract_year(pubdate: str) -> str: """Extract a 4-digit year from a pubdate string.""" if not pubdate: return "" match = re.search(r"(\d{4})", str(pubdate)) return match.group(1) if match else "" def load_journal_db(vault: Path) -> dict[str, dict]: """Load zoterostyle.json journal database.""" global _JOURNAL_DB if _JOURNAL_DB is not None: return _JOURNAL_DB from paperforge.config import load_vault_config zoterostyle_path = vault / load_vault_config(vault)["system_dir"] / "Zotero" / "zoterostyle.json" if zoterostyle_path.exists(): try: _JOURNAL_DB = read_json(zoterostyle_path) except (JSONDecodeError, Exception): _JOURNAL_DB = {} else: _JOURNAL_DB = {} return _JOURNAL_DB def lookup_impact_factor(journal_name: str, extra: str, vault: Path) -> str: """Lookup impact factor: prefer zoterostyle.json, fallback to extra field.""" if not journal_name: return "" journal_db = load_journal_db(vault) if journal_name in journal_db: rank_data = journal_db[journal_name].get("rank", {}) if isinstance(rank_data, dict): sciif = rank_data.get("sciif", "") if sciif: return str(sciif) if extra: if_match = re.search("影响因子[::]\\s*([0-9.]+)", extra) if if_match: return if_match.group(1) return "" # --- JSON I/O --- # write_json re-exported from paperforge.core.io def read_jsonl(path: Path): rows = [] if not path.exists(): return rows for line in path.read_text(encoding="utf-8").splitlines(): line = line.strip() if line: rows.append(json.loads(line)) return rows def write_jsonl(path: Path, rows) -> None: path.parent.mkdir(parents=True, exist_ok=True) text = "\n".join(json.dumps(row, ensure_ascii=False) for row in rows) if text: text += "\n" path.write_text(text, encoding="utf-8") def sync_workspace_fulltext(source_fulltext: Path, target_fulltext: Path) -> bool: if not source_fulltext.exists(): return False if target_fulltext.exists(): try: if source_fulltext.read_text(encoding="utf-8") == target_fulltext.read_text(encoding="utf-8"): return False except Exception: pass target_fulltext.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(str(source_fulltext), str(target_fulltext)) return True # --- YAML Helpers --- def yaml_quote(value: str) -> str: if isinstance(value, bool): return "true" if value else "false" return '"' + str(value or "").replace("\\", "\\\\").replace('"', '\\"') + '"' def yaml_block(value: str) -> list[str]: value = (value or "").strip() if not value: return ["abstract: |-", " "] lines = ["abstract: |-"] for line in value.splitlines(): lines.append(f" {line}") return lines def yaml_list(key: str, values) -> list[str]: cleaned = [str(value).strip() for value in values or [] if value is not None and str(value).strip()] if not cleaned: return [f"{key}: []"] lines = [f"{key}:"] for value in cleaned: lines.append(f" - {yaml_quote(value)}") return lines # --- String / Path Utils --- def slugify_filename(text: str) -> str: cleaned = re.sub('[<>:"/\\\\|?*]+', "", text).strip() cleaned = cleaned[:120].rstrip(" .") return cleaned or "untitled" # --- Deep-Reading Queue --- def _resolve_formal_note_path(vault: Path, zotero_key: str, domain: str) -> Path | None: """Resolve formal literature note by zotero_key.""" from paperforge.config import paperforge_paths lit_root = paperforge_paths(vault)["literature"] domain_dir = lit_root / domain if not domain_dir.exists(): return None frontmatter_pattern = re.compile(rf'^\s*zotero_key:\s*"?{re.escape(zotero_key)}"?\s*$', re.MULTILINE) for note_path in domain_dir.rglob("*.md"): try: text = note_path.read_text(encoding="utf-8") except UnicodeDecodeError: text = note_path.read_text(encoding="utf-8", errors="ignore") if frontmatter_pattern.search(text): return note_path return None def get_analyze_queue(vault: Path) -> list[dict]: """Scan formal literature notes for analyze=true entries. Reads frontmatter directly from formal notes — no intermediary index. This eliminates the stale-index problem: when a user ticks analyze in the Base view (which updates formal note frontmatter), the queue picks it up immediately without requiring a sync. Return value fields: - zotero_key: str - domain: str - title: str - analyze: bool (always True in results) - do_ocr: bool - deep_reading_status: str - ocr_status: str - note_path: Path | None """ from paperforge.config import paperforge_paths paths = paperforge_paths(vault) lit_root = paths.get("literature") if not lit_root or not lit_root.exists(): return [] results = [] for note_file in lit_root.rglob("*.md"): if note_file.name in ("fulltext.md", "deep-reading.md", "discussion.md"): continue try: text = note_file.read_text(encoding="utf-8") except Exception: continue # Quick exit: check analyze before extracting other fields analyze_match = re.search( r"^analyze:\s*(?:[\"'])?(true|false)(?:[\"'])?\s*$", text, re.MULTILINE | re.IGNORECASE ) if not analyze_match or analyze_match.group(1).lower() != "true": continue zotero_key = "" key_match = re.search(r'^zotero_key:\s*"?(.+?)"?\s*$', text, re.MULTILINE) if key_match: zotero_key = key_match.group(1).strip() domain = "" domain_match = re.search(r'^domain:\s*"?(.+?)"?\s*$', text, re.MULTILINE) if domain_match: domain = domain_match.group(1).strip() title = "" title_match = re.search(r'^title:\s*"?(.+?)"?\s*$', text, re.MULTILINE) if title_match: title = title_match.group(1).strip() do_ocr = False do_ocr_match = re.search(r"^do_ocr:\s*(?:[\"'])?(true|false)(?:[\"'])?\s*$", text, re.MULTILINE | re.IGNORECASE) if do_ocr_match: do_ocr = do_ocr_match.group(1).lower() == "true" ocr_status = "pending" ocr_match = re.search(r'^ocr_status:\s*"?(.+?)"?\s*$', text, re.MULTILINE) if ocr_match: ocr_status = ocr_match.group(1).strip() dr_status = "pending" dr_match = re.search(r'^deep_reading_status:\s*"?(.+?)"?\s*$', text, re.MULTILINE) if dr_match: dr_status = dr_match.group(1).strip() results.append( { "zotero_key": zotero_key, "domain": domain, "title": title, "analyze": True, "do_ocr": do_ocr, "ocr_status": ocr_status, "deep_reading_status": dr_status, "note_path": note_file, } ) results.sort(key=lambda r: (r["domain"], r["zotero_key"])) return results def pipeline_paths(vault: Path) -> dict[str, Path]: """Build complete PaperForge path inventory — delegates to shared resolver.""" shared = paperforge_paths(vault) root = shared["paperforge"] control_root = shared["control"] return { **shared, "pipeline": root, "candidates": root / "candidates" / "candidates.json", "candidate_inbox": root / "candidates" / "inbox", "candidate_archive": root / "candidates" / "archive", "search_tasks": root / "search" / "tasks", "search_archive": root / "search" / "archive", "search_results": root / "search" / "results", "harvest_root": root / "skill-prototypes" / "zotero-review-manuscript-writer", "records": control_root / "candidate-records", "review": root / "candidates" / "review-latest.md", "config": root / "config" / "domain-collections.json", "queue": root / "writeback" / "writeback-queue.jsonl", "log": root / "writeback" / "writeback-log.jsonl", "bridge_config": root / "zotero-bridge" / "bridge-config.json", "bridge_config_sample": root / "zotero-bridge" / "bridge-config.sample.json", "index": root / "indexes" / "formal-library.json", "ocr_queue": root / "ocr" / "ocr-queue.json", } def install_obsidian_plugin(vault: Path) -> bool: """Copy Obsidian plugin files into .obsidian/plugins/paperforge/. Source priority: vault copy (git/zip) -> Python package (pip). Lives in _utils.py so it can be reloaded after update. """ try: plugin_dst = vault / ".obsidian" / "plugins" / "paperforge" plugin_src = vault / "paperforge" / "plugin" if not plugin_src.is_dir(): import paperforge plugin_src = Path(paperforge.__file__).parent.resolve() / "plugin" if not plugin_src.is_dir(): logger.warning("Plugin source not found: %s", plugin_src) return False plugin_dst.mkdir(parents=True, exist_ok=True) count = 0 for f in plugin_src.glob("*"): if f.is_file(): shutil.copy2(f, plugin_dst / f.name) count += 1 if count: logger.info("Obsidian plugin installed: %d files -> %s", count, plugin_dst) return True except Exception as e: logger.warning("Failed to install Obsidian plugin: %s", e) return False