From ed30568092504ff24b15cd44876336f213033563 Mon Sep 17 00:00:00 2001 From: viewit Date: Tue, 16 Jun 2026 13:12:04 +0200 Subject: [PATCH] build: sources for v0.9.22 --- CHANGELOG.md | 38 +++++ VERSION | 2 +- config_loader.py | 46 +++++- kobrax_moonraker_bridge.py | 231 +++++++++++++++++++++-------- web/themes/default/app.js | 243 +++++++++++++++++++++++++----- web/themes/default/index.html | 272 +++++++++++++++++++++------------- web/themes/default/style.css | 18 +++ web/translations/de.json | 15 +- web/translations/en.json | 15 +- web/translations/es.json | 15 +- web/translations/fr.json | 15 +- web/translations/zh-cn.json | 15 +- 12 files changed, 715 insertions(+), 210 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d075487..5ed96cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [0.9.22] – 2026-06-16 + +### New +- **Restructured Settings panel.** The settings modal has been replaced by a + persistent Master-Detail panel with five categories: Connection, Printer, + Appearance, Filament, and System. Poll interval is now adjustable live. +- **Vendor visibility filter (issue #41).** A new checklist in the Filament + settings lets you restrict the slot profile dropdown to specific manufacturers. + "Generic" and your own imported profiles are always visible. The list updates + automatically after a profile import. +- **Idle file actions in the progress card (issue #55).** After uploading a file + while the printer is idle, three quick-action buttons appear directly in the + progress card: ▶ Print, ⚙ Map Slots, and ✕ Clear — matching the file browser + workflow without navigating away. + +### Fixed +- **Mobileraker: app crashed with `Null is not a subtype of Object` in + `ConfigExtruder.fromJson` (issue #48).** `configfile.config` was returned as + an empty object `{}`. Mobileraker parses both `configfile.settings` and + `configfile.config` through the same strict Dart parser — both are now + populated with the same extruder/bed/stepper stub. +- **Mobileraker: app hung indefinitely on refresh (issue #48).** The WebSocket + `server.files.metadata` handler called a non-existent store method + (`get_file_by_filename`), always returning empty metadata. Mobileraker retried + this thousands of times per second. Both the HTTP and WS paths now share a + single `_build_file_metadata()` method. +- **Mobileraker: ETA / remaining time not shown (issue #48).** A side effect of + the metadata loop fix — once `currentFile` resolves, Mobileraker can calculate + ETA from `estimated_time`. +- **Mobileraker: `notify_status_update` triggered repeated `ConfigFile.parse` + (issue #48).** Static objects (`configfile`, `webhooks`, `heaters`, `history`) + were included in every live status push. They are now filtered out; only live + telemetry is broadcast. +- `motion_report` (`live_position`, `live_velocity`) added to printer objects + for Mobileraker motion display. +- Saving filament slot profiles no longer silently drops the `visible_vendors` + setting from `config.ini`. + ## [0.9.21] – 2026-06-14 ### Fixed diff --git a/VERSION b/VERSION index b216023..ea3f0d7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.9.21 +0.9.22 diff --git a/config_loader.py b/config_loader.py index 807f911..ff14dd1 100644 --- a/config_loader.py +++ b/config_loader.py @@ -227,10 +227,17 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool: return False cfg = configparser.ConfigParser() cfg.read(path, encoding="utf-8") + # visible_vendors (Issue #41) ist kein Slot-Mapping — beim Ersetzen der + # Sektion erhalten, sonst geht der Vendor-Filter beim Slot-Save verloren. + preserved_vendors = None + if cfg.has_option("filament_profiles", "visible_vendors"): + preserved_vendors = cfg.get("filament_profiles", "visible_vendors") if cfg.has_section("filament_profiles"): cfg.remove_section("filament_profiles") - if profiles: + if profiles or preserved_vendors: cfg["filament_profiles"] = {} + if preserved_vendors: + cfg["filament_profiles"]["visible_vendors"] = preserved_vendors for slot_idx in sorted(profiles.keys()): entry = profiles[slot_idx] or {} if entry.get("vendor"): @@ -244,6 +251,43 @@ def save_filament_profiles(profiles: dict[int, dict]) -> bool: return True +def list_visible_vendors() -> list[str]: + """Liest [filament_profiles] visible_vendors (komma-separiert) aus config.ini. + + Vendor-Sichtbarkeitsfilter für das Slot-Profil-Dropdown (Issue #41 Option A). + Leere Liste = keine Einschränkung (rückwärtskompatibel: alle Vendoren). + """ + path = _find_config_file() + if not path: + return [] + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + if not cfg.has_option("filament_profiles", "visible_vendors"): + return [] + raw = cfg.get("filament_profiles", "visible_vendors") + return [v.strip() for v in raw.split(",") if v.strip()] + + +def save_visible_vendors(vendors: list[str]) -> bool: + """Schreibt visible_vendors in [filament_profiles], ohne die Slot-Mappings + (slot_N_*) zu verlieren. Leere Liste entfernt den Key wieder.""" + path = _find_config_file() + if not path: + return False + cfg = configparser.ConfigParser() + cfg.read(path, encoding="utf-8") + if not cfg.has_section("filament_profiles"): + cfg.add_section("filament_profiles") + clean = [v.strip() for v in (vendors or []) if v and v.strip()] + if clean: + cfg["filament_profiles"]["visible_vendors"] = ", ".join(clean) + elif cfg.has_option("filament_profiles", "visible_vendors"): + cfg.remove_option("filament_profiles", "visible_vendors") + with open(path, "w", encoding="utf-8") as f: + cfg.write(f) + return True + + def get(key: str, default: str = "") -> str: return os.environ.get(key, default) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8caf876..cea6819 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -34,6 +34,7 @@ except ImportError: import env_loader import asyncio import hashlib +import copy import json import logging import os @@ -751,6 +752,13 @@ class KobraXBridge: self._filament_profiles: dict[int, dict] = _cl.list_filament_profiles() except Exception: self._filament_profiles = {} + # Vendor-Sichtbarkeitsfilter fürs Slot-Profil-Dropdown (Issue #41 Option A). + # Leere Liste = alle Vendoren sichtbar (rückwärtskompatibel). + try: + import config_loader as _cl + self._visible_vendors: list[str] = _cl.list_visible_vendors() + except Exception: + self._visible_vendors = [] self._last_state: dict = {} self._state = { "nozzle_temp": 0.0, @@ -1694,13 +1702,22 @@ class KobraXBridge: # WebSocket push # ------------------------------------------------------------------------- + # Statische Objekte, die sich zur Laufzeit nie ändern. Sie werden einmalig + # per objects.query/subscribe ausgeliefert, aber NICHT in jedem + # notify_status_update mitgeschickt — sonst läuft Mobilerakers + # ConfigFile.parse (teuer + strikt) bei jedem Status-Tick erneut und die App + # hängt/crasht beim Refresh (Issue #48). + _STATIC_STATUS_OBJECTS = ("configfile", "webhooks", "heaters", "history") + def _push_status_update(self): if not self.ws_clients: return + objs = self._build_printer_objects() + live = {k: v for k, v in objs.items() if k not in self._STATIC_STATUS_OBJECTS} msg = { "jsonrpc": "2.0", "method": "notify_status_update", - "params": [self._build_printer_objects(), time.time()], + "params": [live, time.time()], } text = json.dumps(msg) dead = set() @@ -1858,6 +1875,17 @@ class KobraXBridge: "homing_origin": [0, 0, 0, 0], "position": [0, 0, self._estimate_current_z(), 0], }, + # motion_report: Mobileraker liest die Live-Geschwindigkeit hier + # (live_velocity). Der Kobra-X-MQTT liefert KEINE echte mm/s, nur + # einen print_speed_mode (1-4). live_velocity bleibt daher 0 — das + # Objekt muss aber existieren, sonst zeigt Mobileraker nichts an + # (motion_report war zuvor null). live_position spiegelt die + # geschätzte Z-Höhe (wie gcode_move). + "motion_report": { + "live_position": [0, 0, self._estimate_current_z(), 0], + "live_velocity": 0.0, + "live_extruder_velocity": 0.0, + }, "fan": { "speed": (int(s.get("fan_speed") or 0)) / 100.0, "rpm": None, @@ -1894,40 +1922,78 @@ class KobraXBridge: # configfile stub — Mobileraker und andere Clients crashen ohne # dieses Objekt (Missing field: configFile). Werte aus der # entschlüsselten avata_main.conf (ACCFG1.0 — Kobra X Firmware). - "configfile": { - "config": {}, - "settings": { - "printer": { - "kinematics": "cartesian", - "max_velocity": 450, - "max_accel": 10000, - "max_z_velocity": 12, - "max_z_accel": 100, - "square_corner_velocity": 20.0, - }, - "extruder": { - "nozzle_diameter": 0.4, - "filament_diameter": 1.75, - "sensor_type": "ATC Semitec 104GT-2", - "min_temp": 0, - "max_temp": 320, - "min_extrude_temp": 10, - }, - "heater_bed": { - "min_temp": 0, - "max_temp": 120, - }, - "stepper_x": {"position_min": -18.5, "position_max": 280}, - "stepper_y": {"position_min": -6.5, "position_max": 272.5}, - "stepper_z": {"position_min": -4, "position_max": 262}, - "virtual_sdcard": {"path": "/data/gcodes"}, - "pause_resume": {}, - "display_status": {}, - }, - "warnings": [], - "save_config_pending": False, - "save_config_pending_items": {}, + # Mobileraker (Issue #48) parst BEIDE Zweige config + settings durch + # denselben ConfigFile.parse → ConfigExtruder.fromJson; ein leeres + # config:{} ließ den nicht-nullbaren Dart-Parser crashen. Daher wird + # config identisch zu settings gespiegelt. + "configfile": self._klipper_configfile_stub(), + } + + def _klipper_configfile_stub(self) -> dict: + """Minimaler Klipper-configfile-Stub für Mobileraker/OctoApp (Issue #48). + + Mobileraker parst BEIDE Zweige `config` und `settings` durch denselben + ConfigFile.parse → ConfigExtruder.fromJson. Ein leeres `config: {}` + ließ den nicht-nullbaren Dart-Parser crashen, daher wird `config` + identisch zu `settings` gespiegelt. Werte aus der entschlüsselten + avata_main.conf (ACCFG1.0 — Kobra X Firmware). + """ + settings = { + "printer": { + "kinematics": "cartesian", + "max_velocity": 450, + "max_accel": 10000, + "max_z_velocity": 12, + "max_z_accel": 100, + "square_corner_velocity": 20.0, }, + "extruder": { + "nozzle_diameter": 0.4, + "filament_diameter": 1.75, + "sensor_type": "ATC Semitec 104GT-2", + "min_temp": 0, + "max_temp": 320, + "min_extrude_temp": 10, + # Mobileraker ConfigExtruder erwartet diese Felder non-nullable + # (max_extrude_only_distance, max_power) bzw. als Key präsent + # (max_extrude_only_velocity/accel dürfen null sein). Fehlen = + # Crash in ConfigExtruder.fromJson (Issue #48). + "max_extrude_only_distance": 100.0, + "max_power": 1.0, + "max_extrude_only_velocity": None, + "max_extrude_only_accel": None, + }, + "heater_bed": { + # Mobileraker ConfigHeaterBed: heater_pin, sensor_type, control + # sind non-nullable. Werte sind Platzhalter (Bridge kennt die + # echten Pins nicht — Anycubic-Firmware, kein Klipper-printer.cfg). + "heater_pin": "PA0", + "sensor_type": "ATC Semitec 104GT-2", + "control": "pid", + "min_temp": 0, + "max_temp": 120, + }, + # stepper_* mit non-nullable Pflichtfeldern (step_pin, dir_pin, + # rotation_distance) füllen, sonst crasht ConfigStepper.fromJson. + "stepper_x": {"step_pin": "PA1", "dir_pin": "PA2", "rotation_distance": 40, + "position_min": -18.5, "position_max": 280}, + "stepper_y": {"step_pin": "PA3", "dir_pin": "PA4", "rotation_distance": 40, + "position_min": -6.5, "position_max": 272.5}, + "stepper_z": {"step_pin": "PA5", "dir_pin": "PA6", "rotation_distance": 8, + "position_min": -4, "position_max": 262}, + "virtual_sdcard": {"path": "/data/gcodes"}, + "pause_resume": {}, + "display_status": {}, + } + # config + settings müssen dieselben Felder enthalten — Mobileraker + # parst beide. deepcopy, damit kein Client durch geteilte Referenz + # versehentlich beide Zweige mutiert. + return { + "config": copy.deepcopy(settings), + "settings": settings, + "warnings": [], + "save_config_pending": False, + "save_config_pending_items": {}, } # ------------------------------------------------------------------------- @@ -2247,6 +2313,31 @@ class KobraXBridge: "name": entry.get("name", ""), "id": entry.get("id", "")}) + async def handle_kx_visible_vendors(self, request): + """GET/POST /kx/filament/visible_vendors — Vendor-Sichtbarkeitsfilter + fürs Slot-Profil-Dropdown (Issue #41 Option A). + + GET → {"result": ["Polymaker", "eSUN", ...]} + POST {"vendors": [...]} → speichert in config.ini [filament_profiles] + visible_vendors. Leere Liste = alle sichtbar. KEIN Bridge-Neustart + nötig (nur Anzeigefilter).""" + if request.method == "POST": + try: + data = await request.json() + except Exception: + data = {} + vendors = data.get("vendors") or [] + if not isinstance(vendors, list): + return self._json_cors({"error": "vendors must be a list"}, status=400) + self._visible_vendors = [str(v).strip() for v in vendors if str(v).strip()] + try: + import config_loader as _cl + _cl.save_visible_vendors(self._visible_vendors) + except Exception as e: + log.warning(f"save_visible_vendors failed: {e}") + return self._json_cors({"error": str(e)}, status=500) + return self._json_cors({"result": self._visible_vendors}) + def _load_orca_filaments(self) -> list[dict]: """Lädt System- + User-Profile aus dem Cache. System-Profile kommen aus bridge/data/orca_filaments.json (Image-embedded), User-Profile @@ -2591,18 +2682,16 @@ class KobraXBridge: }) return web.json_response({"result": files}) - async def handle_files_metadata(self, request): - """Moonraker /server/files/metadata — moonraker-obico-Plugin holt das - einmal pro Druck und liest daraus `object_height` (für `currentZ`- - Anzeige im Obico-UI: `mmProgress` braucht maxZ), `layer_count`, - `layer_height` und `first_layer_height` (für die Layer-Berechnung). + def _build_file_metadata(self, filename: str) -> dict: + """Baut die Moonraker-file-metadata für eine Datei. Gemeinsame Quelle + für HTTP /server/files/metadata UND den WS-RPC server.files.metadata + (vorher hatte der WS-Pfad eigene, kaputte Logik mit einer nicht + existierenden Store-Methode → leere Antwort → Mobileraker fragte in + Endlosschleife, App hing beim Refresh, Issue #48). - Quelle: aktueller `_state` + GCode-Store-Eintrag wenn vorhanden. - Wenn Layer-Heights weder im State noch im Store sind, Fallback auf die - OrcaSlicer-Default-Filename-Heuristik (`_layer_height_from_filename`).""" - filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "") - if not filename: - return web.json_response({"result": {}}) + Liefert Mobileraker-kompatible Pflichtfelder: `filename`, `size`, + `modified` sind in GCodeFile non-nullable; `print_start_time` und die + Slicer-Felder optional.""" s = self._state layer_h = float(s.get("layer_height") or 0.0) first_h = float(s.get("first_layer_height") or 0.0) @@ -2626,16 +2715,27 @@ class KobraXBridge: if layer_h and not first_h: first_h = layer_h object_height = round(first_h + max(0, total_layers - 1) * layer_h, 3) if (layer_h and total_layers) else 0.0 - return web.json_response({"result": { + return { "filename": filename, - "size": size_bytes, + # GCodeFile (Mobileraker) verlangt size als non-nullable int. + "size": size_bytes or 1, "modified": time.time(), "estimated_time": est_time or None, "layer_height": layer_h or None, "first_layer_height": first_h or None, "layer_count": total_layers or None, "object_height": object_height or None, - }}) + "thumbnails": [], + } + + async def handle_files_metadata(self, request): + """Moonraker /server/files/metadata — moonraker-obico + Mobileraker + holen Datei-Metadaten (Slicer-Zeit, Layer, object_height). + Logik in _build_file_metadata (gemeinsam mit WS-RPC).""" + filename = request.rel_url.query.get("filename", "") or self._state.get("filename", "") + if not filename: + return web.json_response({"result": {}}) + return web.json_response({"result": self._build_file_metadata(filename)}) # ── Moonraker-Stubs für moonraker-obico ────────────────────────────────── async def handle_access_api_key(self, request): @@ -3797,6 +3897,9 @@ class KobraXBridge: "auto_leveling": getattr(self._args, "auto_leveling", 1), "camera_on_print": getattr(self._args, "camera_on_print", 0), "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "poll_interval": getattr(self._args, "poll_interval", 3), + "filament_profiles": {str(k): v for k, v in self._filament_profiles.items()}, + "visible_vendors": self._visible_vendors, "ace_dry_presets": self._ace_dry_presets, }) @@ -3827,7 +3930,13 @@ class KobraXBridge: cfg.set("print", "auto_leveling", str(data.get("auto_leveling", getattr(self._args, "auto_leveling", 1)))) cfg.set("print", "camera_on_print", str(int(bool(data.get("camera_on_print", getattr(self._args, "camera_on_print", 0)))))) cfg.set("print", "web_upload_warning", str(int(bool(data.get("web_upload_warning", getattr(self._args, "web_upload_warning", 1)))))) - if not cfg.has_option("bridge", "poll_interval"): + if "poll_interval" in data: + try: + pi = max(1, min(60, int(data["poll_interval"]))) + except (TypeError, ValueError): + pi = 3 + cfg.set("bridge", "poll_interval", str(pi)) + elif not cfg.has_option("bridge", "poll_interval"): cfg.set("bridge", "poll_interval", "3") printer_name = str(data.get("printer_name", "")).strip() if printer_name: @@ -4491,23 +4600,13 @@ class KobraXBridge: elif method == "machine.update.status": result = {"busy": False, "version_info": {}} elif method == "server.files.metadata": - # Obico fragt nach Metadaten zu einer Datei (filename in params) + # Obico + Mobileraker fragen Metadaten zu einer Datei. Dieselbe + # Logik wie der HTTP-Endpoint (vorher eigener kaputter Pfad mit + # nicht existierender Store-Methode → leere Antwort → + # Mobileraker-Endlosschleife, Issue #48). fname = (params or {}).get("filename") if isinstance(params, dict) else None - meta = {} - if fname: - try: - rec = self._store.get_file_by_filename(fname) if hasattr(self._store, "get_file_by_filename") else None - except Exception: - rec = None - if rec: - meta = { - "filename": rec.get("filename"), - "size": rec.get("size_bytes") or 0, - "modified": time.time(), - "estimated_time": rec.get("est_print_time_sec") or 0, - "thumbnails": [], - } - result = meta + fname = fname or self._state.get("filename", "") + result = self._build_file_metadata(fname) if fname else {} else: log.debug(f"Unbekannte RPC-Methode: {method}") result = {} @@ -4711,6 +4810,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: 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) + r.add_get("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors) + r.add_post("/kx/filament/visible_vendors", bridge.handle_kx_visible_vendors) # Custom-Profile-Import (Issue #41) — User lädt eigene Orca-Filament- # Profile als ZIP/JSON hoch (z.B. aus ~/.config/OrcaSlicer/user//filament/), # weil die Bridge typischerweise nicht auf demselben Host wie OrcaSlicer läuft. diff --git a/web/themes/default/app.js b/web/themes/default/app.js index a84ba72..44e425f 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -8,6 +8,7 @@ var tempHistory={n:[],b:[]}; var camOn=false; var camUserStopped=false; // user stopped camera manually — suppress auto-restart for this print var _camPollInterval=null; // snapshot-polling interval for Android (no MJPEG support) +var _lastLoadedFile=null; // zuletzt geladene/gedruckte Datei für Progress-Karten-Aktionen (Issue #55) var currentStep=1; var currentPanel='dashboard'; var aceAutoRefillPrefs=(function(){ @@ -167,6 +168,8 @@ async function setLanguage(lang){ localStorage.setItem('lang',l); var langSel=document.getElementById('lang-select'); if(langSel)langSel.value=l; + var sLangSel=document.getElementById('s-lang-select'); + if(sLangSel)sLangSel.value=l; document.documentElement.setAttribute('lang',l); applyLang(); } @@ -310,12 +313,26 @@ function applyLang(){ document.querySelectorAll('.temp-input').forEach(e=>e.setAttribute('placeholder',T.label_target_c.replace(':',''))); // Console setText('ptitle-console',T.panel_console_title); - // Settings modal - setText('modal-title-settings',T.settings_title); + // Settings-Panel setText('modal-sec-connection',T.settings_connection); setText('modal-sec-print',T.settings_print); - setText('modal-sec-poll',T.settings_poll); setText('modal-sec-version',T.settings_version); + // Nav + Kategorie-Labels (mit Fallback falls i18n-Key noch fehlt) + setText('nav-settings',T.nav_settings||'Einstellungen'); + setText('setcat-lbl-connection',T.settings_connection||'Verbindung'); + setText('setcat-lbl-printer',T.settings_print||'Drucker'); + setText('setcat-lbl-display',T.settings_cat_display||'Darstellung'); + setText('setcat-lbl-display2',T.settings_cat_display||'Darstellung'); + setText('setcat-lbl-filament',T.settings_cat_filament||'Filament'); + setText('setcat-lbl-system',T.settings_version||'System'); + setText('lbl-set-lang',T.settings_cat_language||'Sprache'); + setText('lbl-set-theme',T.settings_cat_theme||'Hell / Dunkel umschalten'); + setText('lbl-poll-interval',T.settings_poll||'Poll-Intervall (Sekunden)'); + setText('lbl-filament-mapping',T.settings_filament_mapping||'Filament-Profil-Mapping (pro Slot)'); + setText('lbl-filament-mapping-save',T.settings_filament_mapping_save||'Mapping speichern'); + setText('lbl-visible-vendors',T.settings_visible_vendors||'Sichtbare Hersteller (Profil-Dropdown)'); + setText('visible-vendors-hint',T.settings_visible_vendors_hint||'Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar.'); + setText('lbl-visible-vendors-save',T.settings_visible_vendors_save||'Auswahl speichern'); // Custom-Profile-Import (Issue #41) setText('modal-sec-orca-profiles',T.orca_profile_section); setText('orca-profiles-hint',T.orca_profile_hint); @@ -343,6 +360,10 @@ function applyLang(){ setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); + // Progress-Karten-Aktionen für geladene/idle Datei (Issue #55) + setText('d-idle-print-lbl',T.progress_action_print||'Drucken'); + setText('d-idle-slots-lbl',T.progress_action_slots||'Slots zuordnen'); + setText('d-idle-clear-lbl',T.progress_action_clear||'Leeren'); // Speed buttons setText('d-spd-lbl-1',T.speed_silent.replace(/^\S+\s/,'')); setText('d-spd-lbl-2',T.speed_normal.replace(/^\S+\s/,'')); @@ -462,6 +483,15 @@ function showPanel(id){ var nb=document.getElementById('nb-'+id);if(nb)nb.classList.add('active'); var bnb=document.getElementById('bnb-'+id);if(bnb)bnb.classList.add('active'); currentPanel=id; + if(id==='settings')openSettings(); +} + +// Settings-Kategorie umschalten (Master-Detail) +function showSettingsCat(name){ + document.querySelectorAll('.set-group').forEach(g=>g.classList.remove('active')); + document.querySelectorAll('.set-cat').forEach(b=>b.classList.remove('active')); + var g=document.getElementById('setgrp-'+name);if(g)g.classList.add('active'); + var c=document.getElementById('setcat-'+name);if(c)c.classList.add('active'); } // ── Console log ── @@ -619,11 +649,13 @@ function applyState(){ // connection error banner – nur wenn überhaupt ein Drucker konfiguriert ist var banner=document.getElementById('conn-error-banner'); if(banner){if(s.connection_error&&_printers.length>0){banner.textContent='⚠ '+tr('lbl_conn_error')+' '+s.connection_error;banner.style.display='block';}else{banner.style.display='none';}} + var bannerVisible=false; var frb=document.getElementById('file-ready-banner'); if(frb){ if(s.file_ready&&s.print_state==='standby'){ document.getElementById('file-ready-name').textContent=s.file_ready; frb.style.display='flex'; + bannerVisible=true; }else{frb.style.display='none';} } // skip-button (mid-print) – nur sichtbar wenn aktuell gedruckt wird @@ -635,6 +667,25 @@ function applyState(){ var ctrlBtns=document.getElementById('d-ctrl-btns'); if(ctrlBtns) ctrlBtns.style.display=printing?'':'none'; updatePauseResumeBtn(); + // Zuletzt geladene Datei merken (Issue #55): solange sie über den State + // sichtbar ist. Beim Druckende/Abbruch leert die Bridge file_ready+filename + // (Issue #29) — die gemerkte Referenz bleibt für die Karten-Aktionen. + if(s.file_ready) _lastLoadedFile=s.file_ready; + else if(s.filename) _lastLoadedFile=s.filename; + // Idle-Aktionen (Drucken/Slots/Leeren) nur wenn nicht gedruckt wird, eine + // Datei bekannt ist und der grüne Banner nicht ohnehin schon dieselbe Aktion + // anbietet. + var idleBtns=document.getElementById('d-idle-btns'); + if(idleBtns){ + var showIdle=(!printing && _lastLoadedFile && !bannerVisible); + idleBtns.style.display=showIdle?'':'none'; + if(showIdle){ + var dfn=document.getElementById('d-fname'); + if(dfn && (!dfn.textContent || dfn.textContent==='–')){ + dfn.textContent=_lastLoadedFile;dfn.title=_lastLoadedFile; + } + } + } // header var b=document.getElementById('h-badge'); @@ -896,10 +947,6 @@ function drawChart(id,_,series){ var _updateTag=''; var _updateUrl=''; function openSettings(){ - // Titel mit aktivem Drucker-Namen aktualisieren - var pname=_activePrinter&&_activePrinter.name?_activePrinter.name:null; - var title=document.getElementById('modal-title-settings'); - if(title)title.textContent=T.settings_title+(pname?' – '+pname:''); fetch(_apiUrl('/api/settings')).then(function(r){return r.json()}).then(function(d){ document.getElementById('s-printer-name').value=d.printer_name||''; document.getElementById('s-printer-ip').value=d.printer_ip||''; @@ -912,23 +959,118 @@ function openSettings(){ document.getElementById('s-auto-leveling').checked=(d.auto_leveling===undefined?true:!!d.auto_leveling); var cop=document.getElementById('s-camera-on-print');if(cop)cop.checked=!!d.camera_on_print; var wuw=document.getElementById('s-web-upload-warning');if(wuw)wuw.checked=(d.web_upload_warning===undefined?true:!!d.web_upload_warning); + // Poll-Intervall (Sekunden) — Backend hat Vorrang vor localStorage + var pi=document.getElementById('s-poll-interval'); + if(pi){ + var sec=d.poll_interval||Math.round((parseInt(localStorage.getItem('pollInterval')||'2000'))/1000)||3; + pi.value=sec; + } + renderFilamentMapping(d.filament_profiles||{}); }); - var v=localStorage.getItem('pollInterval')||'2000'; - document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); - var pb=document.getElementById('poll-'+Math.round(parseInt(v)/1000)); - if(pb)pb.classList.add('active'); + // Sprach-Select im Settings-Panel mit aktueller Sprache spiegeln + var ls=document.getElementById('s-lang-select'); + if(ls)ls.value=(localStorage.getItem('lang')||document.documentElement.lang||'de'); document.getElementById('s-version-label').textContent='v'+('__VERSION__'||'?'); document.getElementById('update-status').textContent=''; document.getElementById('btn-update-apply').style.display='none'; var cl=document.getElementById('update-changelog');if(cl)cl.style.display='none'; _updateTag='';_updateUrl=''; - document.getElementById('settings-modal').classList.add('open'); - // Custom-Profile-Liste laden (Issue #41 — Verwaltung von User-importierten - // OrcaSlicer-Filament-Profilen) + // Custom-Profile-Liste laden (Issue #41) refreshUserProfileList(); + // Vendor-Sichtbarkeitsfilter (Issue #41 Option A) + loadVendorChecklist(); } function closeSettings(){ - document.getElementById('settings-modal').classList.remove('open'); + // Panel-Variante: zurück zum Dashboard + showPanel('dashboard'); +} + +// Poll-Intervall-Feld → Live-Poll sofort übernehmen (Persistenz erst beim Speichern) +function onPollIntervalInput(){ + var pi=document.getElementById('s-poll-interval'); + if(!pi)return; + var sec=parseInt(pi.value,10); + if(sec>=1&&sec<=60)setPoll(sec*1000); +} + +// ── Filament-Profil-Mapping pro Slot ([filament_profiles]) ── +function renderFilamentMapping(map){ + var el=document.getElementById('filament-mapping-list'); + if(!el)return; + var rows=''; + for(var i=0;i<4;i++){ + var m=map[i]||map[String(i)]||{}; + var idHint=m.id?' ('+m.id+')':''; + rows+=''; + } + el.innerHTML=rows; +} +function saveFilamentMapping(){ + // Nutzt den per-Slot-Endpoint (vendor,name → ID-Lookup im Backend). + // Leere Felder = Mapping entfernen. + var chain=Promise.resolve(); + for(var i=0;i<4;i++){ + (function(slot){ + var vendor=((document.getElementById('fmap-'+slot+'-vendor')||{}).value||'').trim(); + var name=((document.getElementById('fmap-'+slot+'-name')||{}).value||'').trim(); + chain=chain.then(function(){ + return fetch(_apiUrl('/kx/filament/slots/'+slot+'/profile'), + {method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({vendor:vendor,name:name})}); + }); + })(i); + } + chain.then(function(){ + clog(tr('log_filament_mapping_saved')||'Filament-Mapping gespeichert','msg-ok'); + openSettings(); // neu laden → ID-Hints aktualisieren + }).catch(function(e){clog('Mapping-Fehler: '+e,'msg-err');}); +} + +// ── Vendor-Sichtbarkeitsfilter (Issue #41 Option A) ── +var _vendorChecklistSel={}; // {vendor:true} — laufende Auswahl im UI +function loadVendorChecklist(){ + // aktuelle Auswahl aus Backend, dann alle verfügbaren Vendoren rendern + _visibleVendors=null; // Cache invalidieren + _loadVisibleVendors(function(vis){ + _vendorChecklistSel={}; + (vis||[]).forEach(function(v){_vendorChecklistSel[v]=true;}); + renderVendorChecklist(); + }); +} +function renderVendorChecklist(){ + var el=document.getElementById('visible-vendors-list'); + if(!el)return; + _loadOrcaFilaments(function(profiles){ + // alle System-Vendoren (ohne Generic — der ist immer sichtbar) sammeln + var set={}; + profiles.forEach(function(p){ if(!p.is_user && p.vendor && p.vendor!=='Generic') set[p.vendor]=1; }); + var vendors=Object.keys(set).sort(); + var q=((document.getElementById('vendor-filter-search')||{}).value||'').toLowerCase(); + if(q)vendors=vendors.filter(function(v){return v.toLowerCase().indexOf(q)>=0;}); + el.innerHTML=vendors.map(function(v){ + var ck=_vendorChecklistSel[v]?'checked':''; + var safe=v.replace(/"/g,'"'); + return ''; + }).join('')||''; + }); +} +function _vendorCheck(cb){ + var v=cb.getAttribute('data-vendor'); + if(cb.checked)_vendorChecklistSel[v]=true; else delete _vendorChecklistSel[v]; +} +function saveVisibleVendors(){ + var vendors=Object.keys(_vendorChecklistSel); + fetch(_apiUrl('/kx/filament/visible_vendors'),{method:'POST',headers:{'Content-Type':'application/json'}, + body:JSON.stringify({vendors:vendors})}).then(function(r){return r.json();}).then(function(){ + _visibleVendors=vendors.slice(); // Dropdown-Cache aktualisieren + clog(tr('log_visible_vendors_saved')||'Hersteller-Auswahl gespeichert','msg-ok'); + }).catch(function(e){clog('Vendor-Filter-Fehler: '+e,'msg-err');}); } // ── Custom Filament Profile Import (Issue #41) ── @@ -1002,6 +1144,9 @@ function doProfileImportUpload(files){ _orcaFilamentCache=null; refreshImportDialogList(); refreshUserProfileList(); + // Vendor-Checkliste neu aufbauen — ein Import kann einen bisher + // unbekannten System-Vendor mitbringen (Issue #41). + if(document.getElementById('visible-vendors-list')) renderVendorChecklist(); // Wenn Slot-Edit offen ist, Dropdown gleich neu befüllen var mat=document.getElementById('slot-edit-mat'); if(mat && document.getElementById('slot-edit-modal').classList.contains('open')){ @@ -1042,6 +1187,14 @@ function updateSlotEditFeedButton(){ btn.textContent=_slotEditLoaded?tr('slot_edit_unload'):tr('slot_edit_load'); } var _orcaFilamentCache=null; // [{id,name,vendor,type,color}, …] +var _visibleVendors=null; // Vendor-Sichtbarkeitsfilter (Issue #41); [] = alle +function _loadVisibleVendors(cb){ + if(_visibleVendors!==null){ cb(_visibleVendors); return; } + fetch(_apiUrl('/kx/filament/visible_vendors')).then(function(r){return r.json();}).then(function(d){ + _visibleVendors=d.result||[]; + cb(_visibleVendors); + }).catch(function(){ _visibleVendors=[]; cb([]); }); +} function _loadOrcaFilaments(cb){ if(_orcaFilamentCache){ cb(_orcaFilamentCache); return; } fetch(_apiUrl('/kx/filament/profiles')).then(function(r){return r.json();}).then(function(d){ @@ -1087,13 +1240,23 @@ function _fillSlotProfileDropdown(material, currentVendor, currentName){ userProfs.forEach(function(p){ _appendOption(gUser, p); }); sel.appendChild(gUser); } - // System-Profile nach Vendor gruppieren - var byVendor={}; - systemProfs.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); }); - Object.keys(byVendor).sort().forEach(function(v){ - var g=document.createElement('optgroup'); g.label=v; - byVendor[v].forEach(function(p){ _appendOption(g, p); }); - sel.appendChild(g); + // Vendor-Sichtbarkeitsfilter (Issue #41 Option A): nur gewählte Vendoren + + // Generic. Leere Liste = alle (rückwärtskompatibel). Eigene Profile (is_user) + // sind oben bereits unkonditional drin. + _loadVisibleVendors(function(vis){ + var filtered=systemProfs; + if(vis&&vis.length){ + var allow={};vis.forEach(function(v){allow[v]=1;}); + allow['Generic']=1; + filtered=systemProfs.filter(function(p){return allow[p.vendor];}); + } + var byVendor={}; + filtered.forEach(function(p){ (byVendor[p.vendor]=byVendor[p.vendor]||[]).push(p); }); + Object.keys(byVendor).sort().forEach(function(v){ + var g=document.createElement('optgroup'); g.label=v; + byVendor[v].forEach(function(p){ _appendOption(g, p); }); + sel.appendChild(g); + }); }); }); } @@ -1147,15 +1310,16 @@ function slotEditFeed(){ }) .catch(function(){}); } -function startReadyFile(){ - var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;}); +function startReadyFile(filename){ + var fn=filename||S.file_ready; + var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;}); if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ - maybeGateWebUpload(currentFile, function(){ startReadyFile(); }); + maybeGateWebUpload(currentFile, function(){ startReadyFile(fn); }); return; } var btn=document.getElementById('file-ready-btn'); if(btn){btn.disabled=true;btn.textContent='…';} - post('/printer/print/start',{filename:S.file_ready}) + post('/printer/print/start',{filename:fn}) .then(function(r){return r.json();}) .then(function(r){ document.getElementById('file-ready-banner').style.display='none'; @@ -1170,6 +1334,20 @@ function cancelReadyFile(){ post('/api/file_ready/clear',{}) .then(function(){document.getElementById('file-ready-banner').style.display='none';}); } + +// ── Aktionen für geladene/idle Datei in der Progress-Karte (Issue #55) ── +function startIdleFile(){ + if(_lastLoadedFile) startReadyFile(_lastLoadedFile); +} +function startIdleFileWithSlots(){ + if(_lastLoadedFile) startReadyFileWithSlots(_lastLoadedFile); +} +function clearIdleFile(){ + _lastLoadedFile=null; + var ib=document.getElementById('d-idle-btns');if(ib)ib.style.display='none'; + var fn=document.getElementById('d-fname');if(fn){fn.textContent='–';fn.title='';} + post('/api/file_ready/clear',{}).catch(function(){}); +} function selectMatPreset(m){ document.getElementById('slot-edit-mat').value=m; highlightMatBtn(m); @@ -1257,9 +1435,6 @@ document.addEventListener('DOMContentLoaded',function(){ }); }); function setPoll(ms){ - document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); - var id='poll-'+Math.round(ms/1000); - var pb=document.getElementById(id);if(pb)pb.classList.add('active'); localStorage.setItem('pollInterval',ms); clearInterval(pollTimer); pollTimer=setInterval(poll,ms); @@ -1281,6 +1456,7 @@ function saveSettings(){ auto_leveling: document.getElementById('s-auto-leveling').checked?1:0, camera_on_print: (document.getElementById('s-camera-on-print')||{}).checked?1:0, web_upload_warning:webUploadWarning, + poll_interval: Math.min(60,Math.max(1,parseInt((document.getElementById('s-poll-interval')||{}).value,10)||3)), }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ @@ -2063,14 +2239,15 @@ function confirmStoreWebVerify(){ }); } -function startReadyFileWithSlots(){ - var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;}); +function startReadyFileWithSlots(filename){ + var fn=filename||S.file_ready; + var currentFile=(storeFiles||[]).find(function(f){return f.filename===fn;}); if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ - maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(); }); + maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(fn); }); return; } _filamentDialogMode='banner'; - _storeFilename=S.file_ready||''; + _storeFilename=fn||''; // Banner must never reuse stale store-file context. _storeFileId=null; _gcodeFilaments=[]; diff --git a/web/themes/default/index.html b/web/themes/default/index.html index d662a58..5be145c 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -42,115 +42,12 @@ - + - + + + @@ -520,6 +425,162 @@
+ + +
+
+
+ + + + + +
+ +
+ +
+
+
🔌 Verbindung
+ + + + + + + +
+
+ + +
+
+
🖨 Druckeinstellungen
+ + + + +
+
+ + +
+
+
🎨 Darstellung
+ + + +
+
+ + +
+
+
🧵 OrcaSlicer-Profile
+
+ Eigene Profile aus OrcaSlicer importieren (User-Dir öffnen via Help → Show Configuration Folder) +
+
+ +
+
+
🎯 Filament-Profil-Mapping (pro Slot)
+
+ Festes Orca-Profil pro AMS-Slot. Beim Slicer-Sync sendet die Bridge dieses Profil statt „Generic". +
+
+ +
+
+
👁 Sichtbare Hersteller (Profil-Dropdown)
+
+ Nur diese Hersteller erscheinen im Slot-Profil-Dropdown. Nichts ausgewählt = alle anzeigen. „Generic" und eigene Profile sind immer sichtbar. +
+ +
+ +
+
+ + +
+
+
Version
+
+ + +
+
+ + +
+
+ + +
+
+
@@ -528,6 +589,7 @@ + diff --git a/web/themes/default/style.css b/web/themes/default/style.css index a2a4572..369830f 100644 --- a/web/themes/default/style.css +++ b/web/themes/default/style.css @@ -212,6 +212,24 @@ canvas.tchart{width:100%;height:60px;display:block;border-radius:6px;background: .panel{display:none} .panel.active{display:block} +/* ── SETTINGS (Master-Detail) ── */ +.settings-wrap{display:grid;grid-template-columns:200px 1fr;gap:16px;align-items:start} +.settings-cats{display:flex;flex-direction:column;gap:4px;position:sticky;top:12px} +.set-cat{display:flex;align-items:center;gap:8px;padding:10px 12px;border-radius:8px; + border:1px solid transparent;background:var(--raised);color:var(--txt2);cursor:pointer; + font-size:13px;text-align:left;transition:background .15s,color .15s} +.set-cat:hover{color:var(--txt)} +.set-cat.active{background:var(--accent);color:#fff;border-color:var(--accent)} +.set-group{display:none} +.set-group.active{display:block} +.set-group .card{margin-bottom:14px} +@media(max-width:768px){ + .settings-wrap{grid-template-columns:1fr} + .settings-cats{flex-direction:row;flex-wrap:wrap;position:static;overflow-x:auto} + .set-cat{flex:1;min-width:auto;justify-content:center;padding:8px 6px;font-size:12px} + .set-cat .nav-text{display:inline} +} + /* ── 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 1d00c22..2c9c7d5 100644 --- a/web/translations/de.json +++ b/web/translations/de.json @@ -127,8 +127,21 @@ "settings_title": "Einstellungen", "settings_connection": "Verbindung", "settings_print": "Druckeinstellungen", - "settings_poll": "Poll-Intervall", + "settings_poll": "Poll-Intervall (Sekunden)", "settings_version": "Version", + "nav_settings": "Einstellungen", + "settings_cat_display": "Darstellung", + "settings_cat_filament": "Filament", + "settings_cat_language": "Sprache", + "settings_cat_theme": "Hell / Dunkel umschalten", + "settings_filament_mapping": "Filament-Profil-Mapping (pro Slot)", + "settings_filament_mapping_save": "Mapping speichern", + "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_save": "Auswahl speichern", + "progress_action_print": "Drucken", + "progress_action_slots": "Slots zuordnen", + "progress_action_clear": "Leeren", "settings_save": "Speichern & Neustart", "settings_printer_name": "Drucker-Name", "settings_printer_ip": "Drucker-IP", diff --git a/web/translations/en.json b/web/translations/en.json index 6bed741..8343491 100644 --- a/web/translations/en.json +++ b/web/translations/en.json @@ -127,7 +127,20 @@ "settings_title": "Settings", "settings_connection": "Connection", "settings_print": "Print Settings", - "settings_poll": "Poll Interval", + "settings_poll": "Poll Interval (seconds)", + "nav_settings": "Settings", + "settings_cat_display": "Appearance", + "settings_cat_filament": "Filament", + "settings_cat_language": "Language", + "settings_cat_theme": "Toggle light / dark", + "settings_filament_mapping": "Filament profile mapping (per slot)", + "settings_filament_mapping_save": "Save mapping", + "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_save": "Save selection", + "progress_action_print": "Print", + "progress_action_slots": "Map slots", + "progress_action_clear": "Clear", "settings_version": "Version", "settings_save": "Save & Restart", "settings_printer_name": "Printer Name", diff --git a/web/translations/es.json b/web/translations/es.json index 8964dc5..5b0e879 100644 --- a/web/translations/es.json +++ b/web/translations/es.json @@ -127,7 +127,20 @@ "settings_title": "Configuración", "settings_connection": "Conexión", "settings_print": "Ajustes de impresión", - "settings_poll": "Intervalo de sondeo", + "settings_poll": "Intervalo de sondeo (segundos)", + "nav_settings": "Ajustes", + "settings_cat_display": "Apariencia", + "settings_cat_filament": "Filamento", + "settings_cat_language": "Idioma", + "settings_cat_theme": "Alternar claro / oscuro", + "settings_filament_mapping": "Asignación de perfil de filamento (por ranura)", + "settings_filament_mapping_save": "Guardar asignación", + "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_save": "Guardar selección", + "progress_action_print": "Imprimir", + "progress_action_slots": "Asignar ranuras", + "progress_action_clear": "Vaciar", "settings_version": "Versión", "settings_save": "Guardar y reiniciar", "settings_printer_name": "Nombre de impresora", diff --git a/web/translations/fr.json b/web/translations/fr.json index 0c040be..8c32693 100644 --- a/web/translations/fr.json +++ b/web/translations/fr.json @@ -127,7 +127,20 @@ "settings_title": "Paramètres", "settings_connection": "Connexion", "settings_print": "Paramètres d'impression", - "settings_poll": "Intervalle de sondage", + "settings_poll": "Intervalle de sondage (secondes)", + "nav_settings": "Paramètres", + "settings_cat_display": "Apparence", + "settings_cat_filament": "Filament", + "settings_cat_language": "Langue", + "settings_cat_theme": "Basculer clair / sombre", + "settings_filament_mapping": "Mappage du profil de filament (par emplacement)", + "settings_filament_mapping_save": "Enregistrer le mappage", + "settings_visible_vendors": "Fabricants visibles (liste des profils)", + "settings_visible_vendors_hint": "Seuls ces fabricants apparaissent dans la liste des profils d'emplacement. Rien de sélectionné = tout afficher. « Generic » et vos propres profils sont toujours visibles.", + "settings_visible_vendors_save": "Enregistrer la sélection", + "progress_action_print": "Imprimer", + "progress_action_slots": "Affecter les emplacements", + "progress_action_clear": "Vider", "settings_version": "Version", "settings_save": "Enregistrer et redémarrer", "settings_printer_name": "Nom de l'imprimante", diff --git a/web/translations/zh-cn.json b/web/translations/zh-cn.json index cf4280e..048cf67 100644 --- a/web/translations/zh-cn.json +++ b/web/translations/zh-cn.json @@ -127,7 +127,20 @@ "settings_title": "设置", "settings_connection": "连接", "settings_print": "打印设置", - "settings_poll": "轮询间隔", + "settings_poll": "轮询间隔(秒)", + "nav_settings": "设置", + "settings_cat_display": "外观", + "settings_cat_filament": "耗材", + "settings_cat_language": "语言", + "settings_cat_theme": "切换浅色 / 深色", + "settings_filament_mapping": "耗材配置映射(每槽位)", + "settings_filament_mapping_save": "保存映射", + "settings_visible_vendors": "可见厂商(配置下拉框)", + "settings_visible_vendors_hint": "仅这些厂商会出现在槽位配置下拉框中。未选择 = 显示全部。“Generic”和您自己的配置始终可见。", + "settings_visible_vendors_save": "保存选择", + "progress_action_print": "打印", + "progress_action_slots": "分配槽位", + "progress_action_clear": "清除", "settings_version": "版本", "settings_save": "保存并重启", "settings_printer_name": "打印机名称",