多语校对已经能发现缺键、参数变化和组合目录覆盖不足,但修订审阅还有另一种噪声:某条文案已经讨论过,下一次运行仍和新问题混在一起。直接把键名加入忽略清单又会遮住后续变化。资源校对台需要保存可比较的内容基线,并让例外精确绑定一次明确修订。

语言修订与精确例外

基线保存资源内容,报告保存问题

LocaleReview继续沿用entry与lexicon作用域、组合覆盖合同和参数解析器。本篇增加内容快照,不改写既有检查的判断。资源基线回答文字从什么变成什么,既有workspace与profiles报告回答当前资源是否满足结构和参数要求,两项结果应一起审阅。

内容身份采用模块、资源目录和键的三元组。entry里的notice与lexicon里的notice各有归属,en_US与en_US-horizontal-tablet-dark也分别保存。不能只拿键名比较,否则某个目录新增专用文案,会被错误解释成另一个目录的通用文本变化。

快照读取所有包含element目录的直接资源目录,继续复用load_strings的重复键检查。结果按身份排序,避免文件遍历顺序变化形成无意义差异。字符串按原文比较,不预先去掉空格或改写标点,因为这些字符可能直接影响界面排版与产品语义。

内容快照采用固定记录结构

新增resource_revision.py,使用Python标准库,沿用前篇Scope与load_strings。read_snapshot在读取基线时检查字段、类型和重复身份。格式版本是文件结构版本,不是应用版本;后续增加其他资源类型时,应明确升级结构而不是悄悄改变同名字段的含义。

from pathlib import Path
import json
from resource_scope import Scope
from locale_audit import load_strings

Identity = tuple[str, str, str]


def collect_strings(scopes: list[Scope]) -> dict[Identity, str]:
    result: dict[Identity, str] = {}
    for scope in scopes:
        for folder in sorted(scope.resources.iterdir()):
            element = folder / "element"
            if not folder.is_dir() or not element.is_dir():
                continue
            for key, value in load_strings(element).items():
                result[(scope.name, folder.name, key)] = value
    return result


def write_snapshot(path: Path, values: dict[Identity, str]) -> None:
    rows = [{"module": item[0], "folder": item[1], "key": item[2], "value": values[item]}
            for item in sorted(values)]
    path.write_text(json.dumps({"format": 1, "strings": rows},
                              ensure_ascii=False, indent=2), encoding="utf-8")


def read_snapshot(path: Path) -> dict[Identity, str]:
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict) or data.get("format") != 1:
        raise ValueError("资源基线格式错误")
    rows = data.get("strings")
    if not isinstance(rows, list):
        raise ValueError("strings必须为数组")
    result: dict[Identity, str] = {}
    for row in rows:
        if not isinstance(row, dict) or any(
            not isinstance(row.get(field), str) for field in ("module", "folder", "key", "value")
        ):
            raise ValueError("资源基线字段错误")
        identity = (row["module"], row["folder"], row["key"])
        if not all(identity) or identity in result:
            raise ValueError("资源基线身份为空或重复")
        result[identity] = row["value"]
    return result

空字符串是有效资源值,因此不能用真假判断区分缺键。比较阶段用None表示键缺失,空文本仍然作为文本保存。新增、删除和修改由前后两项是否存在来区分,报告中的空值语义必须一直保持一致。

采集对象仍是源码中的资源集合,不把它当作运行时最终解析结果。构建合并、运行配置和语言选择有各自规则;本篇记录的是每份源码文案的修订,足以支持发布审阅,也便于把问题定位回具体模块和目录。

例外锁定前后内容与截止日期

新增revision_diff.py。例外需要完整身份、原文、新文、理由与截止日期,只有所有内容匹配且日期有效才生效。对重复或字段错误的例外直接报告输入问题,避免清单里存在两个互相竞争的解释。

from dataclasses import dataclass
from datetime import date
from pathlib import Path
import json
from resource_revision import Identity


@dataclass(frozen=True)
class Change:
    identity: Identity
    before: str | None
    after: str | None


