@allansantos-dev/smart-tool 0.9.45 → 0.9.47

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/notice_action.py CHANGED
@@ -33,6 +33,14 @@ def run(uri):
33
33
  import daemon_launcher
34
34
  webbrowser.open(daemon_launcher.ensure_daemon_running().rstrip("/") + "/setup#" + params.get("section", "models"))
35
35
  return "setup opened"
36
+ if action == "open-update-log":
37
+ import update_check
38
+ os.startfile(update_check.UPDATE_LOG)
39
+ return "update log opened"
40
+ if action == "open-changes":
41
+ import update_check
42
+ webbrowser.open(update_check.SITE_URL)
43
+ return "changes opened"
36
44
  if action == "mute-model":
37
45
  import model_health
38
46
  return model_health.mute(params.get("model", ""))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@allansantos-dev/smart-tool",
3
- "version": "0.9.45",
3
+ "version": "0.9.47",
4
4
  "description": "Local MCP server that gives coding agents (Claude Code, Codex) cheaper, sharper tools than their built-in search. Windows.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Allan Santos",
package/projects_ui.py CHANGED
@@ -1,13 +1,16 @@
1
1
  """Tela local de projetos; todas as operações passam pelos jobs do daemon."""
2
2
  import html
3
3
  import re
4
+ import i18n
4
5
  import setup_ui
5
6
  import project_maps_ui
6
7
  import project_insights_ui
7
8
 
8
9
 
9
10
  def render_projects_page():
11
+ """The projects page with its token, map and insight components and the interface language script."""
10
12
  return (_PAGE.replace("__TOKEN__", html.escape(setup_ui.SETUP_TOKEN, quote=True))
13
+ .replace("__I18N__", i18n.page_script(setup_ui.SETUP_TOKEN))
11
14
  .replace("__MAP_STYLE__", project_maps_ui.STYLE)
12
15
  .replace("__MAP_HTML__", project_maps_ui.HTML)
13
16
  .replace("__MAP_SCRIPT__", project_maps_ui.SCRIPT)
@@ -24,13 +27,14 @@ def render_map_window():
24
27
  '__STYLE__': style.replace('__MAP_STYLE__', project_maps_ui.STYLE).replace('__INSIGHTS_STYLE__', project_insights_ui.STYLE),
25
28
  '__MAP_HTML__': project_maps_ui.HTML,
26
29
  '__MAP_SCRIPT__': project_maps_ui.SCRIPT,
30
+ '__I18N__': i18n.page_script(setup_ui.SETUP_TOKEN),
27
31
  }
28
32
  return re.sub('|'.join(re.escape(key) for key in values), lambda m:values[m[0]], _MAP_WINDOW)
29
33
 
30
34
 
31
35
  _MAP_WINDOW = r'''<!doctype html>
32
36
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
33
- <meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title><style>
37
+ <meta name="setup-token" content="__TOKEN__"><title>Project maps · Smart Tool</title>__I18N__<style>
34
38
  __STYLE__
35
39
  body.map-window{padding:0}.map-window header,.map-window main{max-width:none}.map-window header{padding:22px 28px 14px}.map-window main{padding:0 20px 30px}.map-window h1{font-size:22px}.map-window #window-root{margin:6px 0 0;font-size:12px;overflow-wrap:anywhere}.map-window .maps{margin-top:0}.map-window .maps>summary{display:none}.map-window .maps-workspace{grid-template-columns:minmax(0,1fr) 285px}.map-window #maps-svg{height:calc(100vh - 360px);min-height:430px}.map-window .maps-tools #maps-new-window{display:none}.map-window #maps-color-legend{max-height:90px}.map-window #window-message:empty{display:none}.map-window #window-message{margin-bottom:14px;overflow-wrap:anywhere}
36
40
  @media(max-width:850px){.map-window .maps-workspace{grid-template-columns:1fr}.map-window header{padding-inline:18px}.map-window main{padding-inline:12px}.map-window #maps-svg{height:450px;min-height:0}}
@@ -51,6 +55,7 @@ _PAGE = r'''<!doctype html>
51
55
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
52
56
  <meta name="setup-token" content="__TOKEN__">
53
57
  <title>Projects and indexing · Smart Tool</title>
58
+ __I18N__
54
59
  <style>
