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

基线保存资源内容,报告保存问题
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;缺字段往往意味着配置遗漏。这个要求让数据输入错误尽早暴露,也防止清单编写者误以为省略新文就表示接受所有后续内容。
基线比较还应保持模块范围稳定。若合同新增或移除一个模块,应把模块集合变化与文案差异一起审阅,避免把整组资源删除误判成普通翻译修订。检查器保留完整身份正是为了让这类变化能够被准确定位。
资源基线记录事实差异,例外记录一次有限的处理决定。两者都保留完整归属与内容,校对工具才能在减少重复提示的同时继续发现新修订,为下一步构建退出码与发布资源清单提供清晰输入。
更多推荐

所有评论(0)