Initial public commit

This commit is contained in:
Forkless
2026-05-11 21:57:24 +02:00
commit fac0699997
60 changed files with 13616 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
#
# json2po.sh — Convert a per-locale JSON translation file into a WordPress
# .po file, using the original .pot for file-reference comments.
#
# Usage:
# ./tools/json2po.sh <locale_json> [pot_path] [output_po]
#
# Defaults:
# pot_path = languages/piperless.pot
# output_po = languages/piperless-<locale>.po
#
# The script only emits entries with non-empty translations.
# Untranslated strings are skipped — WordPress falls back to the English
# msgid at runtime.
set -euo pipefail
LOCALE_JSON="${1:?Usage: $0 <locale_json> [pot_path] [output_po]}"
POT="${2:-languages/piperless.pot}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
# Resolve paths relative to project root.
[[ "$LOCALE_JSON" != /* ]] && LOCALE_JSON="${PROJECT_DIR}/${LOCALE_JSON}"
[[ "$POT" != /* ]] && POT="${PROJECT_DIR}/${POT}"
if [[ ! -f "$LOCALE_JSON" ]]; then
echo "Error: locale JSON not found: $LOCALE_JSON" >&2
exit 1
fi
if [[ ! -f "$POT" ]]; then
echo "Error: .pot file not found: $POT" >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required." >&2
exit 1
fi
# ══════════════════════════════════════════════════════════════════════
# Helper: escape a string for PO msgid/msgstr double-quoted format.
# ══════════════════════════════════════════════════════════════════════
po_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
printf '%s' "$s"
}
# ── Determine locale and output path ──────────────────────────────────
LOCALE=$(jq -r '._meta.locale // "unknown"' "$LOCALE_JSON")
if [[ -n "${3:-}" ]]; then
OUT_PO="${3}"
[[ "$OUT_PO" != /* ]] && OUT_PO="${PROJECT_DIR}/${OUT_PO}"
else
OUT_PO="${LOCALE_JSON%.json}.po"
fi
# ── Build msgid → references map from the .pot ────────────────────────
# Format: msgid<TAB>#: file:line file:line
declare -A refs
current_refs=""
current_msgid=""
in_msgid=false
while IFS= read -r line || [[ -n "$line" ]]; do
if [[ "$line" =~ ^#:\ (.*) ]]; then
current_refs="#: ${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^#:\ ]]; then
# Continuation of references (e.g. "#: piperless.php:47 piperless.php:56")
: # handled by the regex above
elif [[ "$line" =~ ^msgid\ \"(.*)\"$ ]]; then
current_msgid="${BASH_REMATCH[1]}"
in_msgid=true
elif $in_msgid && [[ "$line" =~ ^\"(.*)\"$ ]]; then
current_msgid+="${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^msgstr ]]; then
in_msgid=false
if [[ -n "$current_msgid" && -n "$current_refs" ]]; then
refs["$current_msgid"]="$current_refs"
fi
current_refs=""
current_msgid=""
fi
done < "$POT"
# ── Counters ──────────────────────────────────────────────────────────
TOTAL=$(jq '._meta.total_strings // 0' "$LOCALE_JSON")
TRANSLATED=$(jq '[.strings[] | select(. != "")] | length' "$LOCALE_JSON")
TODAY=$(date +%Y-%m-%d)
# ── PO header ─────────────────────────────────────────────────────────
LANG_CODE="${LOCALE%_*}" # e.g. "de" from "de_DE"
LANG_NAME="${LOCALE}" # full locale as fallback
# Try to get a human-readable language name.
case "$LANG_CODE" in
de) LANG_DISPLAY="German" ;;
fr) LANG_DISPLAY="French" ;;
es) LANG_DISPLAY="Spanish" ;;
ja) LANG_DISPLAY="Japanese" ;;
nl) LANG_DISPLAY="Dutch" ;;
*) LANG_DISPLAY="$LANG_CODE" ;;
esac
# ── Generate .po ──────────────────────────────────────────────────────
{
cat <<HEADER
# Piperless — Audio Transcripts
# Copyright (C) 2024 Piperless Team
# This file is distributed under the MIT license.
#
msgid ""
msgstr ""
"Project-Id-Version: Piperless 1.0.0\\n"
"Report-Msgid-Bugs-To: https://github.com/example/piperless/issues\\n"
"POT-Creation-Date: 2024-01-01 00:00+0000\\n"
"PO-Revision-Date: ${TODAY} 00:00+0000\\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\\n"
"Language-Team: ${LANG_DISPLAY} <LL@li.org>\\n"
"Language: ${LOCALE}\\n"
"MIME-Version: 1.0\\n"
"Content-Type: text/plain; charset=UTF-8\\n"
"Content-Transfer-Encoding: 8bit\\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\\n"
HEADER
# ── Emit translated entries ───────────────────────────────────────
emitted=0
while IFS=$'\t' read -r key val; do
# Skip empty translations.
[[ -z "$val" ]] && continue
# Emit file-reference comment if known.
ref="${refs["$key"]:-}"
if [[ -n "$ref" ]]; then
echo "$ref"
fi
# msgid — escape for PO format.
printf 'msgid "%s"\n' "$(po_escape "$key")"
# msgstr — escape for PO format.
printf 'msgstr "%s"\n' "$(po_escape "$val")"
echo ""
emitted=$((emitted + 1))
done < <(jq -r '.strings | to_entries[] | "\(.key)\t\(.value)"' "$LOCALE_JSON")
} > "$OUT_PO"
# ── Compile .po → .mo ─────────────────────────────────────────────────
# WordPress loads .mo files at runtime, not .po.
OUT_MO="${OUT_PO%.po}.mo"
if command -v msgfmt &>/dev/null; then
msgfmt -o "$OUT_MO" "$OUT_PO" 2>/dev/null
MO_OK=true
else
MO_OK=false
fi
# ── Report ────────────────────────────────────────────────────────────
echo "json2po: ${LOCALE}${OUT_PO}"
echo " translated : ${TRANSLATED} / ${TOTAL}"
echo " emitted : ${emitted:-0} entries"
if [[ "$MO_OK" == true ]]; then
echo " compiled : ${OUT_MO}"
else
echo " compiled : skipped (msgfmt not found)"
fi
if [[ ${emitted:-0} -eq 0 ]]; then
echo " WARNING: no translations found. .po file contains headers only."
fi
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env bash
#
# lock-translations.sh — Acquire / release / inspect translation locks.
#
# A lock marks a locale file as "being translated" so no two translators
# work on the same file simultaneously. The lock is recorded in two places:
# 1. .translations-lock.json (project-level registry)
# 2. Each locale JSON's _meta.locked flag
#
# Usage:
# ./tools/lock-translations.sh lock <locale> [who]
# ./tools/lock-translations.sh unlock <locale>
# ./tools/lock-translations.sh lock-all [who]
# ./tools/lock-translations.sh unlock-all
# ./tools/lock-translations.sh status
#
# Exit codes:
# 0 — success
# 1 — general error
# 2 — locale already locked
# 4 — locale not locked (on unlock)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LOCKFILE="${PROJECT_DIR}/.translations-lock.json"
LANG_DIR="${PROJECT_DIR}/languages"
# ── Ensure lock file exists ───────────────────────────────────────────
init_lockfile() {
if [[ ! -f "$LOCKFILE" ]]; then
cat > "$LOCKFILE" <<'EOF'
{
"version": 1,
"locks": {
"de_DE": null,
"fr_FR": null,
"es_ES": null,
"ja": null,
"nl_NL": null
}
}
EOF
fi
}
# ── Read lock state for a locale ──────────────────────────────────────
get_lock_state() {
local locale="$1"
# jq -r outputs the literal string "null" for JSON null.
jq -r ".locks[\"$locale\"]" "$LOCKFILE"
}
# ── Set lock state ────────────────────────────────────────────────────
set_lock_state() {
local locale="$1"
local who="$2"
local ts
ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [[ "$who" == "null" ]]; then
jq --arg loc "$locale" '.locks[$loc] = null' "$LOCKFILE" > "${LOCKFILE}.tmp"
else
jq --arg loc "$locale" --arg who "$who" --arg ts "$ts" \
'.locks[$loc] = {locked_by: $who, locked_at: $ts}' \
"$LOCKFILE" > "${LOCKFILE}.tmp"
fi
mv "${LOCKFILE}.tmp" "$LOCKFILE"
}
# ── Sync _meta.locked in the locale JSON ──────────────────────────────
sync_locale_meta() {
local locale="$1"
local locked="$2" # "true" or "false"
local file="${LANG_DIR}/piperless-${locale}.json"
if [[ -f "$file" ]]; then
jq --argjson l "$locked" '._meta.locked = $l' "$file" > "${file}.tmp"
mv "${file}.tmp" "$file"
fi
}
# ── Status table ──────────────────────────────────────────────────────
cmd_status() {
init_lockfile
printf "%-8s %-10s %-6s %s\n" "LOCALE" "TRANS" "LOCKED" "HELD BY"
printf "%-8s %-10s %-6s %s\n" "------" "------" "------" "-------"
for locale in de_DE fr_FR es_ES ja nl_NL; do
local file="${LANG_DIR}/piperless-${locale}.json"
local translated=0 total=0
if [[ -f "$file" ]]; then
translated=$(jq -r '._meta.translated // 0' "$file")
total=$(jq -r '._meta.total_strings // 0' "$file")
fi
local lock_info
lock_info=$(jq -r ".locks[\"$locale\"]" "$LOCKFILE")
local locked="no"
local who="-"
if [[ "$lock_info" != "null" ]]; then
locked="YES"
who=$(echo "$lock_info" | jq -r '.locked_by // "?"')
fi
printf "%-8s %3s/%-6s %-6s %s\n" \
"$locale" "$translated" "$total" "$locked" "$who"
done
}
# ── Lock ──────────────────────────────────────────────────────────────
cmd_lock() {
local locale="$1"
local who="${2:-${USER:-unknown}}"
init_lockfile
# Validate locale exists.
local file="${LANG_DIR}/piperless-${locale}.json"
if [[ ! -f "$file" ]]; then
echo "Error: locale file not found: $file" >&2
exit 1
fi
# Check if already locked.
local current
current=$(get_lock_state "$locale")
if [[ "$current" != "null" ]]; then
local by
by=$(echo "$current" | jq -r '.locked_by // "?"')
echo "Error: $locale is already locked by $by." >&2
echo "Use 'unlock $locale' first, or contact $by." >&2
exit 2
fi
set_lock_state "$locale" "$who"
sync_locale_meta "$locale" true
echo "Locked $locale → held by $who"
}
# ── Unlock ────────────────────────────────────────────────────────────
cmd_unlock() {
local locale="$1"
init_lockfile
local current
current=$(get_lock_state "$locale")
if [[ "$current" == "null" ]]; then
echo "Error: $locale is not locked." >&2
exit 4
fi
set_lock_state "$locale" "null"
sync_locale_meta "$locale" false
echo "Unlocked $locale"
}
# ── Lock all ──────────────────────────────────────────────────────────
cmd_lock_all() {
local who="${1:-${USER:-unknown}}"
init_lockfile
for locale in de_DE fr_FR es_ES ja nl_NL; do
local current
current=$(get_lock_state "$locale")
if [[ "$current" != "null" ]]; then
local by
by=$(echo "$current" | jq -r '.locked_by // "?"')
echo "Skipping $locale (already locked by $by)"
continue
fi
set_lock_state "$locale" "$who"
sync_locale_meta "$locale" true
echo "Locked $locale"
done
}
# ── Unlock all ────────────────────────────────────────────────────────
cmd_unlock_all() {
init_lockfile
for locale in de_DE fr_FR es_ES ja nl_NL; do
set_lock_state "$locale" "null"
sync_locale_meta "$locale" false
echo "Unlocked $locale"
done
}
# ══════════════════════════════════════════════════════════════════════
# Main
# ══════════════════════════════════════════════════════════════════════
CMD="${1:-status}"
shift || true
case "$CMD" in
status) cmd_status "$@";;
lock) cmd_lock "$@";;
unlock) cmd_unlock "$@";;
lock-all) cmd_lock_all "$@";;
unlock-all) cmd_unlock_all "$@";;
*)
echo "Usage: $0 {status|lock|unlock|lock-all|unlock-all} [locale] [who]" >&2
exit 1
;;
esac
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env bash
#
# pot2json.sh — Extract translatable strings from a WordPress .pot file
# and produce a clean JSON keyed by msgid.
#
# Usage:
# ./tools/pot2json.sh [pot_path] [output_json]
#
# Defaults:
# pot_path = languages/piperless.pot
# output_json = languages/translations.json
set -euo pipefail
POT="${1:-languages/piperless.pot}"
OUT="${2:-languages/translations.json}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
POT="${PROJECT_DIR}/${POT#./}"
OUT="${PROJECT_DIR}/${OUT#./}"
if [[ ! -f "$POT" ]]; then
echo "Error: .pot file not found at $POT" >&2
exit 1
fi
# ── Extract msgid/msgstr pairs into a tmp file ────────────────────────
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
current_msgid=""
current_msgstr=""
in_msgid=false
in_msgstr=false
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^# ]] && continue
if [[ "$line" =~ ^msgid\ \"(.*)\"$ ]]; then
current_msgid="${BASH_REMATCH[1]}"
in_msgid=true
in_msgstr=false
current_msgstr=""
elif [[ "$line" =~ ^msgstr\ \"(.*)\"$ ]]; then
current_msgstr="${BASH_REMATCH[1]}"
in_msgid=false
in_msgstr=true
if [[ -n "$current_msgid" ]]; then
# Write key<tab>value to tmp file (sorted later).
printf '%s\t%s\n' "$current_msgid" "$current_msgstr" >> "$TMP"
fi
elif $in_msgid && [[ "$line" =~ ^\"(.*)\"$ ]]; then
current_msgid+="${BASH_REMATCH[1]}"
elif $in_msgstr && [[ "$line" =~ ^\"(.*)\"$ ]]; then
current_msgstr+="${BASH_REMATCH[1]}"
fi
done < "$POT"
# ── Build JSON from sorted pairs ──────────────────────────────────────
{
echo "{"
first=true
sort "$TMP" | while IFS=$'\t' read -r key val; do
# Escape for JSON.
esc_key=$(printf '%s' "$key" | sed 's/\\/\\\\/g; s/"/\\"/g')
esc_val=$(printf '%s' "$val" | sed 's/\\/\\\\/g; s/"/\\"/g')
if $first; then
first=false
else
echo ","
fi
printf ' "%s": "%s"' "$esc_key" "$esc_val"
done
echo ""
echo "}"
} > "$OUT"
count=$(wc -l < "$TMP")
echo "Extracted ${count} strings → ${OUT}"
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
#
# sync-translations.sh — Generate / update per-locale JSON translation files
# from the master translations.json.
#
# Usage:
# ./tools/sync-translations.sh [locale...]
#
# With no arguments: syncs all known locales.
# With one or more locale codes: syncs only those (e.g. de_DE fr_FR).
#
# The master file (languages/translations.json) keys on the original English
# string. Each locale file gets a _meta block and a "strings" object.
# Existing translations are preserved; new keys from master are added with
# empty values. Removed keys are dropped.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
LANG_DIR="${PROJECT_DIR}/languages"
MASTER="${LANG_DIR}/translations.json"
# ── Known target locales ──────────────────────────────────────────────
DEFAULT_LOCALES=("de_DE" "fr_FR" "es_ES" "ja" "nl_NL")
if [[ $# -gt 0 ]]; then
LOCALES=("$@")
else
LOCALES=("${DEFAULT_LOCALES[@]}")
fi
# ── Validate master ───────────────────────────────────────────────────
if [[ ! -f "$MASTER" ]]; then
echo "Error: master translations.json not found. Run pot2json.sh first." >&2
exit 1
fi
if ! command -v jq &>/dev/null; then
echo "Error: jq is required. Install with: sudo apt install jq" >&2
exit 1
fi
TODAY=$(date +%Y-%m-%d)
# ── Count keys in master ──────────────────────────────────────────────
MASTER_COUNT=$(jq 'keys | length' "$MASTER")
for LOCALE in "${LOCALES[@]}"; do
LOCALE_FILE="${LANG_DIR}/piperless-${LOCALE}.json"
if [[ -f "$LOCALE_FILE" ]]; then
# ── Merge: keep existing translations, add new keys, drop removed ──
OLD_TRANSLATED=$(jq '._meta.translated // 0' "$LOCALE_FILE")
OLD_LOCKED=$(jq '._meta.locked // false' "$LOCALE_FILE")
# Build a new strings object: for every key in master, use the
# existing translation if present; otherwise empty string.
jq -n --slurpfile master "$MASTER" --slurpfile local "$LOCALE_FILE" '
($local[0].strings // {}) as $existing |
{
_meta: {
locale: "'"$LOCALE"'",
source: "translations.json",
generated: "'"$TODAY"'",
total_strings: ($master[0] | keys | length),
translated: 0,
locked: '"$OLD_LOCKED"'
},
strings: ($master[0] | to_entries | map({
key: .key,
value: ($existing[.key] // "")
}) | from_entries)
}
' > "${LOCALE_FILE}.tmp"
# Re-count translated (non-empty values).
NEW_TRANSLATED=$(jq '[.strings[] | select(. != "")] | length' "${LOCALE_FILE}.tmp")
jq --argjson t "$NEW_TRANSLATED" '._meta.translated = $t' "${LOCALE_FILE}.tmp" > "$LOCALE_FILE"
rm -f "${LOCALE_FILE}.tmp"
echo "Updated ${LOCALE}: ${NEW_TRANSLATED} / ${MASTER_COUNT} translated (was ${OLD_TRANSLATED})"
else
# ── Create new locale file ─────────────────────────────────────
jq -n --slurpfile master "$MASTER" '
{
_meta: {
locale: "'"$LOCALE"'",
source: "translations.json",
generated: "'"$TODAY"'",
total_strings: ($master[0] | keys | length),
translated: 0,
locked: false
},
strings: ($master[0] | to_entries | map({
key: .key,
value: .value
}) | from_entries)
}
' > "$LOCALE_FILE"
echo "Created ${LOCALE}: 0 / ${MASTER_COUNT} translated"
fi
done
echo ""
echo "Done. Locale files in ${LANG_DIR}/"