多语发布校对台已经能够找出英文目录缺少的cancel,但键齐全仍不足以保证文案能正确替换参数。中文写成“某人有几项”,英文希望先写数量再写姓名,这种换序有业务意义;若把数量参数从整数改成字符串,页面即使仍显示文字,调用契约也已经改变。资源检查需要继续深入到每个字符串内部,比较参数身份与类型。

按位置与类型核对资源占位参数

参数签名不等于占位符出现顺序

继续沿用LocaleReview的资源扫描链路。load_strings仍负责收集默认资源和语言资源,集合差检查继续保留;本篇增加placeholder_signature,只有双方都存在的键才进入签名比较。缺键、格式问题和签名差异分别记录,避免一个问题被重复输出成多种错误。

本项目接受字符串、整数、浮点三类参数,分别使用s、d、f;相邻两个百分号表示普通百分号,不占参数位置。带位置编号的参数可以换序,未编号参数按出现次序分配位置,同一条字符串混用两种方式会被校对规则拒绝。这里定义的是项目资源合同,具体格式化接口的语法和参数要求仍应按API说明选择。

例如默认文本为“%1$s 有 %2$d 项”,英文为“%2$d items for %1$s”,两者都映射到位置一的字符串和位置二的整数。把英文第二个位置改成s,就会形成差异。检查器同时保留出现次数:若译文重复使用某个参数,本项目要求人工确认后调整规则,避免误把复制错误放行。

写一个逐字符推进的解析器

新增 placeholder_signature.py。解析从每个百分号开始,普通字符直接跳过;双百分号先于参数匹配处理。遇到未知写法直接给出位置,防止正则只挑出认识的部分,把格式问题漏掉。位置上限设为一百,是项目对资源复杂度的控制,不是系统接口的参数上限。

import re
from collections import Counter

TOKEN = re.compile(r"%(?:(?P<index>[1-9][0-9]*)\$)?(?P<kind>[sdf])")


def placeholder_signature(text: str) -> list[tuple[int, str, int]]:
    counts: Counter[tuple[int, str]] = Counter()
    kinds: dict[int, str] = {}
    mode: str | None = None
    implicit = 0
    cursor = 0
    while cursor < len(text):
        if text[cursor] != "%":
            cursor += 1
            continue
        if text.startswith("%%", cursor):
            cursor += 2
            continue
        match = TOKEN.match(text, cursor)
        if match is None:
            raise ValueError(f"不支持的参数格式,位置{cursor}")
        indexed = match.group("index")
        current_mode = "explicit" if indexed else "implicit"
        if mode is not None and mode != current_mode:
            raise ValueError("同一文案混用编号与未编号参数")
        mode = current_mode
        if indexed:
            position = int(indexed)
        else:
            implicit += 1
            position = implicit
        if position > 100:
            raise ValueError("参数位置超出项目范围")
        kind = match.group("kind")
        if position in kinds and kinds[position] != kind:
            raise ValueError(f"位置{position}重复声明了不同类型")
        kinds[position] = kind
        counts[(position, kind)] += 1
        cursor = match.end()
    if kinds and set(kinds) != set(range(1, max(kinds) + 1)):
        raise ValueError("参数编号存在空洞")
    return [(position, kind, amount)
            for (position, kind), amount in sorted(counts.items())]

连续的字面百分号不产生参数,因此“完成百分之百”的资源可以写成100%%。若直接出现单个百分号,本项目要求修订为明确格式,避免不同维护者按各自理解处理。扫描器报出的是字符串中的字符位置,定位时应打开对应资源值,而不是按整个JSON文件的列号查找。

编号空洞也单独拒绝。只出现第二个位置,却没有第一个位置,虽然可以在代码里传入额外值凑齐调用,却会让文案难以维护。将这类复杂写法挡在资源层,可以让业务调用始终按紧凑的参数列表组织。

合并到原有缺键检查

下面是 locale_audit.py 的完整更新版本,和前面的解析器放在同一个目录。原有读取逻辑继续拦截重复键,新增参数检查按交集逐键处理。解析异常归入当前键的问题记录,不影响其他资源继续检查;文件无法读取或JSON损坏仍按输入失败退出。

from pathlib import Path
import argparse
import json
import sys
from placeholder_signature import placeholder_signature


