mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-14 15:24:00 -04:00
643 lines
26 KiB
Python
Executable File
643 lines
26 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
import sys
|
|
import json
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from i18nsync import (
|
|
REPO_ROOT, EN_JSON, COMMON_EN_JSON, LANGUAGES,
|
|
OFFICIAL_PLUGINS_REPO, OFFICIAL_PLUGINS_DIR,
|
|
error, warn, info, success, get_env_or_error,
|
|
poeditor_request, poeditor_upload, upload_source_strings, export_language, list_terms,
|
|
normalize_json, json_changed, write_if_changed, git, clone_or_update, entry_keys, load_common_entries,
|
|
)
|
|
|
|
sys.path.insert(0, str(REPO_ROOT / "translations"))
|
|
from extract_translations import extract_plugin_strings, create_plugin_poeditor_json, split_plugin_context
|
|
|
|
# I18n.trFor terms of every supported plugin live in the DankPlugins
|
|
# POEditor project, keyed by context "<pluginId>:<term>" so two plugins can
|
|
# translate the same English term differently. Supported: the official
|
|
# monorepo plus every registry plugin flagged "i18n": true. Exports are
|
|
# written back into each checkout's translations/ dir with the prefix
|
|
# stripped, which is the shape PluginService loads at runtime.
|
|
PLUGIN_REGISTRY_REPO = "https://github.com/AvengeMedia/dms-plugin-registry.git"
|
|
EXTERNAL_PLUGINS_DIR = REPO_ROOT / "dms-plugins-external"
|
|
REGISTRY_DIR = EXTERNAL_PLUGINS_DIR / ".registry"
|
|
SYNC_STATE = REPO_ROOT / ".git" / "i18n_plugins_sync_state.json"
|
|
|
|
PLUGIN_PR_BRANCH = "i18n/poeditor-sync"
|
|
PLUGIN_PR_TITLE = "i18n: sync translations from POEditor"
|
|
|
|
DELETE_BATCH = 100
|
|
|
|
def i18n_registry_plugins():
|
|
plugins = {}
|
|
for entry_file in sorted((REGISTRY_DIR / 'plugins').glob('*.json')):
|
|
with open(entry_file) as f:
|
|
entry = json.load(f)
|
|
if entry.get('i18n') is not True:
|
|
continue
|
|
if not entry.get('id') or not entry.get('repo'):
|
|
error(f"Registry entry {entry_file.name} has i18n set but no id or repo")
|
|
plugins[entry['id']] = entry
|
|
return plugins
|
|
|
|
def update_plugin_checkouts():
|
|
clone_or_update(OFFICIAL_PLUGINS_REPO, OFFICIAL_PLUGINS_DIR)
|
|
clone_or_update(PLUGIN_REGISTRY_REPO, REGISTRY_DIR)
|
|
plugins = i18n_registry_plugins()
|
|
for plugin_id, entry in plugins.items():
|
|
clone_or_update(entry['repo'], EXTERNAL_PLUGINS_DIR / plugin_id)
|
|
for child in EXTERNAL_PLUGINS_DIR.iterdir():
|
|
if child.is_dir() and not child.name.startswith('.') and child.name not in plugins:
|
|
warn(f"dms-plugins-external/{child.name} is not flagged i18n in the registry but its terms still get uploaded; remove it if it was unapproved")
|
|
success(f"Plugin checkouts current: official + {len(plugins)} registry plugins")
|
|
|
|
def checkout_candidates():
|
|
if OFFICIAL_PLUGINS_DIR.is_dir():
|
|
yield from sorted(OFFICIAL_PLUGINS_DIR.iterdir())
|
|
if not EXTERNAL_PLUGINS_DIR.is_dir():
|
|
return
|
|
paths = {plugin_id: entry.get('path') or '' for plugin_id, entry in i18n_registry_plugins().items()}
|
|
for child in sorted(EXTERNAL_PLUGINS_DIR.iterdir()):
|
|
if child.name.startswith('.'):
|
|
continue
|
|
yield child / paths.get(child.name, '')
|
|
|
|
def plugin_checkouts():
|
|
return [
|
|
path for path in checkout_candidates()
|
|
if not path.name.startswith('.') and (path / 'plugin.json').is_file()
|
|
]
|
|
|
|
def manifest_id(checkout):
|
|
with open(checkout / 'plugin.json') as f:
|
|
return json.load(f).get('id')
|
|
|
|
def find_checkout(plugin_id):
|
|
for checkout in plugin_checkouts():
|
|
if manifest_id(checkout) == plugin_id or checkout.name == plugin_id:
|
|
return checkout
|
|
error(f"No plugin checkout for '{plugin_id}' under dms-plugins/ or dms-plugins-external/")
|
|
|
|
def extract_plugin_entries(checkouts):
|
|
entries = []
|
|
owners = {}
|
|
shell_terms = {}
|
|
for checkout in checkouts:
|
|
scoped, plain = extract_plugin_strings(checkout)
|
|
expected = manifest_id(checkout)
|
|
for plugin_id in sorted({plugin_id for plugin_id, _ in scoped}):
|
|
if plugin_id != expected:
|
|
warn(f"{checkout.name}: I18n.trFor uses id '{plugin_id}' but plugin.json says '{expected}'; runtime never resolves the mismatch")
|
|
if plugin_id in owners and owners[plugin_id] != checkout:
|
|
error(f"Plugin id '{plugin_id}' is used by both {owners[plugin_id].name} and {checkout.name}")
|
|
owners[plugin_id] = checkout
|
|
entries.extend(create_plugin_poeditor_json(scoped))
|
|
shell_terms[checkout] = plain
|
|
return entries, owners, shell_terms
|
|
|
|
def warn_unresolved_shell_terms(shell_terms):
|
|
catalog = {e['term'] for e in normalize_json(EN_JSON)} | {e['term'] for e in normalize_json(COMMON_EN_JSON)}
|
|
for checkout, plain in sorted(shell_terms.items(), key=lambda item: item[0].name):
|
|
missing = sorted(term for term in plain if term not in catalog)
|
|
if not missing:
|
|
continue
|
|
warn(f"{checkout.name}: {len(missing)} I18n.tr terms are not in the shell catalog and stay untranslated (use I18n.trFor):")
|
|
for term in missing[:10]:
|
|
occ = plain[term][0]
|
|
print(f" {term!r} ({occ['file']}:{occ['line']})", file=sys.stderr)
|
|
if len(missing) > 10:
|
|
print(f" ... and {len(missing) - 10} more", file=sys.stderr)
|
|
|
|
def gh(args, cwd=None, required=True):
|
|
result = subprocess.run(['gh', *args], cwd=cwd, capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
if required:
|
|
error(f"gh {' '.join(args)} failed:\n{result.stderr.strip()}")
|
|
return None
|
|
|
|
def git_output(args, cwd):
|
|
result = subprocess.run(['git', *args], cwd=cwd, capture_output=True, text=True)
|
|
if result.returncode != 0:
|
|
return None
|
|
return result.stdout.strip()
|
|
|
|
def repo_slug(checkout):
|
|
url = git_output(['remote', 'get-url', 'origin'], checkout) or ""
|
|
if 'github.com' not in url:
|
|
return None
|
|
return url.removesuffix('.git').split('github.com', 1)[1].lstrip(':/')
|
|
|
|
def default_branch(checkout):
|
|
ref = git_output(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], checkout)
|
|
if not ref:
|
|
return 'main'
|
|
return ref.split('/', 1)[-1]
|
|
|
|
def pending_translation_files(checkout):
|
|
out = git_output(['status', '--porcelain', '--untracked-files=all', '--', 'translations'], checkout)
|
|
if not out:
|
|
return []
|
|
names = (line[3:].rsplit('/', 1)[-1] for line in out.splitlines())
|
|
return sorted(name for name in names if name.endswith('.json'))
|
|
|
|
def pending_plugin_prs():
|
|
external = []
|
|
official = []
|
|
for checkout in plugin_checkouts():
|
|
files = pending_translation_files(checkout)
|
|
if not files:
|
|
continue
|
|
if checkout.is_relative_to(EXTERNAL_PLUGINS_DIR):
|
|
external.append((checkout, files))
|
|
else:
|
|
official.append((checkout, files))
|
|
return external, official
|
|
|
|
def plugin_pr_body(files):
|
|
return (
|
|
"Translations synced from the DankPlugins POEditor project, where this plugin's "
|
|
"I18n.trFor strings are translated by the DMS community.\n\n"
|
|
f"Updated: {', '.join(sorted(files))}\n\n"
|
|
"Strings this repo already shipped were uploaded to POEditor before the export, "
|
|
"so existing translations are preserved rather than overwritten.\n"
|
|
)
|
|
|
|
def user_fork(slug, login):
|
|
forks = gh(['api', '--paginate', f'repos/{slug}/forks', '-q', f'.[] | select(.owner.login == "{login}") | .full_name'])
|
|
if forks:
|
|
return forks.splitlines()[0]
|
|
info(f"Forking {slug}")
|
|
return gh(['api', '-X', 'POST', f'repos/{slug}/forks', '-q', '.full_name'])
|
|
|
|
def push_to_fork(checkout, fork):
|
|
# a fresh fork is created asynchronously and can reject pushes briefly
|
|
for _ in range(5):
|
|
pushed = subprocess.run(
|
|
['git', 'push', '--quiet', '--force', f'[email protected]:{fork}.git', PLUGIN_PR_BRANCH],
|
|
cwd=checkout, capture_output=True, text=True
|
|
)
|
|
if pushed.returncode == 0:
|
|
return None
|
|
time.sleep(5)
|
|
return pushed.stderr.strip()
|
|
|
|
def open_plugin_pr(checkout, files):
|
|
slug = repo_slug(checkout)
|
|
if not slug:
|
|
warn(f"{checkout.name}: origin is not a github remote, skipping PR")
|
|
return
|
|
|
|
login = gh(['api', 'user', '-q', '.login'])
|
|
fork = user_fork(slug, login)
|
|
|
|
base = default_branch(checkout)
|
|
git(['checkout', '--quiet', '-B', PLUGIN_PR_BRANCH], checkout)
|
|
git(['add', 'translations'], checkout)
|
|
|
|
committed = subprocess.run(
|
|
['git', 'commit', '--quiet', '-m', PLUGIN_PR_TITLE],
|
|
cwd=checkout, capture_output=True, text=True
|
|
)
|
|
if committed.returncode != 0:
|
|
git(['checkout', '--quiet', base], checkout)
|
|
warn(f"{checkout.name}: nothing to commit, skipping PR")
|
|
return
|
|
|
|
push_error = push_to_fork(checkout, fork)
|
|
git(['checkout', '--quiet', base], checkout)
|
|
if push_error:
|
|
warn(f"{checkout.name}: push to {fork} failed, re-run sync to retry:\n{push_error}")
|
|
return
|
|
|
|
existing = gh([
|
|
'pr', 'list', '--repo', slug, '--head', PLUGIN_PR_BRANCH, '--state', 'open',
|
|
'--json', 'url,headRepositoryOwner', '-q', f'.[] | select(.headRepositoryOwner.login == "{login}") | .url'
|
|
], required=False)
|
|
if existing:
|
|
success(f"{checkout.name}: updated {existing}")
|
|
return
|
|
|
|
url = gh([
|
|
'pr', 'create', '--repo', slug, '--base', base, '--head', f'{login}:{PLUGIN_PR_BRANCH}',
|
|
'--title', PLUGIN_PR_TITLE, '--body', plugin_pr_body(files)
|
|
])
|
|
success(f"{checkout.name}: opened {url}")
|
|
|
|
def report_pending_prs(open_prs):
|
|
pending_prs, official_pending = pending_plugin_prs()
|
|
for checkout, files in official_pending:
|
|
info(f"{checkout.name}: {len(files)} translation files to commit in dms-plugins")
|
|
if open_prs:
|
|
for checkout, files in pending_prs:
|
|
open_plugin_pr(checkout, files)
|
|
return
|
|
for checkout, files in pending_prs:
|
|
info(f"{checkout.name} has {len(files)} uncommitted translation files")
|
|
if pending_prs:
|
|
info("Re-run without --no-pr (or run 'pr') to push them to your fork and open the PRs upstream.")
|
|
|
|
def checkout_translations(checkout, filename):
|
|
result = subprocess.run(
|
|
['git', 'show', f'HEAD:./translations/{filename}'],
|
|
cwd=checkout, capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
return {}
|
|
try:
|
|
return json.loads(result.stdout)
|
|
except json.JSONDecodeError:
|
|
warn(f"{checkout.name}/translations/{filename} is not valid JSON in git HEAD")
|
|
return {}
|
|
|
|
def keep_existing_translations(existing, incoming):
|
|
return {
|
|
context: {term: value or existing.get(context, {}).get(term, "") for term, value in bucket.items()}
|
|
for context, bucket in incoming.items()
|
|
}
|
|
|
|
def missing_from_poeditor(existing, incoming):
|
|
gaps = {}
|
|
for context, bucket in incoming.items():
|
|
for term, value in bucket.items():
|
|
if value:
|
|
continue
|
|
local = existing.get(context, {}).get(term, "")
|
|
if not local:
|
|
continue
|
|
gaps[(context, term)] = local
|
|
return gaps
|
|
|
|
def split_plugin_export(data, owners):
|
|
parts = {}
|
|
unknown = set()
|
|
for context, terms in data.items():
|
|
if not isinstance(terms, dict):
|
|
continue
|
|
plugin_id, local_context = split_plugin_context(context)
|
|
checkout = owners.get(plugin_id)
|
|
if not checkout:
|
|
unknown.add(plugin_id or context)
|
|
continue
|
|
for term, value in terms.items():
|
|
parts.setdefault(plugin_id, {}).setdefault(local_context, {})[term] = value
|
|
return parts, unknown
|
|
|
|
def download_translations(api_token, project_id, owners):
|
|
info("Downloading translations from POEditor...")
|
|
changed = {}
|
|
seed = {}
|
|
unknown = set()
|
|
|
|
for po_lang, filename in LANGUAGES.items():
|
|
info(f"Fetching {po_lang}...")
|
|
data = export_language(api_token, project_id, po_lang)
|
|
if data is None:
|
|
continue
|
|
|
|
parts, lang_unknown = split_plugin_export(data, owners)
|
|
unknown |= lang_unknown
|
|
|
|
for plugin_id, part in sorted(parts.items()):
|
|
checkout = owners[plugin_id]
|
|
target_dir = checkout / "translations"
|
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
existing = checkout_translations(checkout, filename)
|
|
for (context, term), value in missing_from_poeditor(existing, part).items():
|
|
seed.setdefault(po_lang, {})[(plugin_id, context, term)] = value
|
|
if write_if_changed(target_dir / filename, keep_existing_translations(existing, part)):
|
|
success(f"Updated {checkout.name} {filename}")
|
|
changed.setdefault(checkout.name, []).append(filename)
|
|
|
|
if unknown:
|
|
warn(f"POEditor holds terms for plugins with no checkout, left untouched: {', '.join(sorted(unknown))}")
|
|
return changed, seed
|
|
|
|
def seed_plugin_translations(api_token, project_id, seed):
|
|
seeded = {}
|
|
for po_lang, values in sorted(seed.items()):
|
|
entries = [
|
|
{'term': term, 'context': f"{plugin_id}:{context}", 'definition': value}
|
|
for (plugin_id, context, term), value in sorted(values.items())
|
|
]
|
|
info(f"Seeding {len(entries)} plugin-authored translations into POEditor ({po_lang})...")
|
|
result = poeditor_upload({
|
|
'api_token': api_token,
|
|
'id': project_id,
|
|
'updating': 'translations',
|
|
'language': po_lang,
|
|
'overwrite': '0',
|
|
'fuzzy_trigger': '0'
|
|
}, entries, LANGUAGES[po_lang], required=False)
|
|
if not result:
|
|
continue
|
|
translations = result.get('translations', {})
|
|
applied = translations.get('added', 0) + translations.get('updated', 0)
|
|
if not applied:
|
|
warn(f"POEditor accepted the {po_lang} seed but applied nothing: {translations}")
|
|
continue
|
|
seeded[po_lang] = applied
|
|
return seeded
|
|
|
|
def report_seed(api_token, project_id, seed, apply):
|
|
pending = sum(len(values) for values in seed.values())
|
|
if not pending:
|
|
return
|
|
if not apply:
|
|
info(f"{pending} plugin-authored translations are missing from POEditor across {len(seed)} languages.")
|
|
info("Re-run with --seed to adopt them (one throttled upload per language).")
|
|
return
|
|
seeded = seed_plugin_translations(api_token, project_id, seed)
|
|
if seeded:
|
|
info("Plugin-authored translations adopted into POEditor: " + ", ".join(f"{lang} +{count}" for lang, count in sorted(seeded.items())))
|
|
|
|
def load_state():
|
|
return normalize_json(SYNC_STATE) or {}
|
|
|
|
def save_state(entries, owners):
|
|
state = {'entries': entries, 'translations': {}}
|
|
for checkout in set(owners.values()):
|
|
state['translations'][checkout.name] = {
|
|
filename: normalize_json(checkout / 'translations' / filename)
|
|
for filename in LANGUAGES.values()
|
|
}
|
|
SYNC_STATE.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(SYNC_STATE, 'w') as f:
|
|
json.dump(state, f, indent=2)
|
|
|
|
def term_diff(local_entries, remote_terms):
|
|
local = entry_keys(local_entries)
|
|
remote = entry_keys(remote_terms)
|
|
return sorted(local - remote), sorted(remote - local)
|
|
|
|
def print_term_diff(added, removed, prune):
|
|
for context, term in added[:15]:
|
|
print(f" + {context}")
|
|
if len(added) > 15:
|
|
print(f" ... and {len(added) - 15} more")
|
|
for context, term in removed[:15]:
|
|
print(f" - {context}" + ("" if prune else " (kept, no --prune)"))
|
|
if len(removed) > 15:
|
|
print(f" ... and {len(removed) - 15} more")
|
|
|
|
def cmd_sync(flags):
|
|
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
|
project_id = get_env_or_error('POEDITOR_PLUGINS_PROJECT_ID')
|
|
prune = '--prune' in flags
|
|
open_prs = '--no-pr' not in flags
|
|
seed = '--seed' in flags
|
|
dry_run = '--dry-run' in flags
|
|
if prune:
|
|
warn("--prune deletes every DankPlugins term missing from the supported plugin checkouts, including its translations.")
|
|
warn("Checkouts are refreshed from the registry first; a plugin removed from the registry loses its terms.")
|
|
|
|
update_plugin_checkouts()
|
|
entries, owners, shell_terms = extract_plugin_entries(plugin_checkouts())
|
|
warn_unresolved_shell_terms(shell_terms)
|
|
success(f"Extracted {len(entries)} plugin terms from {len(owners)} plugins: {', '.join(sorted(owners))}")
|
|
|
|
if dry_run:
|
|
added, removed = term_diff(entries, list_terms(api_token, project_id))
|
|
info(f"Dry run: {len(added)} terms to add, {len(removed)} in POEditor but not local")
|
|
print_term_diff(added, removed, prune)
|
|
return
|
|
|
|
state = load_state()
|
|
terms_changed = json.dumps(entries, sort_keys=True) != json.dumps(state.get('entries'), sort_keys=True)
|
|
if terms_changed or prune:
|
|
upload_source_strings(api_token, project_id, entries, prune)
|
|
else:
|
|
info("No changes in plugin source strings")
|
|
|
|
changed, pending_seed = download_translations(api_token, project_id, owners)
|
|
save_state(entries, owners)
|
|
|
|
for checkout_name, files in sorted(changed.items()):
|
|
info(f"{checkout_name} translations updated: {', '.join(files)}")
|
|
if not changed:
|
|
info("Plugin translations already in sync")
|
|
|
|
report_pending_prs(open_prs)
|
|
report_seed(api_token, project_id, pending_seed, seed)
|
|
|
|
def cmd_check():
|
|
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
|
project_id = get_env_or_error('POEDITOR_PLUGINS_PROJECT_ID')
|
|
entries, owners, _ = extract_plugin_entries(plugin_checkouts())
|
|
state = load_state()
|
|
if json.dumps(entries, sort_keys=True) != json.dumps(state.get('entries'), sort_keys=True):
|
|
error("plugin i18n out of sync - run 'python3 scripts/i18nsync_plugins.py sync' first")
|
|
for checkout in set(owners.values()):
|
|
for filename in LANGUAGES.values():
|
|
if json_changed(checkout / 'translations' / filename, state.get('translations', {}).get(checkout.name, {}).get(filename, {})):
|
|
error(f"{checkout.name}/translations/{filename} differs from the last sync")
|
|
first_lang = next(iter(LANGUAGES))
|
|
data = export_language(api_token, project_id, first_lang)
|
|
if data is not None:
|
|
parts, _ = split_plugin_export(data, owners)
|
|
for plugin_id, part in parts.items():
|
|
checkout = owners[plugin_id]
|
|
existing = checkout_translations(checkout, LANGUAGES[first_lang])
|
|
if json_changed(checkout / 'translations' / LANGUAGES[first_lang], keep_existing_translations(existing, part)):
|
|
error(f"{checkout.name}: POEditor has newer {first_lang} translations")
|
|
success("plugin i18n in sync")
|
|
|
|
def translation_index(terms):
|
|
return {
|
|
(t.get('context') or t['term'], t['term']): (t.get('translation') or {}).get('content') or ""
|
|
for t in terms
|
|
}
|
|
|
|
def plugin_keys(plugin_id, checkout):
|
|
scoped, _ = extract_plugin_strings(checkout)
|
|
entries = [e for e in create_plugin_poeditor_json(scoped) if split_plugin_context(e['context'])[0] == plugin_id]
|
|
if not entries:
|
|
error(f"{checkout.name} has no I18n.trFor(\"{plugin_id}\", ...) calls")
|
|
return entries, [(split_plugin_context(e['context'])[1], e['term']) for e in entries]
|
|
|
|
def cmd_migrate(plugin_ids, flags):
|
|
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
|
main_id = get_env_or_error('POEDITOR_PROJECT_ID')
|
|
project_id = get_env_or_error('POEDITOR_PLUGINS_PROJECT_ID')
|
|
dry_run = '--dry-run' in flags
|
|
|
|
update_plugin_checkouts()
|
|
plugins = {}
|
|
entries = []
|
|
for plugin_id in plugin_ids:
|
|
checkout = find_checkout(plugin_id)
|
|
plugin_entries, keys = plugin_keys(plugin_id, checkout)
|
|
plugins[plugin_id] = (checkout, keys)
|
|
entries.extend(plugin_entries)
|
|
info(f"{plugin_id}: {len(plugin_entries)} terms from {checkout.name}")
|
|
info(f"{len(entries)} terms total" + (" (dry run)" if dry_run else ""))
|
|
|
|
if not dry_run:
|
|
upload_source_strings(api_token, project_id, entries)
|
|
|
|
print(f"{'lang':<8} {'merged':>6} {'main':>6} {'repo':>6}")
|
|
for po_lang, filename in LANGUAGES.items():
|
|
from_main = translation_index(list_terms(api_token, main_id, po_lang))
|
|
merged = {}
|
|
main_hits = 0
|
|
for plugin_id, (checkout, keys) in plugins.items():
|
|
from_repo = checkout_translations(checkout, filename)
|
|
for context, term in keys:
|
|
value = from_main.get((context, term))
|
|
if value:
|
|
main_hits += 1
|
|
else:
|
|
value = from_repo.get(context, {}).get(term, "")
|
|
if value:
|
|
merged[(plugin_id, context, term)] = value
|
|
print(f"{po_lang:<8} {len(merged):>6} {main_hits:>6} {len(merged) - main_hits:>6}")
|
|
if dry_run or not merged:
|
|
continue
|
|
payload = [
|
|
{'term': term, 'context': f"{plugin_id}:{context}", 'definition': value}
|
|
for (plugin_id, context, term), value in sorted(merged.items())
|
|
]
|
|
result = poeditor_upload({
|
|
'api_token': api_token,
|
|
'id': project_id,
|
|
'updating': 'translations',
|
|
'language': po_lang,
|
|
'overwrite': '0',
|
|
'fuzzy_trigger': '0'
|
|
}, payload, filename, required=False)
|
|
if result:
|
|
translations = result.get('translations', {})
|
|
info(f" {po_lang}: {translations.get('added', 0)} added, {translations.get('updated', 0)} updated")
|
|
|
|
if dry_run:
|
|
info("Dry run: nothing uploaded. Re-run without --dry-run to migrate.")
|
|
return
|
|
success(f"Migrated into DankPlugins: {', '.join(plugin_ids)}. Run 'sync' next, then 'purge-main'.")
|
|
|
|
def head_en_json():
|
|
result = subprocess.run(
|
|
['git', 'show', f'HEAD:./{EN_JSON.relative_to(REPO_ROOT)}'],
|
|
capture_output=True, text=True, cwd=REPO_ROOT
|
|
)
|
|
if result.returncode != 0:
|
|
error("Cannot read translations/en.json from git HEAD")
|
|
return json.loads(result.stdout)
|
|
|
|
def cmd_purge_main(plugin_ids, flags):
|
|
api_token = get_env_or_error('POEDITOR_API_TOKEN')
|
|
main_id = get_env_or_error('POEDITOR_PROJECT_ID')
|
|
project_id = get_env_or_error('POEDITOR_PLUGINS_PROJECT_ID')
|
|
dry_run = '--dry-run' in flags
|
|
confirmed = '--yes' in flags
|
|
|
|
update_plugin_checkouts()
|
|
owners = {}
|
|
current = {}
|
|
for plugin_id in plugin_ids:
|
|
checkout = find_checkout(plugin_id)
|
|
_, keys = plugin_keys(plugin_id, checkout)
|
|
owners[f"plugin-{plugin_id.lower()}"] = plugin_id
|
|
current[plugin_id] = set(keys)
|
|
|
|
shared = entry_keys(load_common_entries())
|
|
candidates = {}
|
|
for entry in head_en_json():
|
|
tags = entry.get('tags') or []
|
|
if not tags or not set(tags) <= set(owners):
|
|
continue
|
|
key = (entry['context'] or entry['term'], entry['term'])
|
|
if key in shared:
|
|
continue
|
|
candidates[key] = [owners[tag] for tag in tags]
|
|
if not candidates:
|
|
error(f"No terms in the committed en.json are used only by {', '.join(plugin_ids)}; nothing to purge")
|
|
info(f"{len(candidates)} terms in the committed en.json are used only by these plugins")
|
|
|
|
main_keys = entry_keys(list_terms(api_token, main_id))
|
|
candidates = {key: ids for key, ids in candidates.items() if key in main_keys}
|
|
info(f"{len(candidates)} of them exist in the main project")
|
|
|
|
users = {key: [pid for pid in ids if key in current[pid]] for key, ids in candidates.items()}
|
|
dead = sorted(key for key, ids in users.items() if not ids)
|
|
live = {key: ids for key, ids in users.items() if ids}
|
|
info(f"{len(live)} still used, {len(dead)} no longer in any plugin (deleted outright, translations not carried over)")
|
|
for context, term in dead[:15]:
|
|
print(f" dead: {context}")
|
|
if len(dead) > 15:
|
|
print(f" ... and {len(dead) - 15} more")
|
|
|
|
plugin_keys_remote = entry_keys(list_terms(api_token, project_id))
|
|
missing = [
|
|
(key, pid) for key, ids in live.items() for pid in ids
|
|
if (f"{pid}:{key[0]}", key[1]) not in plugin_keys_remote
|
|
]
|
|
if missing:
|
|
for (context, term), pid in missing[:15]:
|
|
print(f" missing in DankPlugins: {pid}:{context}")
|
|
error(f"{len(missing)} plugin terms are not in DankPlugins yet; run 'migrate' first")
|
|
|
|
print(f"{'lang':<8} {'main':>6} {'plugins':>8} {'lost':>6}")
|
|
lost_any = False
|
|
for po_lang in LANGUAGES:
|
|
from_main = translation_index(list_terms(api_token, main_id, po_lang))
|
|
from_plugins = translation_index(list_terms(api_token, project_id, po_lang))
|
|
translated = [key for key in live if from_main.get(key)]
|
|
lost = [
|
|
key for key in translated
|
|
if any(not from_plugins.get((f"{pid}:{key[0]}", key[1])) for pid in live[key])
|
|
]
|
|
lost_any = lost_any or bool(lost)
|
|
print(f"{po_lang:<8} {len(translated):>6} {len(translated) - len(lost):>8} {len(lost):>6}")
|
|
if lost_any:
|
|
error("Some main translations are missing in DankPlugins; run 'migrate' again before purging")
|
|
|
|
doomed = sorted(set(live) | set(dead))
|
|
if dry_run:
|
|
info(f"Dry run: nothing deleted. {len(doomed)} terms would go.")
|
|
return
|
|
if not confirmed:
|
|
error(f"Re-run with --yes to delete these {len(doomed)} terms from the main project")
|
|
|
|
deleted = 0
|
|
for start in range(0, len(doomed), DELETE_BATCH):
|
|
batch = [{'term': term, 'context': context} for context, term in doomed[start:start + DELETE_BATCH]]
|
|
resp = poeditor_request('terms/delete', {'api_token': api_token, 'id': main_id, 'data': json.dumps(batch)})
|
|
if resp.get('response', {}).get('status') != 'success':
|
|
error(f"terms/delete failed after {deleted} deletions: {resp}")
|
|
deleted += resp.get('result', {}).get('terms', {}).get('deleted', 0)
|
|
success(f"Deleted {deleted} terms from the main project. Run 'i18nsync.py sync' to refresh en.json and poexports.")
|
|
|
|
def main():
|
|
usage = "Usage: i18nsync_plugins.py sync [--prune] [--no-pr] [--seed] [--dry-run] | check | pr | migrate <pluginId>... [--dry-run] | purge-main <pluginId>... [--dry-run] [--yes]"
|
|
if len(sys.argv) < 2:
|
|
error(usage)
|
|
command = sys.argv[1]
|
|
args = sys.argv[2:]
|
|
flags = [a for a in args if a.startswith('--')]
|
|
positional = [pid for a in args if not a.startswith('--') for pid in a.split()]
|
|
|
|
if command == 'sync':
|
|
cmd_sync(flags)
|
|
elif command == 'check':
|
|
cmd_check()
|
|
elif command == 'pr':
|
|
report_pending_prs(True)
|
|
elif command == 'migrate':
|
|
if not positional:
|
|
error(usage)
|
|
cmd_migrate(positional, flags)
|
|
elif command == 'purge-main':
|
|
if not positional:
|
|
error(usage)
|
|
cmd_purge_main(positional, flags)
|
|
else:
|
|
error(f"Unknown command: {command}")
|
|
|
|
if __name__ == '__main__':
|
|
main()
|