Merge main into migration/run-02 with full Spring removal for pulled-in code

This commit is contained in:
Anthony Stirling
2026-06-19 14:48:03 +01:00
1137 changed files with 126205 additions and 80988 deletions
+1 -1
View File
@@ -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) {
+2 -2
View File
@@ -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)) {
-5
View File
@@ -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" ^
-80
View File
@@ -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',
+31
View File
@@ -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
}
+30
View File
@@ -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"
+16
View File
@@ -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
+75
View File
@@ -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" },
]
+60
View File
@@ -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())
+618
View File
@@ -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())
+40
View File
@@ -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