feat: preserve user tags during sync

This commit is contained in:
Research Assistant 2026-05-31 20:01:16 +08:00
parent 6039c78ff8
commit acb0c2bcb5
4 changed files with 88 additions and 11 deletions

View file

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

View file

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

View file

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

View file

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