This is an automated email from the ASF dual-hosted git repository.
morningman pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-website.git
The following commit(s) were added to refs/heads/master by this push:
new 12d50a851e3 feat(i18n): plan Japanese translation tasks from current
docs (#4227)
12d50a851e3 is described below
commit 12d50a851e3f80df09de5e04d6a4821883a02b65
Author: Mingyu Chen (Rayner) <[email protected]>
AuthorDate: Tue Oct 13 15:46:25 2026 +0800
feat(i18n): plan Japanese translation tasks from current docs (#4227)
## Summary
- Add an offline Japanese translation task planner based on the complete
native Dev, 4.x, and community audit at the checked-out source commit.
- Plan new, changed, moved, and deleted documents with stable IDs. Keep
active PR candidates separate from merged coverage, protect edited
targets and ambiguous source states, and report Chinese-only counterpart
changes.
- Add a reviewed deletion ledger, command and candidate schema
documentation, and planner tests to Build Check.
## Validation
- `node --test scripts/i18n/__tests__/*.test.cjs`: 43 tests passed.
- Two full scans of commit `58dc485ae1f60fcb2168b65b405384c97485a561`
were byte-identical. The final head
`c08868902b36e5ac32d309108223d360e8035221` also completed a full scan:
3,286 required, 233 merged/current, 3,053 pending; 18 tasks selected
under the default 50,000 approximate source-token budget.
- `git diff --check` passed. No model service or GitHub write API is
called by the planner.
## Scope
This PR delivers T06 only. It does not generate translations or change
website routes and content. T07 and T08 remain separate tasks.
---------
Co-authored-by: morningman <[email protected]>
---
.github/workflows/build-check.yml | 1 +
scripts/i18n/__tests__/planner.test.cjs | 189 +++++++++++++++++++
scripts/i18n/__tests__/t04.test.cjs | 5 +
scripts/i18n/audit-coverage.cjs | 9 +-
scripts/i18n/plan-translations-baseline.cjs | 13 ++
scripts/i18n/plan-translations.cjs | 261 ++++++++++++++++++++++++++
scripts/i18n/structure.cjs | 4 +
translations/README.md | 70 +++++++
translations/state/ja/resolved-deletions.json | 4 +
9 files changed, 552 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/build-check.yml
b/.github/workflows/build-check.yml
index f3c6afaf408..06971519cfc 100644
--- a/.github/workflows/build-check.yml
+++ b/.github/workflows/build-check.yml
@@ -96,6 +96,7 @@ jobs:
yarn
yarn key-features:generate
node --test scripts/i18n/__tests__/t04.test.cjs
+ node --test scripts/i18n/__tests__/planner.test.cjs
# Dependencies are installed unconditionally above because
the
# governance steps below need node_modules; only the site
build
diff --git a/scripts/i18n/__tests__/planner.test.cjs
b/scripts/i18n/__tests__/planner.test.cjs
new file mode 100644
index 00000000000..e721af07a60
--- /dev/null
+++ b/scripts/i18n/__tests__/planner.test.cjs
@@ -0,0 +1,189 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const {hash} = require('../state.cjs');
+const {plan, formattingOnly, validateActive, validateResolved} =
require('../plan-translations.cjs');
+
+const sha = 'a'.repeat(40);
+const baselineSha = 'b'.repeat(40);
+const scope = {schemaVersion: 1, locale: 'ja', maxBatchPages: 20, groups: [
+ {id: 'DEV', pluginId: 'default', version: 'current', sourceRoot: 'docs'},
+ {id: '4X', pluginId: 'default', version: '4.x', sourceRoot:
'versioned_docs/version-4.x'},
+ {id: 'COMMUNITY', pluginId: 'community', version: 'current', sourceRoot:
'community'},
+]};
+const source = '# Example\n\nHello.\n';
+function page(docId, options = {}) {
+ const version = options.version ?? 'current';
+ const pluginId = options.pluginId ?? 'default';
+ const root = pluginId === 'community' ? 'community' : version === '4.x' ?
'versioned_docs/version-4.x' : 'docs';
+ const sourcePath = options.sourcePath ?? `${root}/${docId}.md`;
+ return {pluginId, version, docId, sourcePath, targetPath:
`i18n/ja/${version}/${docId}.md`,
+ permalink: `/ja/${version}/${docId}/`, sourceHash:
hash(options.content ?? source),
+ targetHash: options.targetHash ?? null, state: options.state ??
'untranslated',
+ reviewStatus: 'unverified', change: options.change ?? 'new'};
+}
+async function run(pages, {old = [], records = [], removed = [], active, files
= {}, historical = {}, reports = [],
+ chineseChangedPaths = [], limit = 20, tokenBudget = 50000} = {}) {
+ const all = Object.fromEntries(pages.map(item => [item.sourcePath,
source]));
+ const report = {schemaVersion: 1, sourceCommit: sha, baselineCommit:
baselineSha,
+ groups: scope.groups.map(group => ({...group, entries:
pages.filter(item => item.pluginId === group.pluginId && item.version ===
group.version),
+ unregistered: []})), removed, reports};
+ return plan({report, scope, records: new Map(records.map(item =>
[JSON.stringify([item.pluginId, item.version, item.docId, 'ja']), item])),
+ baseline: old, active, chineseChangedPaths, readCurrent: name =>
files[name] ?? all[name] ?? null,
+ readAt: (ref, name) => historical[`${ref}:${name}`] ?? null, limit,
tokenBudget});
+}
+
+test('stable full scan includes new pages after baseline, isolates versions,
and never requeues current pages', async () => {
+ const pages = [page('new'), page('new', {version: '4.x'}), page('done',
{state: 'current'})];
+ const first = await run(pages);
+ const second = await run(pages);
+ assert.deepEqual(first, second);
+ assert.equal(first.counts.required, 3);
+ assert.equal(first.counts.mergedCurrent, 1);
+ assert.equal(first.tasks.length, 2);
+ assert.notEqual(first.tasks[0].id, first.tasks[1].id);
+ assert(first.tasks.every(task => task.kind === 'translate_new'));
+});
+
+test('modified completed source is an update and valid active PR suppresses
selection without adding coverage', async () => {
+ const previous = {...page('edited'), locale: 'ja', sourceHash: hash('#
Old\n'), sourceCommit: baselineSha,
+ targetHash: hash('訳文'), disposition: 'translate'};
+ const changed = page('edited', {state: 'stale_technical', change:
'source_changed', targetHash: hash('訳文')});
+ const active = {schemaVersion: 1, candidates: [{pluginId: 'default',
version: 'current', docId: 'edited',
+ locale: 'ja', sourcePath: changed.sourcePath, sourceHash:
changed.sourceHash, pr: '#123'}]};
+ const result = await run([changed], {old: [previous], active});
+ assert.equal(result.tasks[0].kind, 'update');
+ assert.equal(result.tasks[0].activePr, '#123');
+ assert.equal(result.counts.mergedCurrent, 0);
+ assert.deepEqual(result.selected, []);
+});
+
+test('stale active PR is reported and a later source revision reenters
selection', async () => {
+ const changed = page('edited', {state: 'stale_unknown'});
+ const active = {schemaVersion: 1, candidates: [{pluginId: 'default',
version: 'current', docId: 'edited',
+ locale: 'ja', sourcePath: changed.sourcePath, sourceHash:
hash('older'), pr: '#123'}]};
+ const result = await run([changed], {old: [{...changed, sourceHash:
hash('older'), sourceCommit: baselineSha}], active});
+ assert.equal(result.exceptions.some(item => item.kind ===
'stale_active_candidate'), true);
+ assert.equal(result.selected.length, 1);
+});
+
+test('an active PR for an old path cannot suppress a moved page with the same
source hash', async () => {
+ const changed = page('new', {sourcePath: 'docs/new.md'});
+ const active = {schemaVersion: 1, candidates: [{pluginId: 'default',
version: 'current', docId: 'new',
+ locale: 'ja', sourcePath: 'docs/old.md', sourceHash:
changed.sourceHash, pr: '#123'}]};
+ const result = await run([changed], {active});
+ assert.equal(result.tasks[0].activePr, null);
+ assert.equal(result.selected.length, 1);
+});
+
+test('same identity path move includes cleanup and only reuses a verified
target', async () => {
+ const old = {...page('old'), locale: 'ja', docId: 'moved', sourceHash:
hash(source),
+ targetHash: hash('訳文'), disposition: 'translate'};
+ const newer = page('moved', {sourcePath: 'docs/new/moved.md', state:
'state_conflict',
+ targetHash: hash('訳文'), change: 'moved'});
+ const result = await run([newer], {old: [old], files: {[old.targetPath]:
'訳文'}});
+ assert.equal(result.tasks[0].kind, 'move');
+ assert.equal(result.tasks[0].reuseTarget, true);
+ assert.equal(result.tasks[0].referenceChecks.oldSourcePath,
old.sourcePath);
+});
+
+test('unique same-content rename pairs a removed doc and has a reference
plan', async () => {
+ const before = {...page('old'), locale: 'ja', disposition: 'translate',
targetHash: hash('訳文')};
+ const after = page('new', {state: 'untranslated'});
+ const result = await run([after], {old: [before], files:
{[before.targetPath]: '訳文'}});
+ assert.equal(result.tasks.length, 1);
+ assert.equal(result.tasks[0].kind, 'move');
+ assert.equal(result.tasks[0].reuseTarget, true);
+ assert.equal(result.tasks[0].referenceChecks.oldSourcePath,
before.sourcePath);
+});
+
+test('ambiguous same-content rename remains new plus deletions, and deleted
records are planned', async () => {
+ const before = {...page('old'), locale: 'ja', disposition: 'translate'};
+ const result = await run([page('new'), page('new2')], {old: [before]});
+ assert.equal(result.tasks.filter(item => item.kind === 'move').length, 0);
+ assert.equal(result.tasks.filter(item => item.kind === 'delete').length,
1);
+});
+
+test('target edits, source mismatch and unsupported routes require human
review', async () => {
+ const result = await run([page('edited', {state: 'target_modified'}),
page('missing', {state: 'source_missing'}),
+ page('route', {state: 'source_route_conflict'})]);
+ assert(result.tasks.every(item => item.requiresHumanReview));
+ assert.deepEqual(result.selected, []);
+});
+
+test('format-only changes are review tasks; SQL, MDX attributes, and front
matter changes remain updates', async () => {
+ const first = '# Example\n\nHello world.\n';
+ assert.equal(await formattingOnly(first, '# Example\n\n\nHello world.\n',
'test.md'), true);
+ assert.equal(await formattingOnly('```sql\nSELECT 1;\n```\n',
'```sql\nSELECT 2;\n```\n', 'test.md'), false);
+ assert.equal(await formattingOnly('<Box size={1}>Hi</Box>\n', '<Box
size={2}>Hi</Box>\n', 'test.mdx'), false);
+ assert.equal(await formattingOnly('---\ntitle: A\n---\nHi\n', '---\ntitle:
B\n---\nHi\n', 'test.md'), false);
+});
+
+test('historical formatting only and prose edits receive different work
kinds', async () => {
+ const current = page('formatted', {state: 'stale_unknown', change:
'source_changed'});
+ const before = {...current, sourceHash: hash('# Example\n\n\nHello.\n'),
sourceCommit: baselineSha};
+ const formatting = await run([current], {old: [before], historical:
{[`${baselineSha}:${before.sourcePath}`]: '# Example\n\n\nHello.\n'}});
+ assert.equal(formatting.tasks[0].kind, 'formatting_review');
+ assert.deepEqual(formatting.selected, []);
+ const prose = await run([current], {old: [{...before, sourceHash: hash('#
Example\n\nGoodbye.\n')}],
+ historical: {[`${baselineSha}:${before.sourcePath}`]: '#
Example\n\nGoodbye.\n'}});
+ assert.equal(prose.tasks[0].kind, 'update');
+});
+
+test('Chinese-only routes are exceptions and never become translation tasks',
async () => {
+ const result = await run([], {reports: [{kind: 'unsupported_source_route',
pluginId: 'default',
+ version: 'current', sourceLocale: 'zh-CN', sourcePath:
'i18n/zh-CN/only.md'}]});
+ assert.equal(result.tasks.length, 0);
+ assert.equal(result.exceptions.length, 1);
+});
+
+test('Chinese-only counterpart edits are reported without creating Japanese
work', async () => {
+ const current = page('shared', {state: 'current'});
+ const before = {...current, disposition: 'translate'};
+ const chinese =
'i18n/zh-CN/docusaurus-plugin-content-docs/current/shared.md';
+ const result = await run([current], {old: [before], chineseChangedPaths:
[chinese]});
+ assert.equal(result.tasks.length, 0);
+ assert.equal(result.exceptions.find(item => item.kind ===
'zh_only_change').chinesePath, chinese);
+ const changedEnglish = await run([current], {old: [{...before, sourceHash:
hash('older')}], chineseChangedPaths: [chinese]});
+ assert.equal(changedEnglish.exceptions.some(item => item.kind ===
'zh_only_change'), false);
+ const translatedAfterEnglishEdit = await run([current], {old: [{...before,
sourceHash: hash('older')}],
+ records: [{...before, sourceHash: current.sourceHash}],
chineseChangedPaths: [chinese]});
+ assert.equal(translatedAfterEnglishEdit.exceptions.some(item => item.kind
=== 'zh_only_change'), false);
+});
+
+test('limit, token budget and path filter apply after full counts without
renumbering', async () => {
+ const a = page('a'), b = page('b');
+ const result = await run([a, b], {tokenBudget: 5});
+ assert.equal(result.counts.pending, 2);
+ assert.equal(result.selected.length, 1);
+ const report = {schemaVersion: 1, sourceCommit: sha, baselineCommit:
baselineSha,
+ groups: scope.groups.map(group => ({...group, entries: [a,
b].filter(item => item.pluginId === group.pluginId && item.version ===
group.version),
+ unregistered: []})), reports: []};
+ const filtered = await plan({report, scope, records: new Map(),
readCurrent: () => source, readAt: () => null,
+ filters: {path: 'docs/b'}, limit: 1});
+ assert.equal(filtered.tasks.length, 1);
+ assert.equal(filtered.counts.pending, 2);
+ assert.equal(filtered.tasks[0].id, result.tasks.find(item => item.docId
=== 'b').id);
+});
+
+test('active input rejects duplicate, out-of-scope and malformed candidates',
() => {
+ const candidate = {pluginId: 'default', version: 'current', docId: 'a',
locale: 'ja', sourcePath: 'docs/a.md',
+ sourceHash: hash(source), pr: '#1'};
+ assert.throws(() => validateActive({schemaVersion: 1, candidates:
[candidate, candidate]}, scope), /Duplicate/);
+ assert.throws(() => validateActive({schemaVersion: 1, candidates:
[{...candidate, version: '3.x'}]}, scope), /outside/);
+ assert.throws(() => validateActive({schemaVersion: 1, candidates:
[{...candidate, sourceHash: 'bad'}]}, scope), /Invalid/);
+});
+
+test('resolved deletion requires review evidence and does not recur', async ()
=> {
+ const before = {...page('old'), locale: 'ja', disposition: 'translate'};
+ const first = await run([], {old: [before]});
+ const deletion = first.tasks[0];
+ const resolved = {schemaVersion: 1, deletions: [{id: deletion.id,
sourceHash: deletion.sourceHash,
+ sourcePath: deletion.sourcePath, reviewedBy: 'reviewer', resolvedAt:
'2026-10-13T00:00:00Z', evidence: 'PR #1'}]};
+ assert.equal(validateResolved(resolved).length, 1);
+ const report = {schemaVersion: 1, sourceCommit: sha, baselineCommit:
baselineSha,
+ groups: scope.groups.map(group => ({...group, entries: [],
unregistered: []})), reports: []};
+ const second = await plan({report, scope, records: new Map(), baseline:
[before], resolved,
+ readCurrent: () => null, readAt: () => null});
+ assert.equal(second.tasks.length, 0);
+ assert.throws(() => validateResolved({schemaVersion: 1, deletions:
[{...resolved.deletions[0], evidence: ''}]}), /Invalid/);
+});
diff --git a/scripts/i18n/__tests__/t04.test.cjs
b/scripts/i18n/__tests__/t04.test.cjs
index dd334250ec4..0e62bb9dd2e 100644
--- a/scripts/i18n/__tests__/t04.test.cjs
+++ b/scripts/i18n/__tests__/t04.test.cjs
@@ -83,6 +83,7 @@ test('full audit finds documents and sidebar keys added after
the T03 snapshot',
write('translations/scope/ja.json',JSON.stringify({schemaVersion:1,locale:'ja',maxBatchPages:20,groups,retainEnglish:[]}));
write('translations/config.json','{}');
write('translations/state/ja/docs.json',JSON.stringify({schemaVersion:1,pages:[]}));
+
write('translations/state/ja/resolved-deletions.json',JSON.stringify({schemaVersion:1,deletions:[]}));
for (const file of
['docusaurus.config.js','versions.json','sidebars.ts','sidebarsCommunity.json','versioned_sidebars/version-4.x-sidebars.json'])
write(file,file+'\n');
const pages = groups.map(group => {
@@ -91,6 +92,9 @@ test('full audit finds documents and sidebar keys added after
the T03 snapshot',
sourceHash:hash(content),targetPath:`i18n/ja/${group.id}/new.md`,targetHash:null,
status:'untranslated',reviewStatus:'unverified',useTarget:false,draft:false,permalink:`/ja/${group.id}/new/`};
});
+ pages[0].status = 'current';
+ pages[0].reviewStatus = 'unverified';
+ pages[0].useTarget = true;
execFileSync('git',['init','-q'],{cwd:root});
execFileSync('git',['add','.'],{cwd:root});
execFileSync('git',['-c','user.name=T04
Test','-c','[email protected]','commit','-qm','candidate
scope'],{cwd:root});
@@ -102,6 +106,7 @@ test('full audit finds documents and sidebar keys added
after the T03 snapshot',
const report = await audit({root,sourceRef:'HEAD',baseline,native});
assert.deepEqual(report.groups.map(group => group.counts.new),[1,1,1]);
assert.deepEqual(report.groups.map(group =>
group.counts.pending),[1,1,1]);
+ assert.equal(report.groups[0].entries[0].state,'unverified');
assert.equal(report.labels.missing,3);
assert.equal(report.removed.length,0);
} finally {fs.rmSync(root,{recursive:true,force:true});}
diff --git a/scripts/i18n/audit-coverage.cjs b/scripts/i18n/audit-coverage.cjs
index 6ffef07052c..cbf1af88419 100644
--- a/scripts/i18n/audit-coverage.cjs
+++ b/scripts/i18n/audit-coverage.cjs
@@ -72,11 +72,12 @@ async function audit({root,sourceRef,baseline =
baselineDir,native}) {
seen.add(page.englishPath); current.add(key(page));
const previous = old.get(key(page));
const disposition = page.draft ? 'draft' :
scope.retainEnglish.some(entry => key(entry) === key(page)) ? 'retain_english'
: 'translate';
- const state = disposition !== 'translate' ? disposition :
page.status === 'untranslated' ? 'untranslated'
- : page.status === 'current' && page.reviewStatus !==
'unverified' && page.useTarget
+ const mergedCurrent = page.status === 'current' &&
page.reviewStatus !== 'unverified' && page.useTarget
&& refContent(root,commit,page.targetPath) !== null
&& hash(refContent(root,commit,page.targetPath)) ===
page.targetHash
- &&
refContent(root,commit,'translations/state/ja/docs.json') !== null ? 'current'
: page.status;
+ &&
refContent(root,commit,'translations/state/ja/docs.json') !== null;
+ const state = disposition !== 'translate' ? disposition :
page.status === 'current'
+ ? mergedCurrent ? 'current' : 'unverified' : page.status;
entries.push({pluginId:page.pluginId,version:page.version,docId:page.docId,sourcePath:page.sourcePath,
targetPath:page.targetPath,sourceHash:page.sourceHash,targetHash:page.targetHash,
sourceCommit:commit,permalink:page.permalink,reviewStatus:page.reviewStatus,state,
@@ -101,7 +102,7 @@ async function audit({root,sourceRef,baseline =
baselineDir,native}) {
.map(state => [state,sidebar.filter(entry => entry.state ===
state).length]));
const routingInputs =
['docusaurus.config.js','versions.json','sidebars.ts','sidebarsCommunity.json','versioned_sidebars/version-4.x-sidebars.json',
'translations/config.json','translations/scope/ja.json'];
- routingInputs.push('translations/state/ja/docs.json');
+ routingInputs.push('translations/state/ja/docs.json',
'translations/state/ja/resolved-deletions.json');
for (const file of routingInputs)
assert.equal(refContent(root,commit,file),fs.readFileSync(path.join(root,file),'utf8'),
`Routing input differs from source ref: ${file}`);
return
{schemaVersion:1,sourceCommit:commit,baselineCommit:JSON.parse(fs.readFileSync(path.join(baseline,'summary.json'),'utf8')).sourceCommit,
diff --git a/scripts/i18n/plan-translations-baseline.cjs
b/scripts/i18n/plan-translations-baseline.cjs
new file mode 100644
index 00000000000..14cedf3b35d
--- /dev/null
+++ b/scripts/i18n/plan-translations-baseline.cjs
@@ -0,0 +1,13 @@
+const fs = require('node:fs');
+const path = require('node:path');
+
+// T03 inventory is a historical comparison only. Never use it to filter the
+// native current-page enumeration.
+function baselineEntries(directory = path.resolve(__dirname,
'../../translations/inventory/ja/2026-10-12')) {
+ return ['dev', '4x', 'community'].flatMap(group => {
+ const metadata = JSON.parse(fs.readFileSync(path.join(directory,
`${group}.json`), 'utf8'));
+ return metadata.entryFiles.flatMap(shard =>
JSON.parse(fs.readFileSync(path.join(directory, shard), 'utf8')).entries);
+ });
+}
+
+module.exports = {baselineEntries};
diff --git a/scripts/i18n/plan-translations.cjs
b/scripts/i18n/plan-translations.cjs
new file mode 100644
index 00000000000..dae3f445c02
--- /dev/null
+++ b/scripts/i18n/plan-translations.cjs
@@ -0,0 +1,261 @@
+// Offline, read-only planner. The native audit supplies the complete current
+// scope; the T03 inventory is historical evidence, never an allowlist.
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const {createHash} = require('node:crypto');
+const {execFileSync} = require('node:child_process');
+const {audit} = require('./audit-coverage.cjs');
+const {baselineEntries} = require('./plan-translations-baseline.cjs');
+const {hash, identity, loadConfig, loadRecords} = require('./state.cjs');
+const {structure} = require('./structure.cjs');
+
+const compare = (a, b) => a < b ? -1 : a > b ? 1 : 0;
+const key = page => identity({...page, locale: 'ja'});
+const sha = value => /^[a-f0-9]{64}$/.test(value ?? '');
+const priority = {delete: 0, move: 1, move_and_update: 1, update: 2,
formatting_review: 2,
+ target_modified: 2, state_conflict: 2, source_divergence: 2,
unsupported_route: 2,
+ translate_new: 3, translate_backlog: 4};
+const managed = (scope, item) => scope.groups.find(group => group.pluginId ===
item.pluginId && group.version === item.version);
+const taskId = item => {
+ const seed = JSON.stringify([item.pluginId, item.version, item.docId,
item.locale, item.kind,
+ item.sourceHash ?? '', item.previousDocId ?? '']);
+ const number =
BigInt(`0x${createHash('sha256').update(seed).digest('hex').slice(0,
12)}`).toString().padStart(15, '0');
+ return `T06-${item.groupId}-${number}`;
+};
+const estimate = source => Math.max(1, Math.ceil((source?.length ?? 0) / 4));
+const chinesePath = (group, page) => {
+ if (!group.sourceRoot ||
!page.sourcePath.startsWith(`${group.sourceRoot}/`)) return null;
+ const plugin = group.pluginId === 'default' ? '' : `-${group.pluginId}`;
+ const version = group.version === 'current' ? 'current' :
`version-${group.version}`;
+ return
`i18n/zh-CN/docusaurus-plugin-content-docs${plugin}/${version}/${page.sourcePath.slice(group.sourceRoot.length
+ 1)}`;
+};
+
+function validateActive(data, scope) {
+ if (!data) return [];
+ assert(data.schemaVersion === 1 && Array.isArray(data.candidates),
'Invalid active candidate schema');
+ const seen = new Set();
+ return data.candidates.map(candidate => {
+ assert(candidate.locale === scope.locale && managed(scope, candidate),
'Active candidate outside managed scope');
+ assert(typeof candidate.docId === 'string' && candidate.docId &&
sha(candidate.sourceHash)
+ && typeof candidate.sourcePath === 'string' &&
candidate.sourcePath, 'Invalid active candidate identity/path/hash');
+ assert(typeof candidate.pr === 'string' && candidate.pr.trim(),
'Active candidate needs a PR reference');
+ const id = key(candidate);
+ assert(!seen.has(id), `Duplicate active candidate: ${id}`);
+ seen.add(id);
+ return candidate;
+ });
+}
+
+function validateResolved(data) {
+ assert(data.schemaVersion === 1 && Array.isArray(data.deletions), 'Invalid
resolved deletion schema');
+ const ids = new Set();
+ for (const item of data.deletions) {
+ assert(/^T06-[A-Z0-9]+-[0-9]{15}$/.test(item.id ?? '') &&
sha(item.sourceHash)
+ && typeof item.sourcePath === 'string' && item.sourcePath
+ && typeof item.reviewedBy === 'string' && item.reviewedBy.trim()
+ && typeof item.evidence === 'string' && item.evidence.trim()
+ && Number.isFinite(Date.parse(item.resolvedAt)), 'Invalid resolved
deletion');
+ assert(!ids.has(item.id), `Duplicate resolved deletion: ${item.id}`);
+ ids.add(item.id);
+ }
+ return data.deletions;
+}
+
+async function formattingOnly(before, after, filename) {
+ if (before == null || after == null) return false;
+ try {
+ const extension = path.extname(filename);
+ const [old, current] = await Promise.all([structure(before,
extension), structure(after, extension)]);
+ return old.semanticHash === current.semanticHash;
+ } catch { return false; }
+}
+
+function referenceChecks(oldPage, newPage) {
+ return {
+ oldSourcePath: oldPage?.sourcePath ?? null,
+ oldTargetPath: oldPage?.targetPath ?? null,
+ oldPermalink: oldPage?.permalink ?? null,
+ newSourcePath: newPage?.sourcePath ?? null,
+ newTargetPath: newPage?.targetPath ?? null,
+ newPermalink: newPage?.permalink ?? null,
+ actions: ['check inbound Markdown/MDX links and heading anchors',
'check sidebar references and redirects',
+ 'remove or relocate obsolete Japanese target and state record'],
+ };
+}
+
+async function plan({report, scope, records, baseline = [], active,
readCurrent, readAt,
+ resolved = {schemaVersion: 1, deletions: []}, chineseChangedPaths = [],
+ limit = scope.maxBatchPages, tokenBudget = 50000, filters = {}}) {
+ assert(report.schemaVersion === 1 &&
/^[a-f0-9]{40}$/.test(report.sourceCommit), 'Invalid source audit');
+ assert(scope.schemaVersion === 1 && scope.locale === 'ja' &&
Number.isInteger(scope.maxBatchPages), 'Invalid managed scope');
+ assert(Number.isInteger(limit) && limit >= 0 && limit <=
scope.maxBatchPages, 'Limit exceeds managed batch size');
+ assert(Number.isInteger(tokenBudget) && tokenBudget >= 0, 'Invalid token
budget');
+ if (filters.locale && filters.locale !== scope.locale) throw new
Error(`Unsupported locale: ${filters.locale}`);
+ const candidates = validateActive(active, scope);
+ const resolvedDeletions = validateResolved(resolved);
+ const activeByKey = new Map(candidates.map(candidate => [key(candidate),
candidate]));
+ const baselineByKey = new Map(baseline.filter(page => managed(scope,
page)).map(page => [key(page), page]));
+ const old = new Map(baselineByKey);
+ for (const record of records.values()) if (managed(scope, record))
old.set(key(record), {...old.get(key(record)), ...record});
+ const pages = report.groups.filter(group => managed(scope,
group)).flatMap(group =>
+ group.entries.filter(page => page.state !== 'draft' && page.state !==
'retain_english')
+ .map(page => ({...page, groupId: group.id})));
+ const current = new Set(pages.map(key));
+ assert.equal(current.size, pages.length, 'Duplicate current identity');
+ const removed = [...old.values()].filter(page => !current.has(key(page))
&& page.disposition !== 'draft'
+ && page.disposition !== 'retain_english').map(page => ({...page,
groupId: managed(scope, page).id}));
+ const matchedRemoval = new Set();
+ const tasks = [];
+ const exceptions = report.groups.flatMap(group =>
group.unregistered.map(sourcePath => ({kind: 'unregistered_file',
+ groupId: group.id, sourcePath}))).concat(report.reports.filter(item =>
managed(scope, item)));
+ const changedChinese = new Set(chineseChangedPaths);
+ for (const page of pages) {
+ const previous = old.get(key(page));
+ const group = managed(scope, page);
+ const chinese = chinesePath(group, page);
+ if (chinese && changedChinese.has(chinese) &&
baselineByKey.get(key(page))?.sourceHash === page.sourceHash) {
+ exceptions.push({kind: 'zh_only_change', groupId: page.groupId,
docId: page.docId,
+ sourcePath: page.sourcePath, chinesePath: chinese,
+ reason: 'English source is unchanged since the inventory
baseline; check terminology and behavior consistency.'});
+ }
+ if (page.state === 'current') continue;
+ const source = readCurrent(page.sourcePath);
+ assert(source !== null && hash(source) === page.sourceHash, `Current
source/hash mismatch: ${page.sourcePath}`);
+ let moveFrom = previous?.sourcePath !== page.sourcePath ? previous :
null;
+ if (!previous || !moveFrom) {
+ // A new docId can still be a move, but only if one removed page in
+ // the same plugin/version has the exact source hash and path
differs.
+ if (!previous) {
+ const matches = removed.filter(item =>
!matchedRemoval.has(key(item)) && item.pluginId === page.pluginId
+ && item.version === page.version && item.sourceHash ===
page.sourceHash && item.sourcePath !== page.sourcePath);
+ const destinations = pages.filter(item => item.pluginId ===
page.pluginId && item.version === page.version
+ && item.sourceHash === page.sourceHash &&
!old.has(key(item)));
+ if (matches.length === 1 && destinations.length === 1)
moveFrom = matches[0];
+ }
+ }
+ if (moveFrom && !current.has(key(moveFrom)))
matchedRemoval.add(key(moveFrom));
+ const candidate = activeByKey.get(key(page));
+ const activeValid = candidate?.sourceHash === page.sourceHash &&
candidate.sourcePath === page.sourcePath;
+ if (candidate && !activeValid) exceptions.push({kind:
'stale_active_candidate', groupId: page.groupId,
+ docId: page.docId, pr: candidate.pr, candidateHash:
candidate.sourceHash, sourceHash: page.sourceHash});
+ let kind;
+ if (['source_missing', 'source_route_conflict'].includes(page.state))
kind = page.state === 'source_missing' ? 'source_divergence' :
'unsupported_route';
+ else if (page.state === 'target_modified') kind = 'target_modified';
+ else if (moveFrom) kind = moveFrom.sourceHash === page.sourceHash ?
'move' : 'move_and_update';
+ else if (page.state === 'state_conflict' || page.state ===
'unverified') kind = 'state_conflict';
+ else if (page.state.startsWith('stale_')) {
+ const historical = previous?.sourcePath
+ ? readAt(previous.sourceCommit ?? report.baselineCommit,
previous.sourcePath) : null;
+ kind = historical && hash(historical) === previous.sourceHash
+ && await formattingOnly(historical, source, page.sourcePath) ?
'formatting_review' : 'update';
+ } else kind = previous ? 'translate_backlog' : 'translate_new';
+ const protectedTask = ['target_modified', 'state_conflict',
'source_divergence', 'unsupported_route', 'formatting_review'].includes(kind);
+ const task = {groupId: page.groupId, pluginId: page.pluginId, version:
page.version, docId: page.docId,
+ locale: scope.locale, kind, sourcePath: page.sourcePath,
targetPath: page.targetPath,
+ permalink: page.permalink, sourceHash: page.sourceHash,
sourceCommit: report.sourceCommit,
+ targetHash: page.targetHash, state: page.state, reviewStatus:
page.reviewStatus,
+ estimatedSourceTokens: estimate(source), requiresHumanReview:
protectedTask,
+ sourceChange: page.change,
+ activePr: activeValid ? candidate.pr : null,
+ ...(moveFrom ? {previousDocId: moveFrom.docId, reuseTarget:
moveFrom.sourceHash === page.sourceHash
+ && !!moveFrom.targetHash && !!moveFrom.targetPath
+ && readCurrent(moveFrom.targetPath) !== null &&
hash(readCurrent(moveFrom.targetPath)) === moveFrom.targetHash,
+ referenceChecks: referenceChecks(moveFrom, page)} : {})};
+ task.id = taskId(task);
+ tasks.push(task);
+ }
+ for (const page of removed.filter(item => !matchedRemoval.has(key(item))))
{
+ const task = {groupId: page.groupId, pluginId: page.pluginId, version:
page.version,
+ docId: page.docId, locale: scope.locale, kind: 'delete',
sourcePath: page.sourcePath,
+ targetPath: page.targetPath ?? null, sourceHash: page.sourceHash
?? null, sourceCommit: report.sourceCommit,
+ estimatedSourceTokens: 0, requiresHumanReview: true, activePr:
null,
+ referenceChecks: referenceChecks(page, null)};
+ task.id = taskId(task);
+ if (!resolvedDeletions.some(item => item.id === task.id &&
item.sourceHash === task.sourceHash
+ && item.sourcePath === task.sourcePath)) tasks.push(task);
+ }
+ const groupOrder = new Map(scope.groups.map((group, index) => [group.id,
index]));
+ tasks.sort((a, b) => priority[a.kind] - priority[b.kind]
+ || groupOrder.get(a.groupId) - groupOrder.get(b.groupId)
+ || compare(a.sourcePath, b.sourcePath) || compare(a.id, b.id));
+ const allCounts = {
+ required: pages.length,
+ mergedCurrent: pages.filter(page => page.state === 'current').length,
+ pending: pages.filter(page => page.state !== 'current').length,
+ untranslated: pages.filter(page => page.state ===
'untranslated').length,
+ needsUpdate: pages.filter(page => page.state !== 'current' &&
page.state !== 'untranslated').length,
+ deletions: tasks.filter(task => task.kind === 'delete').length,
+ moves: tasks.filter(task => task.kind === 'move' || task.kind ===
'move_and_update').length,
+ activeCandidates: tasks.filter(task => task.activePr).length,
+ };
+ assert.equal(allCounts.required, allCounts.mergedCurrent +
allCounts.pending);
+ const selectedTasks = tasks.filter(task => (!filters.plugin ||
task.pluginId === filters.plugin)
+ && (!filters.version || task.version === filters.version)
+ && (!filters.path || task.sourcePath.startsWith(filters.path) ||
task.targetPath?.startsWith(filters.path)));
+ const selected = [];
+ let tokens = 0;
+ for (const task of selectedTasks) {
+ if (selected.length >= limit) break;
+ if (task.activePr || task.requiresHumanReview) continue;
+ if (tokens + task.estimatedSourceTokens > tokenBudget) continue;
+ selected.push(task.id);
+ tokens += task.estimatedSourceTokens;
+ }
+ return {schemaVersion: 1, locale: scope.locale, sourceCommit:
report.sourceCommit,
+ baselineCommit: report.baselineCommit, counts: allCounts,
selectedCount: selected.length,
+ selectedEstimatedSourceTokens: tokens, selected, tasks: selectedTasks,
+ exceptions: exceptions.sort((a, b) => compare(JSON.stringify(a),
JSON.stringify(b)))};
+}
+
+function argumentsFrom(argv) {
+ const options = {}, known = new Set(['--source-ref', '--output',
'--active-candidates', '--locale', '--plugin',
+ '--version', '--path', '--limit', '--token-budget']);
+ for (let i = 0; i < argv.length; i += 2) {
+ assert(known.has(argv[i]) && argv[i + 1] && !options[argv[i]],
`Unknown or incomplete option: ${argv[i]}`);
+ options[argv[i]] = argv[i + 1];
+ }
+ return options;
+}
+
+async function main() {
+ const options = argumentsFrom(process.argv.slice(2));
+ const root = path.resolve(__dirname, '../..');
+ const scope = JSON.parse(fs.readFileSync(path.join(root,
'translations/scope/ja.json'), 'utf8'));
+ const config = loadConfig(root);
+ assert(config.locales[scope.locale]?.sourceLocale === 'en', 'Planner
requires an English primary source');
+ const report = await audit({root, sourceRef: options['--source-ref'] ??
'HEAD'});
+ const chineseRoots = ['i18n/zh-CN/docusaurus-plugin-content-docs',
'i18n/zh-CN/docusaurus-plugin-content-docs-community'];
+ execFileSync('git', ['diff', '--quiet', report.sourceCommit, '--',
...chineseRoots], {cwd: root});
+ const untrackedChinese = execFileSync('git', ['ls-files', '--others',
'--exclude-standard', '--', ...chineseRoots],
+ {cwd: root, encoding: 'utf8'}).trim();
+ assert(!untrackedChinese, `Uncommitted Chinese counterpart:
${untrackedChinese}`);
+ const chineseChangedPaths = execFileSync('git', ['diff', '--name-only',
report.baselineCommit, report.sourceCommit, '--', ...chineseRoots],
+ {cwd: root, encoding: 'utf8'}).trim().split('\n').filter(Boolean);
+ const records = loadRecords(root, scope.locale);
+ const active = options['--active-candidates'] ?
JSON.parse(fs.readFileSync(options['--active-candidates'], 'utf8')) : null;
+ const resolved = JSON.parse(fs.readFileSync(path.join(root,
`translations/state/${scope.locale}/resolved-deletions.json`), 'utf8'));
+ const readCurrent = file => {
+ try { return fs.readFileSync(path.join(root, file), 'utf8'); } catch
(error) {
+ if (error.code === 'ENOENT') return null;
+ throw error;
+ }
+ };
+ const readAt = (ref, file) => {
+ try { return execFileSync('git', ['show', `${ref}:${file}`],
+ {cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 16 * 1024 * 1024}); }
+ catch { return null; }
+ };
+ const result = await plan({report, scope, records, baseline:
baselineEntries(), active, resolved,
+ chineseChangedPaths, readCurrent, readAt,
+ limit: options['--limit'] === undefined ? scope.maxBatchPages :
Number(options['--limit']),
+ tokenBudget: options['--token-budget'] === undefined ? 50000 :
Number(options['--token-budget']),
+ filters: {locale: options['--locale'], plugin: options['--plugin'],
version: options['--version'], path: options['--path']}});
+ const output = JSON.stringify(result, null, 2) + '\n';
+ if (options['--output']) fs.writeFileSync(options['--output'], output);
+ else process.stdout.write(output);
+ process.stderr.write(`Japanese plan ${result.sourceCommit}:
${JSON.stringify(result.counts)}, selected ${result.selectedCount}\n`);
+}
+
+if (require.main === module) main().catch(error => {console.error(error);
process.exitCode = 1;});
+module.exports = {plan, validateActive, validateResolved, formattingOnly,
argumentsFrom};
diff --git a/scripts/i18n/structure.cjs b/scripts/i18n/structure.cjs
index 25f0a549dc0..27fa8d37636 100644
--- a/scripts/i18n/structure.cjs
+++ b/scripts/i18n/structure.cjs
@@ -63,6 +63,10 @@ async function structure(raw, extension, anchorsMaintainCase
= false) {
return {
content, headings, format, protectedNodes, technicalMetadata,
technicalHash: hash(JSON.stringify([technicalMetadata, protectedNodes,
headings.map(({depth, id}) => ({depth, id}))])),
+ // A secondary comparison for planning only. The state sourceHash stays
+ // byte-sensitive, so a formatting edit still needs an explicit review.
+ semanticHash:
hash(JSON.stringify([Object.fromEntries(Object.entries(data).sort(([a], [b]) =>
a.localeCompare(b))),
+ withoutPositions(tree)])),
};
}
diff --git a/translations/README.md b/translations/README.md
index 9724949bc9a..fdf1266e245 100644
--- a/translations/README.md
+++ b/translations/README.md
@@ -114,6 +114,76 @@ The candidate scope covers all formal Dev, 4.x and
community documents. See
[initial complete batch ledger](inventory/ja/2026-10-12/batches.md). Scope
limits
translation candidates, not the native website build or existing fallback
routes.
+## Offline Japanese task planner
+
+The planner recounts the native Dev, 4.x and community pages at the checked-out
+commit. The 2026-10-12 inventory is only a comparison baseline. New registered
+pages enter the plan even when they have never appeared in that inventory.
+
+```bash
+node scripts/i18n/plan-translations.cjs --source-ref HEAD \
+ --limit 20 --token-budget 50000 --output /tmp/japanese-plan.json
+```
+
+The source ref must match the checked-out source, routing files, Japanese state
+and targets. The scanner fails on mismatches instead of counting a working-tree
+candidate as merged coverage. It needs no model credential, network or GitHub
+write permission. Without `--output`, JSON goes to stdout; a compact count goes
+to stderr. `--locale ja`, `--plugin default`, `--version current` and
+`--path docs/install/` filter visible tasks after the full recount. The limit
+cannot exceed `translations/scope/ja.json`'s `maxBatchPages` (20). The token
+budget uses an approximate source length of four characters per token, so a
+generation tool must check its actual model budget again.
+
+`counts` cover the full managed scope. `tasks` list pending work, while
+`selected` holds the next eligible task IDs within the page and token limits.
+Stable numeric IDs derive from the page identity, task kind and source hash;
+they do not renumber existing T09 batches. Updates, deletion and moves precede
+historical backlog. Formatting-only changes still require review of the exact
+new source hash. Target edits, invalid state, missing sources and unsupported
+routes are reported as protected tasks and never enter `selected`. Move tasks
+include old/new paths and a reference checklist; `reuseTarget` is true only
+when the old target's content hash matches its recorded hash. No plan changes
+translation state or declares a page translated.
+
+To avoid assigning work already in an open human or bot PR, pass a local JSON
+file with the reviewed PR candidates:
+
+```json
+{
+ "schemaVersion": 1,
+ "candidates": [
+ {
+ "pluginId": "default",
+ "version": "current",
+ "docId": "getting-started/what-is-apache-doris",
+ "locale": "ja",
+ "sourcePath": "docs/getting-started/what-is-apache-doris.md",
+ "sourceHash": "<64 lowercase hex digits>",
+ "pr": "#1234"
+ }
+ ]
+}
+```
+
+Use `--active-candidates /path/to/open-prs.json`. A matching source hash marks
+the task as active and removes it from `selected`, but does not add coverage.
+A source change makes the PR candidate stale and requeues the page. The planner
+does not query GitHub: a caller that wants complete PR deduplication must
supply
+all active candidate PRs, including manually opened T09 PRs.
+
+The plan also reports `zh_only_change` exceptions when a committed Chinese
+counterpart changed since the T03 baseline while its English fact source did
+not. These are consistency checks, never Japanese translation candidates.
+
+After a deletion cleanup PR has removed or checked the Japanese target, state,
+links, sidebars and redirects, record the completed task in
+`state/ja/resolved-deletions.json` with its ID, source hash, source path,
+`reviewedBy`, ISO `resolvedAt` and PR `evidence`. This separate schema 1 ledger
+prevents the fixed T03 baseline from reopening an already reviewed deletion.
+The live docs adapter continues to read `state/ja/docs.json` unchanged. A
+reintroduced page returns to normal native enumeration.
+
## Validation
```bash
diff --git a/translations/state/ja/resolved-deletions.json
b/translations/state/ja/resolved-deletions.json
new file mode 100644
index 00000000000..22890804fd1
--- /dev/null
+++ b/translations/state/ja/resolved-deletions.json
@@ -0,0 +1,4 @@
+{
+ "schemaVersion": 1,
+ "deletions": []
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]