mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-30 02:26:53 +00:00
ProxMenux 1.2.6.2-beta: OCI containers in the Monitor, docs and fixes
OCI manager Apps - App tab: containers installed from an OCI image are identified from their installation record; the application and image versions are shown and an update is detected by image digest; repository link; Refresh data. - Updates tab for OCI containers: Update and Recreate run the same flow as the OCI menu in the Monitor terminal; the pre-update backup can be kept in a backup storage; scheduled image updates with an optional minimum age. - Logs tab: console output of the application, kept on the host (lxc.console.logfile + logrotate) and followed live. - The Proxmox console opens a shell (cmode: shell) when the image has one. - A damaged image download is fetched again before failing. - Multi-container applications open at their LAN address; volume mount points on block storage report their usage. Monitor - Proxmox notifications are delivered to a loopback-only HTTP listener when HTTPS is enabled, so they no longer fail certificate verification. - Log persistence counts recurring patterns only; an ended burst is not reported as persistent and its warning clears on its own (#386). - Proxmox notification config backups are deduplicated and capped at three. - The update icon on the Apps page opens the container on its Updates tab. - Version 1.2.6.2-beta and its release notes in every Monitor language. Docs - OCI manager Apps and Audit & Report rebuilt as per-page message files, with a new page for OCI containers in the Monitor. - Seven pages fixed where rich-text tags were missing from t.rich. Translations - Spanish fixes across the OCI engine, the Monitor and the TUI menus. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
386d33df6e
commit
4437a671d2
@@ -18,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent))
|
||||
from build_translation_cache import ( # noqa: E402
|
||||
clean_translation,
|
||||
translate_appimage,
|
||||
translate_argos,
|
||||
translate_google_web,
|
||||
translate_googletrans,
|
||||
)
|
||||
@@ -86,6 +87,7 @@ PROTECTED_PATTERNS = (
|
||||
re.compile(r"(?<![\w-])--[A-Za-z0-9][A-Za-z0-9_-]*"),
|
||||
re.compile(r"(?<![A-Za-z0-9<])/(?:[A-Za-z0-9._~:@%+=-]+/)*[A-Za-z0-9._~:@%+=-]+"),
|
||||
re.compile(r"\b[A-Za-z0-9_.-]+\.(?:json|ya?ml|toml|conf|service|socket|sh|py|tsx?|jsx?|md)\b"),
|
||||
re.compile(r"(?<![\w.@])@[A-Za-z_][A-Za-z0-9_]*"),
|
||||
)
|
||||
LITERAL_TAG_RE = re.compile(
|
||||
r"<(code|kbd|pre)\b[^>]*>.*?</\1>",
|
||||
@@ -100,6 +102,11 @@ TERM_RE = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
TAG_RE = re.compile(r"</?([A-Za-z][A-Za-z0-9]*)>")
|
||||
# Every tag as written, attributes and self-closing slash included.
|
||||
ANY_TAG_RE = re.compile(r"</?[A-Za-z][^<>]*>")
|
||||
# Set from --provider: a local model rewrites bracketed sentinels, a remote
|
||||
# one drops bare ones beside inline markup. See protect_text.
|
||||
BARE_SENTINELS = False
|
||||
PLACEHOLDER_RE = re.compile(r"\{[A-Za-z_][A-Za-z0-9_.-]*\}")
|
||||
NON_TRANSLATABLE_KEYS = {
|
||||
"command",
|
||||
@@ -206,13 +213,13 @@ def needs_translation(
|
||||
source: str,
|
||||
target: Any,
|
||||
path: tuple[str | int, ...],
|
||||
refresh: bool,
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> bool:
|
||||
if should_copy(source, path):
|
||||
return False
|
||||
if refresh:
|
||||
return True
|
||||
# An existing translation is never replaced. Only a leaf whose token was
|
||||
# named on the command line may be retranslated over a real value, so an
|
||||
# overwrite is always one somebody asked for rather than a side effect.
|
||||
if forced_tokens and leaf_token(path) in forced_tokens:
|
||||
return True
|
||||
return not isinstance(target, str) or not target.strip() or target == source
|
||||
@@ -279,6 +286,17 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
for match in TERM_RE.finditer(text)
|
||||
if not overlaps_literal(match.start(), match.end())
|
||||
)
|
||||
if BARE_SENTINELS:
|
||||
# A remote provider carries inline tags through untouched, so they are
|
||||
# only validated afterwards. A local model rewrites them — dropping a
|
||||
# </code>, merging two <em> — and the whole file is then discarded for
|
||||
# a broken contract. Protecting them costs the translator the tag as
|
||||
# context and saves the paragraph.
|
||||
candidates.extend((match.start(), match.end()) for match in ANY_TAG_RE.finditer(text))
|
||||
# A local model also rewrites what a literal block holds: it drops the
|
||||
# underscores of an identifier, translates a subcommand and turns a
|
||||
# pipe into punctuation. The whole block is one opaque token instead.
|
||||
candidates.extend(literal_ranges)
|
||||
candidates.sort(key=lambda item: (item[0], -(item[1] - item[0])))
|
||||
|
||||
selected: list[tuple[int, int]] = []
|
||||
@@ -296,7 +314,12 @@ def protect_text(text: str) -> tuple[str, dict[str, str]]:
|
||||
# sit directly beside inline markup (for example an opening <em>
|
||||
# token followed by translated prose). Triple brackets remain opaque
|
||||
# in that position and preserve the complete rich-text contract.
|
||||
token = f"[[[PMXDOC{index:04d}]]]"
|
||||
#
|
||||
# A local model treats the brackets as punctuation instead, and
|
||||
# rewrites them: argos returns [PMXDOC0000] in German, [[PMXDOC0000]]
|
||||
# in Slovak and [[[[PMXDOC0000]]]]] in Italian. The bare identifier
|
||||
# survives every provider tried, so it is what a local run uses.
|
||||
token = f"PMXDOC{index:04d}" if BARE_SENTINELS else f"[[[PMXDOC{index:04d}]]]"
|
||||
chunks.append(text[cursor:start])
|
||||
chunks.append(token)
|
||||
mapping[token] = text[start:end]
|
||||
@@ -317,13 +340,20 @@ def restore_text(text: str, mapping: dict[str, str]) -> str:
|
||||
def validate_contract(source: str, target: str) -> None:
|
||||
if sorted(TAG_RE.findall(source)) != sorted(TAG_RE.findall(target)):
|
||||
raise ValueError("rich-text tag contract changed")
|
||||
if sorted(ANY_TAG_RE.findall(source)) != sorted(ANY_TAG_RE.findall(target)):
|
||||
raise ValueError("markup changed")
|
||||
literal = lambda text: sorted(match.group(0) for match in LITERAL_TAG_RE.finditer(text))
|
||||
if literal(source) != literal(target):
|
||||
raise ValueError("literal code changed")
|
||||
if sorted(PLACEHOLDER_RE.findall(source)) != sorted(PLACEHOLDER_RE.findall(target)):
|
||||
raise ValueError("placeholder contract changed")
|
||||
|
||||
|
||||
def provider_function(args: argparse.Namespace) -> Callable[[str, str], str]:
|
||||
def translate(text: str, language: str) -> str:
|
||||
if args.provider == "googletrans":
|
||||
if args.provider == "argos":
|
||||
raw = translate_argos(text, language)
|
||||
elif args.provider == "googletrans":
|
||||
raw = translate_googletrans(text, language, args.context)
|
||||
elif args.provider == "google-web":
|
||||
raw = translate_google_web(text, language, args.context, args.timeout)
|
||||
@@ -446,7 +476,6 @@ def collect_memory(source_root: Path, messages_root: Path, language: str) -> dic
|
||||
def pending_leaves(
|
||||
source: Any,
|
||||
target: Any,
|
||||
refresh: bool,
|
||||
forced_tokens: set[str] | None = None,
|
||||
) -> list[Leaf]:
|
||||
return [
|
||||
@@ -456,7 +485,6 @@ def pending_leaves(
|
||||
leaf.source,
|
||||
get_at_path(target, leaf.path),
|
||||
leaf.path,
|
||||
refresh,
|
||||
forced_tokens,
|
||||
)
|
||||
]
|
||||
@@ -533,13 +561,14 @@ def translate_file(
|
||||
memory: dict[str, str],
|
||||
translate: Callable[[str, str], str],
|
||||
args: argparse.Namespace,
|
||||
) -> tuple[Any | None, list[str], int]:
|
||||
) -> tuple[Any | None, list[str], int, set[str]]:
|
||||
resolved: dict[tuple[str | int, ...], str] = {}
|
||||
failures: list[str] = []
|
||||
failed_tokens: set[str] = set()
|
||||
jobs: dict[str, list[tuple[str | int, ...]]] = {}
|
||||
|
||||
for leaf in leaves:
|
||||
if not args.refresh and leaf.source in memory:
|
||||
if leaf.source in memory:
|
||||
resolved[leaf.path] = memory[leaf.source]
|
||||
continue
|
||||
jobs.setdefault(leaf.source, []).append(leaf.path)
|
||||
@@ -566,12 +595,17 @@ def translate_file(
|
||||
resolved[path] = translated
|
||||
except Exception as exc:
|
||||
failures.append(f"{text[:90]}: {exc}")
|
||||
failed_tokens.update(leaf_token(path) for path in jobs[text])
|
||||
if args.sleep:
|
||||
time.sleep(args.sleep)
|
||||
|
||||
if failures:
|
||||
return None, failures, len(jobs)
|
||||
return merge_tree(source, target, resolved), [], len(jobs)
|
||||
# What did translate is kept. merge_tree leaves a string it has no
|
||||
# translation for as whatever the locale already held, or as the English
|
||||
# source, so a file is written with the strings that worked rather than
|
||||
# discarded whole: a dense paragraph that a provider cannot carry its
|
||||
# protected tokens through used to take the other hundred and seventy
|
||||
# strings of its file with it.
|
||||
return merge_tree(source, target, resolved), failures, len(jobs), failed_tokens
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
@@ -586,8 +620,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=("google-web", "googletrans", "appimage"),
|
||||
choices=("argos", "google-web", "googletrans", "appimage"),
|
||||
default="googletrans",
|
||||
help=(
|
||||
"Translation provider. The default is what CI uses; `argos` runs "
|
||||
"locally with no quota, which is what a corpus this size needs."
|
||||
),
|
||||
)
|
||||
parser.add_argument("--appimage-path", type=Path, default=Path("ProxMenux-Monitor.AppImage"))
|
||||
parser.add_argument(
|
||||
@@ -623,7 +661,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
global BARE_SENTINELS
|
||||
args = build_parser().parse_args()
|
||||
BARE_SENTINELS = args.provider == "argos"
|
||||
if args.workers < 1 or args.retries < 0 or args.max_files < 0:
|
||||
print("workers must be positive; retries and max-files cannot be negative", file=sys.stderr)
|
||||
return 2
|
||||
@@ -646,7 +686,8 @@ def main() -> int:
|
||||
print("No target languages selected.", file=sys.stderr)
|
||||
return 2
|
||||
if args.refresh:
|
||||
print("WARNING: --refresh overwrites existing translations in the selected scope.")
|
||||
print("--refresh no longer overwrites existing translations; "
|
||||
"name the leaves with --tokens instead.")
|
||||
|
||||
translate = provider_function(args)
|
||||
state = load_source_state(source_state_path)
|
||||
@@ -716,7 +757,7 @@ def main() -> int:
|
||||
pending_tokens.update(changed_tokens)
|
||||
pending_tokens.update(
|
||||
leaf_token(leaf.path)
|
||||
for leaf in pending_leaves(source, target, args.refresh)
|
||||
for leaf in pending_leaves(source, target)
|
||||
)
|
||||
set_state_pending_tokens(state, language, relative, pending_tokens)
|
||||
|
||||
@@ -742,6 +783,11 @@ def main() -> int:
|
||||
|
||||
total_failures = 0
|
||||
total_written = 0
|
||||
# The provider decides the sentinel format, so a run that cannot be
|
||||
# attributed to one cannot be diagnosed afterwards: a night of failures
|
||||
# reading "translation provider changed protected token" left no way to
|
||||
# tell whether the provider was the one the tokens were shaped for.
|
||||
print(f"Provider: {args.provider} | bare sentinels: {BARE_SENTINELS}")
|
||||
print(f"English files: {len(files)} | locales: {', '.join(languages)}")
|
||||
|
||||
for language in languages:
|
||||
@@ -773,7 +819,7 @@ def main() -> int:
|
||||
source = file_info[relative_key][1]
|
||||
target = read_json(target_path)
|
||||
queued_tokens = state_pending_tokens(state, language, relative_key)
|
||||
leaves = pending_leaves(source, target, args.refresh, queued_tokens)
|
||||
leaves = pending_leaves(source, target, queued_tokens)
|
||||
total_strings += len(iter_leaves(source))
|
||||
missing_strings += len(leaves)
|
||||
schema_changed = not schema_matches(source, target)
|
||||
@@ -816,7 +862,7 @@ def main() -> int:
|
||||
f"({len(leaves)} strings{suffix})",
|
||||
flush=True,
|
||||
)
|
||||
built, failures, calls = translate_file(
|
||||
built, failures, calls, failed_tokens = translate_file(
|
||||
source,
|
||||
target,
|
||||
leaves,
|
||||
@@ -827,18 +873,18 @@ def main() -> int:
|
||||
)
|
||||
if failures:
|
||||
total_failures += len(failures)
|
||||
set_state_pending_tokens(state, language, relative_key, queued_tokens)
|
||||
write_json(source_state_path, state)
|
||||
print(
|
||||
f" skipped atomically after {len(failures)} failures "
|
||||
f"({calls} calls)",
|
||||
f" {len(failures)} strings left untranslated ({calls} calls)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
for failure in failures[:5]:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
continue
|
||||
write_json(target_path, built)
|
||||
set_state_pending_tokens(state, language, relative_key, set())
|
||||
# The strings that failed stay pending, so the next run comes back
|
||||
# for them and leaves the ones that translated alone.
|
||||
set_state_pending_tokens(state, language, relative_key, failed_tokens)
|
||||
if failures:
|
||||
write_json(source_state_path, state)
|
||||
write_json(source_state_path, state)
|
||||
total_written += 1
|
||||
print(f" wrote {target_path} ({calls} provider calls)", flush=True)
|
||||
|
||||
Reference in New Issue
Block a user