Merge main into migration/run-02 with full Spring removal for pulled-in code
This commit is contained in:
@@ -12,7 +12,7 @@ public class PropSync {
|
||||
File folder = new File("C:\\Users\\systo\\git\\Stirling-PDF\\app\\core\\src\\main\\resources");
|
||||
File[] files = folder.listFiles((dir, name) -> name.matches("messages_.*\\.properties"));
|
||||
|
||||
List<String> enLines = Files.readAllLines(Paths.get(folder + "\\messages_en_GB.properties"), StandardCharsets.UTF_8);
|
||||
List<String> enLines = Files.readAllLines(Path.of(folder + "\\messages_en_GB.properties"), StandardCharsets.UTF_8);
|
||||
Map<String, String> enProps = linesToProps(enLines);
|
||||
|
||||
for (File file : files) {
|
||||
|
||||
@@ -19,9 +19,9 @@ public class RestartHelper {
|
||||
Map<String, String> cli = parseArgs(args);
|
||||
|
||||
long pid = Long.parseLong(req(cli, "pid"));
|
||||
Path appJar = Paths.get(req(cli, "app")).toAbsolutePath().normalize();
|
||||
Path appJar = Path.of(req(cli, "app")).toAbsolutePath().normalize();
|
||||
String javaBin = cli.getOrDefault("java", "java");
|
||||
Path argsFile = cli.containsKey("argsFile") ? Paths.get(cli.get("argsFile")) : null;
|
||||
Path argsFile = cli.containsKey("argsFile") ? Path.of(cli.get("argsFile")) : null;
|
||||
long backoffMs = Long.parseLong(cli.getOrDefault("backoffMs", "1000"));
|
||||
|
||||
if (!Files.isRegularFile(appJar)) {
|
||||
|
||||
@@ -34,11 +34,6 @@ pip-compile --allow-unsafe --generate-hashes --upgrade --strip-extras ^
|
||||
--output-file=".github\scripts\requirements_dev.txt" ^
|
||||
".github\scripts\requirements_dev.in"
|
||||
|
||||
echo Generating .github\scripts\requirements_pre_commit.txt
|
||||
pip-compile --generate-hashes --upgrade --strip-extras ^
|
||||
--output-file=".github\scripts\requirements_pre_commit.txt" ^
|
||||
".github\scripts\requirements_pre_commit.in"
|
||||
|
||||
echo Generating .github\scripts\requirements_sync_readme.txt
|
||||
pip-compile --generate-hashes --upgrade --strip-extras ^
|
||||
--output-file=".github\scripts\requirements_sync_readme.txt" ^
|
||||
|
||||
@@ -22,13 +22,11 @@ ignore = [
|
||||
'admin.settings.connections.oauth2.label',
|
||||
'admin.settings.connections.saml2.label',
|
||||
'convert.dpi',
|
||||
'endpointStatistics.dataTypeApi',
|
||||
'getPdfInfo.other.javaScript',
|
||||
'getPdfInfo.other.xmp',
|
||||
'getPdfInfo.summary.tech.javaScript',
|
||||
'home.devApi.title',
|
||||
'language.direction',
|
||||
'removeBlanks.whitePercent.unit',
|
||||
'showJS.tags',
|
||||
'usage.controls.dataType.api',
|
||||
'validateSignature.downloadType.csv',
|
||||
@@ -76,7 +74,6 @@ ignore = [
|
||||
'admin.settings.premium.title',
|
||||
'alphabet',
|
||||
'annotation.contents',
|
||||
'annotation.ellipse',
|
||||
'annotation.polygon',
|
||||
'audit.events.details',
|
||||
'audit.tabs.dashboard',
|
||||
@@ -85,19 +82,10 @@ ignore = [
|
||||
'certSign.name',
|
||||
'common.save',
|
||||
'convert.dpi',
|
||||
'convert.markdown',
|
||||
'convert.pptExt',
|
||||
'convert.rtfExt',
|
||||
'convert.textRtf',
|
||||
'cookieBanner.services.posthog',
|
||||
'cookieBanner.services.scarf',
|
||||
'crop.autoCrop',
|
||||
'details',
|
||||
'endpointStatistics.dataTypeApi',
|
||||
'endpointStatistics.dataTypeUi',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'fileChooser.dragAndDrop',
|
||||
'fileManager.fileFormat',
|
||||
'fileManager.fileName',
|
||||
'fileManager.fileVersion',
|
||||
@@ -117,29 +105,21 @@ ignore = [
|
||||
'home.devApi.title',
|
||||
'language.direction',
|
||||
'legal.impressum',
|
||||
'licenses.version',
|
||||
'login.debug',
|
||||
'pdfTextEditor.options.advanced.title',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'plan.enterprise.name',
|
||||
'plan.pro.name',
|
||||
'plan.upgrade',
|
||||
'pro',
|
||||
'quickAccess.reader',
|
||||
'redact.manual.zoom',
|
||||
'removeBlanks.whitePercent.unit',
|
||||
'settings.connection.mode.saas',
|
||||
'settings.connection.server',
|
||||
'settings.licensingAnalytics.audit',
|
||||
'settings.licensingAnalytics.plan',
|
||||
'settings.workspace.teams',
|
||||
'setup.mode.saas.title',
|
||||
'setup.server.type.saas',
|
||||
'sign.saved.type.text',
|
||||
'sign.type.text',
|
||||
'signup.name',
|
||||
'sponsor',
|
||||
'text',
|
||||
'toolPanel.alpha',
|
||||
'update.priority.normal',
|
||||
@@ -155,7 +135,6 @@ ignore = [
|
||||
'validateSignature.downloadType.json',
|
||||
'validateSignature.downloadType.pdf',
|
||||
'validateSignature.report.downloads',
|
||||
'validateSignature.status._value',
|
||||
'watermark.type.1',
|
||||
'watermark.watermarkType.text',
|
||||
'workspace.people.actions.upgrade',
|
||||
@@ -164,7 +143,6 @@ ignore = [
|
||||
'workspace.people.emailInvite.emailsPlaceholder',
|
||||
'workspace.people.inviteLink.emailPlaceholder',
|
||||
'workspace.people.inviteMode.link',
|
||||
'workspace.people.status',
|
||||
'workspace.people.team',
|
||||
'workspace.teams.system',
|
||||
'workspace.teams.title',
|
||||
@@ -192,7 +170,6 @@ ignore = [
|
||||
'admin.settings.connections.pageFormatA4',
|
||||
'admin.settings.connections.saml2.label',
|
||||
'admin.settings.security.xFrameOptions.label',
|
||||
'adminUserSettings.roles',
|
||||
'audit.clearData.irreversible',
|
||||
'certSign.appearance.invisible',
|
||||
'certSign.appearance.visible',
|
||||
@@ -210,22 +187,13 @@ ignore = [
|
||||
'convert.cbzToPdf',
|
||||
'convert.dpi',
|
||||
'convert.ebookToPdf',
|
||||
'convert.markdown',
|
||||
'convert.pdfToCbr',
|
||||
'convert.pdfToCbz',
|
||||
'convert.pdfToEpub',
|
||||
'convert.pptExt',
|
||||
'convert.svgToPdf',
|
||||
'cookieBanner.services.posthog',
|
||||
'cookieBanner.services.scarf',
|
||||
'credits.modal.enterpriseSubscription',
|
||||
'credits.modal.popular',
|
||||
'credits.modal.teamSubscription',
|
||||
'defaultApp.error.title',
|
||||
'endpointStatistics.dataTypeApi',
|
||||
'endpointStatistics.dataTypeUi',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'error._value',
|
||||
'fileManager.filterLocal',
|
||||
'fileManager.googleDrive',
|
||||
@@ -246,8 +214,6 @@ ignore = [
|
||||
'plan.enterprise.name',
|
||||
'plan.popular',
|
||||
'plan.pro.name',
|
||||
'plan.team.name',
|
||||
'plan.trial.message',
|
||||
'pro',
|
||||
'provider.googledrive.name',
|
||||
'provider.oauth2.generic.scope',
|
||||
@@ -255,9 +221,7 @@ ignore = [
|
||||
'provider.saml2.name',
|
||||
'provider.saml2.scope',
|
||||
'quickAccess.accessRoleEditor',
|
||||
'redact.manual.zoom',
|
||||
'redact.modeSelector.manual',
|
||||
'removeBlanks.whitePercent.unit',
|
||||
'settings.configuration.endpoints',
|
||||
'settings.connection.mode.saas',
|
||||
'settings.general.title',
|
||||
@@ -267,8 +231,6 @@ ignore = [
|
||||
'settings.planBilling.tier.teamBadge',
|
||||
'settings.policiesPrivacy.legal',
|
||||
'settings.securityAuth.telegram',
|
||||
'setup.mode.saas.title',
|
||||
'setup.server.type.saas',
|
||||
'showJS.tags',
|
||||
'storageShare.roleEditor',
|
||||
'team.personal',
|
||||
@@ -303,18 +265,9 @@ ignore = [
|
||||
'AddStampRequest.alphabet',
|
||||
'AddStampRequest.position',
|
||||
'AddStampRequest.rotation',
|
||||
'adminUserSettings.actions',
|
||||
'alphabet',
|
||||
'cookieBanner.preferencesModal.serviceCounterLabel',
|
||||
'endpointStatistics.top',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'language.direction',
|
||||
'licenses.license',
|
||||
'licenses.module',
|
||||
'licenses.nav',
|
||||
'licenses.version',
|
||||
'multiTool.page',
|
||||
'page',
|
||||
'pages',
|
||||
'pipeline.title',
|
||||
@@ -341,10 +294,6 @@ ignore = [
|
||||
'admin.settings.database.port.label',
|
||||
'admin.settings.legal.impressum.label',
|
||||
'convert.dpi',
|
||||
'convert.markdown',
|
||||
'convert.pptExt',
|
||||
'endpointStatistics.dataTypeApi',
|
||||
'endpointStatistics.dataTypeUi',
|
||||
'fileManager.googleDrive',
|
||||
'footer.discord',
|
||||
'footer.issues',
|
||||
@@ -356,10 +305,7 @@ ignore = [
|
||||
'legal.impressum',
|
||||
'plan.pro.name',
|
||||
'pro',
|
||||
'removeBlanks.whitePercent.unit',
|
||||
'settings.connection.mode.saas',
|
||||
'setup.mode.saas.title',
|
||||
'setup.server.type.saas',
|
||||
'showJS.tags',
|
||||
'usage.controls.dataType.api',
|
||||
'usage.controls.dataType.ui',
|
||||
@@ -381,8 +327,6 @@ ignore = [
|
||||
|
||||
[hu_HU]
|
||||
ignore = [
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'language.direction',
|
||||
'pro',
|
||||
'showJS.tags',
|
||||
@@ -399,9 +343,7 @@ ignore = [
|
||||
'no',
|
||||
'password',
|
||||
'pipeline.title',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
'showJS.tags',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[ja_JP]
|
||||
@@ -428,7 +370,6 @@ ignore = [
|
||||
[nl_NL]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[no_NB]
|
||||
@@ -436,7 +377,6 @@ ignore = [
|
||||
'adminUserSettings.admin',
|
||||
'info',
|
||||
'language.direction',
|
||||
'sponsor',
|
||||
]
|
||||
|
||||
[pk_sk]
|
||||
@@ -452,15 +392,10 @@ ignore = [
|
||||
[pt_BR]
|
||||
ignore = [
|
||||
'language.direction',
|
||||
'pipelineOptions.pipelineHeader',
|
||||
]
|
||||
|
||||
[pt_PT]
|
||||
ignore = [
|
||||
'endpointStatistics.endpoint',
|
||||
'endpointStatistics.top',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'language.direction',
|
||||
'pro',
|
||||
'update.priority.normal',
|
||||
@@ -490,9 +425,6 @@ ignore = [
|
||||
|
||||
[sr_LATN_RS]
|
||||
ignore = [
|
||||
'endpointStatistics.top',
|
||||
'endpointStatistics.top10',
|
||||
'endpointStatistics.top20',
|
||||
'font',
|
||||
'language.direction',
|
||||
'pro',
|
||||
@@ -555,18 +487,13 @@ ignore = [
|
||||
'certSign.collab.signRequest.text.fontSizePlaceholder',
|
||||
'convert.cbrToPdf',
|
||||
'convert.cbzToPdf',
|
||||
'convert.defaultFilename',
|
||||
'convert.dpi',
|
||||
'convert.ebookToPdf',
|
||||
'convert.emlToPdf',
|
||||
'convert.markdown',
|
||||
'convert.pdfToCbr',
|
||||
'convert.pdfToCbz',
|
||||
'convert.pdfToEpub',
|
||||
'convert.pptExt',
|
||||
'convert.svgToPdf',
|
||||
'endpointStatistics.dataTypeApi',
|
||||
'endpointStatistics.dataTypeUi',
|
||||
'fileManager.googleDrive',
|
||||
'footer.discord',
|
||||
'footer.issues',
|
||||
@@ -577,8 +504,6 @@ ignore = [
|
||||
'home.devApi.title',
|
||||
'language.direction',
|
||||
'payment.emailStage.emailPlaceholder',
|
||||
'pdfToSinglePage.filenamePrefix',
|
||||
'plan.trial.message',
|
||||
'provider.googledrive.name',
|
||||
'provider.oauth2.generic.scope',
|
||||
'provider.oauth2.generic.scopes.label',
|
||||
@@ -589,12 +514,8 @@ ignore = [
|
||||
'provider.saml2.name',
|
||||
'provider.saml2.scope',
|
||||
'quickAccess.accessEmailPlaceholder',
|
||||
'removeBlanks.whitePercent.unit',
|
||||
'settings.connection.mode.saas',
|
||||
'settings.securityAuth.telegram',
|
||||
'setup.login.email.label',
|
||||
'setup.mode.saas.title',
|
||||
'setup.server.type.saas',
|
||||
'showJS.tags',
|
||||
'toolPanel.alpha',
|
||||
'usage.controls.dataType.api',
|
||||
@@ -602,7 +523,6 @@ ignore = [
|
||||
'validateSignature.downloadType.csv',
|
||||
'validateSignature.downloadType.json',
|
||||
'validateSignature.downloadType.pdf',
|
||||
'watermark.filenamePrefix',
|
||||
'workspace.people.addMember.usernamePlaceholder',
|
||||
'workspace.people.authType.oauth',
|
||||
'workspace.people.authType.saml',
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Download, checksum-verify and extract the gitleaks binary.
|
||||
#
|
||||
# Usage: install-gitleaks.ps1 -Url <url> -Sha <sha256> -Dest <dest>
|
||||
#
|
||||
# Called by the pre-commit:gitleaks-bin Task target, which owns the pinned
|
||||
# version and per-platform checksums and passes the resolved values in.
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Url,
|
||||
[Parameter(Mandatory)] [string]$Sha,
|
||||
[Parameter(Mandatory)] [string]$Dest
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $Sha) {
|
||||
throw 'No pinned gitleaks checksum for this platform'
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Dest) | Out-Null
|
||||
|
||||
$archive = New-TemporaryFile
|
||||
$extract = Join-Path $env:TEMP 'gitleaks-extract'
|
||||
try {
|
||||
Invoke-WebRequest -Uri $Url -OutFile $archive
|
||||
if ((Get-FileHash $archive -Algorithm SHA256).Hash -ne $Sha) {
|
||||
throw 'gitleaks checksum mismatch'
|
||||
}
|
||||
Expand-Archive -Force -Path $archive -DestinationPath $extract
|
||||
Move-Item -Force -Path (Join-Path $extract 'gitleaks.exe') -Destination $Dest
|
||||
} finally {
|
||||
Remove-Item -Force -ErrorAction SilentlyContinue $archive, $extract -Recurse
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
# Download, checksum-verify and extract the gitleaks binary.
|
||||
#
|
||||
# Usage: install-gitleaks.sh <url> <sha256> <dest>
|
||||
#
|
||||
# Called by the pre-commit:gitleaks-bin Task target, which owns the pinned
|
||||
# version and per-platform checksums and passes the resolved values in.
|
||||
set -euo pipefail
|
||||
|
||||
url=$1
|
||||
sha=$2
|
||||
dest=$3
|
||||
|
||||
if [ -z "$sha" ]; then
|
||||
echo "No pinned gitleaks checksum for this platform" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$dest")"
|
||||
archive=$(mktemp)
|
||||
trap 'rm -f "$archive"' EXIT
|
||||
|
||||
curl -fsSL "$url" -o "$archive"
|
||||
actual=$(shasum -a 256 "$archive" | awk '{print $1}')
|
||||
if [ "$actual" != "$sha" ]; then
|
||||
echo "gitleaks checksum mismatch: expected $sha, got $actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
tar -xzO -f "$archive" gitleaks > "$dest"
|
||||
chmod +x "$dest"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Pinned Python lint/format tools for `task pre-commit`. uv.lock locks these
|
||||
# plus their transitive dependencies by hash, so `uv run --project
|
||||
# scripts/pre-commit --locked <tool>` is reproducible and integrity-checked.
|
||||
# This is not a packaged project - it only exists to lock the tooling.
|
||||
[project]
|
||||
name = "stirling-precommit-tools"
|
||||
version = "0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"ruff==0.15.14",
|
||||
"codespell==2.4.2",
|
||||
"toml-sort==0.24.4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
Generated
+75
@@ -0,0 +1,75 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.11"
|
||||
|
||||
[[package]]
|
||||
name = "codespell"
|
||||
version = "2.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2d/9d/1d0903dff693160f893ca6abcabad545088e7a2ee0a6deae7c24e958be69/codespell-2.4.2.tar.gz", hash = "sha256:3c33be9ae34543807f088aeb4832dfad8cb2dae38da61cac0a7045dd376cfdf3", size = 352058, upload-time = "2026-03-05T18:10:42.936Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/a1/52fa05533e95fe45bcc09bcf8a503874b1c08f221a4e35608017e0938f55/codespell-2.4.2-py3-none-any.whl", hash = "sha256:97e0c1060cf46bd1d5db89a936c98db8c2b804e1fdd4b5c645e82a1ec6b1f886", size = 353715, upload-time = "2026-03-05T18:10:41.398Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/8a/8bce2894573e9dae6ff4d77fe34ad727d79b9e6238ad288c5638990d90f6/ruff-0.15.14.tar.gz", hash = "sha256:48e866b165be4a9bdbf310f7d3c9a07edef2fe8cd63ffeb4e00bb590506ebf9f", size = 4700910, upload-time = "2026-05-21T14:34:55.177Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c8/74a92c6ff9fcfb4f1f947126d3ebee8389276e161ecc85de5bda7cda51bd/ruff-0.15.14-py3-none-linux_armv6l.whl", hash = "sha256:8dd2db9416e487c8d4b01fa7056bb02c4d05969d4f8d17a08c229c2f4ff3c108", size = 10739177, upload-time = "2026-05-21T14:34:37.332Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/254a35c20acc38a7223c9d2d594af12e794432464f2cdeb52af1dc4a892d/ruff-0.15.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:be4ff55af755bd71a00ab3dc6bd7ffc467bd76e0df6881e286c2e3d23e8fb43b", size = 11144969, upload-time = "2026-05-21T14:34:43.978Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/9e/d13e40f83b8d0a94430e6778ce1d94a43b38cf2efe63278bdd2b4c65abbf/ruff-0.15.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48d5909d7d06276ce7dde6d32bfa4b0d4cb2651145cd8ee4b440722cbc77832f", size = 10478207, upload-time = "2026-05-21T14:34:48.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/f1/b15a7839fa4f332f8acec78e20564f26bb2d866e3d21710b877fd0263000/ruff-0.15.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca8cbfa94c4f90984a67561978602746d4cd27103568f745fa90eee3f0d4107d", size = 10818459, upload-time = "2026-05-21T14:34:22.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/33/53d651177f84f94b400a0e27f8824eeada3dddc9d5ee8aeb048f4352a520/ruff-0.15.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a6bbc0333f1ab053423bcbf6226477d266ca7cec7738c4c8e3f55647803f3c4", size = 10541800, upload-time = "2026-05-21T14:34:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a6/868f87e0bf9786ed24b5d0d0ad8676b8a94fd1912f42cddf9cfc7857818a/ruff-0.15.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a24a4f7605d7003a6674d4387651effd939dead3fddd0f36561eb77a9a2e542", size = 11342149, upload-time = "2026-05-21T14:34:46.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/8b/38cd5c19faffdcc05a408d2b78edccc69492ab9720eadb49ea15ef80d768/ruff-0.15.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:049b5326e53ed80978f2fc041a280603f69dd6b0c95464342a2bb4572d9d9e2f", size = 12212563, upload-time = "2026-05-21T14:34:28.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/4d/a3c5b874a556d5731e3e657aaf04311bb76f0a5c3ec220ed43051be6b64b/ruff-0.15.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4ed42e6696c8dfa5f06728e6441993901f548eb92d73bc472cb5a38d1395fbf", size = 11493299, upload-time = "2026-05-21T14:34:41.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/c0/56472c251d09858a53e51efbd485b09e1995d8731668b76d52e5dd6ee0f1/ruff-0.15.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:715c543cf450c4888251f91c52f1942a800541d9bddd7ac060aa4e6b77ae7cba", size = 11455931, upload-time = "2026-05-21T14:34:57.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/4a/e2e7b4d8dbf233d4eace59c75bc3435fa6d8bd3bae82d351d4e4300c0fd1/ruff-0.15.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:72ebab6013ec887d439d8b7593737a0a4ffb06d45d209d4e4bf2e92813082d3f", size = 11400794, upload-time = "2026-05-21T14:34:39.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c7/83c0539fe34c3e09136204d1e75d6052492364e0b3cb05e9465423f567d7/ruff-0.15.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:49072d36abdbe97a8dd7f480afe9c675699c0c495d4c84076e2c1203c4550581", size = 10804759, upload-time = "2026-05-21T14:34:31.045Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a6/18f2bfc095a2ab4a78745644e428205532ce6653a5d0fa8501572891534d/ruff-0.15.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:958522aee105068640c2c2ceae08f413ae44d922f52a1374ac13d6a96032fc93", size = 10539517, upload-time = "2026-05-21T14:34:53.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3a/5a8b3b69c654d4e4bf1d246ac5b49cbcdac6eaab6905925f8915f31e3b80/ruff-0.15.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f3707da619a143a2e8830e2abab8224478d69ace2d28cb6c20543ae97c36bf61", size = 11065169, upload-time = "2026-05-21T14:34:24.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/c5/8864e4e7925b836ea354b31d57641ec03830564e281a8b6f061f8c3e0ec1/ruff-0.15.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:bb01d645694e3ec0102105d07ef2d53703970407d59c04e59d3ba0b7a1d53553", size = 11560214, upload-time = "2026-05-21T14:34:50.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/38/012bf76752e1f89ed50b77b99532d90f3a3e287bc7918e1fc0948ac866ac/ruff-0.15.14-py3-none-win32.whl", hash = "sha256:6d0c1ad2a0ab718d39b6d8fd2217981ce4d625cd96a720095f798fb47d8b13e6", size = 10805548, upload-time = "2026-05-21T14:34:33.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b7/4ea2c170f10ad760fff2a5250beb18897719dc8b52b53a24cddbb9dd3f19/ruff-0.15.14-py3-none-win_amd64.whl", hash = "sha256:802342981e056db3851a7836e5b070f8f15f67d4a685ae2a6160939d364b2902", size = 11939523, upload-time = "2026-05-21T14:34:18.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/d5/bc97ff895ec35cf3925d4bd60f3b39d822f377a446906ec9bcc87405e59b/ruff-0.15.14-py3-none-win_arm64.whl", hash = "sha256:ff47b90a9ef6a40c9e2f3b479c1fb78531adf055b94c1eba0a7ba04b31951826", size = 11208607, upload-time = "2026-05-21T14:34:26.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stirling-precommit-tools"
|
||||
version = "0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "codespell" },
|
||||
{ name = "ruff" },
|
||||
{ name = "toml-sort" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "codespell", specifier = "==2.4.2" },
|
||||
{ name = "ruff", specifier = "==0.15.14" },
|
||||
{ name = "toml-sort", specifier = "==0.24.4" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml-sort"
|
||||
version = "0.24.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tomlkit" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/c5/d6f650fdcf8e1f83096815fa67fb13a9a345b99da6015c60c4b7e4a8ea2b/toml_sort-0.24.4.tar.gz", hash = "sha256:429b69f5b98b7047a11380c80ecf0838556bdea1a8902d0be564961c48841423", size = 17793, upload-time = "2026-03-24T14:05:53.637Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/5a/1f0e54df4eacf0f4d8f94ba50cf72be33d2a3f04babdfb1931bead48a0ab/toml_sort-0.24.4-py3-none-any.whl", hash = "sha256:125aa5fb94f33c542c6901040456145dd38f79bbb310b56b436a93057d30a739", size = 16577, upload-time = "2026-03-24T14:05:54.757Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomlkit"
|
||||
version = "0.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Trailing-whitespace and end-of-file normalisation, driven by Task.
|
||||
|
||||
Replaces the end-of-file-fixer / trailing-whitespace pre-commit hooks, which
|
||||
have no read-only mode. Run via `task pre-commit` (check) and `task
|
||||
pre-commit:fix`; Task selects the files (with `git ls-files`) and passes them
|
||||
as arguments.
|
||||
|
||||
python scripts/whitespace.py <files>... # check: report, exit 1 if any need fixing
|
||||
python scripts/whitespace.py --fix <files>... # fix: rewrite in place
|
||||
|
||||
Operates on bytes and only ever touches trailing spaces/tabs and the final
|
||||
newline, so it never mangles content or line endings. Binary files (those with
|
||||
a NUL byte) are skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def normalise(data: bytes) -> bytes:
|
||||
# Strip trailing spaces/tabs from each line (leave \r so CRLF survives).
|
||||
lines = [line.rstrip(b" \t") for line in data.split(b"\n")]
|
||||
body = b"\n".join(lines)
|
||||
# Ensure a non-empty file ends with exactly one newline.
|
||||
stripped = body.rstrip(b"\r\n")
|
||||
return stripped + b"\n" if stripped else body
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
fix = "--fix" in args
|
||||
paths = [a for a in args if a != "--fix"]
|
||||
|
||||
offenders: list[str] = []
|
||||
for path in paths:
|
||||
data = Path(path).read_bytes()
|
||||
if b"\0" in data:
|
||||
continue
|
||||
fixed = normalise(data)
|
||||
if fixed == data:
|
||||
continue
|
||||
offenders.append(path)
|
||||
if fix:
|
||||
Path(path).write_bytes(fixed)
|
||||
|
||||
if offenders and not fix:
|
||||
print(f"{len(offenders)} file(s) need whitespace fixing:")
|
||||
for path in offenders:
|
||||
print(f" {path}")
|
||||
return 1
|
||||
if offenders and fix:
|
||||
print(f"Fixed whitespace in {len(offenders)} file(s).")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,618 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync en-US translations from en-GB and normalise spelling variants.
|
||||
|
||||
Two jobs, one pass:
|
||||
|
||||
1. Copy every key that exists in en-GB but is missing from en-US into en-US,
|
||||
converting British spellings to American on the way in. Keys that only exist
|
||||
in en-US (e.g. SaaS-only strings) are left untouched.
|
||||
|
||||
2. Fix any American spellings that have leaked into the en-GB *values* by
|
||||
converting them back to British.
|
||||
|
||||
Rules that keep this safe:
|
||||
* Only the VALUE (the quoted right-hand side) is ever rewritten. TOML keys and
|
||||
section headers are never touched -- they are code identifiers.
|
||||
* Matching is whole-word and case-preserving, driven by an explicit word map.
|
||||
We never match on substrings/stems, so "parameters", "entire", "literal",
|
||||
"programmatically", "checkbox" etc. are never mangled.
|
||||
* Software-ambiguous words whose "British" form would be wrong in a UI
|
||||
(program, dialog, disk, check/cheque, story, catalog as a CS term...) are
|
||||
deliberately left out of the map. See AMBIGUOUS_NOTES.
|
||||
|
||||
Usage:
|
||||
python3 scripts/sync_en_us_spelling.py # apply changes
|
||||
python3 scripts/sync_en_us_spelling.py --dry-run # report only
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
LOCALES = Path(__file__).resolve().parent.parent / "frontend/editor/public/locales"
|
||||
EN_US = LOCALES / "en-US/translation.toml"
|
||||
EN_GB = LOCALES / "en-GB/translation.toml"
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Spelling map: British (uk) -> American (us), as full whole words.
|
||||
#
|
||||
# Inflected forms are generated for the regular families so the map stays
|
||||
# readable. Irregular / one-off pairs are listed explicitly at the bottom.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
# Words intentionally NOT mapped because the "British" form is wrong or
|
||||
# ambiguous in a software UI context (documented, not applied):
|
||||
AMBIGUOUS_NOTES = {
|
||||
"program/programme": "‘program’ is correct in British English for software.",
|
||||
"dialog/dialogue": "‘dialog’ is standard UI terminology (dialog box) in all locales.",
|
||||
"disk/disc": "‘disk’ is standard for storage in all locales.",
|
||||
"check/cheque": "‘check’ (verb/checkbox) is identical in both; only the money sense differs.",
|
||||
"analog/analogue": "rare in this UI and collides with technical usage.",
|
||||
"story/storey": "‘story’ (narrative/UI) is identical; only the building-floor sense differs.",
|
||||
"meter/metre": "‘meter’ (a device/‘metered’) is valid in British English too.",
|
||||
}
|
||||
|
||||
uk_to_us: dict[str, str] = {}
|
||||
|
||||
|
||||
def add(uk: str, us: str) -> None:
|
||||
"""Register a British->American whole-word pair (lower-cased key)."""
|
||||
uk_to_us[uk.lower()] = us.lower()
|
||||
|
||||
|
||||
# --- -our / -or family (colour -> color) ---------------------------------- #
|
||||
# us = uk with the single 'our' turned into 'or'.
|
||||
_OUR = [
|
||||
"colour",
|
||||
"colours",
|
||||
"coloured",
|
||||
"colouring",
|
||||
"colourful",
|
||||
"colourless",
|
||||
"colourise",
|
||||
"colourised",
|
||||
"colouriser",
|
||||
"favour",
|
||||
"favours",
|
||||
"favoured",
|
||||
"favouring",
|
||||
"favourite",
|
||||
"favourites",
|
||||
"favourable",
|
||||
"favourably",
|
||||
"favouritism",
|
||||
"behaviour",
|
||||
"behaviours",
|
||||
"behavioural",
|
||||
"neighbour",
|
||||
"neighbours",
|
||||
"neighbouring",
|
||||
"labour",
|
||||
"labours",
|
||||
"laboured",
|
||||
"labouring",
|
||||
"honour",
|
||||
"honours",
|
||||
"honoured",
|
||||
"honouring",
|
||||
"honourable",
|
||||
"humour",
|
||||
"humours",
|
||||
"humoured",
|
||||
"flavour",
|
||||
"flavours",
|
||||
"flavoured",
|
||||
"flavouring",
|
||||
"harbour",
|
||||
"harbours",
|
||||
"rumour",
|
||||
"rumours",
|
||||
"rumoured",
|
||||
"vapour",
|
||||
"vapours",
|
||||
"odour",
|
||||
"odours",
|
||||
"valour",
|
||||
"splendour",
|
||||
"armour",
|
||||
"armoured",
|
||||
"saviour",
|
||||
"saviours",
|
||||
"endeavour",
|
||||
"endeavours",
|
||||
"endeavoured",
|
||||
"parlour",
|
||||
"savour",
|
||||
"savoured",
|
||||
"savouring",
|
||||
"savoury",
|
||||
"candour",
|
||||
"demeanour",
|
||||
"rigour",
|
||||
"vigour",
|
||||
]
|
||||
for w in _OUR:
|
||||
add(w, w.replace("our", "or"))
|
||||
|
||||
# --- -re / -er family (centre -> center) ---------------------------------- #
|
||||
_RE = {
|
||||
"centre": "center",
|
||||
"centres": "centers",
|
||||
"centred": "centered",
|
||||
"centring": "centering",
|
||||
"metre": "meter",
|
||||
"metres": "meters", # length unit; ‘meter’ device excluded above
|
||||
"litre": "liter",
|
||||
"litres": "liters",
|
||||
"fibre": "fiber",
|
||||
"fibres": "fibers",
|
||||
# NB: ‘calibre’ is omitted -- in this codebase it is the proper noun
|
||||
# ‘Calibre’ (the e-book conversion tool), not the British ‘caliber’.
|
||||
"sombre": "somber",
|
||||
"spectre": "specter",
|
||||
"lustre": "luster",
|
||||
"theatre": "theater",
|
||||
"theatres": "theaters",
|
||||
"centimetre": "centimeter",
|
||||
"centimetres": "centimeters",
|
||||
"millimetre": "millimeter",
|
||||
"millimetres": "millimeters",
|
||||
"kilometre": "kilometer",
|
||||
"kilometres": "kilometers",
|
||||
"manoeuvre": "maneuver",
|
||||
"manoeuvres": "maneuvers",
|
||||
}
|
||||
for uk, us in _RE.items():
|
||||
add(uk, us)
|
||||
|
||||
# --- -ise / -isation family (organise -> organize) ------------------------ #
|
||||
# Generate the regular inflections from a list of stems (the part before 'se').
|
||||
_ISE_STEMS = [
|
||||
"organi",
|
||||
"recogni",
|
||||
"customi",
|
||||
"optimi",
|
||||
"authori",
|
||||
"reali",
|
||||
"finali",
|
||||
"initiali",
|
||||
"normali",
|
||||
"synchroni",
|
||||
"summari",
|
||||
"prioriti",
|
||||
"personali",
|
||||
"capitali",
|
||||
"categori",
|
||||
"standardi",
|
||||
"visuali",
|
||||
"apologi",
|
||||
"utili",
|
||||
"locali",
|
||||
"digiti",
|
||||
"moderni",
|
||||
"centrali",
|
||||
"minimi",
|
||||
"maximi",
|
||||
"emphasi",
|
||||
"characteri",
|
||||
"stabili",
|
||||
"generali",
|
||||
"specifi? ".strip(), # codespell:ignore specifi
|
||||
"sterili",
|
||||
"neutrali",
|
||||
"saniti",
|
||||
"tokeni",
|
||||
"serie? ".strip(), # codespell:ignore serie
|
||||
"alphabeti",
|
||||
"synthesi",
|
||||
"memori",
|
||||
"itemi",
|
||||
"colouri",
|
||||
]
|
||||
_ISE_STEMS = [s for s in _ISE_STEMS if s and not s.endswith("?")]
|
||||
_ISE_SUFFIXES = [
|
||||
"se",
|
||||
"ses",
|
||||
"sed",
|
||||
"sing",
|
||||
"ser", # codespell:ignore ser
|
||||
"sers",
|
||||
"sation",
|
||||
"sations",
|
||||
]
|
||||
for stem in _ISE_STEMS:
|
||||
for suf in _ISE_SUFFIXES:
|
||||
add(stem + suf, stem + suf.replace("s", "z", 1))
|
||||
|
||||
# -yse -> -yze verbs. Deliberately excludes the 3rd-person ‘analyses/analyzes’
|
||||
# because it collides with the noun ‘analyses’ (identical in both locales), and
|
||||
# ‘analysis/analyses’ which are spelled the same on both sides.
|
||||
for uk, us in {
|
||||
"analyse": "analyze",
|
||||
"analysed": "analyzed",
|
||||
"analysing": "analyzing",
|
||||
"analyser": "analyzer",
|
||||
"paralyse": "paralyze",
|
||||
"paralysed": "paralyzed",
|
||||
"paralysing": "paralyzing",
|
||||
"catalyse": "catalyze",
|
||||
"catalysed": "catalyzed",
|
||||
}.items():
|
||||
add(uk, us)
|
||||
|
||||
# --- doubled-l family (cancelled -> canceled) ----------------------------- #
|
||||
_LL = {
|
||||
"cancelled": "canceled",
|
||||
"cancelling": "canceling",
|
||||
"labelled": "labeled",
|
||||
"labelling": "labeling",
|
||||
"modelled": "modeled",
|
||||
"modelling": "modeling",
|
||||
"signalled": "signaled",
|
||||
"signalling": "signaling",
|
||||
"travelled": "traveled",
|
||||
"travelling": "traveling",
|
||||
"traveller": "traveler",
|
||||
"travellers": "travelers",
|
||||
"fuelled": "fueled",
|
||||
"fuelling": "fueling",
|
||||
"marvellous": "marvelous",
|
||||
"counsellor": "counselor",
|
||||
"jewellery": "jewelry",
|
||||
}
|
||||
for uk, us in _LL.items():
|
||||
add(uk, us)
|
||||
|
||||
# single-l where US doubles it (enrol -> enroll, fulfil -> fulfill)
|
||||
_L_TO_LL = {
|
||||
"enrol": "enroll",
|
||||
"enrols": "enrolls",
|
||||
"enrolment": "enrollment",
|
||||
"enrolments": "enrollments",
|
||||
"fulfil": "fulfill",
|
||||
"fulfils": "fulfills",
|
||||
"fulfilment": "fulfillment",
|
||||
"fulfilments": "fulfillments",
|
||||
"instalment": "installment",
|
||||
"instalments": "installments",
|
||||
"skilful": "skillful",
|
||||
"wilful": "willful",
|
||||
}
|
||||
for uk, us in _L_TO_LL.items():
|
||||
add(uk, us)
|
||||
|
||||
# --- -ce / -se nouns (licence -> license, defence -> defense) ------------- #
|
||||
# Only the unambiguous noun forms; verb/adjective forms ‘licensed/licensing’
|
||||
# are identical in both and are left alone.
|
||||
_CE = {
|
||||
"licence": "license",
|
||||
"licences": "licenses",
|
||||
"defence": "defense",
|
||||
"defences": "defenses",
|
||||
"offence": "offense",
|
||||
"offences": "offenses",
|
||||
"pretence": "pretense",
|
||||
}
|
||||
for uk, us in _CE.items():
|
||||
add(uk, us)
|
||||
|
||||
# --- -ogue (catalogue -> catalog) ----------------------------------------- #
|
||||
# Note: ‘dialogue/dialog’ and ‘analogue/analog’ are excluded (UI terminology).
|
||||
_OGUE = {
|
||||
"catalogue": "catalog",
|
||||
"catalogues": "catalogs",
|
||||
"catalogued": "cataloged",
|
||||
"cataloguing": "cataloging",
|
||||
}
|
||||
for uk, us in _OGUE.items():
|
||||
add(uk, us)
|
||||
|
||||
# --- miscellaneous irregulars --------------------------------------------- #
|
||||
_MISC = {
|
||||
"grey": "gray",
|
||||
"greys": "grays",
|
||||
"greyed": "grayed",
|
||||
"greying": "graying",
|
||||
"greyscale": "grayscale",
|
||||
"mould": "mold",
|
||||
"moulds": "molds",
|
||||
"sceptical": "skeptical",
|
||||
"sceptic": "skeptic",
|
||||
"scepticism": "skepticism",
|
||||
"aluminium": "aluminum",
|
||||
"artefact": "artifact",
|
||||
"artefacts": "artifacts",
|
||||
"speciality": "specialty",
|
||||
"specialities": "specialties",
|
||||
"judgement": "judgment",
|
||||
"judgements": "judgments",
|
||||
"acknowledgement": "acknowledgment",
|
||||
"acknowledgements": "acknowledgments",
|
||||
"kerb": "curb",
|
||||
"kerbs": "curbs",
|
||||
"tyre": "tire",
|
||||
"tyres": "tires",
|
||||
"sulphur": "sulfur",
|
||||
"enquiry": "inquiry",
|
||||
"enquiries": "inquiries",
|
||||
"ageing": "aging",
|
||||
"cancelled": "canceled", # belt & braces (also in _LL)
|
||||
# NB: while/whilst, toward/towards, among/amongst are deliberately omitted.
|
||||
# They are lexical/register choices, not spelling variants -- and "while",
|
||||
# "toward", "among" are all valid British English. Converting them would be
|
||||
# intrusive and out of scope for a spelling pass.
|
||||
}
|
||||
for uk, us in _MISC.items():
|
||||
add(uk, us)
|
||||
|
||||
# Reverse map for fixing American spellings inside en-GB.
|
||||
us_to_uk: dict[str, str] = {}
|
||||
for uk, us in uk_to_us.items():
|
||||
# Don't overwrite a genuine British form that happens to equal another US form.
|
||||
us_to_uk.setdefault(us, uk)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Case-preserving whole-word replacement.
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _match_case(template: str, replacement: str) -> str:
|
||||
if template.isupper():
|
||||
return replacement.upper()
|
||||
if template[:1].isupper():
|
||||
return replacement[:1].upper() + replacement[1:]
|
||||
return replacement
|
||||
|
||||
|
||||
# Literal technical tokens that must never be respelled, even though they look
|
||||
# like convertible words. These are protocol/identifier strings, not prose.
|
||||
PROTECTED = re.compile(
|
||||
r"Authorization(?=\s*:)" # the HTTP "Authorization:" header
|
||||
r"|Authorization\s+header",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_SENTINEL = "\x00{}\x00"
|
||||
|
||||
|
||||
def make_converter(mapping: dict[str, str]):
|
||||
if not mapping:
|
||||
return lambda text: (text, [])
|
||||
# Longest-first so multi-word/longer forms win; \b ensures whole words.
|
||||
pattern = re.compile(
|
||||
r"\b("
|
||||
+ "|".join(re.escape(w) for w in sorted(mapping, key=len, reverse=True))
|
||||
+ r")\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
def convert(text: str) -> tuple[str, list[tuple[str, str]]]:
|
||||
# Mask protected literals so they pass through untouched.
|
||||
protected: list[str] = []
|
||||
|
||||
def mask(m: re.Match[str]) -> str:
|
||||
protected.append(m.group(0))
|
||||
return _SENTINEL.format(len(protected) - 1)
|
||||
|
||||
masked = PROTECTED.sub(mask, text)
|
||||
|
||||
changes: list[tuple[str, str]] = []
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
src = m.group(0)
|
||||
dst = _match_case(src, mapping[src.lower()])
|
||||
if dst != src:
|
||||
changes.append((src, dst))
|
||||
return dst
|
||||
|
||||
converted = pattern.sub(repl, masked)
|
||||
for i, original in enumerate(protected):
|
||||
converted = converted.replace(_SENTINEL.format(i), original)
|
||||
return converted, changes
|
||||
|
||||
return convert
|
||||
|
||||
|
||||
uk_to_us_convert = make_converter(uk_to_us)
|
||||
us_to_uk_convert = make_converter(us_to_uk)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# TOML line model (value-only edits; structure preserved verbatim).
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
SECTION_RE = re.compile(r"^\[(.+)\]$")
|
||||
KV_RE = re.compile(r'^([A-Za-z0-9_.-]+)\s*=\s*"(.*)"$')
|
||||
|
||||
|
||||
def fq(section: str, key: str) -> str:
|
||||
return f"{section}.{key}" if section else key
|
||||
|
||||
|
||||
def parse_keys(path: Path) -> tuple[dict[str, str], list[tuple[str, str, str]]]:
|
||||
"""Return {fq_key: value} and an ordered list of (section, key, value)."""
|
||||
keys: dict[str, str] = {}
|
||||
ordered: list[tuple[str, str, str]] = []
|
||||
section = ""
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
s = raw.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
sec = SECTION_RE.match(s)
|
||||
if sec:
|
||||
section = sec.group(1)
|
||||
continue
|
||||
kv = KV_RE.match(s)
|
||||
if kv:
|
||||
k, v = kv.group(1), kv.group(2)
|
||||
keys[fq(section, k)] = v
|
||||
ordered.append((section, k, v))
|
||||
return keys, ordered
|
||||
|
||||
|
||||
def fix_en_gb(dry_run: bool) -> int:
|
||||
"""Rewrite American spellings in en-GB values to British. Returns # changed."""
|
||||
out_lines: list[str] = []
|
||||
section = ""
|
||||
changed = 0
|
||||
report: list[str] = []
|
||||
for raw in EN_GB.read_text(encoding="utf-8").splitlines():
|
||||
s = raw.strip()
|
||||
sec = SECTION_RE.match(s)
|
||||
if sec:
|
||||
section = sec.group(1)
|
||||
out_lines.append(raw)
|
||||
continue
|
||||
kv = KV_RE.match(s)
|
||||
if not kv:
|
||||
out_lines.append(raw)
|
||||
continue
|
||||
key, value = kv.group(1), kv.group(2)
|
||||
new_value, edits = us_to_uk_convert(value)
|
||||
if edits:
|
||||
changed += 1
|
||||
for src, dst in edits:
|
||||
report.append(f" [en-GB] {fq(section, key)}: {src} -> {dst}")
|
||||
indent = raw[: len(raw) - len(raw.lstrip())]
|
||||
out_lines.append(f'{indent}{key} = "{new_value}"')
|
||||
else:
|
||||
out_lines.append(raw)
|
||||
if report:
|
||||
print(f"en-GB: {changed} value(s) Americanised -> British:")
|
||||
print("\n".join(report))
|
||||
else:
|
||||
print("en-GB: no American spellings found in values.")
|
||||
if not dry_run and changed:
|
||||
EN_GB.write_text("\n".join(out_lines) + "\n", encoding="utf-8")
|
||||
return changed
|
||||
|
||||
|
||||
def parse_structured(
|
||||
path: Path,
|
||||
) -> tuple[list[tuple[str, str]], list[str], dict[str, list[tuple[str, str]]]]:
|
||||
"""Return (top_level_kvs, section_order, {section: kvs}) preserving file order."""
|
||||
top: list[tuple[str, str]] = []
|
||||
order: list[str] = []
|
||||
sections: dict[str, list[tuple[str, str]]] = {}
|
||||
section = ""
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
s = raw.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
sec = SECTION_RE.match(s)
|
||||
if sec:
|
||||
section = sec.group(1)
|
||||
if section not in sections:
|
||||
order.append(section)
|
||||
sections[section] = []
|
||||
continue
|
||||
kv = KV_RE.match(s)
|
||||
if kv:
|
||||
(top if section == "" else sections[section]).append(
|
||||
(kv.group(1), kv.group(2))
|
||||
)
|
||||
return top, order, sections
|
||||
|
||||
|
||||
def _insert_ci(items: list, new, key_lower):
|
||||
"""Insert `new` into `items` at the case-insensitive sorted position."""
|
||||
nk = key_lower(new)
|
||||
for i, existing in enumerate(items):
|
||||
if key_lower(existing) > nk:
|
||||
items.insert(i, new)
|
||||
return
|
||||
items.append(new)
|
||||
|
||||
|
||||
def sync_en_us(dry_run: bool) -> int:
|
||||
"""Regenerate en-US to mirror en-GB's structure, British->American.
|
||||
|
||||
en-GB is the source of truth for which keys exist and in what order. For
|
||||
keys present in both, en-US keeps its own (US) wording; en-GB-only keys are
|
||||
added in their en-GB position; en-US-only keys/sections are slotted into the
|
||||
correct case-insensitive sorted position so the file stays ordered. Every
|
||||
value is run through the British->American converter.
|
||||
"""
|
||||
us_keys, _ = parse_keys(EN_US)
|
||||
gb_keys, _ = parse_keys(EN_GB)
|
||||
gb_top, gb_order, gb_sections = parse_structured(EN_GB)
|
||||
us_top, us_order, us_sections = parse_structured(EN_US)
|
||||
|
||||
added = [k for k in gb_keys if k not in us_keys]
|
||||
us_only = [k for k in us_keys if k not in gb_keys]
|
||||
|
||||
def pick(section: str, key: str, gb_value: str) -> str:
|
||||
"""Prefer en-US's own wording for shared keys; convert UK->US either way."""
|
||||
value = us_keys.get(fq(section, key), gb_value)
|
||||
return uk_to_us_convert(value)[0]
|
||||
|
||||
# --- top-level keys: mirror en-GB, slot en-US-only keys in sorted order ---
|
||||
out_top: list[tuple[str, str]] = [(k, pick("", k, v)) for k, v in gb_top]
|
||||
gb_top_keys = {k for k, _ in gb_top}
|
||||
for k, v in us_top:
|
||||
if k not in gb_top_keys:
|
||||
_insert_ci(out_top, (k, uk_to_us_convert(v)[0]), lambda kv: kv[0].lower())
|
||||
|
||||
# --- sections: mirror en-GB order/keys, merge en-US-only keys & sections ---
|
||||
out_sections: list[tuple[str, list[tuple[str, str]]]] = []
|
||||
for name in gb_order:
|
||||
gb_kvs = gb_sections[name]
|
||||
gb_section_keys = {k for k, _ in gb_kvs}
|
||||
merged = [(k, pick(name, k, v)) for k, v in gb_kvs]
|
||||
# en-US-only keys that belong to this (shared) section
|
||||
for k, v in us_sections.get(name, []):
|
||||
if k not in gb_section_keys:
|
||||
_insert_ci(
|
||||
merged, (k, uk_to_us_convert(v)[0]), lambda kv: kv[0].lower()
|
||||
)
|
||||
out_sections.append((name, merged))
|
||||
|
||||
# en-US-only sections (absent from en-GB): insert by ci header order
|
||||
gb_section_names = set(gb_order)
|
||||
for name in us_order:
|
||||
if name not in gb_section_names:
|
||||
kvs = [(k, uk_to_us_convert(v)[0]) for k, v in us_sections[name]]
|
||||
_insert_ci(out_sections, (name, kvs), lambda s: s[0].lower())
|
||||
|
||||
# --- emit (top-level block, then one blank line before each section) ---
|
||||
lines: list[str] = [f'{k} = "{v}"' for k, v in out_top]
|
||||
for name, kvs in out_sections:
|
||||
lines.append("")
|
||||
lines.append(f"[{name}]")
|
||||
lines.extend(f'{k} = "{v}"' for k, v in kvs)
|
||||
|
||||
print(
|
||||
f"en-US: +{len(added)} key(s) from en-GB, "
|
||||
f"{len(us_only)} en-US-only key(s) preserved (British->American applied)."
|
||||
)
|
||||
for k in added:
|
||||
print(f" [en-US] + {k}")
|
||||
if not dry_run:
|
||||
EN_US.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return len(added)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument(
|
||||
"--dry-run", action="store_true", help="report changes without writing"
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if not EN_US.exists() or not EN_GB.exists():
|
||||
print(f"error: expected files under {LOCALES}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Spelling map: {len(uk_to_us)} British->American word forms.\n")
|
||||
added = sync_en_us(args.dry_run)
|
||||
print()
|
||||
fixed = fix_en_gb(args.dry_run)
|
||||
print()
|
||||
mode = "DRY RUN (no files written)" if args.dry_run else "written"
|
||||
print(f"Done [{mode}]: +{added} en-US key(s), {fixed} en-GB value(s) corrected.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -482,6 +482,46 @@ title = "Add Page Numbers"
|
||||
|
||||
Keys use dot notation internally (e.g., `addPageNumbers.selectText.1`).
|
||||
|
||||
### Pluralization Suffixes
|
||||
|
||||
Pluralized i18n keys use the i18next/Intl plural suffixes:
|
||||
`_zero`, `_one`, `_two`, `_few`, `_many`, and `_other`.
|
||||
|
||||
Define the shared base key plus one entry for each plural category the language
|
||||
needs. Always provide `_other`; i18next uses it as the fallback plural form.
|
||||
Use `{{count}}` as the count placeholder, and call the base key from code with a
|
||||
`count` value:
|
||||
|
||||
```toml
|
||||
[common]
|
||||
fileCount_one = "{{count}} file"
|
||||
fileCount_other = "{{count}} files"
|
||||
fileCount_zero = "No files"
|
||||
```
|
||||
|
||||
Languages with more plural categories can add their required forms. For example,
|
||||
Arabic-style plural rules distinguish `0`, `1`, `2`, small counts, large counts,
|
||||
and the fallback form:
|
||||
|
||||
```toml
|
||||
[common]
|
||||
uploadCount_zero = "No files uploaded" # count = 0
|
||||
uploadCount_one = "One file uploaded" # count = 1
|
||||
uploadCount_two = "Two files uploaded" # count = 2
|
||||
uploadCount_few = "{{count}} files uploaded" # count = 3-10
|
||||
uploadCount_many = "{{count}} files uploaded" # count = 11-99
|
||||
uploadCount_other = "{{count}} files uploaded" # count = 100, fractions, fallback
|
||||
```
|
||||
|
||||
```ts
|
||||
t("common.uploadCount", { count: files.length });
|
||||
```
|
||||
|
||||
Do not translate the suffix names themselves. They are CLDR plural categories,
|
||||
not English words. Add only the forms required by the target language, but keep
|
||||
the plural key set aligned with `en-US` unless the target language needs
|
||||
additional CLDR forms such as `_few` or `_many`.
|
||||
|
||||
## Key Features
|
||||
|
||||
### Placeholder Preservation
|
||||
|
||||
Reference in New Issue
Block a user