"""Knowtation-specific merge strategy implementations. These functions are called by the Knowtation merger (Phase 2.4) when :func:`~muse.core.attributes.resolve_strategy` returns a Knowtation-specific strategy string. They share a common signature:: fn(ours: bytes, theirs: bytes, base: bytes) -> bytes *ours* — the current-branch (left) version of the note. *theirs* — the incoming-branch (right) version of the note. *base* — the common merge-base version of the note. All three arguments are raw Markdown bytes (UTF-8, LF line endings normalised by the caller). Return value is the resolved content as raw bytes. Strategies defined here ----------------------- ``prefer-newer-date`` → :func:`prefer_newer_date` ``union-sorted`` → :func:`union_sorted` ``knowtation-3way`` → :func:`knowtation_3way` Security notes -------------- - Frontmatter is parsed with ``yaml.safe_load`` only. - Date comparisons use ``datetime.date.fromisoformat`` — invalid strings are caught and treated as missing rather than propagating exceptions. - Input size is not capped here (the merge engine is responsible for that); however, none of the implementations do anything super-linear on input size. OBA review note (prefer-newer-date fallback) -------------------------------------------- The fallback hierarchy was chosen after analysis of the three failure modes: 1. **Both dates missing**: No temporal signal — default to ``ours`` (current branch is the authoritative capture point). 2. **One date missing**: The versioned note carries deliberate metadata; prefer the versioned side regardless of which branch it is on. 3. **Dates equal**: Tie-break with ``ours`` (current branch wins; import pipelines that produce the same timestamp for the same event will never trigger an unwanted remote override). 4. **Date malformed** (e.g. ``date: "yesterday"``): Treat as missing; do not crash — SPEC §2.2 allows free-form date strings for human notes. """ from __future__ import annotations import logging from datetime import date, datetime from typing import Any, Callable logger = logging.getLogger(__name__) # Strategy function type. StrategyFn = Callable[[bytes, bytes, bytes], bytes] # --------------------------------------------------------------------------- # Internal helpers # --------------------------------------------------------------------------- def _parse_date(fm_date: str | None) -> date | None: """Parse a SPEC §2.1 date string → ``datetime.date``, or ``None``. Accepts ISO 8601 datetime strings and plain ``YYYY-MM-DD`` dates. Returns ``None`` for any string that cannot be parsed. Args: fm_date: Value of the ``date`` frontmatter field, or ``None``. Returns: A :class:`datetime.date` on success, or ``None`` on failure. """ if not fm_date: return None # Try plain date first (most common). try: return date.fromisoformat(str(fm_date).strip()[:10]) except (ValueError, TypeError): pass # Try full ISO 8601 datetime (e.g. "2025-01-15T14:30:00Z"). try: return datetime.fromisoformat(str(fm_date).strip()[:19]).date() except (ValueError, TypeError): return None def _parse_fm_safe(content: bytes) -> dict[str, Any] | None: """Extract raw frontmatter dict from *content* without raising. Returns ``None`` when the note has no frontmatter or the YAML is invalid. """ from muse.plugins.knowtation.parser import _extract_frontmatter_block import yaml raw = _extract_frontmatter_block(content) if raw is None: return None try: data = yaml.safe_load(raw) except yaml.YAMLError: return None return data if isinstance(data, dict) else None def _reconstruct( original: bytes, fm_data: dict[str, Any], ) -> bytes: """Serialise *fm_data* back into a YAML frontmatter block and re-attach body. The note body (everything after the closing ``---``) is preserved verbatim from *original*. Args: original: Original note bytes (used to extract the body). fm_data: Frontmatter dict to serialise. Returns: Bytes with rebuilt frontmatter followed by the original body. """ import yaml try: text = original.decode("utf-8", errors="replace") except Exception: text = "" # Extract body: everything after the closing --- delimiter. body = "" if text.startswith("---"): rest = text[3:] for delim in ("\n---", "\n..."): pos = rest.find(delim) if pos != -1: body = rest[pos + len(delim):] break fm_yaml = yaml.dump( fm_data, allow_unicode=True, default_flow_style=False, sort_keys=False, ) return f"---\n{fm_yaml}---\n{body}".encode("utf-8") def _coerce_list(value: Any) -> list[str]: """Coerce a YAML value to a sorted list of strings (set semantics).""" from muse.plugins.knowtation.parser import _coerce_str_or_list return _coerce_str_or_list(value) # Set-valued frontmatter fields that support union/union-sorted semantics. _SET_FIELDS: frozenset[str] = frozenset( {"tags", "entity", "follows", "summarizes", "attachments"} ) # --------------------------------------------------------------------------- # prefer-newer-date # --------------------------------------------------------------------------- def prefer_newer_date(ours: bytes, theirs: bytes, base: bytes) -> bytes: """Return whichever note has the later ``date`` frontmatter field. Implements the ``prefer-newer-date`` strategy. The *base* argument is accepted for interface compatibility but is not used — the decision is purely between *ours* and *theirs*. Fallback hierarchy (see module docstring for OBA rationale): 1. Both parseable, *theirs* is strictly later → return *theirs*. 2. Otherwise → return *ours*. Args: ours: Current-branch note bytes. theirs: Incoming-branch note bytes. base: Common merge-base note bytes (not used). Returns: The resolved note bytes. """ fm_ours = _parse_fm_safe(ours) fm_theirs = _parse_fm_safe(theirs) date_ours = _parse_date(fm_ours.get("date") if fm_ours else None) date_theirs = _parse_date(fm_theirs.get("date") if fm_theirs else None) # Case: only theirs has a parseable date → prefer theirs (more intentional). if date_ours is None and date_theirs is not None: logger.debug("prefer-newer-date: ours has no parseable date; returning theirs") return theirs # Case: only ours has a parseable date, or both missing → ours. if date_theirs is None: return ours # Both parseable: strictly later → theirs; equal or earlier → ours. if date_theirs > date_ours: logger.debug( "prefer-newer-date: theirs (%s) > ours (%s); returning theirs", date_theirs, date_ours, ) return theirs return ours # --------------------------------------------------------------------------- # union-sorted # --------------------------------------------------------------------------- def union_sorted(ours: bytes, theirs: bytes, base: bytes) -> bytes: """Return a union of both notes' set-valued frontmatter fields, sorted. Implements the ``union-sorted`` strategy: - **Set fields** (``tags``, ``entity``, ``follows``, ``summarizes``, ``attachments``): union of both sides, deduplicated and sorted alphabetically. - **Scalar fields**: ours wins (current-branch is authoritative for non-set values). - **Body**: ours is returned verbatim (body merging is handled by the section-level merger in Phase 2.4). Args: ours: Current-branch note bytes. theirs: Incoming-branch note bytes. base: Common merge-base note bytes (used to detect deletions). Returns: Resolved note bytes with sorted set-valued frontmatter. """ fm_ours = _parse_fm_safe(ours) fm_theirs = _parse_fm_safe(theirs) if fm_ours is None: return ours if fm_theirs is None: return ours fm_base = _parse_fm_safe(base) or {} # Build merged frontmatter starting from ours. merged: dict[str, Any] = dict(fm_ours) for field_name in _SET_FIELDS: set_ours = set(_coerce_list(fm_ours.get(field_name))) set_theirs = set(_coerce_list(fm_theirs.get(field_name))) set_base = set(_coerce_list(fm_base.get(field_name))) # Union of additions from both sides; honour deletions agreed by both. ours_deletions = set_base - set_ours theirs_deletions = set_base - set_theirs both_deleted = ours_deletions & theirs_deletions union = (set_ours | set_theirs) - both_deleted if union: merged[field_name] = sorted(union) elif field_name in merged: del merged[field_name] return _reconstruct(ours, merged) # --------------------------------------------------------------------------- # knowtation-3way # --------------------------------------------------------------------------- def knowtation_3way(ours: bytes, theirs: bytes, base: bytes) -> bytes: """Delegate to the Knowtation structured three-way merger. Implements the ``knowtation-3way`` strategy. When the :mod:`~muse.plugins.knowtation.merger` module is available (Phase 2.4), calls its ``merge_notes()`` entry point. Falls back to ``ours`` with a warning when the merger is not yet available, so that Phase 2.2 tests and Phase 2.3 policies can be exercised before Phase 2.4 lands. Args: ours: Current-branch note bytes. theirs: Incoming-branch note bytes. base: Common merge-base note bytes. Returns: Resolved note bytes from the structured merger, or *ours* on fallback. """ try: from muse.plugins.knowtation.merger import merge_notes return merge_notes(ours=ours, theirs=theirs, base=base) except ImportError: logger.warning( "knowtation-3way: merger.py (Phase 2.4) is not yet available; " "falling back to 'ours'." ) return ours except Exception as exc: logger.warning( "knowtation-3way: merger raised %s; falling back to 'ours'.", exc, ) return ours # --------------------------------------------------------------------------- # Dispatch table # --------------------------------------------------------------------------- #: Maps each Knowtation-specific strategy name to its implementation function. #: Used by the merger and any caller that needs to dispatch by name. STRATEGY_DISPATCH: dict[str, StrategyFn] = { "prefer-newer-date": prefer_newer_date, "union-sorted": union_sorted, "knowtation-3way": knowtation_3way, } def apply_strategy( strategy: str, ours: bytes, theirs: bytes, base: bytes, ) -> bytes | None: """Apply a Knowtation strategy by name. Returns the resolved bytes when *strategy* is a Knowtation-specific strategy, or ``None`` when the strategy is not in :data:`STRATEGY_DISPATCH` (meaning it should be handled by the generic merge engine instead). Args: strategy: Strategy string (e.g. ``"prefer-newer-date"``). ours: Current-branch bytes. theirs: Incoming-branch bytes. base: Common merge-base bytes. Returns: Resolved bytes, or ``None`` when the strategy is not Knowtation-specific. """ fn = STRATEGY_DISPATCH.get(strategy) if fn is None: return None return fn(ours, theirs, base)