diff --git a/NIGHTLY_CHANGELOG.md b/NIGHTLY_CHANGELOG.md index f1eff73..a7acaab 100644 --- a/NIGHTLY_CHANGELOG.md +++ b/NIGHTLY_CHANGELOG.md @@ -1,2 +1,3 @@ ## Changes in this build +- Feat: the GCode browser now has a second tab showing files stored directly on the printer's own internal storage (e.g. from prints started via Anycubic Slicer Next, bypassing the bridge) — list, multi-select, and delete them right from the Bridge UI instead of needing the printer's own display. diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 5dc5dfd..71c2923 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -1051,6 +1051,13 @@ class KobraXBridge: self._head_tools_model: int = -1 self._filament_mode: str = "toolhead" self._last_uploaded_file: str = "" + # Pending waiters for a specific file/report `action` (e.g. "listLocal", + # "deleteBatch"). publish()'s own return value for these actions is just + # a generic immediate ACK skeleton (code=0, all fields empty) - the real + # answer arrives later via the file/report callback (_on_file), same + # as the existing fileDetails fire-and-forget pattern. Format: + # {action: {"event": threading.Event(), "result": dict|None}}. + self._file_action_waiters: dict[str, dict] = {} self._store = store if store is not None else GCodeStore(args.data_dir) self._serve_dir_path: str = self._store._gcode_dir self._current_job_id: str = "" @@ -1559,7 +1566,41 @@ class KobraXBridge: if payload.get("state") == "done" or payload.get("code") == 200: log.info(f"Skip response: state={payload.get('state')} code={payload.get('code')} skipped={skipped}") + def _wait_for_file_action(self, action: str, send_fn, timeout: float = 8.0) -> dict | None: + """Sends a file/* MQTT request (via send_fn, which must call + self.client.publish(..., timeout=0) fire-and-forget) and blocks the + calling thread until a matching file/report with this `action` + arrives via _on_file, or the timeout elapses. + + Needed because the printer's publish() return value for actions like + listLocal/deleteBatch is just a generic immediate ACK skeleton + (code=0, empty fields) - the real response is a separate, later + file/report message, same as the existing fileDetails pattern. + Must be called from a worker thread (e.g. via run_in_executor), not + the asyncio event loop, since it blocks on a threading.Event. + """ + event = threading.Event() + waiter = {"event": event, "result": None} + self._file_action_waiters[action] = waiter + try: + send_fn() + event.wait(timeout) + return waiter["result"] + finally: + if self._file_action_waiters.get(action) is waiter: + del self._file_action_waiters[action] + def _on_file(self, payload: dict): + # Deliver to any pending listLocal/deleteBatch waiter first (see + # _wait_for_file_action) - these actions carry no file_details/ + # thumbnail payload of their own, so this doesn't interfere with the + # handling below. + action = payload.get("action") or "" + waiter = self._file_action_waiters.get(action) + if waiter is not None: + waiter["result"] = payload + waiter["event"].set() + d = payload.get("data") or {} details = d.get("file_details") or {} thumb = details.get("thumbnail") or details.get("png_image") or "" @@ -2776,6 +2817,59 @@ class KobraXBridge: return self._json_cors({"result": "ok"}) return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_printer_files(self, request): + """GET /kx/printer-files - lists files on the printer's OWN internal + storage (file/listLocal MQTT action), as opposed to /kx/files which + lists what the bridge itself has stored. Needed because prints + started directly from Anycubic Slicer Next (bypassing the bridge) + leave files on the printer that were previously only visible/ + deletable from the printer's own display (Issue #102 context).""" + loop = asyncio.get_event_loop() + def _fetch(): + return self._wait_for_file_action( + "listLocal", + lambda: self.client.publish( + "file", "listLocal", + {"page_num": 1, "page_size": 200, "path": "/"}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _fetch) + if not result or result.get("code") != 200: + return self._json_cors({"error": "printer unreachable or query failed"}, status=502) + records = (result.get("data") or {}).get("records") or [] + files = [r for r in records if not r.get("is_dir")] + return self._json_cors({"result": files}) + + async def handle_kx_printer_file_delete(self, request): + """POST /kx/printer-files/delete - body: {"filenames": ["a.gcode", ...]}. + Single endpoint for both single and multi-select delete - the + printer's file/deleteBatch MQTT action natively accepts a list.""" + try: + body = await request.json() + except Exception: + body = {} + filenames = body.get("filenames") or [] + if not filenames: + return self._json_cors({"error": "no filenames given"}, status=400) + files = [{"path": "/", "filename": fn} for fn in filenames if fn] + loop = asyncio.get_event_loop() + def _delete(): + return self._wait_for_file_action( + "deleteBatch", + lambda: self.client.publish( + "file", "deleteBatch", + {"root": "local", "files": files}, + timeout=0, + ), + timeout=8.0, + ) + result = await loop.run_in_executor(None, _delete) + if not result or result.get("state") != "success": + return self._json_cors({"error": "delete failed", "detail": result}, status=502) + return self._json_cors({"result": "ok"}) + async def handle_kx_file_download(self, request): file_id = request.match_info["file_id"] f = self._store.get_file(file_id) @@ -5733,6 +5827,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_delete("/kx/files/{file_id}", bridge.handle_kx_file_delete) r.add_get("/kx/files/{file_id}/download", bridge.handle_kx_file_download) r.add_post("/kx/files/{file_id}/verify", bridge.handle_kx_file_verify) + r.add_get("/kx/printer-files", bridge.handle_kx_printer_files) + r.add_post("/kx/printer-files/delete", bridge.handle_kx_printer_file_delete) r.add_get("/kx/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/filament/profiles", bridge.handle_kx_filament_profiles) r.add_post("/kx/filament/slots/{idx}/profile", bridge.handle_kx_filament_slot_profile) diff --git a/tests/test_printer_files_endpoint.py b/tests/test_printer_files_endpoint.py new file mode 100644 index 0000000..d03070b --- /dev/null +++ b/tests/test_printer_files_endpoint.py @@ -0,0 +1,144 @@ +""" +Tests für /kx/printer-files (list) und /kx/printer-files/delete — +der zweite Browser-Tab, der Dateien auf dem Drucker selbst zeigt +(via MQTT file/listLocal + file/deleteBatch, live gegen den echten +Kobra X verifiziert, siehe Memory reference_mqtt_listlocal.md). + +Important: publish()'s own return value for these actions is just a +generic immediate ACK skeleton (code=0, empty fields) - the real answer +arrives asynchronously via the file/report callback (_on_file), same as +the existing fileDetails fire-and-forget pattern. So publish() itself +returns None/skeleton here, and the "real" response is delivered by +firing bridge._on_file(...) from a background thread, simulating what +the MQTT reader thread would do when the printer's file/report arrives. +""" +import threading +import time + +import pytest + + +LISTLOCAL_SUCCESS = { + "action": "listLocal", + "code": 200, + "state": "success", + "data": { + "list_mode": 0, + "records": [ + {"filename": "a.gcode", "is_dir": False, "size": 100, "timestamp": 1700000000000}, + {"filename": "subdir", "is_dir": True, "size": 0, "timestamp": 1700000001000}, + {"filename": "b.gcode", "is_dir": False, "size": 200, "timestamp": 1700000002000}, + ], + }, +} + +LISTLOCAL_FAILED = { + "action": "listLocal", + "code": 10112, + "state": "failed", + "data": None, +} + +DELETEBATCH_SUCCESS = { + "action": "deleteBatch", + "code": 200, + "state": "success", + "data": None, + "msg": "done", +} + +DELETEBATCH_FAILED = { + "action": "deleteBatch", + "code": 10112, + "state": "failed", + "data": None, +} + + +def _deliver_async(bridge, payload, delay=0.05): + """Simulates the MQTT reader thread delivering a file/report a moment + after the fire-and-forget publish() call returns.""" + def _fire(): + time.sleep(delay) + bridge._on_file(payload) + threading.Thread(target=_fire, daemon=True).start() + + +@pytest.mark.asyncio +async def test_printer_files_lists_files_and_excludes_dirs(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 200 + data = await resp.json() + filenames = [f["filename"] for f in data["result"]] + assert filenames == ["a.gcode", "b.gcode"] # "subdir" (is_dir=True) excluded + + +@pytest.mark.asyncio +async def test_printer_files_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_SUCCESS), None)[1] + await c.get("/kx/printer-files") + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "listLocal" + assert args[2] == {"page_num": 1, "page_size": 200, "path": "/"} + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_printer_failure(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, LISTLOCAL_FAILED), None)[1] + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_files_returns_502_on_timeout(client): + c, bridge = client + bridge.client.publish.return_value = None # no file/report ever arrives + resp = await c.get("/kx/printer-files") + assert resp.status == 502 + + +@pytest.mark.asyncio +async def test_printer_file_delete_success(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 200 + data = await resp.json() + assert data["result"] == "ok" + + +@pytest.mark.asyncio +async def test_printer_file_delete_uses_correct_mqtt_payload(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_SUCCESS), None)[1] + await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode", "b.gcode"]}) + args, kwargs = bridge.client.publish.call_args + assert args[0] == "file" + assert args[1] == "deleteBatch" + assert args[2] == { + "root": "local", + "files": [ + {"path": "/", "filename": "a.gcode"}, + {"path": "/", "filename": "b.gcode"}, + ], + } + + +@pytest.mark.asyncio +async def test_printer_file_delete_empty_filenames_returns_400(client): + c, _ = client + resp = await c.post("/kx/printer-files/delete", json={"filenames": []}) + assert resp.status == 400 + + +@pytest.mark.asyncio +async def test_printer_file_delete_returns_502_on_printer_rejection(client): + c, bridge = client + bridge.client.publish.side_effect = lambda *a, **kw: (_deliver_async(bridge, DELETEBATCH_FAILED), None)[1] + resp = await c.post("/kx/printer-files/delete", json={"filenames": ["a.gcode"]}) + assert resp.status == 502 diff --git a/web/themes/default/app.js b/web/themes/default/app.js index 8b583ec..ae98222 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -357,6 +357,11 @@ function applyLang(){ setText('store-lbl-select-all',T.store_select_all||'Select All'); setText('store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); setText('store-lbl-exit-select',T.store_exit_select||'Cancel'); + setText('btab-lbl-uploaded',T.browser_tab_uploaded||'Uploaded'); + setText('btab-lbl-printer',T.browser_tab_printer||'On Printer'); + setText('printer-store-lbl-select-all',T.store_select_all||'Select All'); + setText('printer-store-lbl-delete-selected',T.store_delete_selected||'Delete Selected'); + setText('printer-store-lbl-exit-select',T.store_exit_select||'Cancel'); // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); @@ -594,6 +599,17 @@ function showSettingsCat(name){ var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active'); } +// Browser-Sub-Tab umschalten: hochgeladene Dateien (Bridge-Store) vs. Dateien +// auf dem Drucker selbst (interner Speicher, via listLocal MQTT). +var _printerFilesLoaded=false; +function showBrowserTab(name){ + document.querySelectorAll('.browser-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.browser-tab').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('browser-group-'+name);if(g)g.classList.add('active'); + var t=document.getElementById('btab-'+name);if(t)t.classList.add('active'); + if(name==='printer'&&!_printerFilesLoaded)loadPrinterFiles(); +} + // ── Console log ── var consoleLogs=[]; var logAutoScroll=true; @@ -2987,6 +3003,143 @@ function storeDeleteSelected(){ }); } +// ── Files on the printer's own internal storage (Issue: 2nd Browser tab) ── +// Uses filename as the identity key (the printer has no numeric file id like +// the bridge's own GCodeStore) and a single batch-delete call, since the +// printer's file/deleteBatch MQTT action natively accepts a filename list. +var printerFiles=[]; +var _printerFilesSelectMode=false; +var _printerFilesSelected={}; // filename -> true + +function loadPrinterFiles(){ + var errEl=document.getElementById('printer-store-error'); + if(errEl)errEl.style.display='none'; + fetch(_apiUrl('/kx/printer-files')).then(function(r){return r.json()}).then(function(d){ + _printerFilesLoaded=true; + if(d.error){ + printerFiles=[]; + if(errEl){errEl.textContent=T.printer_store_unreachable||d.error;errEl.style.display='block';} + renderPrinterFiles(); + return; + } + printerFiles=d.result||[]; + _printerFilesSelected={}; + _printerFilesSelectMode=false; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='none'; + renderPrinterFiles(); + }).catch(function(e){ + _printerFilesLoaded=true; + if(errEl){errEl.textContent=T.printer_store_unreachable||String(e);errEl.style.display='block';} + }); +} + +function renderPrinterFiles(){ + var grid=document.getElementById('printer-store-grid'); + var empty=document.getElementById('printer-store-empty'); + if(!grid||!empty)return; + if(!printerFiles.length){ + empty.textContent=T.printer_store_empty||'No files on the printer.'; + grid.innerHTML=''; + empty.style.display='block'; + return; + } + empty.style.display='none'; + grid.innerHTML=printerFiles.map(function(f){ + var name=f.filename.length>28?f.filename.slice(0,25)+'…':f.filename; + var sizeKb=f.size?(f.size/1024).toFixed(0)+' KB':'–'; + var date=f.timestamp?new Date(f.timestamp).toISOString().replace('T',' ').slice(0,16):''; + var isSelected=!!_printerFilesSelected[f.filename]; + var selectBorder=isSelected?'border:2px solid var(--accent);padding:9px':'border:1px solid var(--border);padding:10px'; + var checkbox=''+ + ''; + var cardClick=_printerFilesSelectMode?'onclick="printerFileToggleSelect(\''+f.filename.replace(/'/g,"\\'")+'\')" style="cursor:pointer;position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"': + 'style="position:relative;background:var(--raised);border-radius:8px;'+selectBorder+';display:flex;flex-direction:column"'; + return '
'+ + checkbox+ + '
🖨
'+ + '
'+name+'
'+ + '
💾 '+sizeKb+'
'+ + '
📅 '+date+'
'+ + '
'+ + ''+ + '
'+ + '
'; + }).join(''); + _printerFilesUpdateSelectBar(); +} + +function _printerFilesUpdateSelectBar(){ + var selectAll=document.getElementById('printer-store-select-all'); + var countEl=document.getElementById('printer-store-selected-count'); + var delBtn=document.getElementById('printer-store-delete-selected-btn'); + if(!selectAll||!countEl||!delBtn)return; + var allNames=printerFiles.map(function(f){return f.filename;}); + var selected=allNames.filter(function(n){return _printerFilesSelected[n];}); + var n=selected.length; + selectAll.checked=n>0&&n===allNames.length; + selectAll.indeterminate=n>0&&n0?(T.store_selected_count||'{n} selected').replace('{n}',n):''; + delBtn.disabled=n===0; +} + +function printerFileToggleSelect(filename){ + if(!_printerFilesSelectMode){ + _printerFilesSelectMode=true; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='flex'; + } + if(_printerFilesSelected[filename])delete _printerFilesSelected[filename]; + else _printerFilesSelected[filename]=true; + renderPrinterFiles(); +} + +function printerFileToggleSelectAll(checked){ + _printerFilesSelected={}; + if(checked)printerFiles.forEach(function(f){_printerFilesSelected[f.filename]=true;}); + renderPrinterFiles(); +} + +function printerFileExitSelectMode(){ + _printerFilesSelectMode=false; + _printerFilesSelected={}; + var bar=document.getElementById('printer-store-select-bar'); + if(bar)bar.style.display='none'; + renderPrinterFiles(); +} + +function _printerFilesDeleteRequest(filenames){ + return fetch(_apiUrl('/kx/printer-files/delete'),{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({filenames:filenames}), + }).then(function(r){return r.json().then(function(d){return{ok:r.ok,body:d};});}); +} + +function printerFileDelete(filename){ + if(!confirm(T.printer_store_delete_confirm||'Delete file?'))return; + _printerFilesDeleteRequest([filename]).then(function(res){ + if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;} + loadPrinterFiles(); + }); +} + +function printerFileDeleteSelected(){ + var names=Object.keys(_printerFilesSelected); + if(!names.length)return; + if(!confirm((T.printer_store_delete_selected_confirm||'Delete {n} selected files?').replace('{n}',names.length)))return; + _printerFilesDeleteRequest(names).then(function(res){ + if(!res.ok){clog((T.log_delete_failed||'Delete failed')+': '+(res.body.error||''),'msg-err');return;} + printerFileExitSelectMode(); + loadPrinterFiles(); + }); +} + var _gcodeFilaments=[]; function _setGcodeFilamentsFromFileObj(fileObj){ diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 0f4e19f..30836fa 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -388,49 +388,81 @@ 🗂 Datei-Browser -
- - - +
+ +
-
- - - GCode hierher ziehen oder durchsuchen - + +
+
+ + + +
+
+ + + GCode hierher ziehen oder durchsuchen + +
+ + +
-
diff --git a/web/themes/default/style.css b/web/themes/default/style.css index efb661f..3e1a248 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -322,6 +322,12 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: .set-cat .nav-text{display:inline} } +/* ── BROWSER SUB-TABS (uploaded vs. on-printer files) ── */ +.browser-tab.active{color:var(--accent);border-bottom-color:var(--accent)!important} +.browser-tab:hover{color:var(--txt)} +.browser-group{display:none} +.browser-group.active{display:block} + /* ── FILE BROWSER UPLOAD ZONE ── */ #store-upload-zone{ display:flex;flex-direction:column;align-items:center;justify-content:center; diff --git a/web/translations/de.json b/web/translations/de.json index d72c052..2a5857e 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "z.B. Kobra X Wohnzimmer", "apd_success": "Drucker hinzugefügt, Bridge startet neu…", "apd_title": "Drucker hinzufügen", + "browser_tab_printer": "Auf dem Drucker", + "browser_tab_uploaded": "Hochgeladen", "btn_cam_start": "▶ Kamera", "btn_cam_start2": "▶ Start", "btn_cam_stop": "◼ Kamera", @@ -68,6 +70,18 @@ "card_speed": "Druckgeschwindigkeit", "card_temps": "Temperaturen", "confirm_cancel": "Druck wirklich abbrechen?", + "dash_done": "Fertig", + "dash_edit": "Dashboard anpassen", + "dash_hidden_cards": "Ausgeblendete Karten", + "dash_hide": "Ausblenden", + "dash_preset_delete_confirm": "Preset \"{name}\" löschen?", + "dash_preset_name_prompt": "Preset-Name:", + "dash_preset_standard": "Standard", + "dash_preset_wide89": "Desktop breit", + "dash_reset": "Zurücksetzen", + "dash_save_preset": "Als Preset speichern", + "dash_show": "Einblenden", + "dash_toggle_width": "Breite umschalten", "fd_cancel": "Abbrechen", "fd_no_matching_material": "Kein passendes Material", "fd_no_slots_msg": "Keine belegten AMS-Slots.{br}Druck trotzdem starten?", @@ -200,6 +214,10 @@ "panel_temps_chart": "Verlauf (letzte 60 Messungen)", "panel_temps_nozzle": "Düse", "print_auto_leveling": "Auto-Leveling für diesen Druck", + "printer_store_delete_confirm": "Datei vom Drucker löschen?", + "printer_store_delete_selected_confirm": "{n} ausgewählte Dateien vom Drucker löschen?", + "printer_store_empty": "Keine Dateien auf dem Drucker.", + "printer_store_unreachable": "Drucker nicht erreichbar oder Abfrage fehlgeschlagen.", "printers_active": "● aktiv", "printers_current": "Aktueller Drucker", "printers_empty_hint": "Noch kein Drucker eingerichtet.", @@ -213,7 +231,6 @@ "progress_action_slots": "Slots zuordnen", "settings_auto_leveling": "Auto-Leveling vor Druck", "settings_auto_leveling_label": "Auto-Leveling vor dem Druck", - "settings_vibration_compensation": "Resonanzkompensation", "settings_btn_tooltip": "Einstellungen", "settings_camera_on_print": "Kamera bei Druckstart einschalten", "settings_cat_connection": "Verbindung", @@ -247,19 +264,6 @@ "settings_password": "MQTT-Passwort", "settings_poll": "Poll-Intervall (Sekunden)", "settings_poll_interval_hint": "Wie oft die Bridge den Druckerstatus abfragt", - "settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)", - "dash_edit": "Dashboard anpassen", - "dash_done": "Fertig", - "dash_reset": "Zurücksetzen", - "dash_preset_standard": "Standard", - "dash_preset_wide89": "Desktop breit", - "dash_hidden_cards": "Ausgeblendete Karten", - "dash_show": "Einblenden", - "dash_hide": "Ausblenden", - "dash_toggle_width": "Breite umschalten", - "dash_save_preset": "Als Preset speichern", - "dash_preset_name_prompt": "Preset-Name:", - "dash_preset_delete_confirm": "Preset \"{name}\" löschen?", "settings_poll_interval_label": "Poll-Intervall (Sekunden)", "settings_print": "Druckeinstellungen", "settings_printer_ip": "Drucker-IP", @@ -271,7 +275,9 @@ "settings_title": "Einstellungen", "settings_username": "MQTT-Benutzername", "settings_vendor_filter_placeholder": "Hersteller suchen…", + "settings_verbose_http_log": "Jede HTTP-Anfrage loggen (ausführlich)", "settings_version": "Version", + "settings_vibration_compensation": "Resonanzkompensation", "settings_visible_vendors": "Sichtbare Hersteller (Profil-Dropdown)", "settings_visible_vendors_hint": "Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic\" und eigene Profile sind immer sichtbar.", "settings_visible_vendors_label": "Sichtbare Hersteller (Profil-Dropdown)", @@ -293,6 +299,7 @@ "skip_sending": "Sende …", "skip_success": "Objekte werden übersprungen.", "skip_title": "✂ Objekte überspringen", + "slot_copy_from": "Farbe von Slot kopieren…", "slot_edit_color": "Farbe", "slot_edit_custom": "z.B. PLA, PETG, ABS…", "slot_edit_load": "⬇ Einziehen", @@ -316,15 +323,15 @@ "store_download": "⬇ Download", "store_empty": "Noch keine Dateien hochgeladen.", "store_estimate": "Schätzung", + "store_exit_select": "Abbrechen", "store_never": "noch nicht gedruckt", "store_no_results": "Keine Dateien gefunden.", "store_print": "▶ Drucken", "store_print_confirm": "Datei drucken?", "store_refresh": "↻ Aktualisieren", + "store_search_placeholder": "🔍 Suche…", "store_select_all": "Alle auswählen", "store_selected_count": "{n} ausgewählt", - "store_exit_select": "Abbrechen", - "store_search_placeholder": "🔍 Suche…", "store_upload_busy": "⏳ Hochladen…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "durchsuchen", @@ -344,6 +351,5 @@ "update_docker_copied": "Kopiert! Ausführen: docker compose pull && docker compose up -d", "update_error": "Fehler", "update_none": "Bereits aktuell", - "update_restarting": "Starte neu...", - "slot_copy_from": "Farbe von Slot kopieren…" -} \ No newline at end of file + "update_restarting": "Starte neu..." +} diff --git a/web/translations/en.json b/web/translations/en.json index 67c699d..c1aa333 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "e.g. Kobra X Living Room", "apd_success": "Printer added, bridge restarting…", "apd_title": "Add printer", + "browser_tab_printer": "On Printer", + "browser_tab_uploaded": "Uploaded", "btn_cam_start": "▶ Camera", "btn_cam_start2": "▶ Start", "btn_cam_stop": "◼ Camera", @@ -68,6 +70,18 @@ "card_speed": "Print Speed", "card_temps": "Temperatures", "confirm_cancel": "Really cancel the print?", + "dash_done": "Done", + "dash_edit": "Customize dashboard", + "dash_hidden_cards": "Hidden cards", + "dash_hide": "Hide", + "dash_preset_delete_confirm": "Delete preset \"{name}\"?", + "dash_preset_name_prompt": "Preset name:", + "dash_preset_standard": "Standard", + "dash_preset_wide89": "Wide desktop", + "dash_reset": "Reset", + "dash_save_preset": "Save as preset", + "dash_show": "Show", + "dash_toggle_width": "Toggle width", "fd_cancel": "Cancel", "fd_no_matching_material": "No matching material", "fd_no_slots_msg": "No loaded AMS slots.{br}Start print anyway?", @@ -200,6 +214,10 @@ "panel_temps_chart": "History (last 60 readings)", "panel_temps_nozzle": "Nozzle", "print_auto_leveling": "Auto-Leveling", + "printer_store_delete_confirm": "Delete file from the printer?", + "printer_store_delete_selected_confirm": "Delete {n} selected files from the printer?", + "printer_store_empty": "No files on the printer.", + "printer_store_unreachable": "Printer unreachable or query failed.", "printers_active": "● active", "printers_current": "Current printer", "printers_empty_hint": "No printer set up yet.", @@ -213,7 +231,6 @@ "progress_action_slots": "Map slots", "settings_auto_leveling": "Auto-Leveling Default", "settings_auto_leveling_label": "Auto-Leveling before print", - "settings_vibration_compensation": "Resonance Compensation", "settings_btn_tooltip": "Settings", "settings_camera_on_print": "Turn camera on at print start", "settings_cat_connection": "Connection", @@ -247,19 +264,6 @@ "settings_password": "MQTT Password", "settings_poll": "Poll Interval (seconds)", "settings_poll_interval_hint": "How often the bridge queries printer status", - "settings_verbose_http_log": "Log every HTTP request (verbose)", - "dash_edit": "Customize dashboard", - "dash_done": "Done", - "dash_reset": "Reset", - "dash_preset_standard": "Standard", - "dash_preset_wide89": "Wide desktop", - "dash_hidden_cards": "Hidden cards", - "dash_show": "Show", - "dash_hide": "Hide", - "dash_toggle_width": "Toggle width", - "dash_save_preset": "Save as preset", - "dash_preset_name_prompt": "Preset name:", - "dash_preset_delete_confirm": "Delete preset \"{name}\"?", "settings_poll_interval_label": "Poll Interval (seconds)", "settings_print": "Print Settings", "settings_printer_ip": "Printer IP", @@ -271,7 +275,9 @@ "settings_title": "Settings", "settings_username": "MQTT Username", "settings_vendor_filter_placeholder": "Search vendors…", + "settings_verbose_http_log": "Log every HTTP request (verbose)", "settings_version": "Version", + "settings_vibration_compensation": "Resonance Compensation", "settings_visible_vendors": "Visible vendors (profile dropdown)", "settings_visible_vendors_hint": "Only these vendors appear in the slot profile dropdown. Nothing selected = show all. \"Generic\" and your own profiles are always visible.", "settings_visible_vendors_label": "Visible vendors (profile dropdown)", @@ -293,6 +299,7 @@ "skip_sending": "Sending …", "skip_success": "Objects will be skipped.", "skip_title": "✂ Skip objects", + "slot_copy_from": "Copy color from slot…", "slot_edit_color": "Color", "slot_edit_custom": "e.g. PLA, PETG, ABS…", "slot_edit_load": "⬇ Load", @@ -316,15 +323,15 @@ "store_download": "⬇ Download", "store_empty": "No files uploaded yet.", "store_estimate": "Estimate", + "store_exit_select": "Cancel", "store_never": "never printed", "store_no_results": "No files found.", "store_print": "▶ Print", "store_print_confirm": "Print file?", "store_refresh": "↻ Refresh", + "store_search_placeholder": "🔍 Search…", "store_select_all": "Select All", "store_selected_count": "{n} selected", - "store_exit_select": "Cancel", - "store_search_placeholder": "🔍 Search…", "store_upload_busy": "⏳ Uploading…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "browse", @@ -344,6 +351,5 @@ "update_docker_copied": "Copied! Run: docker compose pull && docker compose up -d", "update_error": "Error", "update_none": "Already up to date", - "update_restarting": "Restarting...", - "slot_copy_from": "Copy color from slot…" -} \ No newline at end of file + "update_restarting": "Restarting..." +} diff --git a/web/translations/es.json b/web/translations/es.json index df5a2d5..6725e6a 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "p. ej. Kobra X Sala", "apd_success": "Impresora añadida, reiniciando bridge…", "apd_title": "Agregar impresora", + "browser_tab_printer": "En la impresora", + "browser_tab_uploaded": "Subidos", "btn_cam_start": "▶ Cámara", "btn_cam_start2": "▶ Iniciar", "btn_cam_stop": "◼ Cámara", @@ -68,6 +70,18 @@ "card_speed": "Velocidad de impresión", "card_temps": "Temperaturas", "confirm_cancel": "¿Realmente cancelar la impresión?", + "dash_done": "Listo", + "dash_edit": "Personalizar panel", + "dash_hidden_cards": "Tarjetas ocultas", + "dash_hide": "Ocultar", + "dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?", + "dash_preset_name_prompt": "Nombre del preset:", + "dash_preset_standard": "Estándar", + "dash_preset_wide89": "Escritorio ancho", + "dash_reset": "Restablecer", + "dash_save_preset": "Guardar como preset", + "dash_show": "Mostrar", + "dash_toggle_width": "Cambiar ancho", "fd_cancel": "Cancelar", "fd_no_matching_material": "No hay material compatible", "fd_no_slots_msg": "No hay slots AMS cargados.{br}¿Iniciar impresión de todos modos?", @@ -200,6 +214,10 @@ "panel_temps_chart": "Historial (últimas 60 lecturas)", "panel_temps_nozzle": "Boquilla", "print_auto_leveling": "Autonivelado para esta impresión", + "printer_store_delete_confirm": "¿Eliminar archivo de la impresora?", + "printer_store_delete_selected_confirm": "¿Eliminar {n} archivos seleccionados de la impresora?", + "printer_store_empty": "No hay archivos en la impresora.", + "printer_store_unreachable": "Impresora inaccesible o consulta fallida.", "printers_active": "● activa", "printers_current": "Impresora actual", "printers_empty_hint": "Aún no hay impresora configurada.", @@ -213,7 +231,6 @@ "progress_action_slots": "Asignar ranuras", "settings_auto_leveling": "Autonivelado antes de imprimir", "settings_auto_leveling_label": "Autonivelado antes de imprimir", - "settings_vibration_compensation": "Compensación de resonancia", "settings_btn_tooltip": "Ajustes", "settings_camera_on_print": "Encender cámara al iniciar impresión", "settings_cat_connection": "Conexión", @@ -247,19 +264,6 @@ "settings_password": "Contraseña MQTT", "settings_poll": "Intervalo de sondeo (segundos)", "settings_poll_interval_hint": "Con qué frecuencia el bridge consulta el estado de la impresora", - "settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)", - "dash_edit": "Personalizar panel", - "dash_done": "Listo", - "dash_reset": "Restablecer", - "dash_preset_standard": "Estándar", - "dash_preset_wide89": "Escritorio ancho", - "dash_hidden_cards": "Tarjetas ocultas", - "dash_show": "Mostrar", - "dash_hide": "Ocultar", - "dash_toggle_width": "Cambiar ancho", - "dash_save_preset": "Guardar como preset", - "dash_preset_name_prompt": "Nombre del preset:", - "dash_preset_delete_confirm": "¿Eliminar el preset \"{name}\"?", "settings_poll_interval_label": "Intervalo de sondeo (segundos)", "settings_print": "Ajustes de impresión", "settings_printer_ip": "IP de impresora", @@ -271,7 +275,9 @@ "settings_title": "Configuración", "settings_username": "Usuario MQTT", "settings_vendor_filter_placeholder": "Buscar fabricantes…", + "settings_verbose_http_log": "Registrar cada solicitud HTTP (detallado)", "settings_version": "Versión", + "settings_vibration_compensation": "Compensación de resonancia", "settings_visible_vendors": "Fabricantes visibles (lista de perfiles)", "settings_visible_vendors_hint": "Solo estos fabricantes aparecen en la lista de perfiles de ranura. Nada seleccionado = mostrar todos. «Generic» y tus propios perfiles siempre son visibles.", "settings_visible_vendors_label": "Fabricantes visibles (lista de perfiles)", @@ -293,6 +299,7 @@ "skip_sending": "Enviando …", "skip_success": "Se omitirán los objetos.", "skip_title": "✂ Omitir objetos", + "slot_copy_from": "Copiar color del slot…", "slot_edit_color": "Color", "slot_edit_custom": "p. ej. PLA, PETG, ABS…", "slot_edit_load": "⬇ Cargar", @@ -316,15 +323,15 @@ "store_download": "⬇ Descargar", "store_empty": "Aún no hay archivos subidos.", "store_estimate": "Estimación", + "store_exit_select": "Cancelar", "store_never": "nunca impreso", "store_no_results": "No se encontraron archivos.", "store_print": "▶ Imprimir", "store_print_confirm": "¿Imprimir archivo?", "store_refresh": "↻ Actualizar", + "store_search_placeholder": "🔍 Buscar…", "store_select_all": "Seleccionar todo", "store_selected_count": "{n} seleccionados", - "store_exit_select": "Cancelar", - "store_search_placeholder": "🔍 Buscar…", "store_upload_busy": "⏳ Subiendo…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "buscar", @@ -344,6 +351,5 @@ "update_docker_copied": "Copiado. Ejecutar: docker compose pull && docker compose up -d", "update_error": "Error", "update_none": "Ya actualizado", - "update_restarting": "Reiniciando...", - "slot_copy_from": "Copiar color del slot…" -} \ No newline at end of file + "update_restarting": "Reiniciando..." +} diff --git a/web/translations/fr.json b/web/translations/fr.json index 526917f..28a3605 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "ex. Kobra X Salon", "apd_success": "Imprimante ajoutée, redémarrage du bridge…", "apd_title": "Ajouter une imprimante", + "browser_tab_printer": "Sur l'imprimante", + "browser_tab_uploaded": "Téléversés", "btn_cam_start": "▶ Caméra", "btn_cam_start2": "▶ Démarrer", "btn_cam_stop": "◼ Caméra", @@ -200,6 +202,10 @@ "panel_temps_chart": "Historique (60 dernières valeurs)", "panel_temps_nozzle": "Buse", "print_auto_leveling": "Mise à niveau auto pour cette impression", + "printer_store_delete_confirm": "Supprimer le fichier de l'imprimante ?", + "printer_store_delete_selected_confirm": "Supprimer {n} fichiers sélectionnés de l'imprimante ?", + "printer_store_empty": "Aucun fichier sur l'imprimante.", + "printer_store_unreachable": "Imprimante injoignable ou requête échouée.", "printers_active": "● actif", "printers_current": "Imprimante actuelle", "printers_empty_hint": "Aucune imprimante configurée.", @@ -279,6 +285,7 @@ "skip_sending": "Envoi …", "skip_success": "Les objets seront ignorés.", "skip_title": "✂ Ignorer des objets", + "slot_copy_from": "Copier la couleur du slot…", "slot_edit_color": "Couleur", "slot_edit_custom": "ex. PLA, PETG, ABS…", "slot_edit_load": "⬇ Charger", @@ -302,15 +309,15 @@ "store_download": "⬇ Télécharger", "store_empty": "Aucun fichier uploadé.", "store_estimate": "Estimation", + "store_exit_select": "Annuler", "store_never": "jamais imprimé", "store_no_results": "Aucun fichier trouvé.", "store_print": "▶ Imprimer", "store_print_confirm": "Imprimer le fichier ?", "store_refresh": "↻ Actualiser", + "store_search_placeholder": "🔍 Rechercher…", "store_select_all": "Tout sélectionner", "store_selected_count": "{n} sélectionné(s)", - "store_exit_select": "Annuler", - "store_search_placeholder": "🔍 Rechercher…", "store_upload_busy": "⏳ Envoi en cours…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "parcourir", @@ -330,6 +337,5 @@ "update_docker_copied": "Copié ! Exécuter : docker compose pull && docker compose up -d", "update_error": "Erreur", "update_none": "Déjà à jour", - "update_restarting": "Redémarrage…", - "slot_copy_from": "Copier la couleur du slot…" -} \ No newline at end of file + "update_restarting": "Redémarrage…" +} diff --git a/web/translations/it.json b/web/translations/it.json index ab170b3..2478eb8 100644 --- a/web/translations/it.json +++ b/web/translations/it.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "es. Kobra X Soggiorno", "apd_success": "Stampante aggiunta, riavvio del bridge in corso…", "apd_title": "Aggiungi stampante", + "browser_tab_printer": "Sulla stampante", + "browser_tab_uploaded": "Caricati", "btn_cam_start": "▶ Camera", "btn_cam_start2": "▶ Avvia", "btn_cam_stop": "◼ Camera", @@ -200,6 +202,10 @@ "panel_temps_chart": "Cronologia (ultime 60 letture)", "panel_temps_nozzle": "Ugello", "print_auto_leveling": "Livellamento automatico", + "printer_store_delete_confirm": "Eliminare il file dalla stampante?", + "printer_store_delete_selected_confirm": "Eliminare {n} file selezionati dalla stampante?", + "printer_store_empty": "Nessun file sulla stampante.", + "printer_store_unreachable": "Stampante non raggiungibile o richiesta fallita.", "printers_active": "● attiva", "printers_current": "Stampante corrente", "printers_empty_hint": "Nessuna stampante ancora configurata.", @@ -279,6 +285,7 @@ "skip_sending": "Invio in corso …", "skip_success": "Gli oggetti verranno saltati.", "skip_title": "✂ Salta oggetti", + "slot_copy_from": "Copia colore dallo slot…", "slot_edit_color": "Colore", "slot_edit_custom": "es. PLA, PETG, ABS…", "slot_edit_load": "⬇ Carica", @@ -302,15 +309,15 @@ "store_download": "⬇ Scarica", "store_empty": "Nessun file caricato.", "store_estimate": "Stima", + "store_exit_select": "Annulla", "store_never": "mai stampato", "store_no_results": "Nessun file trovato.", "store_print": "▶ Stampa", "store_print_confirm": "Stampare il file?", "store_refresh": "↻ Aggiorna", + "store_search_placeholder": "🔍 Cerca…", "store_select_all": "Seleziona tutto", "store_selected_count": "{n} selezionati", - "store_exit_select": "Annulla", - "store_search_placeholder": "🔍 Cerca…", "store_upload_busy": "⏳ Caricamento in corso…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "sfoglia", @@ -330,6 +337,5 @@ "update_docker_copied": "Copiato! Eseguire: docker compose pull && docker compose up -d", "update_error": "Errore", "update_none": "Già aggiornato", - "update_restarting": "Riavvio in corso...", - "slot_copy_from": "Copia colore dallo slot…" -} \ No newline at end of file + "update_restarting": "Riavvio in corso..." +} diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index 9a7cf27..d460fc0 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -42,6 +42,8 @@ "apd_placeholder_name": "例如 Kobra X 客厅", "apd_success": "打印机已添加,Bridge 正在重启…", "apd_title": "添加打印机", + "browser_tab_printer": "打印机上", + "browser_tab_uploaded": "已上传", "btn_cam_start": "▶ 相机", "btn_cam_start2": "▶ 启动", "btn_cam_stop": "◼ 相机", @@ -68,6 +70,18 @@ "card_speed": "打印速度", "card_temps": "温度", "confirm_cancel": "确定要取消打印吗?", + "dash_done": "完成", + "dash_edit": "自定义仪表盘", + "dash_hidden_cards": "隐藏的卡片", + "dash_hide": "隐藏", + "dash_preset_delete_confirm": "删除预设 \"{name}\"?", + "dash_preset_name_prompt": "预设名称:", + "dash_preset_standard": "标准", + "dash_preset_wide89": "宽屏桌面", + "dash_reset": "重置", + "dash_save_preset": "另存为预设", + "dash_show": "显示", + "dash_toggle_width": "切换宽度", "fd_cancel": "取消", "fd_no_matching_material": "无匹配材料", "fd_no_slots_msg": "没有已装载的 AMS 槽位。{br}仍要开始打印吗?", @@ -200,6 +214,10 @@ "panel_temps_chart": "历史 (最近 60 次读数)", "panel_temps_nozzle": "喷嘴", "print_auto_leveling": "本次打印自动调平", + "printer_store_delete_confirm": "从打印机删除文件?", + "printer_store_delete_selected_confirm": "从打印机删除选中的 {n} 个文件?", + "printer_store_empty": "打印机上没有文件。", + "printer_store_unreachable": "无法连接打印机或查询失败。", "printers_active": "● 活动", "printers_current": "当前打印机", "printers_empty_hint": "尚未设置打印机。", @@ -213,7 +231,6 @@ "progress_action_slots": "分配槽位", "settings_auto_leveling": "打印前自动调平", "settings_auto_leveling_label": "打印前自动调平", - "settings_vibration_compensation": "打印前共振补偿", "settings_btn_tooltip": "设置", "settings_camera_on_print": "打印开始时开启相机", "settings_cat_connection": "连接", @@ -247,19 +264,6 @@ "settings_password": "MQTT 密码", "settings_poll": "轮询间隔(秒)", "settings_poll_interval_hint": "Bridge 查询打印机状态的频率", - "settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)", - "dash_edit": "自定义仪表盘", - "dash_done": "完成", - "dash_reset": "重置", - "dash_preset_standard": "标准", - "dash_preset_wide89": "宽屏桌面", - "dash_hidden_cards": "隐藏的卡片", - "dash_show": "显示", - "dash_hide": "隐藏", - "dash_toggle_width": "切换宽度", - "dash_save_preset": "另存为预设", - "dash_preset_name_prompt": "预设名称:", - "dash_preset_delete_confirm": "删除预设 \"{name}\"?", "settings_poll_interval_label": "轮询间隔(秒)", "settings_print": "打印设置", "settings_printer_ip": "打印机 IP", @@ -271,7 +275,9 @@ "settings_title": "设置", "settings_username": "MQTT 用户名", "settings_vendor_filter_placeholder": "搜索厂商…", + "settings_verbose_http_log": "记录每个 HTTP 请求(详细模式)", "settings_version": "版本", + "settings_vibration_compensation": "打印前共振补偿", "settings_visible_vendors": "可见厂商(配置下拉框)", "settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。", "settings_visible_vendors_label": "可见厂商(配置下拉框)", @@ -293,6 +299,7 @@ "skip_sending": "发送中 …", "skip_success": "对象将被跳过。", "skip_title": "✂ 跳过对象", + "slot_copy_from": "从插槽复制颜色…", "slot_edit_color": "颜色", "slot_edit_custom": "例如 PLA, PETG, ABS…", "slot_edit_load": "⬇ 进料", @@ -316,15 +323,15 @@ "store_download": "⬇ 下载", "store_empty": "尚未上传文件。", "store_estimate": "估算", + "store_exit_select": "取消", "store_never": "从未打印", "store_no_results": "未找到文件。", "store_print": "▶ 打印", "store_print_confirm": "打印文件?", "store_refresh": "↻ 刷新", + "store_search_placeholder": "🔍 搜索…", "store_select_all": "全选", "store_selected_count": "已选择 {n} 个", - "store_exit_select": "取消", - "store_search_placeholder": "🔍 搜索…", "store_upload_busy": "⏳ 上传中…", "store_upload_error": "✗ {error}", "store_upload_label_browse": "浏览", @@ -344,6 +351,5 @@ "update_docker_copied": "已复制!执行:docker compose pull && docker compose up -d", "update_error": "错误", "update_none": "已是最新版本", - "update_restarting": "重启中...", - "slot_copy_from": "从插槽复制颜色…" -} \ No newline at end of file + "update_restarting": "重启中..." +}