From 42898c385cb819d61643555520fd402775fc89ac Mon Sep 17 00:00:00 2001 From: gangoke Date: Wed, 27 May 2026 23:37:41 +0200 Subject: [PATCH] feat: GCode Web-Upload + Download + Verify-Dialog (PR #32) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Übernommen mit Anpassungen aus PR #32 von @gangoke: - Drag&Drop GCode-Upload - Download-Button pro Datei - Web-Upload-Verify-Dialog (persistent Flag, global abschaltbar) Review-Fixes: - Content-Disposition mit RFC5987 filename*= + ASCII-Fallback - DE-Strings übersetzt Dev-Repo: viewit/KX-Bridge@7c834bc Co-authored-by: gangoke Co-committed-by: gangoke --- config.ini.example | 3 + config_loader.py | 3 + kobrax_moonraker_bridge.py | 60 +++++++++-- web/themes/default/app.js | 190 ++++++++++++++++++++++++++++++++-- web/themes/default/index.html | 30 ++++++ web/themes/default/style.css | 16 +++ 6 files changed, 290 insertions(+), 12 deletions(-) diff --git a/config.ini.example b/config.ini.example index fa1be8b..bf338b4 100644 --- a/config.ini.example +++ b/config.ini.example @@ -29,6 +29,9 @@ default_ams_slot = auto # Auto-Leveling vor jedem Druck (1 = an, 0 = aus) auto_leveling = 1 +# Warnung vor Druck von Web-Uploads (1 = an, 0 = aus) +web_upload_warning = 1 + [bridge] # Poll-Intervall in Sekunden poll_interval = 3 diff --git a/config_loader.py b/config_loader.py index 4aa81f7..35e8fa1 100644 --- a/config_loader.py +++ b/config_loader.py @@ -60,6 +60,7 @@ def _load_config_file(path: pathlib.Path): "DEFAULT_AMS_SLOT": (CONFIG_SECTION_PRINT, "default_ams_slot"), "AUTO_LEVELING": (CONFIG_SECTION_PRINT, "auto_leveling"), "CAMERA_ON_PRINT": (CONFIG_SECTION_PRINT, "camera_on_print"), + "WEB_UPLOAD_WARNING": (CONFIG_SECTION_PRINT, "web_upload_warning"), "BRIDGE_PRINTER_NAME": (CONFIG_SECTION_BRIDGE, "printer_name"), } for env_key, (section, option) in mapping.items(): @@ -97,6 +98,7 @@ def migrate_env_to_config(env_path: pathlib.Path, config_path: pathlib.Path): "default_ams_slot": env_vals.get("DEFAULT_AMS_SLOT", "auto"), "auto_leveling": env_vals.get("AUTO_LEVELING", "1"), "camera_on_print": env_vals.get("CAMERA_ON_PRINT", "0"), + "web_upload_warning": env_vals.get("WEB_UPLOAD_WARNING", "1"), } cfg[CONFIG_SECTION_BRIDGE] = { "poll_interval": "3", @@ -178,3 +180,4 @@ DEVICE_ID = get("DEVICE_ID", "") DEFAULT_AMS_SLOT = get("DEFAULT_AMS_SLOT", "auto") AUTO_LEVELING = int(get("AUTO_LEVELING","1")) CAMERA_ON_PRINT = int(get("CAMERA_ON_PRINT","0")) +WEB_UPLOAD_WARNING = int(get("WEB_UPLOAD_WARNING", "1")) diff --git a/kobrax_moonraker_bridge.py b/kobrax_moonraker_bridge.py index 8ca71ec..43dd03a 100644 --- a/kobrax_moonraker_bridge.py +++ b/kobrax_moonraker_bridge.py @@ -362,7 +362,8 @@ class GCodeStore: layer_count INTEGER, gcode_filaments TEXT, objects_skip_parts TEXT, - svg_image TEXT + svg_image TEXT, + web_unverified INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS print_jobs ( id TEXT PRIMARY KEY, @@ -389,10 +390,17 @@ class GCodeStore: self._conn.commit() except Exception: pass + # Migration: Flag für Web-Uploads (Warnhinweis vor Druck) + try: + self._conn.execute("ALTER TABLE gcode_files ADD COLUMN web_unverified INTEGER NOT NULL DEFAULT 0") + self._conn.commit() + except Exception: + pass def save_file(self, file_id: str, filename: str, data: bytes, est_time_sec: int = 0, thumbnail_b64: str = "", - gcode_filaments: list | None = None) -> str: + gcode_filaments: list | None = None, + web_unverified: bool = False) -> str: """Speichert GCode-Datei auf Disk und in DB. Gibt Pfad zurück.""" safe_name = os.path.basename(filename) path = os.path.join(self._gcode_dir, safe_name) @@ -403,9 +411,9 @@ class GCodeStore: filaments_json = json.dumps(gcode_filaments) if gcode_filaments else None self._conn.execute( """INSERT OR REPLACE INTO gcode_files - (id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments) - VALUES (?,?,?,?,?,?,?,?)""", - (file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json) + (id, filename, path, size_bytes, uploaded_at, thumbnail_b64, est_print_time_sec, gcode_filaments, web_unverified) + VALUES (?,?,?,?,?,?,?,?,?)""", + (file_id, filename, path, len(data), now, thumbnail_b64 or None, est_time_sec or None, filaments_json, 1 if web_unverified else 0) ) self._conn.commit() return path @@ -453,6 +461,15 @@ class GCodeStore: ) self._conn.commit() + def clear_web_unverified(self, file_id: str) -> bool: + with self._lock: + cur = self._conn.execute( + "UPDATE gcode_files SET web_unverified=0 WHERE id=?", + (file_id,), + ) + self._conn.commit() + return cur.rowcount > 0 + def delete_file(self, file_id: str) -> bool: row = self.get_file(file_id) if not row: @@ -1484,6 +1501,7 @@ class KobraXBridge: for j in reversed(jobs): last_job[j["gcode_file_id"]] = j for f in files: + f["web_unverified"] = bool(f.get("web_unverified")) lj = last_job.get(f["id"]) f["last_print_status"] = lj["status"] if lj else None f["last_print_duration"] = lj["duration_sec"] if lj else None @@ -1496,6 +1514,25 @@ class KobraXBridge: return self._json_cors({"result": "ok"}) return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_file_download(self, request): + file_id = request.match_info["file_id"] + f = self._store.get_file(file_id) + if not f: + return self._json_cors({"error": "not found"}, status=404) + path = f.get("path") or "" + if not path or not os.path.isfile(path): + return self._json_cors({"error": "not found"}, status=404) + filename = os.path.basename(f.get("filename") or path) + return web.FileResponse(path, headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }) + + async def handle_kx_file_verify(self, request): + file_id = request.match_info["file_id"] + if self._store.clear_web_unverified(file_id): + return self._json_cors({"result": "ok"}) + return self._json_cors({"error": "not found"}, status=404) + async def handle_kx_filament_slots(self, request): slots = [] for i, s in enumerate(self._ams_slots): @@ -1811,6 +1848,7 @@ class KobraXBridge: if "multipart" not in ct: return web.json_response({"error": "expected multipart"}, status=400) auto_print = False + web_upload = False reader = await request.multipart() file_data = None remote_filename = self._last_uploaded_file or "upload.gcode" @@ -1827,6 +1865,9 @@ class KobraXBridge: elif part.name == "print": val = (await part.read()).decode("utf-8", errors="replace").strip().lower() auto_print = val == "true" + elif part.name == "web_upload": + val = (await part.read()).decode("utf-8", errors="replace").strip().lower() + web_upload = val == "true" else: log.debug(f"Unbekanntes Multipart-Feld: {part.name}") @@ -1850,6 +1891,7 @@ class KobraXBridge: est_time_sec=est_time, thumbnail_b64=thumbnail_b64, gcode_filaments=gcode_filaments or None, + web_unverified=web_upload, ) serve_path = os.path.join(self._serve_dir_path, os.path.basename(remote_filename)) del file_data # RAM freigeben @@ -2651,6 +2693,7 @@ class KobraXBridge: "camera_url": s["camera_url"], "fan_speed": s["fan_speed"], "print_speed_mode": s["print_speed_mode"], + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), "light_on": s["light_on"], "light_brightness": s["light_brightness"], "ams_slots": self._ams_slots, @@ -2743,7 +2786,8 @@ class KobraXBridge: "default_ams_slot": getattr(self._args, "default_ams_slot", "auto"), "auto_leveling": getattr(self._args, "auto_leveling", 1), "camera_on_print": getattr(self._args, "camera_on_print", 0), - "ace_dry_presets": self._ace_dry_presets, + "web_upload_warning": getattr(self._args, "web_upload_warning", 1), + "ace_dry_presets": self._ace_dry_presets, }) async def handle_api_settings_post(self, request): @@ -2772,6 +2816,7 @@ class KobraXBridge: cfg.set("print", "default_ams_slot", str(data.get("default_ams_slot", getattr(self._args, "default_ams_slot", "auto")))) 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"): cfg.set("bridge", "poll_interval", "3") printer_name = str(data.get("printer_name", "")).strip() @@ -3451,6 +3496,8 @@ def build_app(bridge: KobraXBridge) -> web.Application: r.add_post("/kx/print", bridge.handle_kx_print) r.add_get("/kx/files", bridge.handle_kx_files) 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/filament/slots", bridge.handle_kx_filament_slots) r.add_get("/kx/history", bridge.handle_kx_history) r.add_get("/kx/ui/{name}", bridge.handle_kx_ui_asset) @@ -3617,6 +3664,7 @@ def main(): parser.add_argument("--default-ams-slot",default=env_loader.DEFAULT_AMS_SLOT) parser.add_argument("--auto-leveling", type=int, default=env_loader.AUTO_LEVELING) parser.add_argument("--camera-on-print", type=int, default=env_loader.CAMERA_ON_PRINT) + parser.add_argument("--web-upload-warning", type=int, default=env_loader.WEB_UPLOAD_WARNING) parser.add_argument("--host", default="0.0.0.0", help="Bind-Adresse für den Bridge-Server") diff --git a/web/themes/default/app.js b/web/themes/default/app.js index ec9bd7a..25848f1 100644 --- a/web/themes/default/app.js +++ b/web/themes/default/app.js @@ -3,7 +3,7 @@ var S={nozzle_temp:0,nozzle_target:0,bed_temp:0,bed_target:0, print_state:'standby',filename:'',progress:0,print_duration:0,remain_time:0, curr_layer:0,total_layers:0,printer_name:'Kobra X',firmware_version:'–', camera_url:'',fan_speed:0,print_speed_mode:2,light_on:false,light_brightness:80, - ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]}}; + ams_slots:[],filament_mode:'toolhead',ace_units:[],ace_dry_presets:null,ace_drying:{status:0,target_temp:0,duration:0,remain_time:0,humidity:null,current_temp:null,units:[]},web_upload_warning:1}; var tempHistory={n:[],b:[]}; var camOn=false; var currentStep=1; @@ -113,6 +113,7 @@ var LANG_DE={ settings_save:'Speichern & Neustart',settings_printer_name:'Drucker-Name',settings_printer_ip:'Drucker-IP',settings_mqtt_port:'MQTT-Port', settings_username:'MQTT-Benutzername',settings_password:'MQTT-Passwort',settings_device_id:'Device-ID',settings_mode_id:'Mode-ID',hint_ip_no_port:'Nur IP-Adresse, kein Port (z.B. 192.168.1.102)', settings_default_slot:'Standard-Slot (Einfarbdruck)',settings_slot_auto:'Auto (alle belegten Slots)',settings_auto_leveling:'Auto-Leveling vor Druck',settings_camera_on_print:'Kamera bei Druckstart einschalten', + settings_web_upload_warning:'Warnung bei Web-Upload-Druck anzeigen', update_check:'Auf Updates prüfen',update_checking:'Prüfe...',update_available:'verfügbar',update_none:'Bereits aktuell', update_apply:'Jetzt installieren',update_applying:'Lade herunter...',update_restarting:'Starte neu...',update_error:'Fehler', btn_connect:'⚡ Verbinden',btn_disconnect:'✕ Trennen', @@ -145,8 +146,13 @@ var LANG_DE={ store_empty:'Noch keine Dateien hochgeladen.', store_refresh:'↻ Aktualisieren', store_print:'▶ Drucken', + store_download:'⬇ Download', store_delete_confirm:'Datei löschen?', store_print_confirm:'Datei drucken?', + store_web_verify_title:'Datei verifizieren', + store_web_verify_msg:'Please verify this file was made for Anycubic Kobra X.', + store_web_verify_confirm:'Confirm', + store_web_verify_abort:'Abort', store_no_results:'Keine Dateien gefunden.', store_never:'noch nicht gedruckt', sf_all:'Alle',sf_ok:'✓ Erfolgreich',sf_err:'✗ Fehler',sf_new:'Neu', @@ -176,6 +182,7 @@ var LANG_EN={ settings_save:'Save & Restart',settings_printer_name:'Printer Name',settings_printer_ip:'Printer IP',settings_mqtt_port:'MQTT Port', settings_username:'MQTT Username',settings_password:'MQTT Password',settings_device_id:'Device ID',settings_mode_id:'Mode ID',hint_ip_no_port:'IP address only, no port (e.g. 192.168.1.102)', settings_default_slot:'Default Slot (single color)',settings_slot_auto:'Auto (all loaded slots)',settings_auto_leveling:'Auto-Leveling before print',settings_camera_on_print:'Turn camera on at print start', + settings_web_upload_warning:'Show warning when printing web uploads', update_check:'Check for Updates',update_checking:'Checking...',update_available:'available',update_none:'Already up to date', update_apply:'Install Now',update_applying:'Downloading...',update_restarting:'Restarting...',update_error:'Error', btn_connect:'⚡ Connect',btn_disconnect:'✕ Disconnect', @@ -208,8 +215,13 @@ var LANG_EN={ store_empty:'No files uploaded yet.', store_refresh:'↻ Refresh', store_print:'▶ Print', + store_download:'⬇ Download', store_delete_confirm:'Delete file?', store_print_confirm:'Print file?', + store_web_verify_title:'Verify file', + store_web_verify_msg:'Please verify this file was made for Anycubic Kobra X.', + store_web_verify_confirm:'Confirm', + store_web_verify_abort:'Abort', store_no_results:'No files found.', store_never:'never printed', sf_all:'All',sf_ok:'✓ Completed',sf_err:'✗ Failed',sf_new:'New', @@ -311,6 +323,10 @@ function applyLang(){ setText('store-empty',T.store_empty); setText('sf-all',T.sf_all);setText('sf-ok',T.sf_ok);setText('sf-err',T.sf_err);setText('sf-new',T.sf_new); setText('ss-date',T.ss_date);setText('ss-name',T.ss_name);setText('ss-dur',T.ss_dur); + setText('store-web-verify-title',T.store_web_verify_title); + setText('store-web-verify-msg',T.store_web_verify_msg); + setText('store-web-verify-confirm',T.store_web_verify_confirm); + setText('store-web-verify-abort',T.store_web_verify_abort); // Dashboard card titles setText('d-card-progress',T.card_progress); setText('d-card-temps',T.card_temps); @@ -363,6 +379,7 @@ function applyLang(){ setText('opt-slot-auto',T.settings_slot_auto); setText('lbl-auto-leveling',T.settings_auto_leveling); setText('lbl-camera-on-print',T.settings_camera_on_print); + setText('lbl-web-upload-warning',T.settings_web_upload_warning); setText('lbl-update-check',T.update_check); setText('lbl-update-apply',T.update_apply); @@ -910,6 +927,7 @@ function openSettings(){ document.getElementById('s-default-slot').value=d.default_ams_slot||'auto'; 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); }); var v=localStorage.getItem('pollInterval')||'2000'; document.querySelectorAll('.poll-btn').forEach(function(b){b.classList.remove('active')}); @@ -978,6 +996,11 @@ function slotEditFeed(){ .catch(function(){}); } function startReadyFile(){ + var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;}); + if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ + maybeGateWebUpload(currentFile, function(){ startReadyFile(); }); + return; + } var btn=document.getElementById('file-ready-btn'); if(btn){btn.disabled=true;btn.textContent='…';} post('/printer/print/start',{filename:S.file_ready}) @@ -1040,6 +1063,8 @@ function setPoll(ms){ function saveSettings(){ var btn=document.getElementById('btn-save-settings'); btn.disabled=true;btn.textContent='…'; + var webUploadWarning=(document.getElementById('s-web-upload-warning')||{}).checked?1:0; + S.web_upload_warning=webUploadWarning; post('/api/settings',{ printer_name: document.getElementById('s-printer-name').value, printer_ip: document.getElementById('s-printer-ip').value, @@ -1051,6 +1076,7 @@ function saveSettings(){ default_ams_slot: document.getElementById('s-default-slot').value, 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, }).then(function(){ btn.textContent=T.update_restarting; setTimeout(function(){ @@ -1536,6 +1562,39 @@ function loadStore(){ }).catch(function(e){clog('Store-Fehler: '+e,'msg-err')}); } +function uploadGcode(file){ + if(!file) return; + var zone=document.getElementById('store-upload-zone'); + var status=document.getElementById('store-upload-status'); + var label=document.getElementById('store-upload-label'); + if(status) { status.textContent='⏳ Hochladen…'; status.style.display=''; status.className='upload-status-busy'; } + if(label) label.style.display='none'; + if(zone) zone.style.pointerEvents='none'; + var fd=new FormData(); + fd.append('file', file); + fd.append('web_upload', 'true'); + fetch(_apiUrl('/api/files/local'),{method:'POST',body:fd}) + .then(function(r){ + if(!r.ok) return r.text().then(function(t){throw new Error(r.status+': '+t);}); + return r.json(); + }) + .then(function(){ + if(status){ status.textContent='✓ '+file.name; status.className='upload-status-ok'; } + loadStore(); + setTimeout(function(){ + if(status){status.style.display='none'; status.className='';} + if(label) label.style.display=''; + if(zone) zone.style.pointerEvents=''; + }, 3000); + }) + .catch(function(e){ + if(status){ status.textContent='✗ '+e.message; status.className='upload-status-err'; } + if(label) label.style.display=''; + if(zone) zone.style.pointerEvents=''; + clog('Upload-Fehler: '+e,'msg-err'); + }); +} + function renderStore(){ var grid=document.getElementById('store-grid'); var empty=document.getElementById('store-empty'); @@ -1606,6 +1665,8 @@ function renderStore(){ '
'+ ''+ + ''+ ''+ '
'+ @@ -1621,6 +1682,9 @@ function formatDur(sec){ var _storeFileId=null; var _storeFilename=null; var _filamentDialogMode='store'; // 'store' oder 'banner' +var _pendingWebVerifyFileId=null; +var _pendingWebVerifyFilename=''; +var _pendingWebVerifyAction=null; // GCode-Store-Dateiliste. MUSS deklariert sein – sonst ReferenceError, wenn // "Slots wählen" im Banner geklickt wird, bevor der Browser-Tab je geladen // wurde (Issue #29 / Theme-Auslagerung PR #27). @@ -1646,15 +1710,120 @@ function storePrint(fileId, filename){ _storeFileId=fileId; _storeFilename=filename; _filamentDialogMode='store'; - // GCode-Filamente aus Store-Datei holen (für Vorschau im Dialog) var fileObj=storeFiles.find(function(f){return f.id===fileId;}); - _setGcodeFilamentsFromFileObj(fileObj); - fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ - openFilamentDialog(d.result||[]); - }).catch(function(){openFilamentDialog([]);}); + openStorePrintDialog(fileId, filename, fileObj); +} + +function openStorePrintDialog(fileId, filename, fileObj){ + _storeFileId=fileId; + _storeFilename=filename; + _filamentDialogMode='store'; + maybeGateWebUpload(fileObj, function(){ + // GCode-Filamente aus Store-Datei holen (für Vorschau im Dialog) + _setGcodeFilamentsFromFileObj(fileObj); + fetch(_apiUrl('/kx/filament/slots')).then(function(r){return r.json()}).then(function(d){ + openFilamentDialog(d.result||[]); + }).catch(function(){openFilamentDialog([]);}); + }); +} + +function webUploadWarningEnabled(){ + return S.web_upload_warning===undefined ? true : !!S.web_upload_warning; +} + +function clearWebUploadWarningFlag(fileId, onDone){ + if(!fileId){ + if(onDone) onDone(); + return; + } + fetch(_apiUrl('/kx/files/'+encodeURIComponent(fileId)+'/verify'), {method:'POST'}) + .then(function(r){ + if(!r.ok) return r.text().then(function(t){throw new Error(r.status+': '+t);}); + return r.json(); + }) + .then(function(){ + var fileObj=(storeFiles||[]).find(function(f){return f.id===fileId;}); + if(fileObj){fileObj.web_unverified=false;} + if(onDone) onDone(); + loadStore(); + }) + .catch(function(e){ + clog('Verifizierungs-Fehler: '+e,'msg-err'); + }); +} + +function maybeGateWebUpload(fileObj, onContinue){ + if(!fileObj || !fileObj.web_unverified){ + if(onContinue) onContinue(); + return; + } + if(!webUploadWarningEnabled()){ + if(onContinue) onContinue(); + return; + } + openWebVerifyDialog(fileObj.id, fileObj.filename, function(){ + clearWebUploadWarningFlag(fileObj.id, onContinue); + }); +} + +function openWebVerifyDialog(fileId, filename, onConfirm){ + _pendingWebVerifyFileId=fileId; + _pendingWebVerifyFilename=filename; + _pendingWebVerifyAction=onConfirm||null; + var status=document.getElementById('store-web-verify-status'); + if(status){status.textContent='';} + openStoreWebVerifyDialog(); +} + +function openStoreWebVerifyDialog(){ + var modal=document.getElementById('store-web-verify-dialog'); + if(modal){modal.classList.add('open');} +} + +function closeStoreWebVerifyDialog(){ + var modal=document.getElementById('store-web-verify-dialog'); + if(modal){modal.classList.remove('open');} + _pendingWebVerifyFileId=null; + _pendingWebVerifyFilename=''; + _pendingWebVerifyAction=null; +} + +function confirmStoreWebVerify(){ + if(!_pendingWebVerifyFileId||!_pendingWebVerifyFilename){ + closeStoreWebVerifyDialog(); + return; + } + var fileId=_pendingWebVerifyFileId; + var action=_pendingWebVerifyAction; + var status=document.getElementById('store-web-verify-status'); + if(status){status.textContent='…';} + fetch(_apiUrl('/kx/files/'+encodeURIComponent(fileId)+'/verify'), {method:'POST'}) + .then(function(r){ + if(!r.ok) return r.text().then(function(t){throw new Error(r.status+': '+t);}); + return r.json(); + }) + .then(function(){ + var fileObj=(storeFiles||[]).find(function(f){return f.id===fileId;}); + if(fileObj){fileObj.web_unverified=false;} + _pendingWebVerifyFileId=null; + _pendingWebVerifyFilename=''; + _pendingWebVerifyAction=null; + closeStoreWebVerifyDialog(); + loadStore(); + if(typeof action==='function') action(); + }) + .catch(function(e){ + if(status){status.textContent='✗ '+e.message;} + clog('Verifizierungs-Fehler: '+e,'msg-err'); + }); } function startReadyFileWithSlots(){ + var currentFile=(storeFiles||[]).find(function(f){return f.filename===S.file_ready;}); + if(currentFile && currentFile.web_unverified && webUploadWarningEnabled()){ + maybeGateWebUpload(currentFile, function(){ startReadyFileWithSlots(); }); + return; + } _filamentDialogMode='banner'; _storeFilename=S.file_ready||''; // Banner must never reuse stale store-file context. @@ -2074,6 +2243,15 @@ function storeDelete(fileId){ }); } +function storeDownload(fileId){ + var a=document.createElement('a'); + a.href=_apiUrl('/kx/files/'+encodeURIComponent(fileId)+'/download'); + a.style.display='none'; + document.body.appendChild(a); + a.click(); + a.remove(); +} + // ── Drucker hinzufügen ── function openAddPrinterDialog(){ document.getElementById('apd-ip').value=''; diff --git a/web/themes/default/index.html b/web/themes/default/index.html index 84b33f0..88ec038 100644 --- a/web/themes/default/index.html +++ b/web/themes/default/index.html @@ -98,6 +98,10 @@ +
@@ -400,6 +404,16 @@
+
+ + + GCode hierher ziehen oder durchsuchen + +
@@ -451,6 +465,22 @@ + + +