Ctrl K
ComunidadGuías…

scripts/xkb_frontmatter.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""在卡片檔案的 frontmatter 上做標記——索引是衍生物,檔案才是資產。

search_index.json 的每一個欄位都可以從卡片檔案重算出來(2026-09-10 實測:
把索引整份重建,1680 筆一模一樣,而且比正在用的那份還準——它找回了三張現行
索引漏掉的卡)。所以索引可以隨時刪掉重建。

但這件事有一個前提:**沒有任何東西只活在索引裡**。原本有兩樣:

    normalize_index_quality  把低品質的項目標成 excluded
    canonicalize_duplicates  把重複的項目標成 excluded

兩支都寫在索引列上,而它們的檔頭都明白寫著「不動 source markdown」。結果是
每次重建都把它們的成果洗掉一次:實測正式索引 1680 筆裡帶 excluded 的有 0 筆,
而讀這個旗標的四個地方一直讀到 False,沒有任何錯誤、沒有任何人發現。

所以標記要寫進檔案。這支模組就是那件事,只有這件事。

刻意不做完整的 YAML 解析:這裡只處理 `key: value` 這一種形狀,而 XKB 的卡片
frontmatter 全部都是這種形狀。引進一個 YAML 相依只為了設一個布林值,換來的是
「重寫整份 frontmatter」的風險——註解、順序、原本的引號都可能被改掉。
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n", re.DOTALL)


def has_frontmatter(text: str) -> bool:
    return bool(FRONTMATTER.match(text))


def parse(text: str) -> dict[str, str]:
    """Read the raw scalar fields used by card ingesters, without rewriting YAML."""
    match = FRONTMATTER.match(text)
    if not match:
        return {}
    fields = {}
    for line in match.group(1).splitlines():
        key, separator, value = line.partition(":")
        if separator:
            fields[key.strip()] = value.strip()
    return fields


def get(text: str, key: str) -> str | None:
    """讀一個 frontmatter 欄位的原始字串;沒有這個欄位回 None。"""
    match = FRONTMATTER.match(text)
    if not match:
        return None
    pattern = re.compile(rf"^{re.escape(key)}:[ \t]*(.*?)[ \t]*\r?$", re.MULTILINE)
    found = pattern.search(match.group(1))
    return found.group(1) if found else None


def set_field(text: str, key: str, value: str) -> str:
    """設一個 frontmatter 欄位,回傳新的內容。

    已經有這個欄位就就地換值,沒有就加在 frontmatter 結尾——不重排、不重寫
    其他行。沒有 frontmatter 的檔案原樣回傳:這支模組的職責是標記,不是替
    別人決定檔案該長什麼樣。
    """
    match = FRONTMATTER.match(text)
    if not match:
        return text
    block = match.group(1)
    line = f"{key}: {value}"
    pattern = re.compile(rf"^{re.escape(key)}:.*$", re.MULTILINE)
    if pattern.search(block):
        # 用 lambda 當替換,不要把 line 交給 re.sub 當模板。
        #
        # `pattern.sub(line, ...)` 會把 line 裡的反斜線當跳脫序列解讀,而寫進
        # 來的值常常含路徑:canonicalize 的理由是
        # "duplicate_source_url; canonical={relative_path}",那個 relative_path
        # 在 Windows 上是 memory\cards\abc.md —— `\c` 直接拋 re.error,`\a` 更
        # 糟,會安靜變成控制字元。而它炸的時機是在已經改寫了一部分卡片之後。
        new_block = pattern.sub(lambda _m: line, block, count=1)
    else:
        new_block = block + "\n" + line
    return text[:match.start(1)] + new_block + text[match.end(1):]


def mark_excluded(path: Path, reason: str = "", *, dry_run: bool = False) -> bool:
    """把一張卡標成不進召回。已經標過就不重寫,回傳有沒有改動。"""
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return False
    if not has_frontmatter(text):
        # 沒有 frontmatter 就沒有地方放這個旗標。安靜跳過會讓「標記了」和
        # 「標記不上」長得一樣,所以回 False 讓呼叫端自己算得出來。
        return False
    current = (get(text, "excluded") or "").strip().lower()
    already = current in {"true", "yes", "1"}
    if already:
        return False
    updated = set_field(text, "excluded", "true")
    if reason:
        updated = set_field(updated, "excluded_reason", reason.replace("\n", " ")[:200])
    if dry_run:
        return True
    try:
        path.write_text(updated, encoding="utf-8")
    except OSError:
        return False
    return True


def unmark_excluded(path: Path, *, dry_run: bool = False) -> bool:
    """撤掉排除標記。回傳有沒有改動。

    一個把知識從召回裡拿掉的決定,必須有辦法反悔。2026-09-10 一個寫錯的
    「標記整組」把重複組裡要保留的那一份也標掉了——那份知識會完全消失,而
    當時沒有任何工具可以還原,只能一個檔案一個檔案手動改。
    """
    try:
        text = path.read_text(encoding="utf-8")
    except OSError:
        return False
    if not has_frontmatter(text):
        return False
    if (get(text, "excluded") or "").strip().lower() not in {"true", "yes", "1"}:
        return False
    updated = set_field(text, "excluded", "false")
    if get(updated, "excluded_reason") is not None:
        updated = set_field(updated, "excluded_reason", "")
    if dry_run:
        return True
    try:
        path.write_text(updated, encoding="utf-8")
    except OSError:
        return False
    return True


def is_excluded(path: Path) -> bool:
    try:
        text = path.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return False
    return (get(text, "excluded") or "").strip().lower() in {"true", "yes", "1"}