def load_strings(element: Path) -> dict[str, str]:
    if not element.is_dir():
        raise ValueError(f"资源目录不存在: {element}")
    result: dict[str, str] = {}
    for source in sorted(element.glob("*.json")):
        payload = json.loads(source.read_text(encoding="utf-8"))
        if not isinstance(payload, dict):
            raise ValueError(f"资源根节点应为对象: {source}")
        rows = payload.get("string", [])
        if not isinstance(rows, list):
            raise ValueError(f"string应为数组: {source}")
        for row in rows:
            if not isinstance(row, dict):
                raise ValueError(f"资源项应为对象: {source}")
            name, value = row.get("name"), row.get("value")
            if not isinstance(name, str) or not name.strip():
                raise ValueError(f"资源名称错误: {source}")
            if name != name.strip() or not isinstance(value, str):
                raise ValueError(f"资源字段错误: {source}")
            if name in result:
                raise ValueError(f"资源键重复: {source}: {name}")
            result[name] = value
    return result


def audit(resources: Path, locales: list[str]) -> dict[str, object]:
    base = load_strings(resources / "base" / "element")
    if not base:
        raise ValueError("默认字符串集合为空")
    base_signatures = {key: placeholder_signature(value)
                       for key, value in base.items()}
    reports: list[dict[str, object]] = []
    for locale in locales:
        element = resources / locale / "element"
        exists = element.is_dir()
        target = load_strings(element) if exists else {}
        issues: list[dict[str, str]] = []
        for key in sorted(set(base) & set(target)):
            try:
                actual = placeholder_signature(target[key])
                if actual != base_signatures[key]:
                    issues.append({"key": key, "reason": "参数签名不同"})
            except ValueError as error:
                issues.append({"key": key, "reason": str(error)})
        reports.append({
            "locale": locale,
            "missing": sorted(set(base) - set(target)),
            "extra": sorted(set(target) - set(base)),
            "directoryMissing": not exists,
            "parameterIssues": issues
        })
    return {"baseCount": len(base), "locales": reports}


def main() -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("resources", type=Path)
    parser.add_argument("--locales", nargs="+", default=["en_US", "zh_CN"])
    args = parser.parse_args()
    try:
        report = audit(args.resources, args.locales)
    except (OSError, ValueError) as error:
        print(str(error), file=sys.stderr)
        return 2
    print(json.dumps(report, ensure_ascii=False, indent=2))
    failed = any(row["missing"] or row["extra"] or row["directoryMissing"]
                 or row["parameterIssues"] for row in report["locales"])
    return 1 if failed else 0


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

默认资源的参数错误会在语言循环前抛出,退出码为二;目标语言的格式错误则进入parameterIssues,退出码为一。这样默认合同本身有问题时,维护者先修基线,而不是让每个目标语言都背负一条相同的差异。零、一、二三个退出码的含义与前一节保持一致。

让样例覆盖合法换序与类型变化

保留之前app_name、publish、cancel三个键,并在base增加notice,值为“%1$s 有 %2$d 项”;en_US的notice为“%2$d items for %1$s”;zh_CN与base一致。英文cancel也补成Cancel。此时默认键数为四,各语言键集合相同,notice签名一致,检查应返回零。

接着只把英文notice中的%2$d改成%2$s。键集合完全不变,参数问题列表增加notice,进程应返回一。再尝试%1$s和%d混用、编号从二开始、未知类型以及同一位置出现不同类型,检查器应分别给出格式问题,而不是把它们都简化成“翻译缺失”。

资源参数与开发校对视图

手机页呈现编号参数样例

以下替换 pages/Index.ets,用于查看固定的合法和错误样例。页面比较器只针对这里展示的编号参数,完整资源扫描由上面的脚本承担。核对按钮重新计算两组样例,便于将合法换序和类型错误同时放在界面上比较。

function sampleSignature(value: string): string {
  const tokens: string[] = value.match(/%[1-9][0-9]*\$[sdf]/g) || [];
  return tokens.sort().join('|');
}

@Entry
@Component
struct Index {
  private baseText: string = '%1$s 有 %2$d 项';
  private goodText: string = '%2$d items for %1$s';
  private badText: string = '%2$s items for %1$s';
  @State reordered: boolean = true;
  @State typeChanged: boolean = true;

  private checkSamples(): void {
    const base: string = sampleSignature(this.baseText);
    this.reordered = base === sampleSignature(this.goodText);
    this.typeChanged = base !== sampleSignature(this.badText);
  }

  aboutToAppear(): void {
    this.checkSamples();
  }

