@allansantos-dev/smart-tool 0.9.45 → 0.9.46
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.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -4
- package/autostart.py +5 -3
- package/client_hooks.py +1 -1
- package/config.py +12 -0
- package/folder_picker.py +7 -5
- package/i18n.py +213 -0
- package/locales/pt-BR.json +923 -0
- package/package.json +1 -1
- package/projects_ui.py +7 -2
- package/setup_audit.py +5 -3
- package/setup_ui.py +15 -2
- package/smart_tool_daemon.py +1 -0
- package/toast_notice.py +3 -2
- package/tray.py +106 -49
- package/update_check.py +28 -10
- package/update_splash.py +197 -0
- package/version.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.46 - beta
|
|
4
|
+
|
|
5
|
+
- The interface speaks Portuguese and English. It follows the Windows display language (any Portuguese shows Brazilian
|
|
6
|
+
Portuguese, anything else English) until the user picks one: a globe button (PT/EN) in the header of the settings,
|
|
7
|
+
projects and map pages, and an "Idioma / Language" submenu in the tray, both saved as `language` in the config. The
|
|
8
|
+
settings and projects pages, including the text their scripts write, the tray menu, tooltip and notifications, the
|
|
9
|
+
Windows toasts, the folder picker and the update screen are translated; agent-facing text (tool answers, hook
|
|
10
|
+
messages) stays English. English is the catalog key (`locales/pt-BR.json`, read by `i18n.py`); the pages translate
|
|
11
|
+
their rendered text in the browser, and `tests/test_i18n.py` fails when a visible string has no entry.
|
|
12
|
+
- Updating never opens a console window. "Check for updates" now says "Checking for updates…" in a Windows notification,
|
|
13
|
+
and an update shows its own small window with no buttons (`update_splash.py`): the version change, the current step
|
|
14
|
+
(downloading, copying files, installing dependencies…) and the result, in the interface language. The install command
|
|
15
|
+
runs with no console, so npm, the installer, pip, PowerShell and schtasks share one hidden console; its output goes to
|
|
16
|
+
`~/.smart-tool/data/update.log`, and a failed update says so on the screen and in a toast with the log path.
|
|
17
|
+
- The tray menu no longer flashes console windows when it opens: its "Start with Windows" check ran `schtasks` from the
|
|
18
|
+
window-less tray without `CREATE_NO_WINDOW`.
|
|
19
|
+
- The tray tells "Smart Tool updated from X to Y" also when the daemon started first after the update: both record the
|
|
20
|
+
new running version, and the tray keeps its own marker (`tray_told`) instead of needing to be the one that found it.
|
|
21
|
+
|
|
3
22
|
## 0.9.45 - beta
|
|
4
23
|
|
|
5
24
|
- The Playwright MCP server starts on first use. What the clients register is now `lazy_mcp_proxy.py` around
|
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ registered as the `playwright` server where this machine lets a browser be drive
|
|
|
17
17
|
An optional hook routes the agent's native Grep/Glob/Read/Bash/WebSearch/WebFetch calls to these tools when they
|
|
18
18
|
are cheaper, or just tells the agent what Smart Tool would do.
|
|
19
19
|
|
|
20
|
-
**Status:** 0.9.
|
|
20
|
+
**Status:** 0.9.46 beta. Windows only. What changed in each version: [CHANGELOG.md](CHANGELOG.md).
|
|
21
21
|
|
|
22
22
|
## How it works
|
|
23
23
|
|
|
@@ -87,12 +87,17 @@ npx @allansantos-dev/smart-tool@latest install
|
|
|
87
87
|
```
|
|
88
88
|
|
|
89
89
|
Updates install themselves, on by default: the tray checks npm when it starts (at logon) and every 24 h, and when there
|
|
90
|
-
is a newer version it runs this same command
|
|
91
|
-
after 24 h, with the previous version kept).
|
|
90
|
+
is a newer version it runs this same command behind its own small update window, with no console window (once per
|
|
91
|
+
version; a failed version is tried again after 24 h, with the previous version kept). The window has no buttons: it shows
|
|
92
|
+
the version change, the current step and the result, and closes by itself; the command's output is kept in
|
|
93
|
+
`~/.smart-tool/data/update.log`. You are told each time: a Windows notification from the tray (newer version,
|
|
92
94
|
updating, updated, update failed), and agent sessions get the same line on their first `smart_search` (and at session
|
|
93
95
|
start when the SessionStart report is on, shown to you too) with the link to the changes. The tray menu has "Update to
|
|
94
96
|
X" (or "Check for updates") on demand and an "Update automatically" switch; `SMART_TOOL_AUTO_UPDATE=off` also turns
|
|
95
|
-
automatic updates off, and the newer-version notice then carries the command to run.
|
|
97
|
+
automatic updates off, and the newer-version notice then carries the command to run. The interface (tray, notifications,
|
|
98
|
+
settings and projects pages, update window) is in Brazilian Portuguese or English: it follows the Windows display
|
|
99
|
+
language until you pick one with the globe button (PT/EN) at the top of the pages or the tray's "Idioma / Language"
|
|
100
|
+
submenu. Running the command by hand also updates; `~/.smart-tool` is kept. It downloads [uv](https://docs.astral.sh/uv/) (checksum
|
|
96
101
|
verified) and a private Python 3.12 into `~/.smart-tool/runtime`, with nothing added to `PATH` and, in the registry, only two per-user (HKCU) keys that make the notification buttons work, then
|
|
97
102
|
copies Smart Tool to `%LOCALAPPDATA%\Programs\SmartTool` with its own virtual environment,
|
|
98
103
|
installs the web runtime, registers a per-user Scheduled Task that starts the tray at logon and starts the daemon on
|
package/autostart.py
CHANGED
|
@@ -14,6 +14,8 @@ import tempfile
|
|
|
14
14
|
import toast_notice
|
|
15
15
|
|
|
16
16
|
TASK_NAME = os.environ.get("SMART_TOOL_TASK_NAME", "SmartTool-Tray")
|
|
17
|
+
# The tray runs under pythonw (no console): schtasks without this flag flashed a console each time its menu was drawn.
|
|
18
|
+
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
class AutostartError(Exception):
|
|
@@ -138,7 +140,7 @@ def _task_installed():
|
|
|
138
140
|
try:
|
|
139
141
|
result = subprocess.run(
|
|
140
142
|
["schtasks", "/Query", "/TN", TASK_NAME],
|
|
141
|
-
capture_output=True, text=True, timeout=20,
|
|
143
|
+
capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
|
|
142
144
|
)
|
|
143
145
|
except (OSError, subprocess.SubprocessError):
|
|
144
146
|
return False
|
|
@@ -159,7 +161,7 @@ def install():
|
|
|
159
161
|
f.write(xml.encode("utf-16"))
|
|
160
162
|
result = subprocess.run(
|
|
161
163
|
["schtasks", "/Create", "/TN", TASK_NAME, "/XML", tmp_path, "/F"],
|
|
162
|
-
capture_output=True, text=True, timeout=20,
|
|
164
|
+
capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
|
|
163
165
|
)
|
|
164
166
|
finally:
|
|
165
167
|
try:
|
|
@@ -182,7 +184,7 @@ def remove():
|
|
|
182
184
|
try:
|
|
183
185
|
result = subprocess.run(
|
|
184
186
|
["schtasks", "/Delete", "/TN", TASK_NAME, "/F"],
|
|
185
|
-
capture_output=True, text=True, timeout=20,
|
|
187
|
+
capture_output=True, text=True, timeout=20, creationflags=_NO_WINDOW,
|
|
186
188
|
)
|
|
187
189
|
if result.returncode == 0:
|
|
188
190
|
removed.append("task")
|
package/client_hooks.py
CHANGED
|
@@ -120,7 +120,7 @@ def _entry_changes(event, matcher, expected, groups):
|
|
|
120
120
|
covered = str(group.get("matcher") or "").split("|")
|
|
121
121
|
left_out = [tool for tool in matcher.split("|") if tool not in covered]
|
|
122
122
|
if left_out:
|
|
123
|
-
changes.append(f"Its {event} entry does not cover
|
|
123
|
+
changes.append(f"Its {event} entry does not cover {', '.join(left_out)}.")
|
|
124
124
|
found = next(hook for hook in group["hooks"] if _our_hook(hook))
|
|
125
125
|
if _target(found) != _target(expected):
|
|
126
126
|
changes.append(f"It calls {_target(found)} instead of {_target(expected)}.")
|
package/config.py
CHANGED
|
@@ -39,6 +39,7 @@ DEFAULT_CONFIG = {
|
|
|
39
39
|
"duplicate_mode": "warn",
|
|
40
40
|
"dependency_mode": "warn",
|
|
41
41
|
"auto_update": True,
|
|
42
|
+
"language": "",
|
|
42
43
|
"pattern_proposals": "on",
|
|
43
44
|
"contact": "",
|
|
44
45
|
"model_adapter": "",
|
|
@@ -132,6 +133,17 @@ def auto_update(cfg=None):
|
|
|
132
133
|
return value
|
|
133
134
|
|
|
134
135
|
|
|
136
|
+
LANGUAGES = ("pt-BR", "en")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def language(cfg=None):
|
|
140
|
+
"""Interface language the user picked ("pt-BR" or "en"), or "" to follow the Windows display language."""
|
|
141
|
+
value = (cfg if cfg is not None else load_config()).get("language", DEFAULT_CONFIG["language"])
|
|
142
|
+
if value not in ("", *LANGUAGES):
|
|
143
|
+
raise ValueError(f"Invalid language ({value!r}) in {CONFIG_PATH}: use one of {', '.join(LANGUAGES)} or \"\".")
|
|
144
|
+
return value
|
|
145
|
+
|
|
146
|
+
|
|
135
147
|
def hook_mode(cfg=None):
|
|
136
148
|
value = (cfg if cfg is not None else load_config()).get("hook_mode") or DEFAULT_CONFIG["hook_mode"]
|
|
137
149
|
if value not in HOOK_MODES:
|
package/folder_picker.py
CHANGED
|
@@ -6,14 +6,16 @@ import threading
|
|
|
6
6
|
import urllib.request
|
|
7
7
|
import webbrowser
|
|
8
8
|
|
|
9
|
+
import i18n
|
|
10
|
+
|
|
9
11
|
_PICKER_LOCK = threading.Lock()
|
|
10
12
|
|
|
11
13
|
|
|
12
14
|
def choose_folder():
|
|
13
15
|
if os.name != "nt":
|
|
14
|
-
raise RuntimeError("Native folder selection is only available on Windows; enter the folder path.")
|
|
16
|
+
raise RuntimeError(i18n.t("Native folder selection is only available on Windows; enter the folder path."))
|
|
15
17
|
if not _PICKER_LOCK.acquire(blocking=False):
|
|
16
|
-
raise ValueError("A folder picker is already open.")
|
|
18
|
+
raise ValueError(i18n.t("A folder picker is already open."))
|
|
17
19
|
import ctypes
|
|
18
20
|
from ctypes import wintypes
|
|
19
21
|
|
|
@@ -37,14 +39,14 @@ def choose_folder():
|
|
|
37
39
|
label = ctypes.create_unicode_buffer(32768)
|
|
38
40
|
info = BrowseInfo()
|
|
39
41
|
info.pszDisplayName = ctypes.cast(label, wintypes.LPWSTR)
|
|
40
|
-
info.lpszTitle = "Smart Tool — select the folder to index"
|
|
42
|
+
info.lpszTitle = i18n.t("Smart Tool — select the folder to index")
|
|
41
43
|
info.ulFlags = 0x0001 | 0x0040 # Diretórios reais; diálogo redimensionável.
|
|
42
44
|
selected = shell.SHBrowseForFolderW(ctypes.byref(info))
|
|
43
45
|
if not selected:
|
|
44
46
|
return None
|
|
45
47
|
path = ctypes.create_unicode_buffer(32768)
|
|
46
48
|
if not shell.SHGetPathFromIDListEx(selected, path, len(path), 0):
|
|
47
|
-
raise ValueError("The selection is not an accessible folder.")
|
|
49
|
+
raise ValueError(i18n.t("The selection is not an accessible folder."))
|
|
48
50
|
return path.value
|
|
49
51
|
finally:
|
|
50
52
|
if selected:
|
|
@@ -64,7 +66,7 @@ def register_from_tray():
|
|
|
64
66
|
page = response.read().decode("utf-8")
|
|
65
67
|
match = re.search(r'<meta name="setup-token" content="([a-zA-Z0-9_-]+)">', page)
|
|
66
68
|
if not match:
|
|
67
|
-
raise RuntimeError("Reopen Smart Tool to load the updated projects page.")
|
|
69
|
+
raise RuntimeError(i18n.t("Reopen Smart Tool to load the updated projects page."))
|
|
68
70
|
request = urllib.request.Request(url + "/setup/projects", method="POST",
|
|
69
71
|
headers={"Content-Type": "application/json", "X-Setup-Token": match.group(1)},
|
|
70
72
|
data=json.dumps({"action": "register", "project_root": root}).encode("utf-8"))
|
package/i18n.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Interface language: Brazilian Portuguese or English, following the Windows display language until the user picks
|
|
2
|
+
one with the language button (config `language`).
|
|
3
|
+
|
|
4
|
+
Strings are written in English in the code and the English text is the catalog key (the gettext convention): a
|
|
5
|
+
missing Portuguese entry shows the English text instead of breaking the screen, and tests/test_i18n.py fails when a
|
|
6
|
+
user-visible string has no entry. The same catalog serves Python (tray, notifications, update screen) and the web
|
|
7
|
+
pages, where PAGE_SCRIPT translates the rendered DOM and whatever the page scripts insert afterwards.
|
|
8
|
+
|
|
9
|
+
Agent-facing text (MCP tool answers, hook messages) stays in English: agents read it, users do not."""
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import config
|
|
16
|
+
|
|
17
|
+
ROOT = os.path.dirname(os.path.abspath(__file__))
|
|
18
|
+
LOCALES_DIR = os.path.join(ROOT, "locales")
|
|
19
|
+
PORTUGUESE_PRIMARY_LANGID = 0x16
|
|
20
|
+
_CATALOGS = {}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def system_language():
|
|
24
|
+
""" "pt-BR" when the Windows display language is any Portuguese, else "en"."""
|
|
25
|
+
if sys.platform != "win32":
|
|
26
|
+
return "en"
|
|
27
|
+
import ctypes
|
|
28
|
+
return "pt-BR" if ctypes.windll.kernel32.GetUserDefaultUILanguage() & 0x3FF == PORTUGUESE_PRIMARY_LANGID else "en"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def language(cfg=None):
|
|
32
|
+
"""The language the interface shows now: the user's choice, else the Windows display language."""
|
|
33
|
+
return config.language(cfg) or system_language()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def set_language(value):
|
|
37
|
+
"""Saves the user's choice ("pt-BR" or "en") and returns it; raises ValueError for any other value."""
|
|
38
|
+
if value not in config.LANGUAGES:
|
|
39
|
+
raise ValueError(f"Unknown language {value!r}: use one of {', '.join(config.LANGUAGES)}.")
|
|
40
|
+
config.update_config(lambda cfg: cfg.__setitem__("language", value))
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def catalog(lang):
|
|
45
|
+
"""English text -> translated text for lang; English itself has an empty catalog. Raises when the locale file is
|
|
46
|
+
missing or malformed: a broken install must not pass for an untranslated one."""
|
|
47
|
+
if lang == "en":
|
|
48
|
+
return {}
|
|
49
|
+
if lang not in _CATALOGS:
|
|
50
|
+
with open(os.path.join(LOCALES_DIR, f"{lang}.json"), encoding="utf-8") as stream:
|
|
51
|
+
data = json.load(stream)
|
|
52
|
+
messages = data["messages"]
|
|
53
|
+
if not isinstance(messages, dict) or not all(isinstance(k, str) and isinstance(v, str) for k, v in messages.items()):
|
|
54
|
+
raise ValueError(f"locales/{lang}.json: 'messages' must map text to text.")
|
|
55
|
+
_CATALOGS[lang] = messages
|
|
56
|
+
return _CATALOGS[lang]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_PLACEHOLDER = re.compile(r"\{(\w*)\}")
|
|
60
|
+
_WORD = re.compile(r"[A-Za-z]")
|
|
61
|
+
_SENTENCE_BREAK = re.compile(r"(?<=[.!?…])\s+(?=[A-Z])")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _normal(text):
|
|
65
|
+
return " ".join(text.split())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _fragment_key(key):
|
|
69
|
+
"""Keys that may be replaced inside a longer text: two words or more, or one word ending in punctuation."""
|
|
70
|
+
return not _PLACEHOLDER.search(key) and (" " in key or (len(key) >= 4 and key[-1] in ".:!?…"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _bounded(fragment):
|
|
74
|
+
"""Regex of a phrase that never matches inside a word or a file name: a boundary before it when it starts with a
|
|
75
|
+
letter, after it when it ends with a letter or sentence punctuation ("Saved." is not "Saved.txt"); phrases cut
|
|
76
|
+
from a sentence may start or end with other punctuation ("Accept selected (" before a count)."""
|
|
77
|
+
return ((r"(?<!\w)" if re.match(r"\w", fragment) else "") + re.escape(fragment)
|
|
78
|
+
+ (r"(?!\w)" if re.search(r"[\w.:!?…]$", fragment) else ""))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Translator:
|
|
82
|
+
"""Translates finished English text with one catalog: the whole text, then a template with placeholders
|
|
83
|
+
("Updated from {old} to {new}."), then each sentence on its own when there are several, then known phrases inside
|
|
84
|
+
it. PAGE_SCRIPT runs the same steps."""
|
|
85
|
+
|
|
86
|
+
def __init__(self, messages):
|
|
87
|
+
self.messages = {_normal(k): v for k, v in messages.items()}
|
|
88
|
+
self.templates = []
|
|
89
|
+
for key, value in self.messages.items():
|
|
90
|
+
literal = _PLACEHOLDER.sub("", key)
|
|
91
|
+
if _PLACEHOLDER.search(key) and len(_WORD.findall(literal)) >= 3:
|
|
92
|
+
parts = _PLACEHOLDER.split(key)
|
|
93
|
+
pattern = "".join(re.escape(part) if i % 2 == 0 else f"(?P<p{i // 2}>.+?)" for i, part in enumerate(parts))
|
|
94
|
+
names = parts[1::2]
|
|
95
|
+
self.templates.append((re.compile(f"^{pattern}$", re.S), names, value))
|
|
96
|
+
self.templates.sort(key=lambda item: -len(item[0].pattern))
|
|
97
|
+
fragments = sorted((k for k in self.messages if _fragment_key(k)), key=len, reverse=True)
|
|
98
|
+
self.fragments = (re.compile("|".join(_bounded(f) for f in fragments)) if fragments else None)
|
|
99
|
+
|
|
100
|
+
def __call__(self, text):
|
|
101
|
+
"""text translated, or text unchanged when nothing in the catalog matches it."""
|
|
102
|
+
key = _normal(text)
|
|
103
|
+
if not key or not self.messages:
|
|
104
|
+
return text
|
|
105
|
+
if key in self.messages:
|
|
106
|
+
return self.messages[key]
|
|
107
|
+
for pattern, names, value in self.templates:
|
|
108
|
+
found = pattern.match(key)
|
|
109
|
+
if found:
|
|
110
|
+
values = {name: found.group(f"p{i}") for i, name in enumerate(names)}
|
|
111
|
+
return _PLACEHOLDER.sub(lambda m: self(values.get(m.group(1), m.group(0))), value)
|
|
112
|
+
sentences = _SENTENCE_BREAK.split(key)
|
|
113
|
+
if len(sentences) > 1:
|
|
114
|
+
return " ".join(self(sentence) for sentence in sentences)
|
|
115
|
+
if self.fragments:
|
|
116
|
+
return self.fragments.sub(lambda m: self.messages[m.group(0)], text)
|
|
117
|
+
return text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_TRANSLATORS = {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def translator(lang=None):
|
|
124
|
+
"""The Translator of lang (default: the current interface language)."""
|
|
125
|
+
lang = lang or language()
|
|
126
|
+
if lang not in _TRANSLATORS:
|
|
127
|
+
_TRANSLATORS[lang] = Translator(catalog(lang))
|
|
128
|
+
return _TRANSLATORS[lang]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def t(text, lang=None, **values):
|
|
132
|
+
"""text (an English catalog key) in the interface language, with its {placeholders} filled from values."""
|
|
133
|
+
translated = translator(lang)(text)
|
|
134
|
+
return translated.format(**values) if values else translated
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def N_(text):
|
|
138
|
+
"""Marks text as a catalog key without translating it yet (gettext_noop): for tables built at import time,
|
|
139
|
+
translated with t() when shown."""
|
|
140
|
+
return text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def page_script(token, lang=None):
|
|
144
|
+
"""The <script> the web pages carry in <head>: catalog, current language, the setup token the language button
|
|
145
|
+
sends, translator and language button."""
|
|
146
|
+
lang = lang or language()
|
|
147
|
+
data = {"lang": lang, "token": token, "messages": catalog(lang)}
|
|
148
|
+
return "<script>window.SMART_TOOL_I18N=" + json.dumps(data, ensure_ascii=False).replace("</", "<\\/") + ";</script>\n" \
|
|
149
|
+
+ "<script>" + PAGE_SCRIPT + "</script>"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
PAGE_SCRIPT = r"""
|
|
153
|
+
(()=>{
|
|
154
|
+
const I=window.SMART_TOOL_I18N,PH=/\{(\w*)\}/g,SKIP=new Set(['SCRIPT','STYLE','CODE','PRE','TEXTAREA','KBD','SAMP','svg','SVG']);
|
|
155
|
+
const INLINE=new Set(['STRONG','EM','B','I','A','CODE','BR','SPAN','SMALL','KBD','ABBR','SUP','SUB','U','MARK']);
|
|
156
|
+
const ATTRS=['title','placeholder','aria-label','alt'];
|
|
157
|
+
const norm=s=>s.replace(/\s+/g,' ').trim();
|
|
158
|
+
const esc=s=>s.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
|
|
159
|
+
const messages={};for(const [k,v] of Object.entries(I.messages))messages[norm(k)]=v;
|
|
160
|
+
const templates=[];
|
|
161
|
+
for(const [k,v] of Object.entries(messages)){if(!/\{\w*\}/.test(k)||(k.replace(PH,'').match(/[A-Za-z]/g)||[]).length<3)continue;
|
|
162
|
+
const parts=k.split(PH),names=[];let src='';parts.forEach((p,i)=>{if(i%2){names.push(p);src+='(.+?)'}else src+=esc(p)});
|
|
163
|
+
templates.push([new RegExp('^'+src+'$','s'),names,v])}
|
|
164
|
+
templates.sort((a,b)=>b[0].source.length-a[0].source.length);
|
|
165
|
+
const frag=Object.keys(messages).filter(k=>!/\{\w*\}/.test(k)&&(k.includes(' ')||(k.length>=4&&/[.:!?…]$/.test(k)))).sort((a,b)=>b.length-a.length);
|
|
166
|
+
const bounded=f=>(/^\w/.test(f)?'(?<!\\w)':'')+esc(f)+(/[\w.:!?…]$/.test(f)?'(?!\\w)':'');
|
|
167
|
+
const fragRe=frag.length?new RegExp(frag.map(bounded).join('|'),'g'):null;
|
|
168
|
+
function tr(text){const key=norm(text);if(!key||I.lang==='en')return text;
|
|
169
|
+
if(key in messages){const lead=text.match(/^\s*/)[0],tail=text.match(/\s*$/)[0];return lead+messages[key]+tail}
|
|
170
|
+
for(const [re,names,v] of templates){const m=key.match(re);if(m){const vals={};names.forEach((n,i)=>vals[n]=m[i+1]);return v.replace(PH,(all,n)=>n in vals?tr(vals[n]):all)}}
|
|
171
|
+
const sentences=key.split(/(?<=[.!?…])\s+(?=[A-Z])/);if(sentences.length>1)return text.match(/^\s*/)[0]+sentences.map(tr).join(' ')+text.match(/\s*$/)[0];
|
|
172
|
+
return fragRe?text.replace(fragRe,m=>messages[m]):text}
|
|
173
|
+
const written=new WeakMap();
|
|
174
|
+
function skipped(el){return !el||!el.closest||el.closest('[data-no-i18n],script,style,noscript,code,pre,textarea,kbd,samp,svg')}
|
|
175
|
+
function translateElement(el){
|
|
176
|
+
if(SKIP.has(el.tagName)||skipped(el))return false;
|
|
177
|
+
for(const a of ATTRS){const v=el.getAttribute(a);if(v&&written.get(el)?.[a]!==v){const t=tr(v);if(t!==v){el.setAttribute(a,t);written.set(el,{...(written.get(el)||{}),[a]:t})}}}
|
|
178
|
+
if(el.childElementCount&&[...el.children].every(c=>INLINE.has(c.tagName)&&!c.id&&!Object.keys(c.dataset).length)&&[...el.childNodes].some(n=>n.nodeType===3&&n.data.trim())){
|
|
179
|
+
const key=norm(el.innerHTML);if(key in messages){el.innerHTML=messages[key];return true}}
|
|
180
|
+
return false}
|
|
181
|
+
function translateText(node){if(skipped(node.parentElement)||written.get(node)===node.data)return;const t=tr(node.data);if(t!==node.data){node.data=t;written.set(node,t)}}
|
|
182
|
+
function walk(root){if(I.lang==='en')return;
|
|
183
|
+
if(root.nodeType===3){translateText(root);return}
|
|
184
|
+
if(root.nodeType!==1)return;
|
|
185
|
+
if(translateElement(root))return;
|
|
186
|
+
const it=document.createTreeWalker(root,NodeFilter.SHOW_ELEMENT|NodeFilter.SHOW_TEXT,{acceptNode:n=>n.nodeType===1&&(SKIP.has(n.tagName)||n.hasAttribute('data-no-i18n'))?NodeFilter.FILTER_REJECT:NodeFilter.FILTER_ACCEPT});
|
|
187
|
+
const nodes=[];for(let n=it.nextNode();n;n=it.nextNode())nodes.push(n);
|
|
188
|
+
const done=new Set();
|
|
189
|
+
for(const n of nodes){if([...done].some(d=>d.contains(n)))continue;if(n.nodeType===1){if(translateElement(n))done.add(n)}else translateText(n)}}
|
|
190
|
+
function applyTitle(){if(I.lang!=='en')document.title=tr(document.title)}
|
|
191
|
+
new MutationObserver(list=>{if(I.lang==='en')return;for(const m of list){
|
|
192
|
+
if(m.type==='characterData')translateText(m.target);
|
|
193
|
+
else if(m.type==='attributes')translateElement(m.target);
|
|
194
|
+
else for(const n of m.addedNodes)walk(n)}}).observe(document.documentElement,{subtree:true,childList:true,characterData:true,attributes:true,attributeFilter:ATTRS});
|
|
195
|
+
const nativeConfirm=window.confirm.bind(window),nativeAlert=window.alert.bind(window),nativePrompt=window.prompt.bind(window);
|
|
196
|
+
window.confirm=m=>nativeConfirm(tr(String(m)));window.alert=m=>nativeAlert(tr(String(m)));window.prompt=(m,d)=>nativePrompt(tr(String(m)),d);
|
|
197
|
+
window.smartToolTr=tr;
|
|
198
|
+
const GLOBE='<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z"/></svg>';
|
|
199
|
+
function paintButton(b){b.querySelector('span').textContent=I.lang==='en'?'EN':'PT';
|
|
200
|
+
const label=I.lang==='en'?'Mudar para português':'Switch to English';b.title=label;b.setAttribute('aria-label',label)}
|
|
201
|
+
function button(){const b=document.createElement('button');b.type='button';b.id='lang-toggle';b.setAttribute('data-no-i18n','');
|
|
202
|
+
b.innerHTML=GLOBE+'<span></span>';
|
|
203
|
+
b.style.cssText='display:inline-flex;align-items:center;gap:6px;margin-left:12px;padding:5px 10px;border-radius:999px;border:1px solid var(--line,var(--border,#c9ced6));background:var(--surface,#fff);color:var(--ink,#1b2430);font:600 13px/1 "Segoe UI",system-ui,sans-serif;cursor:pointer;flex:none';
|
|
204
|
+
paintButton(b);
|
|
205
|
+
b.addEventListener('click',async()=>{const next=I.lang==='en'?'pt-BR':'en';b.disabled=true;
|
|
206
|
+
try{const r=await fetch('/setup/language',{method:'POST',headers:{'Content-Type':'application/json','X-Setup-Token':I.token},body:JSON.stringify({language:next})});
|
|
207
|
+
const data=await r.json().catch(()=>({}));if(!r.ok)throw Error(data.error||('HTTP '+r.status));location.reload()}
|
|
208
|
+
catch(e){b.disabled=false;nativeAlert((I.lang==='en'?'Could not change the language: ':'Não foi possível trocar o idioma: ')+e.message)}});
|
|
209
|
+
return b}
|
|
210
|
+
document.addEventListener('DOMContentLoaded',()=>{document.documentElement.lang=I.lang;walk(document.body);applyTitle();
|
|
211
|
+
const header=document.querySelector('header');if(header)header.appendChild(button())});
|
|
212
|
+
})();
|
|
213
|
+
"""
|