From acb0c2bcb5818d0b65a104566c333233b7b6085e Mon Sep 17 00:00:00 2001 From: Research Assistant Date: Sun, 31 May 2026 20:01:16 +0800 Subject: [PATCH] feat: preserve user tags during sync --- paperforge/adapters/obsidian_frontmatter.py | 10 ++-- paperforge/worker/asset_index.py | 6 ++- paperforge/worker/sync.py | 24 +++++++-- tests/test_asset_index.py | 59 ++++++++++++++++++++- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/paperforge/adapters/obsidian_frontmatter.py b/paperforge/adapters/obsidian_frontmatter.py index 0965d1d5..7f1347d4 100644 --- a/paperforge/adapters/obsidian_frontmatter.py +++ b/paperforge/adapters/obsidian_frontmatter.py @@ -354,8 +354,10 @@ def _normalize_tags(tags_value: object) -> list[str]: def extract_preserved_tags(text: str) -> list[str] | None: """Extract preserved tags from existing note frontmatter. - Returns None when frontmatter is malformed/unreadable. - Returns a normalized list of tag strings otherwise. + Returns None when frontmatter is malformed/unreadable, or when the + tags key is absent (caller should supply defaults). + Returns a normalized list of tag strings otherwise (empty list means + tags were explicitly set to empty). Only trusts YAML-parsed data — does not use regex fallback. """ fm_match = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL) @@ -363,7 +365,7 @@ def extract_preserved_tags(text: str) -> list[str] | None: if not fm_match: if text.strip().startswith("---"): return None - return [] + return None try: import yaml @@ -371,6 +373,8 @@ def extract_preserved_tags(text: str) -> list[str] | None: data = yaml.safe_load(fm_match.group(1)) if not isinstance(data, dict): return None + if "tags" not in data: + return None return _normalize_tags(data.get("tags")) except Exception: return None diff --git a/paperforge/worker/asset_index.py b/paperforge/worker/asset_index.py index 19318c45..921efd1c 100644 --- a/paperforge/worker/asset_index.py +++ b/paperforge/worker/asset_index.py @@ -37,6 +37,7 @@ import filelock from paperforge import __version__ as _paperforge_version from paperforge.adapters.obsidian_frontmatter import ( _legacy_control_flags, + extract_preserved_tags, read_frontmatter_dict, ) from paperforge.config import paperforge_paths @@ -269,6 +270,7 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir: import shutil from paperforge import __version__ as PAPERFORGE_VERSION + from paperforge.adapters.obsidian_frontmatter import has_deep_reading_content from paperforge.worker._utils import lookup_impact_factor, read_json, slugify_filename, write_json, yaml_quote from paperforge.worker.asset_state import ( compute_health, @@ -278,7 +280,6 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir: ) from paperforge.worker.ocr import validate_ocr_meta from paperforge.worker.paper_meta import write_paper_meta - from paperforge.adapters.obsidian_frontmatter import has_deep_reading_content from paperforge.worker.sync import ( collection_fields, frontmatter_note, @@ -459,7 +460,8 @@ def _build_entry(item: dict, vault: Path, paths: dict, domain: str, zotero_dir: fm_close = text.find("---\n", 4) # closing --- after opening --- if fm_close != -1: body = text[fm_close + 4 :] # everything after frontmatter - new_full = frontmatter_note(entry, "") + preserved_tags = extract_preserved_tags(text) + new_full = frontmatter_note(entry, text, preserved_tags=preserved_tags) new_fm_close = new_full.find("---\n", 4) if new_fm_close != -1: new_fm = new_full[: new_fm_close + 4] # new frontmatter block with closing ---\n diff --git a/paperforge/worker/sync.py b/paperforge/worker/sync.py index 331269a2..f668cd8b 100644 --- a/paperforge/worker/sync.py +++ b/paperforge/worker/sync.py @@ -28,6 +28,7 @@ from paperforge.adapters.obsidian_frontmatter import ( canonicalize_decision, compute_final_collection, extract_preserved_deep_reading, + extract_preserved_tags, update_frontmatter_field, ) from paperforge.adapters.zotero_paths import ( @@ -994,8 +995,10 @@ def next_key(domain: str, export_rows: list[dict]) -> str: return f"{prefix}{max_num + 1:03d}" -def frontmatter_note(entry: dict, existing_text: str = "") -> str: +def frontmatter_note(entry: dict, existing_text: str = "", preserved_tags: list[str] | None = None) -> str: preserved_deep = extract_preserved_deep_reading(existing_text) + if preserved_tags is None and existing_text: + preserved_tags = extract_preserved_tags(existing_text) first_author = entry.get("first_author", "") if not first_author: authors = entry.get("authors", []) @@ -1028,9 +1031,22 @@ def frontmatter_note(entry: dict, existing_text: str = "") -> str: f"deep_reading_status: {yaml_quote(entry.get('deep_reading_status', 'pending'))}", f"pdf_path: {yaml_quote(entry.get('pdf_path', ''))}", f"fulltext_md_path: {yaml_quote('[[{}]]'.format(entry['fulltext_path']) if entry.get('ocr_status') == 'done' and entry.get('fulltext_path') else '')}", - "tags:", - " - 文献阅读", - f" - {entry.get('domain', '')}", + ] + ) + if preserved_tags is not None: + lines.append("tags:") + for tag in preserved_tags: + lines.append(f" - {tag}") + else: + lines.extend( + [ + "tags:", + " - 文献阅读", + f" - {entry.get('domain', '')}", + ] + ) + lines.extend( + [ "---", "", f"# {entry.get('title', '')}", diff --git a/tests/test_asset_index.py b/tests/test_asset_index.py index 6cbc6803..9da50d50 100644 --- a/tests/test_asset_index.py +++ b/tests/test_asset_index.py @@ -8,7 +8,6 @@ from __future__ import annotations import json import os import threading -import time from pathlib import Path import filelock @@ -17,13 +16,13 @@ import pytest from paperforge.worker.asset_index import ( CURRENT_SCHEMA_VERSION, INDEX_FILENAME, - LOCK_TIMEOUT, atomic_write_index, build_envelope, build_index, get_index_path, summarize_index, ) +from paperforge.worker.sync import frontmatter_note class TestGetIndexPath: @@ -373,6 +372,62 @@ def _minimal_vault(tmp_path: Path) -> Path: return vault +class TestFrontmatterNoteTagPreservation: + """Unit tests for frontmatter_note() tag preservation behavior.""" + + def _make_entry(self, zotero_key: str, domain: str = "test") -> dict: + return { + "title": "Test Paper", + "domain": domain, + "zotero_key": zotero_key, + "first_author": "Author", + "authors": ["Author"], + "doi": "", + "pmid": "", + "year": "", + "journal": "", + "citation_key": "", + "collections": [], + "collection_tags": [], + "collection_path": "", + "impact_factor": "", + "abstract": "", + "has_pdf": False, + "do_ocr": False, + "ocr_status": "pending", + "deep_reading_status": "pending", + "pdf_path": "", + "fulltext_path": "", + } + + def test_frontmatter_note_preserves_custom_tags(self): + entry = self._make_entry("ABC1") + existing_text = "---\ntags:\n - mine\n - keep\n---\nmy body\n" + result = frontmatter_note(entry, existing_text) + assert " - mine" in result + assert " - keep" in result + assert " - 文献阅读" not in result + + def test_frontmatter_note_adds_defaults_when_no_tags(self): + entry = self._make_entry("ABC2") + existing_text = "---\ntitle: Test Paper\n---\nbody\n" + result = frontmatter_note(entry, existing_text) + assert " - 文献阅读" in result + assert " - test" in result + + def test_frontmatter_note_preserves_empty_tags(self): + entry = self._make_entry("ABC3") + existing_text = "---\ntags: []\n---\nbody\n" + result = frontmatter_note(entry, existing_text) + assert " - 文献阅读" not in result + + def test_frontmatter_note_falls_back_when_malformed(self): + entry = self._make_entry("ABC4") + existing_text = "---\ntags:\n - foo\nbody without closing frontmatter" + result = frontmatter_note(entry, existing_text) + assert " - 文献阅读" in result + + def _ensure_domain_config(vault: Path) -> None: """Create domain config so load_domain_config returns a valid configuration.""" from paperforge.config import paperforge_paths as _pp