  build() {
    Scroll() {
      Column({ space: 16 }) {
        Text('多语发布校对').fontSize(26).fontWeight(FontWeight.Bold)
        Text('参数位置与类型').fontSize(18)
        Column({ space: 12 }) {
          Text('notice').fontSize(22)
          Text('base').fontSize(16)
          Text(this.baseText).fontSize(20)
          Text('en_US').fontSize(16)
          Text(this.goodText).fontSize(20)
          Text(this.reordered ? '编号参数可换序' : '参数签名不同').fontSize(18)
        }.width('100%').padding(20).backgroundColor('#EDF3FF').borderRadius(16)
        Column({ space: 12 }) {
          Text('类型检查').fontSize(22)
          Text('%2$d → %2$s').fontSize(20)
          Text(this.typeChanged ? '参数2类型变化' : '参数签名一致').fontSize(18)
        }.width('100%').padding(20).backgroundColor('#FFF3DD').borderRadius(16)
        Button('核对样例').width('100%').backgroundColor('#245AC7')
          .onClick(() => { this.checkSamples(); })
      }.width('100%').padding(20)
    }.width('100%').height('100%').backgroundColor('#FFFFFF')
  }
}

编号换序与类型差异样例页

避免把修复变成文案拼接

发现参数顺序问题后,应该在资源里使用清楚的位置编号,而不是在页面中拼接姓名、数量和单位。不同语言的语序并不相同,页面拆分得越细,翻译越难保持句子完整。调用位置仍按参数身份传值,资源决定它们在句子中的排列。

同样不要用字符串替换把所有d改成s来消除差异。类型参与调用契约,修改资源类型之后还需要核对业务传值。排错时从问题键回到默认签名,再查看目标资源和对应调用,确认修改解决的是位置、类型还是文案语义。若只看到界面显示正常就结束排查,某个极端值仍可能暴露问题。

参数检查应有稳定的失败表达

在日常维护中,可以将相同键的缺键、签名变化和解析错误归并到一个资源问题面板,但底层问题类型仍要保留。新增规则时继续向parameterIssues添加具体原因,避免改变原有缺键列表的含义,让已经接入的流水线能够稳定读取。

本次改动把发布校对从集合层推进到字符串内部。合法的语言换序保持自由,参数身份和类型保持一致,格式错误拥有独立定位。资源合同越明确,调用端越容易写清楚,后续跨模块归属和版本差异检查也就有了可靠的比较基础。

让语法范围成为团队约定

检查器当前只接受单字符类型以及可选的位置编号。宽度、精度或其他格式标记不在本项目合同内,遇到这类资源会返回格式问题。若后来确实需要更复杂的数字呈现,应先明确调用接口支持的写法,再同时更新解析规则、基准样本与资源编写约定,而不是为了让某个文件通过检查而临时放宽正则。

显式编号和未编号参数可以跨语言采用不同写法,只要归一后的签名一致。例如中文依次使用字符串和整数,英文写成带编号的反向顺序,仍可以表达相同的参数身份。真正需要拒绝的是一条文案内部混用编号方式,因为这会使维护者难以判断某个未编号参数应占哪个位置。规则应围绕可解释的合同设计。

重复参数的次数也值得保留。译文中两次出现同一个编号,可能是语言表达需要,也可能是复制时留下的错误。本项目先把次数变化视为需要处理的差异,后续可为特定资源建立显式允许规则。允许规则应绑定语言和资源键,不能把“忽略所有重复次数”变成全局开关,否则会失去发现复制错误的机会。

参数类型一致仍不能替代业务含义一致。两个字符串参数即使类型相同,也可能分别代表姓名和机构。若译文交换了它们的编号,签名集合仍相同,工具无法单靠类型理解语义。资源维护时应在翻译交付信息中说明每个位置的含义,界面校阅再检查实际填充值是否出现在正确位置。

在流水线保留可追溯输入

多语言资源变更时,检查结果应该关联当前资源修订,避免旧输出覆盖新的问题。执行命令仍从当前模块的resources开始,结果保留语言、键与原因;流水线把它关联到本次构建即可。不要把构建记录写进用户界面的翻译文本,也不要把问题键临时改名来绕过已有比较。

修复后应回到最小样本复核,而不是只重新打开完整应用。先让合法换序通过,再确认类型变化仍被发现,然后检查字面百分号和格式错误分支。这样能够验证修复没有削弱规则。默认合同发生变化时,还应同步查看代码传入的参数数量和顺序,保证资源与调用一起完成调整。

相关资料:资源分类与访问、资源管理接口、避免硬编码与直接拼接。

Logo

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

更多推荐