55
60
  :root{color-scheme:light dark;--bg:#f6f7f9;--surface:#fff;--border:#dde1e6;--ink:#1b2430;--muted:#5b6472;--accent:#2f6fed;--good:#18723c;--bad:#b52c2c;--warn:#805300;--selected:#eaf1ff}
56
61
  @media(prefers-color-scheme:dark){:root{--bg:#14171c;--surface:#1c2027;--border:#39414e;--ink:#e6e9ee;--muted:#a6afbb;--accent:#91baff;--good:#68dc93;--bad:#ff9696;--warn:#e0b565;--selected:#263751}}
@@ -96,7 +101,7 @@ function setAdd(open){const restore=$('add-form').contains(document.activeElemen
96
101
  function select(id){selected=id;scopeKey='';scopeDirty=false;window.SmartMaps?.reset();window.SmartInsights?.reset();history.replaceState(null,'','#'+id);render()}
97
102
  function jobsHtml(rows){return rows.map(j=>`<div class="job"><div class="job-line"><strong>${esc(kindNames[j.kind]||j.kind)}</strong><span class="state ${j.status==='error'?'bad':''}">${esc(states[j.status]||j.status)}</span></div>${j.message?`<p>${esc(j.cancel_requested?'Cancellation requested; waiting for the current call to finish.':j.message)}</p>`:''}${j.status==='pending'?`<progress aria-label="Indexing progress" ${j.total_files>0&&j.processed_files!=null?`max="${j.total_files}" value="${j.processed_files}"`:''}></progress>`:''}${j.error?`<p class="state bad">${esc(j.error)}</p>`:''}${j.warning?`<p>${esc(j.warning)}</p>`:''}<small>${esc(j.job_id)}${j.elapsed_s!=null?' · '+j.elapsed_s+' s':''}</small></div>`).join('')}
98
103
  function render(){
99
- $('count').textContent=projects.length+' folder'+(projects.length===1?' registered':'s registered');
104
+ $('count').textContent=projects.length===1?'1 folder registered':projects.length+' folders registered';
100
105
  const list=$('project-list');
101
106
  for(const node of [...list.children])if(!projects.some(p=>p.id===node.dataset.project))node.remove();
102
107
  for(const p of projects){let button=list.querySelector(`[data-project="${p.id}"]`);if(!button){button=document.createElement('button');button.className='project-item';button.dataset.project=p.id;button.append(document.createElement('strong'),document.createElement('small'));list.append(button)}button.setAttribute('aria-current',String(p.id===selected));button.firstChild.textContent=p.name;button.lastChild.textContent=(states[p.status]||p.status)+(p.watch&&!p.paused?' · Watching':'')}
package/setup_audit.py CHANGED
@@ -31,6 +31,7 @@ import yaml
31
31
 
32
32
  import atomic_io
33
33
  import endpoint_sync
34
+ import i18n
34
35
  import paths
35
36
 
36
37
  REPORT_DIR = os.path.join(paths.DATA_DIR, "setup-audit")
@@ -904,13 +905,14 @@ def notices_due():
904
905
 
905
906
 
906
907
  def toast_text(row):
907
- """(title, lines) of the toast for one `notices_due` row."""
908
+ """(title, lines) of the toast for one `notices_due` row, in the interface language."""
908
909
  total = row["errors"] + row["warnings"]
909
- title = f"Setup audit: {total} new problem{'s' if total != 1 else ''} in the agent configuration"
910
+ title = (i18n.t("Setup audit: 1 new problem in the agent configuration") if total == 1
911
+ else i18n.t("Setup audit: {count} new problems in the agent configuration", count=total))
910
912
  lines = [os.path.basename(row["project"].rstrip("\\/")) or row["project"]]
911
913
  lines += [f"{item['severity']}: {item['check']} - {item['subject']}"[:120] for item in row["items"][:3]]
912
914
  if len(row["items"]) > 3:
913
- lines.append(f"and {len(row['items']) - 3} more")
915
+ lines.append(i18n.t("and {count} more", count=len(row["items"]) - 3))
914
916
  return title, lines
915
917
 
916
918
 
package/setup_ui.py CHANGED
@@ -28,6 +28,7 @@ import config
28
28
  import daemon_launcher
29
29
  import endpoint_sync
30
30
  import gateway
31
+ import i18n
31
32
  import model_catalog
32
33
  import model_defaults
33
34
  import model_health
@@ -101,6 +102,7 @@ def render_setup_page():
101
102
  # Único valor que entra no corpo do HTML, e não no <script>: escapado porque vem
102
103
  # de variável de ambiente, ainda que só quem já executa código possa definí-la.
103
104
  "__SETUP_ORIGIN__": html.escape(setup_origin(), quote=True),
105
+ "__I18N__": i18n.page_script(SETUP_TOKEN),
104
106
  }
105
107
  # Um único passe sobre o template original: .replace() encadeado re-escanearia o
106
108
  # próprio resultado e corromperia o JSON se um valor coincidisse com o outro placeholder.
@@ -419,6 +421,16 @@ def handle_providers_save(body):
419
421
  "validated_results": validated}
420
422
 
421
423
 
424
+ def handle_language_save(body):
425
+ """Saves the interface language the language button picked; returns {"language": <saved>}."""
426
+ if not isinstance(body, dict):
427
+ raise SetupError("Request body must be a JSON object with 'language'.")
428
+ try:
429
+ return {"language": i18n.set_language(body.get("language"))}
430
+ except ValueError as exc:
431
+ raise SetupError(str(exc)) from exc
432
+
433
+
422
434
  def handle_autostart_get():
423
435
  return autostart.status()
424
436
 
@@ -494,6 +506,7 @@ _PAGE_TEMPLATE = """<!doctype html>
494
506
  <meta charset="utf-8">
495
507
  <meta name="viewport" content="width=device-width, initial-scale=1">
496
508
  <title>Settings · Smart Tool</title>
509
+ __I18N__
497
510
  <style>
498
511
  :root { color-scheme: light dark;
499
512
  --bg: #f6f7f9; --surface: #ffffff; --line: #dde1e6; --ink: #1b2430; --muted: #5b6472; --soft: #eef1f5;
@@ -665,7 +678,7 @@ _PAGE_TEMPLATE = """<!doctype html>
665
678
  </head>
666
679
  <body>
667
680
  <header class="top">
668
- <div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ · version <span id="app-version"></span></span></div>
681
+ <div><h1>Smart Tool settings</h1><span class="where">__SETUP_ORIGIN__ · <span>Version</span> <span id="app-version"></span></span></div>
669
682
  <nav><a href="/projects">Projects and indexing →</a></nav>
670
683
  </header>
671
684
  <div class="shell">
@@ -1010,7 +1023,7 @@ function renderOverview() {
1010
1023
  badge.hidden = !entry;
1011
1024
  badge.textContent = entry ? entry.count : "";
1012
1025
  badge.dataset.tone = entry && entry.bad ? "bad" : "warn";
1013
- badge.title = entry ? plural(entry.count, "item") + (entry.count === 1 ? " needs" : " need") + " attention" : "";
1026
+ badge.title = !entry ? "" : entry.count === 1 ? "1 item needs attention" : entry.count + " items need attention";
1014
1027
  }
1015
1028
  }
1016
1029
 
@@ -4163,6 +4163,7 @@ _SETUP_SAVES = {
4163
4163
  "/setup/gateway": setup_ui.handle_gateway_save,
4164
4164
  "/setup/autostart": setup_ui.handle_autostart_action,
4165
4165
  "/setup/contact": setup_ui.handle_contact_save,
4166
+ "/setup/language": setup_ui.handle_language_save,
4166
4167
  "/setup/audit": setup_ui.handle_audit,
4167
4168
  }
4168
4169
 
package/toast_notice.py CHANGED
@@ -74,13 +74,14 @@ def action_uri(action, **params):
74
74
 
75
75
 
76
76
  def toast_xml(title, lines, buttons):
77
- """`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast."""
77
+ """`buttons` is a list of (label, uri); each one launches its protocol link and closes the toast. An empty list
78
+ gives a toast without an actions element."""
78
79
  text = "".join(f"<text>{escape(line)}</text>" for line in [title, *lines])
79
80
  actions = "".join(
80
81
  f'<action content={quoteattr(label)} arguments={quoteattr(uri)} activationType="protocol"/>'
81
82
  for label, uri in buttons)
82
83
  return (f'<toast><visual><binding template="ToastGeneric">{text}</binding></visual>'
83
- f'<actions>{actions}</actions></toast>')
84
+ + (f'<actions>{actions}</actions>' if actions else '') + '</toast>')
84
85
 
85
86
 
86
87
  def show(title, lines, buttons, tag=None):
package/tray.py CHANGED
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
22
22
  import autostart
23
23
  import config
24
24
  import daemon_launcher
25
+ import i18n
25
26
  import setup_audit
26
27
  import toast_notice
27
28
  import update_check
@@ -60,14 +61,14 @@ _ICONS = {
60
61
  }
61
62
 
62
63
  _TITLES = {
63
- "ok": "Smart Tool - daemon running",
64
- "starting": "Smart Tool - starting the daemon...",
65
- "needs_key": "Smart Tool - model gateway unavailable",
66
- "needs_models": "Smart Tool - choose the models in settings",
67
- "catalog_unavailable": "Smart Tool - model catalog unavailable",
68
- "config_corrupt": "Smart Tool - settings file has an error",
69
- "status_error": "Smart Tool - failed to read the settings",
70
- "down": "Smart Tool - daemon down",
64
+ "ok": i18n.N_("Smart Tool - running"),
65
+ "starting": i18n.N_("Smart Tool - starting..."),
66
+ "needs_key": i18n.N_("Smart Tool - model gateway unavailable"),
67
+ "needs_models": i18n.N_("Smart Tool - choose the models in settings"),
68
+ "catalog_unavailable": i18n.N_("Smart Tool - model catalog unavailable"),
69
+ "config_corrupt": i18n.N_("Smart Tool - settings file has an error"),
70
+ "status_error": i18n.N_("Smart Tool - failed to read the settings"),
71
+ "down": i18n.N_("Smart Tool - stopped"),
71
72
  }
72
73
 
73
74
 
@@ -78,7 +79,30 @@ def _set_state(health, icon=None):
78
79
  _state["health"] = health
79
80
  if icon is not None:
80
81
  icon.icon = _ICONS[health]
81
- icon.title = _TITLES[health]
82
+ icon.title = i18n.t(_TITLES[health])
83
+
84
+
85
+ def _notify(icon, text, buttons=(), **values):
86
+ """Windows notification, translated (text is an English catalog key; buttons are (catalog key, protocol link)). A
87
+ toast under Smart Tool's own app id, since the tray balloon is shown as "Python"; when Windows refuses the toast,
88
+ the balloon carries the text and the refusal."""
89
+ message = i18n.t(text, **values)
90
+ try:
91
+ toast_notice.show("Smart Tool", [message], [(i18n.t(label), uri) for label, uri in buttons])
92
+ except Exception as exc:
93
+ icon.notify(f"{message} ({exc})", "Smart Tool")
94
+
95
+
96
+ def _follow_language(icon):
97
+ """Redraws the menu and the tooltip after the language changed here or in a settings page."""
98
+ try:
99
+ current = i18n.language()
100
+ except Exception:
101
+ return
102
+ if _state.get("language") != current:
103
+ _state["language"] = current
104
+ icon.title = i18n.t(_TITLES[_state["health"]])
105
+ icon.update_menu()
82
106
 
83
107
 
84
108
  def _configuration_state(url):
@@ -111,6 +135,7 @@ def _poll_loop(icon):
111
135
  _set_state("status_error", icon)
112
136
  except Exception:
113
137
  _set_state("down", icon)
138
+ _follow_language(icon)
114
139
  time.sleep(POLL_INTERVAL_S)
115
140
 
116
141
 
@@ -121,19 +146,22 @@ def _show_notices(icon, notices):
121
146
  if offer:
122
147
  lines = [f"{role}: {change['current']} -> {change['new']}" for role, change in offer["changes"].items()]
123
148
  nonce = offer["nonce"]
124
- toast_notice.show("Smart Tool has new recommended models", lines, [
125
- ("Apply now", toast_notice.action_uri("models-apply", n=nonce)),
126
- ("Don't show again", toast_notice.action_uri("models-decline", n=nonce)),
149
+ toast_notice.show(i18n.t("Smart Tool has new recommended models"), lines, [
150
+ (i18n.t("Apply now"), toast_notice.action_uri("models-apply", n=nonce)),
151
+ (i18n.t("Don't show again"), toast_notice.action_uri("models-decline", n=nonce)),
127
152
  ], tag="models-upgrade")
128
153
  for row in notices.get("failing") or []:
129
- last = row.get("last_error") or "see the failure log in the settings"
154
+ last = row.get("last_error") or i18n.t("see the failure log in the settings")
130
155
  toast_notice.show(
131
- f"{row['model']} is failing", [f"{row['errors']} of {row['calls']} calls failed in 24 h as {row['role']}.", last[:160]],
132
- [("Switch model", toast_notice.action_uri("open-setup", section="models")),
133
- ("Mute 7 days", toast_notice.action_uri("mute-model", model=row["model"]))], tag="model-failing:" + row["model"])
156
+ i18n.t("{model} is failing", model=row["model"]),
157
+ [i18n.t("{errors} of {calls} calls failed in 24 h as {role}.", errors=row["errors"], calls=row["calls"],
158
+ role=row["role"]), last[:160]],
159
+ [(i18n.t("Switch model"), toast_notice.action_uri("open-setup", section="models")),
160
+ (i18n.t("Mute 7 days"), toast_notice.action_uri("mute-model", model=row["model"]))],
161
+ tag="model-failing:" + row["model"])
134
162
  for row in notices.get("audit") or []:
135
163
  title, lines = setup_audit.toast_text(row)
136
- toast_notice.show(title, lines, [("Open the audit", toast_notice.action_uri("open-setup", section="audit"))],
164
+ toast_notice.show(title, lines, [(i18n.t("Open the audit"), toast_notice.action_uri("open-setup", section="audit"))],
137
165
  tag="setup-audit:" + row["project"])
138
166
 
139
167
 
@@ -151,19 +179,21 @@ def _notice_loop(icon):
151
179
  except Exception as exc:
152
180
  if str(exc) != _state.get("notice_error"):
153
181
  _state["notice_error"] = str(exc)
154
- icon.notify(f"Could not show a Smart Tool notice: {exc}", "Smart Tool")
182
+ _notify(icon, "Could not show a Smart Tool notice: {error}", error=exc)
155
183
  time.sleep(NOTICE_INTERVAL_S)
156
184
 
157
185
 
158
186
  def _announce_start(icon):
159
187
  """Tells the user, once per start, that Smart Tool was just updated or that the last automatic update failed."""
160
- updated = update_check.note_running_version()
188
+ updated = update_check.update_for_tray()
161
189
  if updated:
162
- icon.notify(f"Smart Tool updated from {updated['from']} to {updated['to']}.", "Smart Tool")
190
+ changes = (i18n.N_("What changed"), toast_notice.action_uri("open-changes"))
191
+ _notify(icon, "Smart Tool updated from {old} to {new}.", [changes], old=updated["from"], new=updated["to"])
163
192
  failed = update_check.failed_update()
164
193
  if failed:
165
- icon.notify(f"The update to {failed} failed; Smart Tool {version.VERSION} is still installed. "
166
- "It is tried again in 24 h, or now from this menu.", "Smart Tool")
194
+ log = (i18n.N_("View log"), toast_notice.action_uri("open-update-log"))
195
+ _notify(icon, "The update to {target} failed; Smart Tool {version} is still installed. It is tried again in "
196
+ "24 h, or now from this menu.", [log], target=failed, version=version.VERSION)
167
197
 
168
198
 
169
199
  def _update_loop(icon):
@@ -172,7 +202,7 @@ def _update_loop(icon):
172
202
  try:
173
203
  _announce_start(icon)
174
204
  except Exception as exc:
175
- icon.notify(f"Could not read the update state: {exc}", "Smart Tool")
205
+ _notify(icon, "Could not read the update state: {error}", error=exc)
176
206
  while True:
177
207
  try:
178
208
  latest = update_check.newer_version()
@@ -182,7 +212,7 @@ def _update_loop(icon):
182
212
  try:
183
213
  update_check.record_check(latest)
184
214
  except Exception as exc:
185
- icon.notify(f"Could not save the update check: {exc}", "Smart Tool")
215
+ _notify(icon, "Could not save the update check: {error}", error=exc)
186
216
  if latest:
187
217
  known = latest == _state["update"]
188
218
  _state["update"] = latest
@@ -191,38 +221,44 @@ def _update_loop(icon):
191
221
  automatic = update_check.automatic_enabled() and update_check.should_auto_update(latest)
192
222
  except Exception as exc:
193
223
  automatic = False
194
- icon.notify(f"Automatic update skipped: {exc}", "Smart Tool")
224
+ _notify(icon, "Automatic update skipped: {error}", error=exc)
195
225
  if automatic:
196
- icon.notify(f"Updating Smart Tool from {version.VERSION} to {latest}...", "Smart Tool")
226
+ _notify(icon, "Updating Smart Tool from {old} to {new}...", old=version.VERSION, new=latest)
197
227
  update_check.run_update(latest, automatic=True)
198
228
  elif not known:
199
- icon.notify(f"Smart Tool {latest} is available: use \"Update to {latest}\" in this menu.", "Smart Tool")
229
+ _notify(icon, "Smart Tool {version} is available: use \"Update to {version}\" in this menu.",
230
+ version=latest)
200
231
  time.sleep(UPDATE_CHECK_INTERVAL_S)
201
232
 
202
233
 
203
234
  def _update_label(item):
204
- return f"Update to {_state['update']}" if _state["update"] else "Check for updates"
235
+ return i18n.t("Update to {version}", version=_state["update"]) if _state["update"] else i18n.t("Check for updates")
205
236
 
206
237
 
207
238
  _update_busy = threading.Lock()
208
239
 
209
240
 
210
241
  def _update_now(icon, item):
211
- """Menu action: installs the known newer version, or checks npm first and says when there is none."""
242
+ """Menu action: installs the known newer version, or checks npm first (telling the user it is checking) and says
243
+ when there is none."""
212
244
  if not _update_busy.acquire(blocking=False):
213
245
  return
214
246
 
215
247
  def run():
216
248
  try:
217
- latest = _state["update"] or update_check.newer_version()
249
+ latest = _state["update"]
218
250
  if not latest:
219
- icon.notify(f"Smart Tool {version.VERSION} is the latest version.", "Smart Tool")
251
+ _notify(icon, "Checking for updates…")
252
+ latest = update_check.newer_version()
253
+ if not latest:
254
+ _notify(icon, "Smart Tool {version} is the latest version.", version=version.VERSION)
220
255
  return
221
256
  _state["update"] = latest
222
257
  icon.update_menu()
258
+ _notify(icon, "Updating Smart Tool from {old} to {new}...", old=version.VERSION, new=latest)
223
259
  update_check.run_update(latest, automatic=False)
224
260
  except Exception as exc:
225
- icon.notify(f"Could not update: {exc}", "Smart Tool")
261
+ _notify(icon, "Could not update: {error}", error=exc)
226
262
  finally:
227
263
  _update_busy.release()
228
264
  threading.Thread(target=run, daemon=True).start()
@@ -237,16 +273,34 @@ def _auto_update_checked(item):
237
273
 
238
274
  def _toggle_auto_update(icon, item):
239
275
  if update_check.env_opted_out():
240
- icon.notify(f"{update_check.ENV_OPT_OUT}=off is set: automatic updates stay off until it is removed.",
241
- "Smart Tool")
276
+ _notify(icon, "{name}=off is set: automatic updates stay off until it is removed.", name=update_check.ENV_OPT_OUT)
242
277
  return
243
278
  try:
244
279
  config.update_config(lambda cfg: cfg.__setitem__("auto_update", not config.auto_update(cfg)))
245
280
  except Exception as exc:
246
- icon.notify(f"Could not change automatic updates: {exc}", "Smart Tool")
281
+ _notify(icon, "Could not change automatic updates: {error}", error=exc)
247
282
  icon.update_menu()
248
283
 
249
284
 
285
+ def _language_checked(lang):
286
+ def checked(item):
287
+ try:
288
+ return i18n.language() == lang
289
+ except Exception:
290
+ return False
291
+ return checked
292
+
293
+
294
+ def _choose_language(lang):
295
+ def choose(icon, item):
296
+ try:
297
+ i18n.set_language(lang)
298
+ except Exception as exc:
299
+ _notify(icon, "Could not change the language: {error}", error=exc)
300
+ _follow_language(icon)
301
+ return choose
302
+
303
+
250
304
  def _open_setup(icon, item):
251
305
  registry = daemon_launcher.read_registry()
252
306
  webbrowser.open((registry["url"].rstrip("/") + "/setup") if registry else SETUP_URL)
@@ -258,7 +312,7 @@ def _open_projects(icon, item):
258
312
  url = daemon_launcher.ensure_daemon_running()
259
313
  webbrowser.open(url.rstrip("/") + "/projects")
260
314
  except Exception as exc:
261
- icon.notify(f"Could not open the projects: {exc}", "Smart Tool")
315
+ _notify(icon, "Could not open the projects: {error}", error=i18n.t(str(exc)))
262
316
  threading.Thread(target=run, daemon=True).start()
263
317
 
264
318
 
@@ -268,7 +322,7 @@ def _add_project(icon, item):
268
322
  import folder_picker
269
323
  folder_picker.register_from_tray()
270
324
  except Exception as exc:
271
- icon.notify(f"Could not select the folder: {exc}", "Smart Tool")
325
+ _notify(icon, "Could not select the folder: {error}", error=exc)
272
326
  threading.Thread(target=run, daemon=True).start()
273
327
 
274
328
 
@@ -293,12 +347,10 @@ def _toggle_autostart(icon, item):
293
347
  else:
294
348
  result = autostart.install()
295
349
  if result.get("method") == "startup_folder" and result.get("fallback_reason"):
296
- icon.notify(
297
- f"Scheduled Task unavailable ({result['fallback_reason']}); using the Startup folder.",
298
- "Smart Tool",
299
- )
350
+ _notify(icon, "Scheduled Task unavailable ({reason}); using the Startup folder.",
351
+ reason=result["fallback_reason"])
300
352
  except Exception as exc:
301
- icon.notify(f"Failed to change automatic start: {exc}", "Smart Tool")
353
+ _notify(icon, "Failed to change automatic start: {error}", error=exc)
302
354
  finally:
303
355
  _autostart_busy.release()
304
356
  icon.update_menu()
@@ -332,18 +384,25 @@ def main():
332
384
  handle = _single_instance_handle()
333
385
  if handle is False:
334
386
  return
387
+ _state["language"] = i18n.language()
335
388
  icon = pystray.Icon(
336
389
  "smart-tool",
337
390
  icon=_ICONS["starting"],
338
- title=_TITLES["starting"],
391
+ title=i18n.t(_TITLES["starting"]),
339
392
  menu=pystray.Menu(
340
- pystray.MenuItem("Projects and indexing…", _open_projects, default=True),
341
- pystray.MenuItem("Index a folder…", _add_project),
342
- pystray.MenuItem("Open settings", _open_setup),
343
- pystray.MenuItem("Start with Windows", _toggle_autostart, checked=_autostart_checked),
393
+ pystray.MenuItem(lambda item: i18n.t("Projects and indexing…"), _open_projects, default=True),
394
+ pystray.MenuItem(lambda item: i18n.t("Index a folder…"), _add_project),
395
+ pystray.MenuItem(lambda item: i18n.t("Open settings"), _open_setup),
396
+ pystray.MenuItem(lambda item: i18n.t("Start with Windows"), _toggle_autostart, checked=_autostart_checked),
344
397
  pystray.MenuItem(_update_label, _update_now),
345
- pystray.MenuItem("Update automatically", _toggle_auto_update, checked=_auto_update_checked),
346
- pystray.MenuItem("Quit", _quit),
398
+ pystray.MenuItem(lambda item: i18n.t("Update automatically"), _toggle_auto_update,
399
+ checked=_auto_update_checked),
400
+ pystray.MenuItem("Idioma / Language", pystray.Menu(
401
+ pystray.MenuItem("Português (Brasil)", _choose_language("pt-BR"), checked=_language_checked("pt-BR"),
402
+ radio=True),
403
+ pystray.MenuItem("English", _choose_language("en"), checked=_language_checked("en"), radio=True),
404
+ )),
405
+ pystray.MenuItem(lambda item: i18n.t("Quit"), _quit),
347
406
  ),
348
407
  )
349
408
  threading.Thread(target=_poll_loop, args=(icon,), daemon=True).start()
package/update_check.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Newer Smart Tool release on npm and the update itself: the tray checks npm at start and every 24 h and, when
2
- automatic updates are on (the default), runs the same install command a user would, in a visible console. A failed
2
+ automatic updates are on (the default), runs the same install command a user would, behind the update screen
3
+ (update_splash.py) and with no console window. A failed
3
4
  version is tried again after 24 h. What happened (newer version available, updated, update failed) is told to the
4
5
  user by the tray and to agent sessions by the daemon, from the state file, without contacting npm again.
5
6
 
@@ -8,12 +9,12 @@ newer-version notice still shows then, with the command to update by hand."""
8
9
  import json
9
10
  import os
10
11
  import subprocess
12
+ import sys
11
13
  import time
12
14
  import urllib.request
13
15
 
14
16
  import atomic_io
15
17
  import config
16
- import node_locator
17
18
  import paths
18
19
  import version
19
20
 
@@ -22,6 +23,7 @@ REGISTRY_URL = "https://registry.npmjs.org/@allansantos-dev%2Fsmart-tool/latest"
22
23
  UPDATE_COMMAND = f"npx --yes {PACKAGE}@latest install"
23
24
  SITE_URL = "https://allansantos-dv.github.io/smart-tool/"
24
25
  STATE_PATH = os.path.join(paths.DATA_DIR, "update-state.json")
26
+ UPDATE_LOG = os.path.join(paths.DATA_DIR, "update.log")
25
27
  ENV_OPT_OUT = "SMART_TOOL_AUTO_UPDATE"
26
28
  RETRY_AFTER_S = 24 * 3600
27
29
  UPDATED_NOTICE_S = 24 * 3600
@@ -102,6 +104,22 @@ def note_running_version(now=None):
102
104
  return found
103
105
 
104
106
 
107
+ def update_for_tray(now=None):
108
+ """The update record (from, to, at) of the running version the first time the tray asks, whether the tray or the
109
+ daemon started first and recorded it; None when there is none, it is older than the updated notice, or the tray
110
+ already told it."""
111
+ note_running_version(now)
112
+ now = now or time.time()
113
+ state = _state()
114
+ updated = state.get("updated") or {}
115
+ if (updated.get("to") != version.VERSION or state.get("tray_told") == version.VERSION
116
+ or now - updated.get("at", 0) >= UPDATED_NOTICE_S):
117
+ return None
118
+ state["tray_told"] = version.VERSION
119
+ _save(state)
120
+ return updated
121
+
122
+
105
123
  def failed_update():
106
124
  """The version an automatic update went for and did not install (the previous version is still running), else
107
125
  None."""
@@ -136,15 +154,16 @@ def notices(now=None):
136
154
 
137
155
 
138
156
  def run_update(latest, automatic):
139
- """Starts the install command in its own visible console, detached from the tray (the installer stops and restarts
140
- the tray). The console closes on success and stays open with the error on failure. npx is looked up now, not in the
141
- PATH the tray was born with: a Node installed or moved since the last logon still runs the update, and the folder
142
- found is put first in the PATH the installer (and the tray it restarts) inherits."""
157
+ """Starts the update screen (update_splash.py) detached from the tray, which the installer stops and restarts:
158
+ it runs the install command with no console window and shows each step and the result. npx is looked up by the
159
+ screen when it starts, not in the PATH the tray was born with."""
143
160
  if automatic:
144
161
  state = _state()
145
162
  state.update(attempted=latest, at=time.time())
146
163
  _save(state)
147
- flags = getattr(subprocess, "CREATE_NEW_CONSOLE", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
148
- command = f'title Smart Tool update to {latest} && {UPDATE_COMMAND} || pause'
149
- subprocess.Popen(["cmd.exe", "/c", command], creationflags=flags, close_fds=True,
150
- cwd=os.path.expanduser("~"), env=node_locator.environment(prefer=node_locator.which("npx.cmd")))
164
+ folder = os.path.dirname(os.path.abspath(__file__))
165
+ pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
166
+ flags = getattr(subprocess, "DETACHED_PROCESS", 0) | getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
167
+ subprocess.Popen([pythonw if os.path.isfile(pythonw) else sys.executable, os.path.join(folder, "update_splash.py"),
168
+ latest], creationflags=flags, close_fds=True, cwd=folder, stdin=subprocess.DEVNULL,
169
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)