def differences(before: dict[Identity, str], after: dict[Identity, str]) -> list[Change]:
    changes: list[Change] = []
    for identity in sorted(set(before) | set(after)):
        old = before.get(identity)
        new = after.get(identity)
        if old != new:
            changes.append(Change(identity, old, new))
    return changes


def accepted_changes(path: Path, changes: list[Change], today: date) -> list[bool]:
    rows = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(rows, list):
        raise ValueError("例外清单必须为数组")
    entries: dict[Identity, tuple[str | None, str | None, date]] = {}
    for row in rows:
        if not isinstance(row, dict):
            raise ValueError("例外项必须为对象")
        if any(not isinstance(row.get(k), str) or not row[k].strip()
               for k in ("module", "folder", "key", "reason", "until")):
            raise ValueError("例外身份、理由或日期缺失")
        if any(k not in row or (row[k] is not None and not isinstance(row[k], str))
               for k in ("before", "after")):
            raise ValueError("例外前后内容字段错误")
        identity = (row["module"], row["folder"], row["key"])
        if identity in entries:
            raise ValueError("例外身份重复")
        entries[identity] = (row["before"], row["after"], date.fromisoformat(row["until"]))
    accepted: list[bool] = []
    for change in changes:
        entry = entries.get(change.identity)
        accepted.append(entry is not None and entry[0] == change.before
                        and entry[1] == change.after and today <= entry[2])
    return accepted

本例把entry的普通英文notice从“%2$d items for %1$s”改成“%1$s: %2$d items”,组合目录保留前篇紧凑文案。默认参数签名没有变化,差异审阅仍会发现普通英文内容更新。参数检查通过与文案已经审阅是两个条件,任何一个都不能替代另一个。

下面是完整例外项的数据形状。日期表示这次审阅允许持续到哪一天,过期后重新进入待审阅。清单应和资源修订一起评审;理由写出业务原因,避免把“已知问题”当成所有后续变更的通行理由。

[
  {
    "module": "entry", "folder": "en_US", "key": "notice",
    "before": "%2$d items for %1$s", "after": "%1$s: %2$d items",
    "reason": "统一英文提示顺序", "until": "2026-12-31"
  }
]

把比较入口接入现有工程

新增revision_check.py。save明确创建基线,check只比较,不覆盖文件。采集前继续加载原合同确定模块范围;既有profile_audit.py应先独立通过,再进行修订审阅。这样例外只处理文字差异,缺键、未知键和参数类型问题仍由原有检查阻断。

from pathlib import Path
from datetime import date
import argparse
import json
import sys
from resource_scope import load_scopes
from resource_revision import collect_strings, write_snapshot, read_snapshot
from revision_diff import differences, accepted_changes


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("action", choices=["save", "check"])
    parser.add_argument("root", type=Path)
    parser.add_argument("contract", type=Path)
    parser.add_argument("baseline", type=Path)
    parser.add_argument("--exceptions", type=Path)
    args = parser.parse_args()
    try:
        scopes, _ = load_scopes(args.root, args.contract)
        current = collect_strings(scopes)
        if args.action == "save":
            if args.baseline.exists():
                raise ValueError("基线已存在,请使用新的修订文件名")
            write_snapshot(args.baseline, current)
            return 0
        changes = differences(read_snapshot(args.baseline), current)
        approved = accepted_changes(args.exceptions, changes, date.today())             if args.exceptions else [False] * len(changes)
        report = [{"module": change.identity[0], "folder": change.identity[1],
                   "key": change.identity[2], "before": change.before,
                   "after": change.after, "accepted": ok}
                  for change, ok in zip(changes, approved)]
        print(json.dumps(report, ensure_ascii=False, indent=2))
        return 1 if any(not ok for ok in approved) else 0
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())

运行入口的参数顺序为动作、工程根目录、resource_contract.json和基线路径,check可追加--exceptions指定清单。基线不存在、结构错误或例外字段错误返回二;尚有待审阅差异返回一;没有差异或全部精确匹配有效例外返回零。命令返回零只表达本项修订检查的结果,流水线仍须组合前篇的结构与参数检查。

语言差异比较的开发视图

页面展示一项明确修订

