From 4786693e1f0b768d42154daca3b5aed251101d6e Mon Sep 17 00:00:00 2001 From: Tim Wortley <5639920+twortley@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:24:38 -0400 Subject: [PATCH] Parser fixes: keep distinct same-titled pages, use current page metadata, survive pyOneNote terminator crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three fixes in the .one page-building path, validated against real notebooks and a small synthetic fixture (tests/test_data/ExporterEval, dummy content): 1. Distinct pages that share a title are no longer dropped. `_build_pages` built one page per page-node GUID (correct) and then collapsed pages by lowercased title, silently discarding legitimately-distinct pages that share a name (e.g. repeated subpages, many "Notes"). Removed the title collapse; identity is the page-node GUID only. 2. Use the current page-metadata revision, not a stale one. A page's object space holds multiple PageMetaData revisions; the GUID->meta lookup overwrote with whichever was processed last — a stale copy with an outdated PageLevel and/or title (from before a page was renamed or promoted/demoted to a subpage). Keep the first (current) metadata. 3. Survive the pyOneNote FileNodeListFragment terminator crash. A terminator node (file_node_id 0/255, baseType 2) is dereferenced before the fragment loop's terminator-break runs, so heavily-revised sections raise AttributeError. Added a guard in _patch_pyonenote(). This is a workaround for an upstream pyOneNote bug (a fix is proposed separately to DissectMalware/pyOneNote); it can be removed once that is released. Adds tests/test_eval_notebook.py (parser-level asserts) + the fixture. --- onenote_export/parser/one_store.py | 70 ++++++++++++++++----- tests/test_data/ExporterEval/DupTitles.one | Bin 0 -> 30592 bytes tests/test_data/ExporterEval/Hierarchy.one | Bin 0 -> 48488 bytes tests/test_eval_notebook.py | 52 +++++++++++++++ 4 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 tests/test_data/ExporterEval/DupTitles.one create mode 100644 tests/test_data/ExporterEval/Hierarchy.one create mode 100644 tests/test_eval_notebook.py diff --git a/onenote_export/parser/one_store.py b/onenote_export/parser/one_store.py index 62016a8..4e00381 100644 --- a/onenote_export/parser/one_store.py +++ b/onenote_export/parser/one_store.py @@ -33,7 +33,25 @@ def _patch_pyonenote() -> None: cProperties : uint32 — number of child PropertySets prid : PropertyID (4 bytes) — only if cProperties > 0 Data : cProperties consecutive PropertySet structures + + 3. ``FileNode.__init__`` crashes on a FileNodeListFragment + *terminator* node. A terminator has ``file_node_id == 0`` (or + 255), which is not in pyOneNote's ``_FileNodeIDs`` map, so its + type resolves to ``"Invalid"`` and the dispatch chain's silent + ``else`` branch never sets ``self.data``. The constructor then + unconditionally recurses for reference nodes + (``if baseType == 2: FileNodeList(..., self.data.ref)``) and + dereferences the missing ``self.data``. The fragment loop *does* + break on ``id in (0, 255)`` — but only *after* the node is + constructed, so the crash beats the guard. Files whose root + fragment ends on a "dirty" terminator (id 0 with non-zero upper + bits, e.g. large/heavily-revised sections) fail to parse at all. + + We wrap the constructor so a terminator node that raises during + construction is treated as empty (``data = None``, no children); + the existing fragment-loop break then ends the fragment cleanly. """ + from pyOneNote.FileNode import FileNode as _FileNode from pyOneNote.FileNode import ObjectSpaceObjectStreamOfIDs _original_read = ObjectSpaceObjectStreamOfIDs.read @@ -121,6 +139,26 @@ def _patched_init( PropertySet.__init__ = _patched_init + _original_filenode_init = _FileNode.__init__ + + def _patched_filenode_init(self, file, document): + try: + _original_filenode_init(self, file, document) + except AttributeError: + # Only swallow the terminator case (id 0/255); anything else + # is a genuine parse error and must propagate. + header = getattr(self, "file_node_header", None) + fnid = getattr(header, "file_node_id", None) + if fnid in (0, 255): + if not hasattr(self, "data"): + self.data = None + if not hasattr(self, "children"): + self.children = [] + else: + raise + + _FileNode.__init__ = _patched_filenode_init + # Apply patches at import time _patch_pyonenote() @@ -350,12 +388,19 @@ def _build_pages(self, objects: list[ExtractedObject]) -> list[ExtractedPage]: pages.append(ExtractedPage(objects=all_content)) return pages - # Build a lookup: GUID -> page metadata + # Build a lookup: GUID -> page metadata. + # + # A page's object space contains MULTIPLE metadata revisions. The + # first one encountered in traversal order is the current revision; + # later ones are stale copies carrying an outdated PageLevel and/or + # title (from before the page was renamed or promoted/demoted to a + # subpage). Keep the first and never overwrite — overwriting with a + # stale copy corrupts both the subpage level and the title. meta_by_guid: dict[str, ExtractedObject] = {} for meta in page_metas: guid = _extract_guid(meta.identity) - # Later entries (newer revisions) overwrite earlier ones - meta_by_guid[guid] = meta + if guid not in meta_by_guid: + meta_by_guid[guid] = meta # Orphan metas: metadata GUIDs with no page node (old revisions) orphan_metas: dict[str, ExtractedObject] = { @@ -375,8 +420,13 @@ def _build_pages(self, objects: list[ExtractedObject]) -> list[ExtractedPage]: _NUMBER_LIST, } - # Build one page per content GUID (GUID that has a PageNode) - seen_titles: dict[str, int] = {} + # Build one page per content GUID (GUID that has a PageNode). + # Each unique page-node GUID is a distinct page; revisions of the + # *same* page already share a GUID and are collapsed by the + # ``page_node_guids`` uniqueness above. We deliberately do NOT + # deduplicate by title — distinct pages legitimately share titles + # (e.g. repeated "Example Output" subpages), and collapsing them + # silently drops real content. for content_guid in page_node_guids: objs = guid_objects.get(content_guid, []) @@ -418,15 +468,7 @@ def _build_pages(self, objects: list[ExtractedObject]) -> list[ExtractedPage]: objects=content, ) - # Deduplicate by title — keep the version with more content - key = title.lower().strip() - if key in seen_titles: - idx = seen_titles[key] - if len(content) > len(pages[idx].objects): - pages[idx] = page - else: - seen_titles[key] = len(pages) - pages.append(page) + pages.append(page) return pages diff --git a/tests/test_data/ExporterEval/DupTitles.one b/tests/test_data/ExporterEval/DupTitles.one new file mode 100644 index 0000000000000000000000000000000000000000..0c7be183754c5d687ead637821be6e9801cc5150 GIT binary patch literal 30592 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 LYElAncgi$tUA(ygoP18h6T+*;b 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 literal 0 HcmV?d00001 diff --git a/tests/test_eval_notebook.py b/tests/test_eval_notebook.py new file mode 100644 index 0000000..86b0eaf --- /dev/null +++ b/tests/test_eval_notebook.py @@ -0,0 +1,52 @@ +"""Parser tests against the shareable ``ExporterEval`` fixture notebook. + +``ExporterEval`` is a small, synthetic (dummy-content) OneNote notebook built to +exercise these parser fixes, so it can be committed as public test data: + + * ``DupTitles`` — distinct pages that share a title must all survive (no + title-based collapse). + * ``Hierarchy`` — page display order and subpage levels (1-3) come straight + from the ``.one`` binary. +""" + +from pathlib import Path + +import pytest + +from onenote_export.parser.one_store import OneStoreParser + +EVAL = Path(__file__).parent / "test_data" / "ExporterEval" +_HAS_EVAL = EVAL.exists() and any(EVAL.glob("*.one")) + +pytestmark = pytest.mark.skipif( + not _HAS_EVAL, reason="ExporterEval fixture not available" +) + + +def _parse(name: str): + return OneStoreParser(EVAL / name).parse() + + +class TestDupTitles: + def test_all_same_titled_pages_retained(self): + sec = _parse("DupTitles.one") + titles = [p.title for p in sec.pages] + assert len(sec.pages) == 4 + assert titles.count("Note") == 3 # three distinct pages titled "Note" + assert "Unique" in titles + + +class TestHierarchy: + EXPECTED = [ + ("Zeta", 1), + ("Zeta-Sub-1", 2), + ("Zeta-Sub-2", 2), + ("Zeta-Sub-2a", 3), # level-3 subpage + ("Alpha", 1), + ("Alpha-Sub-1", 2), + ("Mike", 1), # leaf; last despite "M" — order is display order, not alphabetical + ] + + def test_order_and_levels(self): + sec = _parse("Hierarchy.one") + assert [(p.title, p.level) for p in sec.pages] == self.EXPECTED