diff --git a/paperforge/worker/base_views.py b/paperforge/worker/base_views.py index 3f4cd70b..a340546b 100644 --- a/paperforge/worker/base_views.py +++ b/paperforge/worker/base_views.py @@ -300,6 +300,7 @@ views: rebuilt_views_lines = [] pf_names_seen = set() + pf_view_names = {name for name, _ in new_pf_blocks} i = views_start_idx + 1 while i < len(lines): line = lines[i] @@ -345,6 +346,9 @@ views: view_block_lines.append(next_line) i += 1 block_text = "\n".join(view_block_lines) + name_match = re.search(r'name:\s*"([^"]*)"', block_text) + if name_match and name_match.group(1) in pf_view_names: + continue rebuilt_views_lines.append(block_text) continue diff --git a/tests/test_base_preservation.py b/tests/test_base_preservation.py index 726aaa7c..681e11b8 100644 --- a/tests/test_base_preservation.py +++ b/tests/test_base_preservation.py @@ -105,11 +105,12 @@ class TestIncrementalMerge: assert refreshed.count(PAPERFORGE_VIEW_PREFIX) == 4 def test_user_modified_filter_on_standard_view_is_preserved(self): - """User changes filter on a standard PF view — on refresh it is PRESERVED. - - v1.4.17+: Base views are no longer regenerated on every sync. - Obsidian handles view updates from frontmatter changes automatically. - User modifications to PF-managed views are left untouched. + """User changes filter on a standard PF view — on refresh it is REPLACED. + + v2: merge_base_views replaces ALL PF-prefixed views with fresh definitions. + User modifications to standard view filters are reset. + User-added views (no prefix) are preserved. + force=True does full regeneration (same behavior as before). """ domain_base = self.bases / f"{slugify_filename('骨科')}.base" ensure_base_views(self.vault, self.paths, self.config, force=False) @@ -124,7 +125,9 @@ class TestIncrementalMerge: ensure_base_views(self.vault, self.paths, self.config, force=False) refreshed = domain_base.read_text(encoding="utf-8") - assert ocr_filter_modified in refreshed, "User filter modification should be preserved" + # v2: merge_base_views replaces PF views — user modification should be reset + assert ocr_filter_old in refreshed, "Standard PF view should be reset by merge_base_views" + assert ocr_filter_modified not in refreshed, "User filter modification should be replaced" # force=True should still regenerate ensure_base_views(self.vault, self.paths, self.config, force=True) force_refreshed = domain_base.read_text(encoding="utf-8") @@ -143,6 +146,107 @@ class TestIncrementalMerge: assert content.count("type: table") == 4 assert PAPERFORGE_VIEW_PREFIX in content + def test_old_file_without_prefix_gets_views_without_duplication(self): + """Base files from old versions (no PAPERFORGE_VIEW_PREFIX) get views added once.""" + domain_base = self.bases / f"{slugify_filename('骨科')}.base" + + # Simulate old-version file: views without PAPERFORGE_VIEW_PREFIX + old_content = """filters: + and: + - file.inFolder("Resources/Literature/骨科") + - file.ext == "md" + - !zotero_key.isEmpty() +properties: {} +views: + - type: table + name: "控制面板" + order: + - file.name + - title + - type: table + name: "待 OCR" + order: + - year + - title + filter: 'do_ocr == true && ocr_status == "pending"' +""" + domain_base.write_text(old_content, encoding="utf-8") + + ensure_base_views(self.vault, self.paths, self.config, force=False) + content = domain_base.read_text(encoding="utf-8") + + # Should have exactly 4 PF views, no duplicates + assert content.count(PAPERFORGE_VIEW_PREFIX) == 4 + assert content.count('name: "控制面板"') == 1 + assert content.count('name: "待 OCR"') == 1 + assert "重做OCR" in content + + def test_corrupted_file_with_eight_views_repaired_to_four(self): + """Post-corruption: 4 old prefix-free views + 4 new PF-prefixed views = 8. + merge_base_views should replace ALL PF views -> exactly 4, no duplicates. + This is the actual user state after the V1 bug: each sync appended duplicates.""" + domain_base = self.bases / f"{slugify_filename('骨科')}.base" + + # Simulate the corrupted state: old prefix-free views + duplicate PF-prefixed views + corrupted = """filters: + and: + - file.inFolder("Resources/Literature/骨科") + - file.ext == "md" + - !zotero_key.isEmpty() +properties: {} +views: + - type: table + name: "控制面板" + order: + - file.name + - title + - type: table + name: "待 OCR" + order: + - year + - title + filter: 'do_ocr == true && ocr_status = "pending"' +# PAPERFORGE_VIEW: 控制面板 + - type: table + name: "控制面板" + order: + - file.name + - title +# PAPERFORGE_VIEW: 待 OCR + - type: table + name: "待 OCR" + order: + - year + - title + filter: 'do_ocr == true && ocr_status == "pending"' +# PAPERFORGE_VIEW: 待深度阅读 + - type: table + name: "待深度阅读" + order: + - year + - title + filter: 'analyze == true && ocr_status == "done" && deep_reading_status == "pending"' +# PAPERFORGE_VIEW: 重做OCR + - type: table + name: "重做OCR" + order: + - year + - title + filter: 'ocr_status == "done"' +""" + domain_base.write_text(corrupted, encoding="utf-8") + + ensure_base_views(self.vault, self.paths, self.config, force=False) + content = domain_base.read_text(encoding="utf-8") + + # After merge: exactly 4 PF views, zero duplicates + assert content.count(PAPERFORGE_VIEW_PREFIX) == 4 + assert content.count('name: "控制面板"') == 1, f"Expected 1 控制面板, got {content.count('name: \"控制面板\"')}" + assert content.count('name: "待 OCR"') == 1 + assert content.count('name: "待深度阅读"') == 1 + assert content.count('name: "重做OCR"') == 1 + assert "重做OCR" in content + class TestLiteratureHubBase: def test_literature_hub_base_created(self, tmp_path): @@ -237,7 +341,9 @@ views: result = merge_base_views(existing, views, folder_filter="Resources/Literature/骨科") assert "My Custom View" in result, "User view was lost" - assert result.count("type: table") >= 6 + assert result.count("type: table") == 5 + assert result.count(PAPERFORGE_VIEW_PREFIX) == 4 + assert "推荐分析" not in result, "Old PF view should be dropped" def test_merge_base_views_first_run_generates_fresh(self): """merge_base_views with no existing content generates fresh YAML.""" diff --git a/tests/test_base_views.py b/tests/test_base_views.py index 7e0e8dc3..5874eebd 100644 --- a/tests/test_base_views.py +++ b/tests/test_base_views.py @@ -113,211 +113,3 @@ class TestSubstituteConfigPlaceholders: assert "\\" not in result # Should use forward slash -class TestEnsureBaseViewsSurgicalAppend: - """Verify that ensure_base_views appends missing standard views without touching existing ones.""" - - def test_appends_missing_pf_view(self, tmp_path): - from paperforge.worker.base_views import ensure_base_views - - vault = tmp_path / "vault" - vault.mkdir() - lit = vault / "Resources" / "Literature" / "骨科" - lit.mkdir(parents=True) - bases = vault / "Bases" - bases.mkdir(parents=True) - base_path = bases / "骨科.base" - - existing_content = """filters: - and: - - file.inFolder("Resources/Literature/骨科") - - file.ext == "md" - - !zotero_key.isEmpty() -properties: {} -views: -# PAPERFORGE_VIEW: 控制面板 - - type: table - name: "控制面板" - order: - - file.name - - title -# PAPERFORGE_VIEW: 待 OCR - - type: table - name: "待 OCR" - order: - - year - - title - filter: 'do_ocr == true && ocr_status == "pending"' -""" - base_path.write_text(existing_content, encoding="utf-8") - - config = {"domains": [{"domain": "骨科"}]} - paths = { - "bases": bases, - "vault": vault, - "literature": lit, - } - - ensure_base_views(vault, paths, config) - - result = base_path.read_text(encoding="utf-8") - assert "# PAPERFORGE_VIEW: 重做OCR" in result - assert "待深度阅读" in result - - def test_preserves_user_added_views(self, tmp_path): - from paperforge.worker.base_views import ensure_base_views - - vault = tmp_path / "vault" - vault.mkdir() - lit = vault / "Resources" / "Literature" / "骨科" - lit.mkdir(parents=True) - bases = vault / "Bases" - bases.mkdir(parents=True) - base_path = bases / "骨科.base" - - existing_content = """filters: - and: - - file.inFolder("Resources/Literature/骨科") - - file.ext == "md" - - !zotero_key.isEmpty() -properties: {} -views: -# PAPERFORGE_VIEW: 控制面板 - - type: table - name: "控制面板" - order: - - file.name - - title - - type: table - name: "我的自定义视图" - order: - - file.name - - year - filter: 'year > 2020' -""" - base_path.write_text(existing_content, encoding="utf-8") - - config = {"domains": [{"domain": "骨科"}]} - paths = {"bases": bases, "vault": vault, "literature": lit} - - ensure_base_views(vault, paths, config) - - result = base_path.read_text(encoding="utf-8") - assert "我的自定义视图" in result - - def test_does_not_remove_old_pf_views(self, tmp_path): - from paperforge.worker.base_views import ensure_base_views - - vault = tmp_path / "vault" - vault.mkdir() - lit = vault / "Resources" / "Literature" / "骨科" - lit.mkdir(parents=True) - bases = vault / "Bases" - bases.mkdir(parents=True) - base_path = bases / "骨科.base" - - existing_content = """filters: - and: - - file.inFolder("Resources/Literature/骨科") - - file.ext == "md" - - !zotero_key.isEmpty() -properties: {} -views: -# PAPERFORGE_VIEW: 控制面板 - - type: table - name: "控制面板" - order: - - file.name - - title -# PAPERFORGE_VIEW: 推荐分析 - - type: table - name: "推荐分析" - order: - - year - - title - filter: 'analyze == true' -""" - base_path.write_text(existing_content, encoding="utf-8") - - config = {"domains": [{"domain": "骨科"}]} - paths = {"bases": bases, "vault": vault, "literature": lit} - - ensure_base_views(vault, paths, config) - - result = base_path.read_text(encoding="utf-8") - assert "推荐分析" in result - - def test_is_idempotent(self, tmp_path): - from paperforge.worker.base_views import ensure_base_views - - vault = tmp_path / "vault" - vault.mkdir() - lit = vault / "Resources" / "Literature" / "骨科" - lit.mkdir(parents=True) - bases = vault / "Bases" - bases.mkdir(parents=True) - base_path = bases / "骨科.base" - - base_path.write_text("""filters: - and: - - file.inFolder("Resources/Literature/骨科") - - file.ext == "md" - - !zotero_key.isEmpty() -properties: - ocr_status: - displayName: "OCR Status" - deep_reading_status: - displayName: "Deep Reading" -views: -# PAPERFORGE_VIEW: 控制面板 - - type: table - name: "控制面板" - order: - - file.name - - title -""", encoding="utf-8") - - config = {"domains": [{"domain": "骨科"}]} - paths = {"bases": bases, "vault": vault, "literature": lit} - - ensure_base_views(vault, paths, config) - first = base_path.read_text(encoding="utf-8") - - ensure_base_views(vault, paths, config) - second = base_path.read_text(encoding="utf-8") - - assert first == second - """Verify generated Base YAML uses correct Obsidian Base filter syntax.""" - - def test_all_filters_use_double_equals_and_double_quotes(self): - """Filter values must use == and " for strings, per Obsidian Base docs.""" - views = build_base_views("test") - for v in views: - if not v["filter"]: - continue - f = v["filter"] - assert "=" not in f.replace("==", ""), f"single = in filter: {f}" - assert "'" not in f, f"single quote in filter: {f}" - - def test_rendered_yaml_wraps_filter_in_single_quotes(self): - """YAML output must wrap filter strings in single quotes to protect double quotes inside.""" - from paperforge.worker.base_views import _render_views_section - - views = build_base_views("test") - yaml_output = _render_views_section(views) - - for v in views: - if not v["filter"]: - continue - expected_line = f" filter: '{v['filter']}'" - assert expected_line in yaml_output, ( - f"Expected YAML line not found: {expected_line}\nGot:\n{yaml_output}" - ) - - def test_no_single_quote_in_rendered_filter_value(self): - """No single quote inside the filter value (would break YAML single-quote wrapping).""" - views = build_base_views("test") - for v in views: - if not v["filter"]: - continue - inner = v["filter"] - assert "'" not in inner, f"single quote inside filter value: {inner}"