校对台的移动端只呈现审阅数据,文件扫描由上述脚本完成。以下完整页面对应普通英文notice变更,按钮用于查看例外匹配条件,状态保持待审阅,不因点击查看就自动批准。

@Entry
@Component
struct Index {
  @State detail: boolean = false;
  build() {
    Column({ space: 18 }) {
      Text('多语发布校对').fontSize(26).fontWeight(FontWeight.Bold)
      Text('语言修订差异').fontSize(20)
      Text('entry / en_US / notice').fontSize(18)
      Text('原文').fontSize(16)
      Text('%2$d items for %1$s').fontSize(20)
      Text('新文').fontSize(16)
      Text('%1$s: %2$d items').fontSize(20)
      Text('待审阅').fontColor('#9A5B00').fontSize(20)
      Text('例外需匹配前后内容').fontSize(16)
      if (this.detail) {
        Text('身份、原文、新文、理由与截止日期都须完整').fontSize(16)
      }
      Button('查看例外').width('100%').onClick(() => { this.detail = !this.detail; })
    }.width('100%').height('100%').padding(24).backgroundColor('#FFFFFF')
  }
}

待审阅的英文资源修订

用再次修改检验例外是否收紧

排查时先保存未改动资源的基线,再只修改普通英文notice,预期得到一项差异。加入精确例外后该项可以匹配,再把新文改一个标点,原例外应失效。这个小变化能有效发现仅按键名放行的问题,比单纯检查例外文件能否读取更有意义。

同时覆盖空字符串变更、资源删除和新增目录,确认None与空文本没有混淆。再把截止日期调整到比较日期之前,结果应回到待审阅。自动化环境应约定日期来源和时区,核心函数接收明确的today,便于在固定日期下检查边界。

基线更新不能隐藏审阅过程

差异检查过程中保持基线只读非常关键。如果每次发现变化就自动把当前资源写成新基线,下次运行自然没有差异,但审阅过程也随之丢失。代码把save和check拆成两个动作,并拒绝覆盖已经存在的基线,要求维护者为新修订选择新路径。文件命名由工程自己的版本管理规则决定。

更新基线之前,应先看清新增、删除和文字修改是否符合预期,再确认既有结构检查已经通过。新基线进入版本库时,资源文件和审阅决定应在同一次变更中出现,便于后来定位。仅提交例外而遗漏资源,或者仅更新基线而没有解释差异,都会削弱这条追踪链路。

例外也需要清理。某项修订纳入新基线后,它不再形成差异,旧例外即使还在文件中也不会继续匹配任何当前变化。维护者应定期移除失去对象的记录,避免清单长期膨胀。若需要把未使用例外作为提示,应单独输出维护信息,不能让它改变当前差异是否合格的判断。

审阅对象保留原文而非摘要

使用原文比较可以直接解释一个标点或空格变化,也能让例外匹配保持直观。若以后资源规模增长,需要用摘要加快索引,仍应在报告里保留必要的前后文本,不能只给一串无法阅读的摘要。摘要算法和编码也应固定,避免不同环境计算方式不一致。

对于很长的文本,页面可折叠显示,比较器仍然读取完整内容。为了界面整洁而截断的文本不能再送回例外文件,否则例外会与完整资源不一致。显示层的省略策略只影响阅读,不改变比较身份和原始值,和前篇模块归属规则保持相同原则。

删除资源的例外中after为null,新增资源的例外中before为null。字段必须明确存在,不能把缺少字段解释成null;缺字段往往意味着配置遗漏。这个要求让数据输入错误尽早暴露,也防止清单编写者误以为省略新文就表示接受所有后续内容。

基线比较还应保持模块范围稳定。若合同新增或移除一个模块,应把模块集合变化与文案差异一起审阅,避免把整组资源删除误判成普通翻译修订。检查器保留完整身份正是为了让这类变化能够被准确定位。

资源基线记录事实差异,例外记录一次有限的处理决定。两者都保留完整归属与内容,校对工具才能在减少重复提示的同时继续发现新修订,为下一步构建退出码与发布资源清单提供清晰输入。

相关资料:资源分类与访问、文本资源引用。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