droidlate 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- droidlate/__init__.py +1 -0
- droidlate/cli_wizard.py +145 -0
- droidlate/config.py +13 -0
- droidlate/main.py +123 -0
- droidlate/parser/__init__.py +1 -0
- droidlate/parser/diff_engine.py +159 -0
- droidlate/parser/xml_parser.py +332 -0
- droidlate/translator/__init__.py +1 -0
- droidlate/translator/apis.py +99 -0
- droidlate/translator/engine.py +59 -0
- droidlate/web/server.py +355 -0
- droidlate/web/static/app.js +706 -0
- droidlate/web/static/index.html +159 -0
- droidlate/web/static/style.css +864 -0
- droidlate-1.0.0.dist-info/METADATA +116 -0
- droidlate-1.0.0.dist-info/RECORD +20 -0
- droidlate-1.0.0.dist-info/WHEEL +5 -0
- droidlate-1.0.0.dist-info/entry_points.txt +2 -0
- droidlate-1.0.0.dist-info/licenses/LICENSE +674 -0
- droidlate-1.0.0.dist-info/top_level.txt +1 -0
droidlate/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# droidlate package init
|
droidlate/cli_wizard.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from .parser.xml_parser import parse_strings_xml, write_string_translation
|
|
6
|
+
from .parser.diff_engine import load_metadata, update_metadata_entry, categorize_key, validate_placeholders
|
|
7
|
+
from .main import auto_detect_res_dir
|
|
8
|
+
|
|
9
|
+
def run_wizard():
|
|
10
|
+
print("=== Droidlate: CLI Wizard ===")
|
|
11
|
+
|
|
12
|
+
# Auto-detect res dir
|
|
13
|
+
res_dir = auto_detect_res_dir()
|
|
14
|
+
print(f"Auto-detected resource directory: {res_dir}")
|
|
15
|
+
|
|
16
|
+
source_path = os.path.join(res_dir, "values", "strings.xml")
|
|
17
|
+
if not os.path.exists(source_path):
|
|
18
|
+
print(f"Error: Could not find base strings file at '{source_path}'.")
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
source_entries = parse_strings_xml(source_path)
|
|
22
|
+
if not source_entries:
|
|
23
|
+
print("Error: Base strings.xml is empty or invalid.")
|
|
24
|
+
sys.exit(1)
|
|
25
|
+
|
|
26
|
+
# Scan for target languages
|
|
27
|
+
locales = []
|
|
28
|
+
for folder in os.listdir(res_dir):
|
|
29
|
+
folder_path = os.path.join(res_dir, folder)
|
|
30
|
+
match = re.match(r"^values-([a-z]{2,3})(?:-r([a-zA-Z]{2,4}))?$", folder)
|
|
31
|
+
if os.path.isdir(folder_path) and match:
|
|
32
|
+
target_xml = os.path.join(folder_path, "strings.xml")
|
|
33
|
+
locales.append((folder, target_xml))
|
|
34
|
+
|
|
35
|
+
if not locales:
|
|
36
|
+
print("No localized values-* directories found.")
|
|
37
|
+
sys.exit(0)
|
|
38
|
+
|
|
39
|
+
print("\nAvailable Locales:")
|
|
40
|
+
for idx, (folder, _) in enumerate(sorted(locales)):
|
|
41
|
+
print(f"[{idx + 1}] {folder}")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
selection = input("\nSelect a language number to translate: ").strip()
|
|
45
|
+
sel_idx = int(selection) - 1
|
|
46
|
+
if sel_idx < 0 or sel_idx >= len(locales):
|
|
47
|
+
raise ValueError()
|
|
48
|
+
except (ValueError, KeyboardInterrupt):
|
|
49
|
+
print("\nExiting wizard.")
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
|
|
52
|
+
target_folder, target_xml = sorted(locales)[sel_idx]
|
|
53
|
+
print(f"\nLoading translations for: {target_folder}")
|
|
54
|
+
|
|
55
|
+
# Load target entries and metadata
|
|
56
|
+
target_entries = parse_strings_xml(target_xml) if os.path.exists(target_xml) else {}
|
|
57
|
+
metadata = load_metadata(target_xml)
|
|
58
|
+
|
|
59
|
+
metadata_changed = False
|
|
60
|
+
|
|
61
|
+
# Warn about orphaned translation keys
|
|
62
|
+
orphaned_keys = [k for k in target_entries.keys() if k not in source_entries]
|
|
63
|
+
if orphaned_keys:
|
|
64
|
+
print(f"\n[!] Warning: Found {len(orphaned_keys)} orphaned key(s) in translation that no longer exist in English source XML.")
|
|
65
|
+
print(" Use the Web interface to safely prune them or edit the XML file manually.")
|
|
66
|
+
|
|
67
|
+
# Auto-initialize legacy translations
|
|
68
|
+
for key, entry in source_entries.items():
|
|
69
|
+
if key in target_entries and key not in metadata:
|
|
70
|
+
from .parser.diff_engine import normalize_source_string, compute_source_hash
|
|
71
|
+
norm_src = normalize_source_string(entry.value)
|
|
72
|
+
metadata[key] = {
|
|
73
|
+
"source_hash": compute_source_hash(norm_src),
|
|
74
|
+
"translated_value": target_entries[key].value
|
|
75
|
+
}
|
|
76
|
+
metadata_changed = True
|
|
77
|
+
if metadata_changed:
|
|
78
|
+
from .parser.diff_engine import save_metadata
|
|
79
|
+
save_metadata(target_xml, metadata)
|
|
80
|
+
|
|
81
|
+
# Filter keys needing attention (untranslated, outdated, warnings)
|
|
82
|
+
todo_keys = []
|
|
83
|
+
for key, entry in source_entries.items():
|
|
84
|
+
tgt_val = target_entries.get(key).value if key in target_entries else None
|
|
85
|
+
meta_val = metadata.get(key)
|
|
86
|
+
status = categorize_key(key, entry.value, tgt_val, meta_val)
|
|
87
|
+
|
|
88
|
+
if status != "translated":
|
|
89
|
+
todo_keys.append((key, entry, tgt_val, status))
|
|
90
|
+
|
|
91
|
+
if not todo_keys:
|
|
92
|
+
print("All strings are already translated and up-to-date!")
|
|
93
|
+
sys.exit(0)
|
|
94
|
+
|
|
95
|
+
print(f"Found {len(todo_keys)} strings requiring attention.")
|
|
96
|
+
print("Commands: Type ':q' to quit, press Enter with empty text to skip.")
|
|
97
|
+
print("-" * 50)
|
|
98
|
+
|
|
99
|
+
for idx, (key, src_entry, tgt_val, status) in enumerate(todo_keys):
|
|
100
|
+
print(f"\n[{idx + 1}/{len(todo_keys)}] Key: {key} [Status: {status.upper()}]")
|
|
101
|
+
print(f"Source: {repr(src_entry.value)}")
|
|
102
|
+
if src_entry.comment:
|
|
103
|
+
print(f"Comment: {src_entry.comment}")
|
|
104
|
+
if tgt_val is not None:
|
|
105
|
+
print(f"Current Target: {repr(tgt_val)}")
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
translation = input("Translation > ").strip()
|
|
109
|
+
except (KeyboardInterrupt, EOFError):
|
|
110
|
+
print("\nWizard interrupted. Exiting safely.")
|
|
111
|
+
break
|
|
112
|
+
|
|
113
|
+
if translation == ":q":
|
|
114
|
+
print("Exiting wizard.")
|
|
115
|
+
break
|
|
116
|
+
elif not translation:
|
|
117
|
+
print("Skipped.")
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Check formatting warnings before saving
|
|
121
|
+
warnings = validate_placeholders(src_entry.value, translation)
|
|
122
|
+
if warnings:
|
|
123
|
+
print("\nWARNING: Placeholder mismatches detected:")
|
|
124
|
+
for warn in warnings:
|
|
125
|
+
print(f" - {warn}")
|
|
126
|
+
confirm = input("Are you sure you want to save this translation? (y/N): ").strip().lower()
|
|
127
|
+
if confirm != 'y':
|
|
128
|
+
print("Skipped (not saved).")
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
print("Saving...")
|
|
132
|
+
|
|
133
|
+
# Order of Writes: target XML first, metadata sidecar second
|
|
134
|
+
# If interrupted here, target exists but metadata does not, resolving to 'outdated' on next load.
|
|
135
|
+
write_success = write_string_translation(target_xml, key, translation, src_entry.attrib)
|
|
136
|
+
if write_success:
|
|
137
|
+
update_metadata_entry(target_xml, key, src_entry.value, translation)
|
|
138
|
+
print("Saved successfully.")
|
|
139
|
+
else:
|
|
140
|
+
print("Error saving to target XML.")
|
|
141
|
+
|
|
142
|
+
print("\nWizard finished.")
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
run_wizard()
|
droidlate/config.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
def get_deepl_api_key() -> str:
|
|
4
|
+
"""Retrieves DeepL API key from environment variables."""
|
|
5
|
+
return os.environ.get("DEEPL_API_KEY", "")
|
|
6
|
+
|
|
7
|
+
def is_deepl_free_api() -> bool:
|
|
8
|
+
"""Returns True if the user is using the DeepL Free API (default) or False for Pro."""
|
|
9
|
+
return os.environ.get("DEEPL_FREE_API", "true").lower() == "true"
|
|
10
|
+
|
|
11
|
+
def get_default_source_lang() -> str:
|
|
12
|
+
"""Returns default source language code."""
|
|
13
|
+
return os.environ.get("DEFAULT_SOURCE_LANG", "en")
|
droidlate/main.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
import argparse
|
|
4
|
+
import webbrowser
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .web.server import start_web_server
|
|
9
|
+
|
|
10
|
+
def auto_detect_res_dir() -> str:
|
|
11
|
+
"""
|
|
12
|
+
Tries to automatically detect the Android resource directory in the current working directory.
|
|
13
|
+
Checks for typical paths like:
|
|
14
|
+
1. app/src/main/res/
|
|
15
|
+
2. src/main/res/
|
|
16
|
+
3. res/
|
|
17
|
+
If none of these are found, defaults to the current working directory.
|
|
18
|
+
"""
|
|
19
|
+
cwd = os.getcwd()
|
|
20
|
+
|
|
21
|
+
candidates = [
|
|
22
|
+
os.path.join(cwd, "app", "src", "main", "res"),
|
|
23
|
+
os.path.join(cwd, "src", "main", "res"),
|
|
24
|
+
os.path.join(cwd, "res"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
for candidate in candidates:
|
|
28
|
+
if os.path.exists(candidate) and os.path.isdir(candidate):
|
|
29
|
+
return candidate
|
|
30
|
+
|
|
31
|
+
return cwd
|
|
32
|
+
|
|
33
|
+
def open_browser_after_delay(url: str, delay: float = 0.5):
|
|
34
|
+
"""Opens browser in a daemon thread after a brief server-warmup delay."""
|
|
35
|
+
def target():
|
|
36
|
+
time.sleep(delay)
|
|
37
|
+
webbrowser.open(url)
|
|
38
|
+
threading.Thread(target=target, daemon=True).start()
|
|
39
|
+
|
|
40
|
+
def main():
|
|
41
|
+
parser = argparse.ArgumentParser(
|
|
42
|
+
description="Droidlate — A local web-based Weblate-like UI for Android strings.xml translations"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Resource directory scanning mode
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--res-dir",
|
|
48
|
+
type=str,
|
|
49
|
+
help="Scan the entire Android resource directory (looks for app/src/main/res by default)"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Direct file editing mode
|
|
53
|
+
parser.add_argument(
|
|
54
|
+
"--source",
|
|
55
|
+
type=str,
|
|
56
|
+
help="Path to the source XML file (typically values/strings.xml)"
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--target",
|
|
60
|
+
type=str,
|
|
61
|
+
help="Path to the target translation XML file (e.g. values-es/strings.xml)"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Port specification
|
|
65
|
+
parser.add_argument(
|
|
66
|
+
"--port",
|
|
67
|
+
type=int,
|
|
68
|
+
default=5000,
|
|
69
|
+
help="Port to run the local web server on (default: 5000)"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
args = parser.parse_args()
|
|
73
|
+
url = f"http://127.0.0.1:{args.port}"
|
|
74
|
+
|
|
75
|
+
# 1. Check for single-file mode
|
|
76
|
+
if args.source or args.target:
|
|
77
|
+
if not args.source or not args.target:
|
|
78
|
+
print("Error: Both --source and --target must be specified for single-file mode.", file=sys.stderr)
|
|
79
|
+
sys.exit(1)
|
|
80
|
+
|
|
81
|
+
if not os.path.exists(args.source):
|
|
82
|
+
print(f"Error: Source file does not exist: {args.source}", file=sys.stderr)
|
|
83
|
+
sys.exit(1)
|
|
84
|
+
|
|
85
|
+
print(f"Running Droidlate...")
|
|
86
|
+
print(f"Web server running at {url}")
|
|
87
|
+
print("Press Ctrl+C to stop.")
|
|
88
|
+
|
|
89
|
+
# Start server in single file mode
|
|
90
|
+
start_web_server(
|
|
91
|
+
source_xml=os.path.abspath(args.source),
|
|
92
|
+
target_xml=os.path.abspath(args.target),
|
|
93
|
+
port=args.port
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
# 2. Otherwise, run in resource directory mode
|
|
97
|
+
else:
|
|
98
|
+
res_dir = args.res_dir
|
|
99
|
+
if not res_dir:
|
|
100
|
+
res_dir = auto_detect_res_dir()
|
|
101
|
+
print(f"No arguments provided. Auto-detected resource directory: {res_dir}")
|
|
102
|
+
|
|
103
|
+
res_dir = os.path.abspath(res_dir)
|
|
104
|
+
source_xml = os.path.join(res_dir, "values", "strings.xml")
|
|
105
|
+
|
|
106
|
+
if not os.path.exists(source_xml):
|
|
107
|
+
print(
|
|
108
|
+
f"Error: Could not find base strings file at '{source_xml}'.\n"
|
|
109
|
+
f"Please ensure you are running the command in an Android project root or "
|
|
110
|
+
f"specify a valid resource directory using --res-dir.",
|
|
111
|
+
file=sys.stderr
|
|
112
|
+
)
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
print(f"Running Droidlate...")
|
|
116
|
+
print(f"Web server running at {url}")
|
|
117
|
+
print("Press Ctrl+C to stop.")
|
|
118
|
+
|
|
119
|
+
# Start server in directory scanning mode
|
|
120
|
+
start_web_server(res_dir=res_dir, port=args.port)
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# droidlate.parser package init
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import json
|
|
4
|
+
import hashlib
|
|
5
|
+
|
|
6
|
+
# Re-use the placeholder regex and logic
|
|
7
|
+
PLACEHOLDER_REGEX = re.compile(r'%([0-9]+\$)?[-#+ 0,\(<]*[0-9]*(\.[0-9]+)?([a-zA-Z%])')
|
|
8
|
+
|
|
9
|
+
def extract_placeholders(s: str) -> list[tuple[int | None, str]]:
|
|
10
|
+
"""Extracts Java/Android string format specifiers, skipping %% literals."""
|
|
11
|
+
placeholders = []
|
|
12
|
+
for match in PLACEHOLDER_REGEX.finditer(s):
|
|
13
|
+
idx_str = match.group(1)
|
|
14
|
+
ptype = match.group(3)
|
|
15
|
+
if ptype == '%':
|
|
16
|
+
continue
|
|
17
|
+
idx = int(idx_str[:-1]) if idx_str else None
|
|
18
|
+
placeholders.append((idx, ptype))
|
|
19
|
+
return placeholders
|
|
20
|
+
|
|
21
|
+
def resolve_placeholders(placeholders: list[tuple[int | None, str]]) -> list[tuple[int, str]]:
|
|
22
|
+
"""Resolves unmarked format placeholders to explicit positional indices."""
|
|
23
|
+
resolved = []
|
|
24
|
+
implicit_idx = 1
|
|
25
|
+
for pos, ptype in placeholders:
|
|
26
|
+
if pos is None:
|
|
27
|
+
resolved.append((implicit_idx, ptype))
|
|
28
|
+
implicit_idx += 1
|
|
29
|
+
else:
|
|
30
|
+
resolved.append((pos, ptype))
|
|
31
|
+
return resolved
|
|
32
|
+
|
|
33
|
+
def validate_placeholders(source_val: str, target_val: str) -> list[str]:
|
|
34
|
+
"""
|
|
35
|
+
Validates placeholder formatting between source and target values.
|
|
36
|
+
Returns a list of distinct validation warning messages (empty if valid).
|
|
37
|
+
"""
|
|
38
|
+
src_pl = extract_placeholders(source_val)
|
|
39
|
+
tgt_pl = extract_placeholders(target_val)
|
|
40
|
+
|
|
41
|
+
src_res = resolve_placeholders(src_pl)
|
|
42
|
+
tgt_res = resolve_placeholders(tgt_pl)
|
|
43
|
+
|
|
44
|
+
src_map = {idx: t for idx, t in src_res}
|
|
45
|
+
tgt_map = {idx: t for idx, t in tgt_res}
|
|
46
|
+
|
|
47
|
+
errors = []
|
|
48
|
+
|
|
49
|
+
# 1. Check for missing or type mismatches
|
|
50
|
+
for idx, src_type in src_map.items():
|
|
51
|
+
if idx not in tgt_map:
|
|
52
|
+
errors.append(f"Missing placeholder %{idx}${src_type}")
|
|
53
|
+
elif tgt_map[idx] != src_type:
|
|
54
|
+
errors.append(f"Type mismatch for placeholder %{idx}: expected '{src_type}', got '{tgt_map[idx]}'")
|
|
55
|
+
|
|
56
|
+
# 2. Check for unexpected/extra placeholders
|
|
57
|
+
for idx, tgt_type in tgt_map.items():
|
|
58
|
+
if idx not in src_map:
|
|
59
|
+
errors.append(f"Extra/unexpected placeholder %{idx}${tgt_type}")
|
|
60
|
+
|
|
61
|
+
return errors
|
|
62
|
+
|
|
63
|
+
def normalize_source_string(val: str) -> str:
|
|
64
|
+
"""Normalizes string value before hashing (collapsing whitespace/newlines)."""
|
|
65
|
+
if not val:
|
|
66
|
+
return ""
|
|
67
|
+
# Collapse multiple consecutive whitespace characters to a single space
|
|
68
|
+
normalized = re.sub(r'\s+', ' ', val)
|
|
69
|
+
return normalized.strip()
|
|
70
|
+
|
|
71
|
+
def compute_source_hash(normalized_val: str) -> str:
|
|
72
|
+
"""Computes a SHA-256 hash of the normalized source string."""
|
|
73
|
+
return hashlib.sha256(normalized_val.encode('utf-8')).hexdigest()
|
|
74
|
+
|
|
75
|
+
def get_project_root(path: str) -> str:
|
|
76
|
+
"""Finds the root directory of the Android project by searching upwards."""
|
|
77
|
+
current = os.path.abspath(path)
|
|
78
|
+
if os.path.isfile(current):
|
|
79
|
+
current = os.path.dirname(current)
|
|
80
|
+
|
|
81
|
+
while current and current != os.path.dirname(current):
|
|
82
|
+
if any(os.path.exists(os.path.join(current, marker)) for marker in ("build.gradle", "build.gradle.kts", "settings.gradle", ".git")):
|
|
83
|
+
return current
|
|
84
|
+
current = os.path.dirname(current)
|
|
85
|
+
|
|
86
|
+
# Fallback to parent of target directory
|
|
87
|
+
return os.path.dirname(os.path.abspath(path))
|
|
88
|
+
|
|
89
|
+
def get_metadata_path(target_xml_path: str) -> str:
|
|
90
|
+
"""Gets the path to the centralized metadata file for a target XML."""
|
|
91
|
+
target_abs = os.path.abspath(target_xml_path)
|
|
92
|
+
project_root = get_project_root(target_abs)
|
|
93
|
+
|
|
94
|
+
locale_folder = os.path.basename(os.path.dirname(target_abs))
|
|
95
|
+
metadata_dir = os.path.join(project_root, ".translation_metadata")
|
|
96
|
+
return os.path.join(metadata_dir, f"{locale_folder}.json")
|
|
97
|
+
|
|
98
|
+
def load_metadata(target_xml_path: str) -> dict:
|
|
99
|
+
"""Loads target sidecar metadata JSON file."""
|
|
100
|
+
path = get_metadata_path(target_xml_path)
|
|
101
|
+
if os.path.exists(path):
|
|
102
|
+
try:
|
|
103
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
104
|
+
return json.load(f)
|
|
105
|
+
except Exception:
|
|
106
|
+
return {}
|
|
107
|
+
return {}
|
|
108
|
+
|
|
109
|
+
def save_metadata(target_xml_path: str, metadata: dict) -> None:
|
|
110
|
+
"""Saves metadata dictionary to sidecar JSON file."""
|
|
111
|
+
path = get_metadata_path(target_xml_path)
|
|
112
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
113
|
+
try:
|
|
114
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
115
|
+
json.dump(metadata, f, ensure_ascii=False, indent=2)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def update_metadata_entry(target_xml_path: str, key: str, source_value: str, translated_value: str) -> None:
|
|
120
|
+
"""Updates a single key's metadata entry."""
|
|
121
|
+
metadata = load_metadata(target_xml_path)
|
|
122
|
+
norm_src = normalize_source_string(source_value)
|
|
123
|
+
src_hash = compute_source_hash(norm_src)
|
|
124
|
+
metadata[key] = {
|
|
125
|
+
"source_hash": src_hash,
|
|
126
|
+
"translated_value": translated_value
|
|
127
|
+
}
|
|
128
|
+
save_metadata(target_xml_path, metadata)
|
|
129
|
+
|
|
130
|
+
def categorize_key(
|
|
131
|
+
key: str,
|
|
132
|
+
source_val: str,
|
|
133
|
+
target_val: str | None,
|
|
134
|
+
metadata_entry: dict | None
|
|
135
|
+
) -> str:
|
|
136
|
+
"""
|
|
137
|
+
Categorizes a translation key as 'untranslated', 'warnings', 'outdated', or 'translated'.
|
|
138
|
+
"""
|
|
139
|
+
# 1. Untranslated check
|
|
140
|
+
if target_val is None:
|
|
141
|
+
return 'untranslated'
|
|
142
|
+
|
|
143
|
+
# 2. Warnings/Errors check
|
|
144
|
+
warnings = validate_placeholders(source_val, target_val)
|
|
145
|
+
if warnings:
|
|
146
|
+
return 'warnings'
|
|
147
|
+
|
|
148
|
+
# 3. Outdated/Modified check
|
|
149
|
+
if not metadata_entry:
|
|
150
|
+
return 'outdated'
|
|
151
|
+
|
|
152
|
+
saved_hash = metadata_entry.get("source_hash")
|
|
153
|
+
current_norm = normalize_source_string(source_val)
|
|
154
|
+
current_hash = compute_source_hash(current_norm)
|
|
155
|
+
|
|
156
|
+
if saved_hash != current_hash:
|
|
157
|
+
return 'outdated'
|
|
158
|
+
|
|
159
|
+
return 'translated'
|