From 304157133c03cf3d01d8b456357085cb3d54e354 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sat, 31 Jan 2026 19:09:56 +0100 Subject: [PATCH 01/15] Bumped version --- CHANGELOG.md | 2 ++ backend/app/core/config.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc9ae3eea..13251aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ All notable changes to Bambuddy will be documented in this file. +## [0.1.7b] - Not released + ## [0.1.6-final] - 2026-01-31 ### New Features diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 473bb521f..adc037119 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -5,7 +5,7 @@ from pathlib import Path from pydantic_settings import BaseSettings # Application version - single source of truth -APP_VERSION = "0.1.6" +APP_VERSION = "0.1.7b" GITHUB_REPO = "maziggy/bambuddy" # App directory - where the application is installed (for static files) From 001e328ce84db53333138c6a93df234ed2c59b25 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 08:15:54 +0100 Subject: [PATCH 02/15] Add configurable FPS and status-only mode to streaming overlay - Add ?fps=N parameter to control camera frame rate (1-30, default 15) - Add ?camera=false parameter for status-only overlay without camera feed - Increase default camera FPS from 10 to 15 across all camera views - Add comprehensive tests for new overlay parameters Resolves user request on Issue #164 for higher FPS and status-only option. --- CHANGELOG.md | 6 + README.md | 2 +- .../pages/StreamOverlayPage.test.tsx | 133 ++++++++++++++++++ .../src/components/EmbeddedCameraViewer.tsx | 2 +- frontend/src/pages/CameraPage.tsx | 2 +- frontend/src/pages/StreamOverlayPage.tsx | 32 +++-- .../{index-DAZbTvYK.js => index-CxQsg2-C.js} | 4 +- static/index.html | 2 +- 8 files changed, 168 insertions(+), 15 deletions(-) rename static/assets/{index-DAZbTvYK.js => index-CxQsg2-C.js} (99%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 13251aca8..20756190c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released +### Enhancements +- **Streaming Overlay Improvements** (Issue #164): + - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15) + - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background + - Increased default camera FPS from 10 to 15 for smoother video across all camera views + ## [0.1.6-final] - 2026-01-31 ### New Features diff --git a/README.md b/README.md index 2e5c1a24c..f5872d234 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ ### 📊 Monitoring & Control - Real-time printer status via WebSocket - Live camera streaming (MJPEG) & snapshots with multi-viewer support -- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`) +- **Streaming overlay for OBS** - Embeddable page with camera + status for live streaming (`/overlay/:printerId`), configurable FPS (`?fps=30`), status-only mode (`?camera=false`) - External camera support (MJPEG, RTSP, HTTP snapshot, USB/V4L2) with layer-based timelapse - **Build plate empty detection** - Auto-pause print if objects detected on plate (multi-reference calibration, ROI adjustment) - Fan status monitoring (part cooling, auxiliary, chamber) diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index 405fb1c82..2babedab5 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -207,6 +207,139 @@ describe('StreamOverlayPage', () => { }); }); + describe('FPS configuration', () => { + it('uses default FPS of 15 when not specified', async () => { + renderOverlayPage(1); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=15'); + }); + }); + + it('uses custom FPS when specified in query params', async () => { + renderOverlayPage(1, '?fps=30'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=30'); + }); + }); + + it('clamps FPS to maximum of 30', async () => { + renderOverlayPage(1, '?fps=60'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=30'); + }); + }); + + it('clamps FPS to minimum of 1', async () => { + renderOverlayPage(1, '?fps=0'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=1'); + }); + }); + + it('handles invalid FPS value gracefully', async () => { + renderOverlayPage(1, '?fps=invalid'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + // Should fall back to default of 15 + expect(img.src).toContain('fps=15'); + }); + }); + }); + + describe('camera toggle (status-only mode)', () => { + it('shows camera by default', async () => { + renderOverlayPage(1); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + + it('hides camera when camera=false', async () => { + renderOverlayPage(1, '?camera=false'); + + await waitFor(() => { + // Status should still be visible + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + // Camera should not be rendered + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('hides camera when camera=0', async () => { + renderOverlayPage(1, '?camera=0'); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('shows camera when camera=true', async () => { + renderOverlayPage(1, '?camera=true'); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + + it('shows camera when camera=1', async () => { + renderOverlayPage(1, '?camera=1'); + + await waitFor(() => { + expect(screen.getByAltText('Camera stream')).toBeInTheDocument(); + }); + }); + }); + + describe('combined parameters', () => { + it('supports fps and camera together', async () => { + renderOverlayPage(1, '?fps=25&camera=true'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=25'); + }); + }); + + it('supports status-only with custom size', async () => { + renderOverlayPage(1, '?camera=false&size=large'); + + await waitFor(() => { + expect(screen.getByText('Printer is idle')).toBeInTheDocument(); + }); + + expect(screen.queryByAltText('Camera stream')).not.toBeInTheDocument(); + }); + + it('supports show parameter with fps', async () => { + server.use( + http.get('/api/v1/printers/:id/status', () => { + return HttpResponse.json(mockStatusPrinting); + }) + ); + + renderOverlayPage(1, '?fps=20&show=progress'); + + await waitFor(() => { + const img = screen.getByAltText('Camera stream') as HTMLImageElement; + expect(img.src).toContain('fps=20'); + expect(screen.getByText('45%')).toBeInTheDocument(); + }); + }); + }); + describe('offline state', () => { beforeEach(() => { server.use( diff --git a/frontend/src/components/EmbeddedCameraViewer.tsx b/frontend/src/components/EmbeddedCameraViewer.tsx index c50ba2049..d9715df9d 100644 --- a/frontend/src/components/EmbeddedCameraViewer.tsx +++ b/frontend/src/components/EmbeddedCameraViewer.tsx @@ -461,7 +461,7 @@ export function EmbeddedCameraViewer({ printerId, printerName, viewerIndex = 0, } }, [isDragging, isResizing, dragOffset]); - const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=10&t=${imageKey}`; + const streamUrl = `/api/v1/printers/${printerId}/camera/stream?fps=15&t=${imageKey}`; return (
- {/* Camera feed - fullscreen background */} - Camera stream + {/* Camera feed - fullscreen background (optional) */} + {config.showCamera && ( + Camera stream + )} {/* Bambuddy logo - top right */} >>=0,h===0?32:31-($e(h)/ut|0)|0}var nt=256,ie=262144,he=4194304;function Ae(h){var g=h&42;if(g!==0)return g;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function be(h,g,_){var M=h.pendingLanes;if(M===0)return 0;var P=0,I=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var ve=M&134217727;return ve!==0?(M=ve&~I,M!==0?P=Ae(M):(J&=ve,J!==0?P=Ae(J):_||(_=ve&~h,_!==0&&(P=Ae(_))))):(ve=M&~I,ve!==0?P=Ae(ve):J!==0?P=Ae(J):_||(_=M&~h,_!==0&&(P=Ae(_)))),P===0?0:g!==0&&g!==P&&(g&I)===0&&(I=P&-P,_=g&-g,I>=_||I===32&&(_&4194048)!==0)?g:P}function Ve(h,g){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&g)===0}function pt(h,g){switch(h){case 1:case 2:case 4:case 8:case 64:return g+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return g+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ct(){var h=he;return he<<=1,(he&62914560)===0&&(he=4194304),h}function jt(h){for(var g=[],_=0;31>_;_++)g.push(h);return g}function Qt(h,g){h.pendingLanes|=g,g!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Ft(h,g,_,M,P,I){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var ve=h.entanglements,Ue=h.expirationTimes,rt=h.hiddenUpdates;for(_=J&~_;0<_;){var mt=31-Ne(_),bt=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Et=/[\n"\\]/g;function $t(h){return h.replace(Et,function(g){return"\\"+g.charCodeAt(0).toString(16)+" "})}function xt(h,g,_,M,P,I,J,ve){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),g!=null?J==="number"?(g===0&&h.value===""||h.value!=g)&&(h.value=""+sn(g)):h.value!==""+sn(g)&&(h.value=""+sn(g)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),g!=null?Dn(h,J,sn(g)):_!=null?Dn(h,J,sn(_)):M!=null&&h.removeAttribute("value"),P==null&&I!=null&&(h.defaultChecked=!!I),P!=null&&(h.checked=P&&typeof P!="function"&&typeof P!="symbol"),ve!=null&&typeof ve!="function"&&typeof ve!="symbol"&&typeof ve!="boolean"?h.name=""+sn(ve):h.removeAttribute("name")}function Ut(h,g,_,M,P,I,J,ve){if(I!=null&&typeof I!="function"&&typeof I!="symbol"&&typeof I!="boolean"&&(h.type=I),g!=null||_!=null){if(!(I!=="submit"&&I!=="reset"||g!=null)){un(h);return}_=_!=null?""+sn(_):"",g=g!=null?""+sn(g):_,ve||g===h.value||(h.value=g),h.defaultValue=g}M=M??P,M=typeof M!="function"&&typeof M!="symbol"&&!!M,h.checked=ve?h.checked:!!M,h.defaultChecked=!!M,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),un(h)}function Dn(h,g,_){g==="number"&&Qe(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function Sn(h,g,_,M){if(h=h.options,g){g={};for(var P=0;P<_.length;P++)g["$"+_[P]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Uh=!1;if(sl)try{var qu={};Object.defineProperty(qu,"passive",{get:function(){Uh=!0}}),window.addEventListener("test",qu,qu),window.removeEventListener("test",qu,qu)}catch{Uh=!1}var go=null,Rl=null,Wu=null;function Rm(){if(Wu)return Wu;var h,g=Rl,_=g.length,M,P="value"in go?go.value:go.textContent,I=P.length;for(h=0;h<_&&g[h]===P[h];h++);var J=_-h;for(M=1;M<=J&&g[_-M]===P[I-M];M++);return Wu=P.slice(h,1=Lx),d5=" ",h5=!1;function f5(h,g){switch(h){case"keyup":return Jre.indexOf(g.keyCode)!==-1;case"keydown":return g.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function m5(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Om=!1;function tie(h,g){switch(h){case"compositionend":return m5(g);case"keypress":return g.which!==32?null:(h5=!0,d5);case"textInput":return h=g.data,h===d5&&h5?null:h;default:return null}}function nie(h,g){if(Om)return h==="compositionend"||!HM&&f5(h,g)?(h=Rm(),Wu=Rl=go=null,Om=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(g.ctrlKey||g.altKey||g.metaKey)||g.ctrlKey&&g.altKey){if(g.char&&1=g)return{node:_,offset:g-h};h=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=w5(_)}}function N5(h,g){return h&&g?h===g?!0:h&&h.nodeType===3?!1:g&&g.nodeType===3?N5(h,g.parentNode):"contains"in h?h.contains(g):h.compareDocumentPosition?!!(h.compareDocumentPosition(g)&16):!1:!1}function M5(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var g=Qe(h.document);g instanceof h.HTMLIFrameElement;){try{var _=typeof g.contentWindow.location.href=="string"}catch{_=!1}if(_)h=g.contentWindow;else break;g=Qe(h.document)}return g}function qM(h){var g=h&&h.nodeName&&h.nodeName.toLowerCase();return g&&(g==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||g==="textarea"||h.contentEditable==="true")}var uie=sl&&"documentMode"in document&&11>=document.documentMode,Lm=null,WM=null,zx=null,KM=!1;function E5(h,g,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;KM||Lm==null||Lm!==Qe(M)||(M=Lm,"selectionStart"in M&&qM(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),zx&&Ux(zx,M)||(zx=M,M=c_(WM,"onSelect"),0>=J,P-=J,Il=1<<32-Ne(g)+P|_<Mn?(Hn=Kt,Kt=null):Hn=Kt.sibling;var rr=ot(Ye,Kt,tt[Mn],gt);if(rr===null){Kt===null&&(Kt=Hn);break}h&&Kt&&rr.alternate===null&&g(Ye,Kt),qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr,Kt=Hn}if(Mn===tt.length)return _(Ye,Kt),Gn&&Mc(Ye,Mn),Jt;if(Kt===null){for(;MnMn?(Hn=Kt,Kt=null):Hn=Kt.sibling;var gd=ot(Ye,Kt,rr.value,gt);if(gd===null){Kt===null&&(Kt=Hn);break}h&&Kt&&gd.alternate===null&&g(Ye,Kt),qe=I(gd,qe,Mn),nr===null?Jt=gd:nr.sibling=gd,nr=gd,Kt=Hn}if(rr.done)return _(Ye,Kt),Gn&&Mc(Ye,Mn),Jt;if(Kt===null){for(;!rr.done;Mn++,rr=tt.next())rr=bt(Ye,rr.value,gt),rr!==null&&(qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr);return Gn&&Mc(Ye,Mn),Jt}for(Kt=M(Kt);!rr.done;Mn++,rr=tt.next())rr=ft(Kt,Ye,Mn,rr.value,gt),rr!==null&&(h&&rr.alternate!==null&&Kt.delete(rr.key===null?Mn:rr.key),qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr);return h&&Kt.forEach(function(kae){return g(Ye,kae)}),Gn&&Mc(Ye,Mn),Jt}function vr(Ye,qe,tt,gt){if(typeof tt=="object"&&tt!==null&&tt.type===b&&tt.key===null&&(tt=tt.props.children),typeof tt=="object"&&tt!==null){switch(tt.$$typeof){case p:e:{for(var Jt=tt.key;qe!==null;){if(qe.key===Jt){if(Jt=tt.type,Jt===b){if(qe.tag===7){_(Ye,qe.sibling),gt=P(qe,tt.props.children),gt.return=Ye,Ye=gt;break e}}else if(qe.elementType===Jt||typeof Jt=="object"&&Jt!==null&&Jt.$$typeof===L&&Zh(Jt)===qe.type){_(Ye,qe.sibling),gt=P(qe,tt.props),qx(gt,tt),gt.return=Ye,Ye=gt;break e}_(Ye,qe);break}else g(Ye,qe);qe=qe.sibling}tt.type===b?(gt=Wh(tt.props.children,Ye.mode,gt,tt.key),gt.return=Ye,Ye=gt):(gt=Ev(tt.type,tt.key,tt.props,null,Ye.mode,gt),qx(gt,tt),gt.return=Ye,Ye=gt)}return J(Ye);case x:e:{for(Jt=tt.key;qe!==null;){if(qe.key===Jt)if(qe.tag===4&&qe.stateNode.containerInfo===tt.containerInfo&&qe.stateNode.implementation===tt.implementation){_(Ye,qe.sibling),gt=P(qe,tt.children||[]),gt.return=Ye,Ye=gt;break e}else{_(Ye,qe);break}else g(Ye,qe);qe=qe.sibling}gt=tE(tt,Ye.mode,gt),gt.return=Ye,Ye=gt}return J(Ye);case L:return tt=Zh(tt),vr(Ye,qe,tt,gt)}if(G(tt))return Gt(Ye,qe,tt,gt);if(D(tt)){if(Jt=D(tt),typeof Jt!="function")throw Error(r(150));return tt=Jt.call(tt),an(Ye,qe,tt,gt)}if(typeof tt.then=="function")return vr(Ye,qe,Rv(tt),gt);if(tt.$$typeof===N)return vr(Ye,qe,Av(Ye,tt),gt);Dv(Ye,tt)}return typeof tt=="string"&&tt!==""||typeof tt=="number"||typeof tt=="bigint"?(tt=""+tt,qe!==null&&qe.tag===6?(_(Ye,qe.sibling),gt=P(qe,tt),gt.return=Ye,Ye=gt):(_(Ye,qe),gt=eE(tt,Ye.mode,gt),gt.return=Ye,Ye=gt),J(Ye)):_(Ye,qe)}return function(Ye,qe,tt,gt){try{Gx=0;var Jt=vr(Ye,qe,tt,gt);return Wm=null,Jt}catch(Kt){if(Kt===qm||Kt===jv)throw Kt;var nr=$s(29,Kt,null,Ye.mode);return nr.lanes=gt,nr.return=Ye,nr}finally{}}}var ef=X5(!0),Y5=X5(!1),Ju=!1;function fE(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function mE(h,g){h=h.updateQueue,g.updateQueue===h&&(g.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function ed(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function td(h,g,_){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(or&2)!==0){var P=M.pending;return P===null?g.next=g:(g.next=P.next,P.next=g),M.pending=g,g=Mv(h),R5(h,null,_),g}return Nv(h,M,g,_),Mv(h)}function Wx(h,g,_){if(g=g.updateQueue,g!==null&&(g=g.shared,(_&4194048)!==0)){var M=g.lanes;M&=h.pendingLanes,_|=M,g.lanes=_,qt(h,_)}}function pE(h,g){var _=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var P=null,I=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};I===null?P=I=J:I=I.next=J,_=_.next}while(_!==null);I===null?P=I=g:I=I.next=g}else P=I=g;_={baseState:M.baseState,firstBaseUpdate:P,lastBaseUpdate:I,shared:M.shared,callbacks:M.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=g:h.next=g,_.lastBaseUpdate=g}var gE=!1;function Kx(){if(gE){var h=Gm;if(h!==null)throw h}}function Xx(h,g,_,M){gE=!1;var P=h.updateQueue;Ju=!1;var I=P.firstBaseUpdate,J=P.lastBaseUpdate,ve=P.shared.pending;if(ve!==null){P.shared.pending=null;var Ue=ve,rt=Ue.next;Ue.next=null,J===null?I=rt:J.next=rt,J=Ue;var mt=h.alternate;mt!==null&&(mt=mt.updateQueue,ve=mt.lastBaseUpdate,ve!==J&&(ve===null?mt.firstBaseUpdate=rt:ve.next=rt,mt.lastBaseUpdate=Ue))}if(I!==null){var bt=P.baseState;J=0,mt=rt=Ue=null,ve=I;do{var ot=ve.lane&-536870913,ft=ot!==ve.lane;if(ft?($n&ot)===ot:(M&ot)===ot){ot!==0&&ot===Vm&&(gE=!0),mt!==null&&(mt=mt.next={lane:0,tag:ve.tag,payload:ve.payload,callback:null,next:null});e:{var Gt=h,an=ve;ot=g;var vr=_;switch(an.tag){case 1:if(Gt=an.payload,typeof Gt=="function"){bt=Gt.call(vr,bt,ot);break e}bt=Gt;break e;case 3:Gt.flags=Gt.flags&-65537|128;case 0:if(Gt=an.payload,ot=typeof Gt=="function"?Gt.call(vr,bt,ot):Gt,ot==null)break e;bt=f({},bt,ot);break e;case 2:Ju=!0}}ot=ve.callback,ot!==null&&(h.flags|=64,ft&&(h.flags|=8192),ft=P.callbacks,ft===null?P.callbacks=[ot]:ft.push(ot))}else ft={lane:ot,tag:ve.tag,payload:ve.payload,callback:ve.callback,next:null},mt===null?(rt=mt=ft,Ue=bt):mt=mt.next=ft,J|=ot;if(ve=ve.next,ve===null){if(ve=P.shared.pending,ve===null)break;ft=ve,ve=ft.next,ft.next=null,P.lastBaseUpdate=ft,P.shared.pending=null}}while(!0);mt===null&&(Ue=bt),P.baseState=Ue,P.firstBaseUpdate=rt,P.lastBaseUpdate=mt,I===null&&(P.shared.lanes=0),sd|=J,h.lanes=J,h.memoizedState=bt}}function Q5(h,g){if(typeof h!="function")throw Error(r(191,h));h.call(g)}function Z5(h,g){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)Q5(_[h],g)}var Km=U(null),Ov=U(0);function J5(h,g){h=Lc,le(Ov,h),le(Km,g),Lc=h|g.baseLanes}function xE(){le(Ov,Lc),le(Km,Km.current)}function bE(){Lc=Ov.current,W(Km),W(Ov)}var Hs=U(null),So=null;function nd(h){var g=h.alternate;le(gi,gi.current&1),le(Hs,h),So===null&&(g===null||Km.current!==null||g.memoizedState!==null)&&(So=h)}function yE(h){le(gi,gi.current),le(Hs,h),So===null&&(So=h)}function eI(h){h.tag===22?(le(gi,gi.current),le(Hs,h),So===null&&(So=h)):rd()}function rd(){le(gi,gi.current),le(Hs,Hs.current)}function Vs(h){W(Hs),So===h&&(So=null),W(gi)}var gi=U(0);function Lv(h){for(var g=h;g!==null;){if(g.tag===13){var _=g.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||MC(_)||EC(_)))return g}else if(g.tag===19&&(g.memoizedProps.revealOrder==="forwards"||g.memoizedProps.revealOrder==="backwards"||g.memoizedProps.revealOrder==="unstable_legacy-backwards"||g.memoizedProps.revealOrder==="together")){if((g.flags&128)!==0)return g}else if(g.child!==null){g.child.return=g,g=g.child;continue}if(g===h)break;for(;g.sibling===null;){if(g.return===null||g.return===h)return null;g=g.return}g.sibling.return=g.return,g=g.sibling}return null}var Tc=0,wn=null,br=null,Mi=null,Iv=!1,Xm=!1,tf=!1,Fv=0,Yx=0,Ym=null,vie=0;function si(){throw Error(r(321))}function vE(h,g){if(g===null)return!1;for(var _=0;_I?I:8;var J=q.T,ve={};q.T=ve,OE(h,!1,g,_);try{var Ue=P(),rt=q.S;if(rt!==null&&rt(ve,Ue),Ue!==null&&typeof Ue=="object"&&typeof Ue.then=="function"){var mt=yie(Ue,M);Zx(h,g,mt,Ws(h))}else Zx(h,g,M,Ws(h))}catch(bt){Zx(h,g,{then:function(){},status:"rejected",reason:bt},Ws())}finally{K.p=I,J!==null&&ve.types!==null&&(J.types=ve.types),q.T=J}}function Mie(){}function RE(h,g,_,M){if(h.tag!==5)throw Error(r(476));var P=kI(h).queue;AI(h,P,g,Q,_===null?Mie:function(){return jI(h),_(M)})}function kI(h){var g=h.memoizedState;if(g!==null)return g;g={memoizedState:Q,baseState:Q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ac,lastRenderedState:Q},next:null};var _={};return g.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ac,lastRenderedState:_},next:null},h.memoizedState=g,h=h.alternate,h!==null&&(h.memoizedState=g),g}function jI(h){var g=kI(h);g.next===null&&(g=h.alternate.memoizedState),Zx(h,g.next.queue,{},Ws())}function DE(){return ba(pb)}function PI(){return xi().memoizedState}function RI(){return xi().memoizedState}function Eie(h){for(var g=h.return;g!==null;){switch(g.tag){case 24:case 3:var _=Ws();h=ed(_);var M=td(g,h,_);M!==null&&(ws(M,g,_),Wx(M,g,_)),g={cache:cE()},h.payload=g;return}g=g.return}}function Cie(h,g,_){var M=Ws();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},Vv(h)?OI(g,_):(_=ZM(h,g,_,M),_!==null&&(ws(_,h,M),LI(_,g,M)))}function DI(h,g,_){var M=Ws();Zx(h,g,_,M)}function Zx(h,g,_,M){var P={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(Vv(h))OI(g,P);else{var I=h.alternate;if(h.lanes===0&&(I===null||I.lanes===0)&&(I=g.lastRenderedReducer,I!==null))try{var J=g.lastRenderedState,ve=I(J,_);if(P.hasEagerState=!0,P.eagerState=ve,Bs(ve,J))return Nv(h,g,P,0),Pr===null&&Sv(),!1}catch{}finally{}if(_=ZM(h,g,P,M),_!==null)return ws(_,h,M),LI(_,g,M),!0}return!1}function OE(h,g,_,M){if(M={lane:2,revertLane:fC(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},Vv(h)){if(g)throw Error(r(479))}else g=ZM(h,_,M,2),g!==null&&ws(g,h,2)}function Vv(h){var g=h.alternate;return h===wn||g!==null&&g===wn}function OI(h,g){Xm=Iv=!0;var _=h.pending;_===null?g.next=g:(g.next=_.next,_.next=g),h.pending=g}function LI(h,g,_){if((_&4194048)!==0){var M=g.lanes;M&=h.pendingLanes,_|=M,g.lanes=_,qt(h,_)}}var Jx={readContext:ba,use:zv,useCallback:si,useContext:si,useEffect:si,useImperativeHandle:si,useLayoutEffect:si,useInsertionEffect:si,useMemo:si,useReducer:si,useRef:si,useState:si,useDebugValue:si,useDeferredValue:si,useTransition:si,useSyncExternalStore:si,useId:si,useHostTransitionStatus:si,useFormState:si,useActionState:si,useOptimistic:si,useMemoCache:si,useCacheRefresh:si};Jx.useEffectEvent=si;var II={readContext:ba,use:zv,useCallback:function(h,g){return Za().memoizedState=[h,g===void 0?null:g],h},useContext:ba,useEffect:vI,useImperativeHandle:function(h,g,_){_=_!=null?_.concat([h]):null,$v(4194308,4,NI.bind(null,g,h),_)},useLayoutEffect:function(h,g){return $v(4194308,4,h,g)},useInsertionEffect:function(h,g){$v(4,2,h,g)},useMemo:function(h,g){var _=Za();g=g===void 0?null:g;var M=h();if(tf){Ge(!0);try{h()}finally{Ge(!1)}}return _.memoizedState=[M,g],M},useReducer:function(h,g,_){var M=Za();if(_!==void 0){var P=_(g);if(tf){Ge(!0);try{_(g)}finally{Ge(!1)}}}else P=g;return M.memoizedState=M.baseState=P,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:P},M.queue=h,h=h.dispatch=Cie.bind(null,wn,h),[M.memoizedState,h]},useRef:function(h){var g=Za();return h={current:h},g.memoizedState=h},useState:function(h){h=TE(h);var g=h.queue,_=DI.bind(null,wn,g);return g.dispatch=_,[h.memoizedState,_]},useDebugValue:jE,useDeferredValue:function(h,g){var _=Za();return PE(_,h,g)},useTransition:function(){var h=TE(!1);return h=AI.bind(null,wn,h.queue,!0,!1),Za().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,g,_){var M=wn,P=Za();if(Gn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=g(),Pr===null)throw Error(r(349));($n&127)!==0||iI(M,g,_)}P.memoizedState=_;var I={value:_,getSnapshot:g};return P.queue=I,vI(sI.bind(null,M,I,h),[h]),M.flags|=2048,Qm(9,{destroy:void 0},aI.bind(null,M,I,_,g),null),_},useId:function(){var h=Za(),g=Pr.identifierPrefix;if(Gn){var _=Fl,M=Il;_=(M&~(1<<32-Ne(M)-1)).toString(32)+_,g="_"+g+"R_"+_,_=Fv++,0<_&&(g+="H"+_.toString(32)),g+="_"}else _=vie++,g="_"+g+"r_"+_.toString(32)+"_";return h.memoizedState=g},useHostTransitionStatus:DE,useFormState:pI,useActionState:pI,useOptimistic:function(h){var g=Za();g.memoizedState=g.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return g.queue=_,g=OE.bind(null,wn,!0,_),_.dispatch=g,[h,g]},useMemoCache:ME,useCacheRefresh:function(){return Za().memoizedState=Eie.bind(null,wn)},useEffectEvent:function(h){var g=Za(),_={impl:h};return g.memoizedState=_,function(){if((or&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},LE={readContext:ba,use:zv,useCallback:EI,useContext:ba,useEffect:kE,useImperativeHandle:MI,useInsertionEffect:wI,useLayoutEffect:SI,useMemo:CI,useReducer:Bv,useRef:yI,useState:function(){return Bv(Ac)},useDebugValue:jE,useDeferredValue:function(h,g){var _=xi();return TI(_,br.memoizedState,h,g)},useTransition:function(){var h=Bv(Ac)[0],g=xi().memoizedState;return[typeof h=="boolean"?h:Qx(h),g]},useSyncExternalStore:rI,useId:PI,useHostTransitionStatus:DE,useFormState:gI,useActionState:gI,useOptimistic:function(h,g){var _=xi();return cI(_,br,h,g)},useMemoCache:ME,useCacheRefresh:RI};LE.useEffectEvent=_I;var FI={readContext:ba,use:zv,useCallback:EI,useContext:ba,useEffect:kE,useImperativeHandle:MI,useInsertionEffect:wI,useLayoutEffect:SI,useMemo:CI,useReducer:CE,useRef:yI,useState:function(){return CE(Ac)},useDebugValue:jE,useDeferredValue:function(h,g){var _=xi();return br===null?PE(_,h,g):TI(_,br.memoizedState,h,g)},useTransition:function(){var h=CE(Ac)[0],g=xi().memoizedState;return[typeof h=="boolean"?h:Qx(h),g]},useSyncExternalStore:rI,useId:PI,useHostTransitionStatus:DE,useFormState:bI,useActionState:bI,useOptimistic:function(h,g){var _=xi();return br!==null?cI(_,br,h,g):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:ME,useCacheRefresh:RI};FI.useEffectEvent=_I;function IE(h,g,_,M){g=h.memoizedState,_=_(M,g),_=_==null?g:f({},g,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var FE={enqueueSetState:function(h,g,_){h=h._reactInternals;var M=Ws(),P=ed(M);P.payload=g,_!=null&&(P.callback=_),g=td(h,P,M),g!==null&&(ws(g,h,M),Wx(g,h,M))},enqueueReplaceState:function(h,g,_){h=h._reactInternals;var M=Ws(),P=ed(M);P.tag=1,P.payload=g,_!=null&&(P.callback=_),g=td(h,P,M),g!==null&&(ws(g,h,M),Wx(g,h,M))},enqueueForceUpdate:function(h,g){h=h._reactInternals;var _=Ws(),M=ed(_);M.tag=2,g!=null&&(M.callback=g),g=td(h,M,_),g!==null&&(ws(g,h,_),Wx(g,h,_))}};function UI(h,g,_,M,P,I,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(M,I,J):g.prototype&&g.prototype.isPureReactComponent?!Ux(_,M)||!Ux(P,I):!0}function zI(h,g,_,M){h=g.state,typeof g.componentWillReceiveProps=="function"&&g.componentWillReceiveProps(_,M),typeof g.UNSAFE_componentWillReceiveProps=="function"&&g.UNSAFE_componentWillReceiveProps(_,M),g.state!==h&&FE.enqueueReplaceState(g,g.state,null)}function nf(h,g){var _=g;if("ref"in g){_={};for(var M in g)M!=="ref"&&(_[M]=g[M])}if(h=h.defaultProps){_===g&&(_=f({},_));for(var P in h)_[P]===void 0&&(_[P]=h[P])}return _}function BI(h){wv(h)}function $I(h){console.error(h)}function HI(h){wv(h)}function Gv(h,g){try{var _=h.onUncaughtError;_(g.value,{componentStack:g.stack})}catch(M){setTimeout(function(){throw M})}}function VI(h,g,_){try{var M=h.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:g.tag===1?g.stateNode:null})}catch(P){setTimeout(function(){throw P})}}function UE(h,g,_){return _=ed(_),_.tag=3,_.payload={element:null},_.callback=function(){Gv(h,g)},_}function GI(h){return h=ed(h),h.tag=3,h}function qI(h,g,_,M){var P=_.type.getDerivedStateFromError;if(typeof P=="function"){var I=M.value;h.payload=function(){return P(I)},h.callback=function(){VI(g,_,M)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){VI(g,_,M),typeof P!="function"&&(od===null?od=new Set([this]):od.add(this));var ve=M.stack;this.componentDidCatch(M.value,{componentStack:ve!==null?ve:""})})}function Tie(h,g,_,M,P){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(g=_.alternate,g!==null&&Hm(g,_,P,!0),_=Hs.current,_!==null){switch(_.tag){case 31:case 13:return So===null?r_():_.alternate===null&&oi===0&&(oi=3),_.flags&=-257,_.flags|=65536,_.lanes=P,M===Pv?_.flags|=16384:(g=_.updateQueue,g===null?_.updateQueue=new Set([M]):g.add(M),uC(h,M,P)),!1;case 22:return _.flags|=65536,M===Pv?_.flags|=16384:(g=_.updateQueue,g===null?(g={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=g):(_=g.retryQueue,_===null?g.retryQueue=new Set([M]):_.add(M)),uC(h,M,P)),!1}throw Error(r(435,_.tag))}return uC(h,M,P),r_(),!1}if(Gn)return g=Hs.current,g!==null?((g.flags&65536)===0&&(g.flags|=256),g.flags|=65536,g.lanes=P,M!==iE&&(h=Error(r(422),{cause:M}),$x(yo(h,_)))):(M!==iE&&(g=Error(r(423),{cause:M}),$x(yo(g,_))),h=h.current.alternate,h.flags|=65536,P&=-P,h.lanes|=P,M=yo(M,_),P=UE(h.stateNode,M,P),pE(h,P),oi!==4&&(oi=2)),!1;var I=Error(r(520),{cause:M});if(I=yo(I,_),ob===null?ob=[I]:ob.push(I),oi!==4&&(oi=2),g===null)return!0;M=yo(M,_),_=g;do{switch(_.tag){case 3:return _.flags|=65536,h=P&-P,_.lanes|=h,h=UE(_.stateNode,M,h),pE(_,h),!1;case 1:if(g=_.type,I=_.stateNode,(_.flags&128)===0&&(typeof g.getDerivedStateFromError=="function"||I!==null&&typeof I.componentDidCatch=="function"&&(od===null||!od.has(I))))return _.flags|=65536,P&=-P,_.lanes|=P,P=GI(P),qI(P,h,_,M),pE(_,P),!1}_=_.return}while(_!==null);return!1}var zE=Error(r(461)),Ei=!1;function ya(h,g,_,M){g.child=h===null?Y5(g,null,_,M):ef(g,h.child,_,M)}function WI(h,g,_,M,P){_=_.render;var I=g.ref;if("ref"in M){var J={};for(var ve in M)ve!=="ref"&&(J[ve]=M[ve])}else J=M;return Yh(g),M=_E(h,g,_,J,I,P),ve=wE(),h!==null&&!Ei?(SE(h,g,P),kc(h,g,P)):(Gn&&ve&&nE(g),g.flags|=1,ya(h,g,M,P),g.child)}function KI(h,g,_,M,P){if(h===null){var I=_.type;return typeof I=="function"&&!JM(I)&&I.defaultProps===void 0&&_.compare===null?(g.tag=15,g.type=I,XI(h,g,I,M,P)):(h=Ev(_.type,null,M,g,g.mode,P),h.ref=g.ref,h.return=g,g.child=h)}if(I=h.child,!KE(h,P)){var J=I.memoizedProps;if(_=_.compare,_=_!==null?_:Ux,_(J,M)&&h.ref===g.ref)return kc(h,g,P)}return g.flags|=1,h=Nc(I,M),h.ref=g.ref,h.return=g,g.child=h}function XI(h,g,_,M,P){if(h!==null){var I=h.memoizedProps;if(Ux(I,M)&&h.ref===g.ref)if(Ei=!1,g.pendingProps=M=I,KE(h,P))(h.flags&131072)!==0&&(Ei=!0);else return g.lanes=h.lanes,kc(h,g,P)}return BE(h,g,_,M,P)}function YI(h,g,_,M){var P=M.children,I=h!==null?h.memoizedState:null;if(h===null&&g.stateNode===null&&(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((g.flags&128)!==0){if(I=I!==null?I.baseLanes|_:_,h!==null){for(M=g.child=h.child,P=0;M!==null;)P=P|M.lanes|M.childLanes,M=M.sibling;M=P&~I}else M=0,g.child=null;return QI(h,g,I,_,M)}if((_&536870912)!==0)g.memoizedState={baseLanes:0,cachePool:null},h!==null&&kv(g,I!==null?I.cachePool:null),I!==null?J5(g,I):xE(),eI(g);else return M=g.lanes=536870912,QI(h,g,I!==null?I.baseLanes|_:_,_,M)}else I!==null?(kv(g,I.cachePool),J5(g,I),rd(),g.memoizedState=null):(h!==null&&kv(g,null),xE(),rd());return ya(h,g,P,_),g.child}function eb(h,g){return h!==null&&h.tag===22||g.stateNode!==null||(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),g.sibling}function QI(h,g,_,M,P){var I=dE();return I=I===null?null:{parent:Ni._currentValue,pool:I},g.memoizedState={baseLanes:_,cachePool:I},h!==null&&kv(g,null),xE(),eI(g),h!==null&&Hm(h,g,M,!0),g.childLanes=P,null}function qv(h,g){return g=Kv({mode:g.mode,children:g.children},h.mode),g.ref=h.ref,h.child=g,g.return=h,g}function ZI(h,g,_){return ef(g,h.child,null,_),h=qv(g,g.pendingProps),h.flags|=2,Vs(g),g.memoizedState=null,h}function Aie(h,g,_){var M=g.pendingProps,P=(g.flags&128)!==0;if(g.flags&=-129,h===null){if(Gn){if(M.mode==="hidden")return h=qv(g,M),g.lanes=536870912,eb(null,h);if(yE(g),(h=zr)?(h=uU(h,wo),h=h!==null&&h.data==="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:Xu!==null?{id:Il,overflow:Fl}:null,retryLane:536870912,hydrationErrors:null},_=O5(h),_.return=g,g.child=_,xa=g,zr=null)):h=null,h===null)throw Qu(g);return g.lanes=536870912,null}return qv(g,M)}var I=h.memoizedState;if(I!==null){var J=I.dehydrated;if(yE(g),P)if(g.flags&256)g.flags&=-257,g=ZI(h,g,_);else if(g.memoizedState!==null)g.child=h.child,g.flags|=128,g=null;else throw Error(r(558));else if(Ei||Hm(h,g,_,!1),P=(_&h.childLanes)!==0,Ei||P){if(M=Pr,M!==null&&(J=Xn(M,_),J!==0&&J!==I.retryLane))throw I.retryLane=J,qh(h,J),ws(M,h,J),zE;r_(),g=ZI(h,g,_)}else h=I.treeContext,zr=No(J.nextSibling),xa=g,Gn=!0,Yu=null,wo=!1,h!==null&&F5(g,h),g=qv(g,M),g.flags|=4096;return g}return h=Nc(h.child,{mode:M.mode,children:M.children}),h.ref=g.ref,g.child=h,h.return=g,h}function Wv(h,g){var _=g.ref;if(_===null)h!==null&&h.ref!==null&&(g.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(g.flags|=4194816)}}function BE(h,g,_,M,P){return Yh(g),_=_E(h,g,_,M,void 0,P),M=wE(),h!==null&&!Ei?(SE(h,g,P),kc(h,g,P)):(Gn&&M&&nE(g),g.flags|=1,ya(h,g,_,P),g.child)}function JI(h,g,_,M,P,I){return Yh(g),g.updateQueue=null,_=nI(g,M,_,P),tI(h),M=wE(),h!==null&&!Ei?(SE(h,g,I),kc(h,g,I)):(Gn&&M&&nE(g),g.flags|=1,ya(h,g,_,I),g.child)}function eF(h,g,_,M,P){if(Yh(g),g.stateNode===null){var I=Um,J=_.contextType;typeof J=="object"&&J!==null&&(I=ba(J)),I=new _(M,I),g.memoizedState=I.state!==null&&I.state!==void 0?I.state:null,I.updater=FE,g.stateNode=I,I._reactInternals=g,I=g.stateNode,I.props=M,I.state=g.memoizedState,I.refs={},fE(g),J=_.contextType,I.context=typeof J=="object"&&J!==null?ba(J):Um,I.state=g.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(IE(g,_,J,M),I.state=g.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof I.getSnapshotBeforeUpdate=="function"||typeof I.UNSAFE_componentWillMount!="function"&&typeof I.componentWillMount!="function"||(J=I.state,typeof I.componentWillMount=="function"&&I.componentWillMount(),typeof I.UNSAFE_componentWillMount=="function"&&I.UNSAFE_componentWillMount(),J!==I.state&&FE.enqueueReplaceState(I,I.state,null),Xx(g,M,I,P),Kx(),I.state=g.memoizedState),typeof I.componentDidMount=="function"&&(g.flags|=4194308),M=!0}else if(h===null){I=g.stateNode;var ve=g.memoizedProps,Ue=nf(_,ve);I.props=Ue;var rt=I.context,mt=_.contextType;J=Um,typeof mt=="object"&&mt!==null&&(J=ba(mt));var bt=_.getDerivedStateFromProps;mt=typeof bt=="function"||typeof I.getSnapshotBeforeUpdate=="function",ve=g.pendingProps!==ve,mt||typeof I.UNSAFE_componentWillReceiveProps!="function"&&typeof I.componentWillReceiveProps!="function"||(ve||rt!==J)&&zI(g,I,M,J),Ju=!1;var ot=g.memoizedState;I.state=ot,Xx(g,M,I,P),Kx(),rt=g.memoizedState,ve||ot!==rt||Ju?(typeof bt=="function"&&(IE(g,_,bt,M),rt=g.memoizedState),(Ue=Ju||UI(g,_,Ue,M,ot,rt,J))?(mt||typeof I.UNSAFE_componentWillMount!="function"&&typeof I.componentWillMount!="function"||(typeof I.componentWillMount=="function"&&I.componentWillMount(),typeof I.UNSAFE_componentWillMount=="function"&&I.UNSAFE_componentWillMount()),typeof I.componentDidMount=="function"&&(g.flags|=4194308)):(typeof I.componentDidMount=="function"&&(g.flags|=4194308),g.memoizedProps=M,g.memoizedState=rt),I.props=M,I.state=rt,I.context=J,M=Ue):(typeof I.componentDidMount=="function"&&(g.flags|=4194308),M=!1)}else{I=g.stateNode,mE(h,g),J=g.memoizedProps,mt=nf(_,J),I.props=mt,bt=g.pendingProps,ot=I.context,rt=_.contextType,Ue=Um,typeof rt=="object"&&rt!==null&&(Ue=ba(rt)),ve=_.getDerivedStateFromProps,(rt=typeof ve=="function"||typeof I.getSnapshotBeforeUpdate=="function")||typeof I.UNSAFE_componentWillReceiveProps!="function"&&typeof I.componentWillReceiveProps!="function"||(J!==bt||ot!==Ue)&&zI(g,I,M,Ue),Ju=!1,ot=g.memoizedState,I.state=ot,Xx(g,M,I,P),Kx();var ft=g.memoizedState;J!==bt||ot!==ft||Ju||h!==null&&h.dependencies!==null&&Tv(h.dependencies)?(typeof ve=="function"&&(IE(g,_,ve,M),ft=g.memoizedState),(mt=Ju||UI(g,_,mt,M,ot,ft,Ue)||h!==null&&h.dependencies!==null&&Tv(h.dependencies))?(rt||typeof I.UNSAFE_componentWillUpdate!="function"&&typeof I.componentWillUpdate!="function"||(typeof I.componentWillUpdate=="function"&&I.componentWillUpdate(M,ft,Ue),typeof I.UNSAFE_componentWillUpdate=="function"&&I.UNSAFE_componentWillUpdate(M,ft,Ue)),typeof I.componentDidUpdate=="function"&&(g.flags|=4),typeof I.getSnapshotBeforeUpdate=="function"&&(g.flags|=1024)):(typeof I.componentDidUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=4),typeof I.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=1024),g.memoizedProps=M,g.memoizedState=ft),I.props=M,I.state=ft,I.context=Ue,M=mt):(typeof I.componentDidUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=4),typeof I.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=1024),M=!1)}return I=M,Wv(h,g),M=(g.flags&128)!==0,I||M?(I=g.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:I.render(),g.flags|=1,h!==null&&M?(g.child=ef(g,h.child,null,P),g.child=ef(g,null,_,P)):ya(h,g,_,P),g.memoizedState=I.state,h=g.child):h=kc(h,g,P),h}function tF(h,g,_,M){return Kh(),g.flags|=256,ya(h,g,_,M),g.child}var $E={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function HE(h){return{baseLanes:h,cachePool:V5()}}function VE(h,g,_){return h=h!==null?h.childLanes&~_:0,g&&(h|=qs),h}function nF(h,g,_){var M=g.pendingProps,P=!1,I=(g.flags&128)!==0,J;if((J=I)||(J=h!==null&&h.memoizedState===null?!1:(gi.current&2)!==0),J&&(P=!0,g.flags&=-129),J=(g.flags&32)!==0,g.flags&=-33,h===null){if(Gn){if(P?nd(g):rd(),(h=zr)?(h=uU(h,wo),h=h!==null&&h.data!=="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:Xu!==null?{id:Il,overflow:Fl}:null,retryLane:536870912,hydrationErrors:null},_=O5(h),_.return=g,g.child=_,xa=g,zr=null)):h=null,h===null)throw Qu(g);return EC(h)?g.lanes=32:g.lanes=536870912,null}var ve=M.children;return M=M.fallback,P?(rd(),P=g.mode,ve=Kv({mode:"hidden",children:ve},P),M=Wh(M,P,_,null),ve.return=g,M.return=g,ve.sibling=M,g.child=ve,M=g.child,M.memoizedState=HE(_),M.childLanes=VE(h,J,_),g.memoizedState=$E,eb(null,M)):(nd(g),GE(g,ve))}var Ue=h.memoizedState;if(Ue!==null&&(ve=Ue.dehydrated,ve!==null)){if(I)g.flags&256?(nd(g),g.flags&=-257,g=qE(h,g,_)):g.memoizedState!==null?(rd(),g.child=h.child,g.flags|=128,g=null):(rd(),ve=M.fallback,P=g.mode,M=Kv({mode:"visible",children:M.children},P),ve=Wh(ve,P,_,null),ve.flags|=2,M.return=g,ve.return=g,M.sibling=ve,g.child=M,ef(g,h.child,null,_),M=g.child,M.memoizedState=HE(_),M.childLanes=VE(h,J,_),g.memoizedState=$E,g=eb(null,M));else if(nd(g),EC(ve)){if(J=ve.nextSibling&&ve.nextSibling.dataset,J)var rt=J.dgst;J=rt,M=Error(r(419)),M.stack="",M.digest=J,$x({value:M,source:null,stack:null}),g=qE(h,g,_)}else if(Ei||Hm(h,g,_,!1),J=(_&h.childLanes)!==0,Ei||J){if(J=Pr,J!==null&&(M=Xn(J,_),M!==0&&M!==Ue.retryLane))throw Ue.retryLane=M,qh(h,M),ws(J,h,M),zE;MC(ve)||r_(),g=qE(h,g,_)}else MC(ve)?(g.flags|=192,g.child=h.child,g=null):(h=Ue.treeContext,zr=No(ve.nextSibling),xa=g,Gn=!0,Yu=null,wo=!1,h!==null&&F5(g,h),g=GE(g,M.children),g.flags|=4096);return g}return P?(rd(),ve=M.fallback,P=g.mode,Ue=h.child,rt=Ue.sibling,M=Nc(Ue,{mode:"hidden",children:M.children}),M.subtreeFlags=Ue.subtreeFlags&65011712,rt!==null?ve=Nc(rt,ve):(ve=Wh(ve,P,_,null),ve.flags|=2),ve.return=g,M.return=g,M.sibling=ve,g.child=M,eb(null,M),M=g.child,ve=h.child.memoizedState,ve===null?ve=HE(_):(P=ve.cachePool,P!==null?(Ue=Ni._currentValue,P=P.parent!==Ue?{parent:Ue,pool:Ue}:P):P=V5(),ve={baseLanes:ve.baseLanes|_,cachePool:P}),M.memoizedState=ve,M.childLanes=VE(h,J,_),g.memoizedState=$E,eb(h.child,M)):(nd(g),_=h.child,h=_.sibling,_=Nc(_,{mode:"visible",children:M.children}),_.return=g,_.sibling=null,h!==null&&(J=g.deletions,J===null?(g.deletions=[h],g.flags|=16):J.push(h)),g.child=_,g.memoizedState=null,_)}function GE(h,g){return g=Kv({mode:"visible",children:g},h.mode),g.return=h,h.child=g}function Kv(h,g){return h=$s(22,h,null,g),h.lanes=0,h}function qE(h,g,_){return ef(g,h.child,null,_),h=GE(g,g.pendingProps.children),h.flags|=2,g.memoizedState=null,h}function rF(h,g,_){h.lanes|=g;var M=h.alternate;M!==null&&(M.lanes|=g),oE(h.return,g,_)}function WE(h,g,_,M,P,I){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:g,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:P,treeForkCount:I}:(J.isBackwards=g,J.rendering=null,J.renderingStartTime=0,J.last=M,J.tail=_,J.tailMode=P,J.treeForkCount=I)}function iF(h,g,_){var M=g.pendingProps,P=M.revealOrder,I=M.tail;M=M.children;var J=gi.current,ve=(J&2)!==0;if(ve?(J=J&1|2,g.flags|=128):J&=1,le(gi,J),ya(h,g,M,_),M=Gn?Bx:0,!ve&&h!==null&&(h.flags&128)!==0)e:for(h=g.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&rF(h,_,g);else if(h.tag===19)rF(h,_,g);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===g)break e;for(;h.sibling===null;){if(h.return===null||h.return===g)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(P){case"forwards":for(_=g.child,P=null;_!==null;)h=_.alternate,h!==null&&Lv(h)===null&&(P=_),_=_.sibling;_=P,_===null?(P=g.child,g.child=null):(P=_.sibling,_.sibling=null),WE(g,!1,P,_,I,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,P=g.child,g.child=null;P!==null;){if(h=P.alternate,h!==null&&Lv(h)===null){g.child=P;break}h=P.sibling,P.sibling=_,_=P,P=h}WE(g,!0,_,null,I,M);break;case"together":WE(g,!1,null,null,void 0,M);break;default:g.memoizedState=null}return g.child}function kc(h,g,_){if(h!==null&&(g.dependencies=h.dependencies),sd|=g.lanes,(_&g.childLanes)===0)if(h!==null){if(Hm(h,g,_,!1),(_&g.childLanes)===0)return null}else return null;if(h!==null&&g.child!==h.child)throw Error(r(153));if(g.child!==null){for(h=g.child,_=Nc(h,h.pendingProps),g.child=_,_.return=g;h.sibling!==null;)h=h.sibling,_=_.sibling=Nc(h,h.pendingProps),_.return=g;_.sibling=null}return g.child}function KE(h,g){return(h.lanes&g)!==0?!0:(h=h.dependencies,!!(h!==null&&Tv(h)))}function kie(h,g,_){switch(g.tag){case 3:_e(g,g.stateNode.containerInfo),Zu(g,Ni,h.memoizedState.cache),Kh();break;case 27:case 5:ke(g);break;case 4:_e(g,g.stateNode.containerInfo);break;case 10:Zu(g,g.type,g.memoizedProps.value);break;case 31:if(g.memoizedState!==null)return g.flags|=128,yE(g),null;break;case 13:var M=g.memoizedState;if(M!==null)return M.dehydrated!==null?(nd(g),g.flags|=128,null):(_&g.child.childLanes)!==0?nF(h,g,_):(nd(g),h=kc(h,g,_),h!==null?h.sibling:null);nd(g);break;case 19:var P=(h.flags&128)!==0;if(M=(_&g.childLanes)!==0,M||(Hm(h,g,_,!1),M=(_&g.childLanes)!==0),P){if(M)return iF(h,g,_);g.flags|=128}if(P=g.memoizedState,P!==null&&(P.rendering=null,P.tail=null,P.lastEffect=null),le(gi,gi.current),M)break;return null;case 22:return g.lanes=0,YI(h,g,_,g.pendingProps);case 24:Zu(g,Ni,h.memoizedState.cache)}return kc(h,g,_)}function aF(h,g,_){if(h!==null)if(h.memoizedProps!==g.pendingProps)Ei=!0;else{if(!KE(h,_)&&(g.flags&128)===0)return Ei=!1,kie(h,g,_);Ei=(h.flags&131072)!==0}else Ei=!1,Gn&&(g.flags&1048576)!==0&&I5(g,Bx,g.index);switch(g.lanes=0,g.tag){case 16:e:{var M=g.pendingProps;if(h=Zh(g.elementType),g.type=h,typeof h=="function")JM(h)?(M=nf(h,M),g.tag=1,g=eF(null,g,h,M,_)):(g.tag=0,g=BE(null,g,h,M,_));else{if(h!=null){var P=h.$$typeof;if(P===E){g.tag=11,g=WI(null,g,h,M,_);break e}else if(P===T){g.tag=14,g=KI(null,g,h,M,_);break e}}throw g=j(h)||h,Error(r(306,g,""))}}return g;case 0:return BE(h,g,g.type,g.pendingProps,_);case 1:return M=g.type,P=nf(M,g.pendingProps),eF(h,g,M,P,_);case 3:e:{if(_e(g,g.stateNode.containerInfo),h===null)throw Error(r(387));M=g.pendingProps;var I=g.memoizedState;P=I.element,mE(h,g),Xx(g,M,null,_);var J=g.memoizedState;if(M=J.cache,Zu(g,Ni,M),M!==I.cache&&lE(g,[Ni],_,!0),Kx(),M=J.element,I.isDehydrated)if(I={element:M,isDehydrated:!1,cache:J.cache},g.updateQueue.baseState=I,g.memoizedState=I,g.flags&256){g=tF(h,g,M,_);break e}else if(M!==P){P=yo(Error(r(424)),g),$x(P),g=tF(h,g,M,_);break e}else{switch(h=g.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(zr=No(h.firstChild),xa=g,Gn=!0,Yu=null,wo=!0,_=Y5(g,null,M,_),g.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Kh(),M===P){g=kc(h,g,_);break e}ya(h,g,M,_)}g=g.child}return g;case 26:return Wv(h,g),h===null?(_=gU(g.type,null,g.pendingProps,null))?g.memoizedState=_:Gn||(_=g.type,h=g.pendingProps,M=u_(fe.current).createElement(_),M[tr]=g,M[_n]=h,va(M,_,h),Fe(M),g.stateNode=M):g.memoizedState=gU(g.type,h.memoizedProps,g.pendingProps,h.memoizedState),null;case 27:return ke(g),h===null&&Gn&&(M=g.stateNode=fU(g.type,g.pendingProps,fe.current),xa=g,wo=!0,P=zr,dd(g.type)?(CC=P,zr=No(M.firstChild)):zr=P),ya(h,g,g.pendingProps.children,_),Wv(h,g),h===null&&(g.flags|=4194304),g.child;case 5:return h===null&&Gn&&((P=M=zr)&&(M=sae(M,g.type,g.pendingProps,wo),M!==null?(g.stateNode=M,xa=g,zr=No(M.firstChild),wo=!1,P=!0):P=!1),P||Qu(g)),ke(g),P=g.type,I=g.pendingProps,J=h!==null?h.memoizedProps:null,M=I.children,wC(P,I)?M=null:J!==null&&wC(P,J)&&(g.flags|=32),g.memoizedState!==null&&(P=_E(h,g,_ie,null,null,_),pb._currentValue=P),Wv(h,g),ya(h,g,M,_),g.child;case 6:return h===null&&Gn&&((h=_=zr)&&(_=oae(_,g.pendingProps,wo),_!==null?(g.stateNode=_,xa=g,zr=null,h=!0):h=!1),h||Qu(g)),null;case 13:return nF(h,g,_);case 4:return _e(g,g.stateNode.containerInfo),M=g.pendingProps,h===null?g.child=ef(g,null,M,_):ya(h,g,M,_),g.child;case 11:return WI(h,g,g.type,g.pendingProps,_);case 7:return ya(h,g,g.pendingProps,_),g.child;case 8:return ya(h,g,g.pendingProps.children,_),g.child;case 12:return ya(h,g,g.pendingProps.children,_),g.child;case 10:return M=g.pendingProps,Zu(g,g.type,M.value),ya(h,g,M.children,_),g.child;case 9:return P=g.type._context,M=g.pendingProps.children,Yh(g),P=ba(P),M=M(P),g.flags|=1,ya(h,g,M,_),g.child;case 14:return KI(h,g,g.type,g.pendingProps,_);case 15:return XI(h,g,g.type,g.pendingProps,_);case 19:return iF(h,g,_);case 31:return Aie(h,g,_);case 22:return YI(h,g,_,g.pendingProps);case 24:return Yh(g),M=ba(Ni),h===null?(P=dE(),P===null&&(P=Pr,I=cE(),P.pooledCache=I,I.refCount++,I!==null&&(P.pooledCacheLanes|=_),P=I),g.memoizedState={parent:M,cache:P},fE(g),Zu(g,Ni,P)):((h.lanes&_)!==0&&(mE(h,g),Xx(g,null,null,_),Kx()),P=h.memoizedState,I=g.memoizedState,P.parent!==M?(P={parent:M,cache:M},g.memoizedState=P,g.lanes===0&&(g.memoizedState=g.updateQueue.baseState=P),Zu(g,Ni,M)):(M=I.cache,Zu(g,Ni,M),M!==P.cache&&lE(g,[Ni],_,!0))),ya(h,g,g.pendingProps.children,_),g.child;case 29:throw g.pendingProps}throw Error(r(156,g.tag))}function jc(h){h.flags|=4}function XE(h,g,_,M,P){if((g=(h.mode&32)!==0)&&(g=!1),g){if(h.flags|=16777216,(P&335544128)===P)if(h.stateNode.complete)h.flags|=8192;else if(PF())h.flags|=8192;else throw Jh=Pv,hE}else h.flags&=-16777217}function sF(h,g){if(g.type!=="stylesheet"||(g.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!_U(g))if(PF())h.flags|=8192;else throw Jh=Pv,hE}function Xv(h,g){g!==null&&(h.flags|=4),h.flags&16384&&(g=h.tag!==22?Ct():536870912,h.lanes|=g,tp|=g)}function tb(h,g){if(!Gn)switch(h.tailMode){case"hidden":g=h.tail;for(var _=null;g!==null;)g.alternate!==null&&(_=g),g=g.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?g||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function Br(h){var g=h.alternate!==null&&h.alternate.child===h.child,_=0,M=0;if(g)for(var P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags&65011712,M|=P.flags&65011712,P.return=h,P=P.sibling;else for(P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags,M|=P.flags,P.return=h,P=P.sibling;return h.subtreeFlags|=M,h.childLanes=_,g}function jie(h,g,_){var M=g.pendingProps;switch(rE(g),g.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Br(g),null;case 1:return Br(g),null;case 3:return _=g.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),g.memoizedState.cache!==M&&(g.flags|=2048),Cc(Ni),ge(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&($m(g)?jc(g):h===null||h.memoizedState.isDehydrated&&(g.flags&256)===0||(g.flags|=1024,aE())),Br(g),null;case 26:var P=g.type,I=g.memoizedState;return h===null?(jc(g),I!==null?(Br(g),sF(g,I)):(Br(g),XE(g,P,null,M,_))):I?I!==h.memoizedState?(jc(g),Br(g),sF(g,I)):(Br(g),g.flags&=-16777217):(h=h.memoizedProps,h!==M&&jc(g),Br(g),XE(g,P,h,M,_)),null;case 27:if(De(g),_=fe.current,P=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==M&&jc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Br(g),null}h=re.current,$m(g)?U5(g):(h=fU(P,M,_),g.stateNode=h,jc(g))}return Br(g),null;case 5:if(De(g),P=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==M&&jc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Br(g),null}if(I=re.current,$m(g))U5(g);else{var J=u_(fe.current);switch(I){case 1:I=J.createElementNS("http://www.w3.org/2000/svg",P);break;case 2:I=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;default:switch(P){case"svg":I=J.createElementNS("http://www.w3.org/2000/svg",P);break;case"math":I=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;case"script":I=J.createElement("div"),I.innerHTML=" + From e1ff8f19e7e38d7c9d3143251848579832831bed Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 08:35:49 +0100 Subject: [PATCH 03/15] Simplify backup/restore with complete database + files ZIP approach Replace the complex JSON-based backup system (~2000 lines) with a simple approach that copies the SQLite database and all data directories into a single ZIP file. Backend changes: - Add close_all_connections() and reinitialize_database() helpers to database.py - New GET /backup endpoint: creates complete ZIP with bambuddy.db and all data directories (archive, virtual_printer, plate_calibration, icons, projects) - New POST /restore endpoint: extracts ZIP, replaces database and directories, requires restart after restore - Move legacy endpoints to /backup-legacy and /restore-legacy for transition Frontend changes: - Simplify api.exportBackup() - no longer takes category parameters - Simplify api.importBackup() - no longer takes overwrite parameter - Remove BackupModal and RestoreModal components from GitHubBackupSettings - Add simple Download/Restore buttons with inline logic - Add blocking modal overlay during backup/restore operations - Add beforeunload handler to prevent accidental navigation - Show operation status messages during backup/restore Benefits: - ~100 lines vs ~2000 lines of backup/restore code - Complete by definition - SQLite database contains ALL data - No code changes needed when schema changes - No ID remapping required - IDs stay the same - Faster - file copy vs querying all tables --- backend/app/api/routes/settings.py | 136 ++++++++++- backend/app/core/database.py | 20 ++ frontend/src/api/client.ts | 34 +-- .../src/components/GitHubBackupSettings.tsx | 212 ++++++++++++++---- 4 files changed, 329 insertions(+), 73 deletions(-) diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 5b0a3dd45..4dd219525 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -238,7 +238,65 @@ async def update_spoolman_settings( @router.get("/backup") -async def export_backup( +async def create_backup(db: AsyncSession = Depends(get_db)): + """Create a complete backup (database + all files) as a ZIP. + + This is a simplified backup that includes the entire SQLite database + and all data directories. It is complete by definition and cannot miss data. + """ + import shutil + import tempfile + + from sqlalchemy import text + + from backend.app.core.database import engine + + base_dir = app_settings.base_dir + db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", "")) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # 1. Checkpoint WAL to ensure all data is in main db file + async with engine.begin() as conn: + await conn.execute(text("PRAGMA wal_checkpoint(TRUNCATE)")) + + # 2. Copy database file + shutil.copy2(db_path, temp_path / "bambuddy.db") + + # 3. Copy data directories (if they exist) + dirs_to_backup = [ + ("archive", base_dir / "archive"), + ("virtual_printer", base_dir / "virtual_printer"), + ("plate_calibration", app_settings.plate_calibration_dir), + ("icons", base_dir / "icons"), + ("projects", base_dir / "projects"), + ] + + for name, src_dir in dirs_to_backup: + if src_dir.exists() and any(src_dir.iterdir()): + shutil.copytree(src_dir, temp_path / name) + + # 4. Create ZIP + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for file_path in temp_path.rglob("*"): + if file_path.is_file(): + arcname = file_path.relative_to(temp_path) + zf.write(file_path, arcname) + + zip_buffer.seek(0) + filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip" + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) + + +@router.get("/backup-legacy") +async def export_backup_legacy( db: AsyncSession = Depends(get_db), include_settings: bool = Query(True, description="Include app settings"), include_notifications: bool = Query(True, description="Include notification providers"), @@ -953,12 +1011,84 @@ async def export_backup( @router.post("/restore") -async def import_backup( +async def restore_backup( + file: UploadFile = File(...), + db: AsyncSession = Depends(get_db), +): + """Restore from a complete backup ZIP. + + This is a simplified restore that replaces the database and all data directories + from the backup ZIP. Requires a restart after restore. + """ + import shutil + import tempfile + + from fastapi import HTTPException + + from backend.app.core.database import close_all_connections + + base_dir = app_settings.base_dir + db_path = Path(app_settings.database_url.replace("sqlite+aiosqlite:///", "")) + + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # 1. Read and extract ZIP + content = await file.read() + + # Check if it's a valid ZIP + if not file.filename or not file.filename.endswith(".zip"): + raise HTTPException(400, "Invalid backup file: must be a .zip file") + + try: + with zipfile.ZipFile(io.BytesIO(content), "r") as zf: + zf.extractall(temp_path) + except zipfile.BadZipFile: + raise HTTPException(400, "Invalid backup file: not a valid ZIP") + + # 2. Validate backup (must have database) + backup_db = temp_path / "bambuddy.db" + if not backup_db.exists(): + raise HTTPException(400, "Invalid backup: missing bambuddy.db") + + # 3. Close current database connections + await close_all_connections() + + # 4. Replace database + shutil.copy2(backup_db, db_path) + + # 5. Replace data directories + dirs_to_restore = [ + ("archive", base_dir / "archive"), + ("virtual_printer", base_dir / "virtual_printer"), + ("plate_calibration", app_settings.plate_calibration_dir), + ("icons", base_dir / "icons"), + ("projects", base_dir / "projects"), + ] + + for name, dest_dir in dirs_to_restore: + src_dir = temp_path / name + if src_dir.exists(): + if dest_dir.exists(): + shutil.rmtree(dest_dir) + shutil.copytree(src_dir, dest_dir) + + # 6. Note: Database connection will be reinitialized on restart + # The application should be restarted after restore + + return { + "success": True, + "message": "Backup restored successfully. Please restart Bambuddy for changes to take effect.", + } + + +@router.post("/restore-legacy") +async def import_backup_legacy( file: UploadFile = File(...), overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"), db: AsyncSession = Depends(get_db), ): - """Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing.""" + """Legacy restore: Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing.""" try: content = await file.read() base_dir = app_settings.base_dir diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 0ab95b10b..688797b79 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -15,6 +15,26 @@ async_session = async_sessionmaker( ) +async def close_all_connections(): + """Close all database connections for backup/restore operations.""" + global engine + await engine.dispose() + + +async def reinitialize_database(): + """Reinitialize database connection after restore.""" + global engine, async_session + engine = create_async_engine( + settings.database_url, + echo=settings.debug, + ) + async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + ) + + class Base(DeclarativeBase): pass diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 5ad78680d..a67bdc315 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2498,25 +2498,9 @@ export const api = { getMQTTStatus: () => request('/settings/mqtt/status'), resetSettings: () => request('/settings/reset', { method: 'POST' }), - exportBackup: async (categories?: Record): Promise<{ blob: Blob; filename: string }> => { - const params = new URLSearchParams(); - if (categories) { - if (categories.settings !== undefined) params.set('include_settings', String(categories.settings)); - if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications)); - if (categories.templates !== undefined) params.set('include_templates', String(categories.templates)); - if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs)); - if (categories.external_links !== undefined) params.set('include_external_links', String(categories.external_links)); - if (categories.printers !== undefined) params.set('include_printers', String(categories.printers)); - if (categories.plate_calibration !== undefined) params.set('include_plate_calibration', String(categories.plate_calibration)); - if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments)); - if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance)); - if (categories.archives !== undefined) params.set('include_archives', String(categories.archives)); - if (categories.projects !== undefined) params.set('include_projects', String(categories.projects)); - if (categories.pending_uploads !== undefined) params.set('include_pending_uploads', String(categories.pending_uploads)); - if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes)); - if (categories.api_keys !== undefined) params.set('include_api_keys', String(categories.api_keys)); - } - const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`; + exportBackup: async (): Promise<{ blob: Blob; filename: string }> => { + // New simplified backup - complete database + all files + const url = `${API_BASE}/settings/backup`; const response = await fetch(url); // Check for errors @@ -2527,7 +2511,7 @@ export const api = { // Get filename from Content-Disposition header const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'bambuddy-backup.json'; + let filename = 'bambuddy-backup.zip'; if (contentDisposition) { const match = contentDisposition.match(/filename=([^;]+)/); if (match) filename = match[1].trim(); @@ -2536,10 +2520,11 @@ export const api = { const blob = await response.blob(); return { blob, filename }; }, - importBackup: async (file: File, overwrite = false) => { + importBackup: async (file: File) => { + // New simplified restore - replaces database + all directories const formData = new FormData(); formData.append('file', file); - const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`; + const url = `${API_BASE}/settings/restore`; const response = await fetch(url, { method: 'POST', body: formData, @@ -2547,11 +2532,6 @@ export const api = { return response.json() as Promise<{ success: boolean; message: string; - restored?: Record; - skipped?: Record; - skipped_details?: Record; - files_restored?: number; - total_skipped?: number; }>; }, checkFfmpeg: () => diff --git a/frontend/src/components/GitHubBackupSettings.tsx b/frontend/src/components/GitHubBackupSettings.tsx index 5c00a8b57..97c2b7580 100644 --- a/frontend/src/components/GitHubBackupSettings.tsx +++ b/frontend/src/components/GitHubBackupSettings.tsx @@ -16,6 +16,7 @@ import { SkipForward, AlertTriangle, Trash2, + RotateCcw, } from 'lucide-react'; import { api } from '../api/client'; import type { @@ -31,8 +32,7 @@ import type { import { Card, CardContent, CardHeader } from './Card'; import { Button } from './Button'; import { Toggle } from './Toggle'; -import { BackupModal } from './BackupModal'; -import { RestoreModal } from './RestoreModal'; +import { ConfirmModal } from './ConfirmModal'; import { useToast } from '../contexts/ToastContext'; interface StatusBadgeProps { @@ -108,9 +108,30 @@ export function GitHubBackupSettings() { const [backupSettings, setBackupSettings] = useState(false); const [enabled, setEnabled] = useState(true); - // Local backup modals - const [showBackupModal, setShowBackupModal] = useState(false); - const [showRestoreModal, setShowRestoreModal] = useState(false); + // Local backup state + const [isExporting, setIsExporting] = useState(false); + const [isRestoring, setIsRestoring] = useState(false); + const [operationStatus, setOperationStatus] = useState(''); + const [showRestoreConfirm, setShowRestoreConfirm] = useState(false); + const [restoreFile, setRestoreFile] = useState(null); + const [restoreResult, setRestoreResult] = useState<{ success: boolean; message: string } | null>(null); + const fileInputRef = useRef(null); + + // Block navigation while backup/restore is in progress + useEffect(() => { + const isOperationInProgress = isExporting || isRestoring; + + if (isOperationInProgress) { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + e.preventDefault(); + e.returnValue = 'A backup operation is in progress. Are you sure you want to leave?'; + return e.returnValue; + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + } + }, [isExporting, isRestoring]); // Test connection state const [testLoading, setTestLoading] = useState(false); @@ -696,80 +717,185 @@ export function GitHubBackupSettings() {

- Export or import your Bambuddy data as a local file for manual backup or migration. + Create a complete backup of your Bambuddy data including the database, archives, uploads, and all files.

+ {/* Export */}
-

Export Data

+

Download Backup

- Download all settings, printers, and profiles + Complete backup: database + all files (ZIP)

-
+ {/* Import */} +
-

Import Backup

+

Restore Backup

- Restore from a previous export file + Replace all data from a backup file

+ { + const file = e.target.files?.[0]; + if (file) { + setRestoreFile(file); + setShowRestoreConfirm(true); + } + e.target.value = ''; + }} + />
+ + {/* Restore result message */} + {restoreResult && ( +
+
+ {restoreResult.success ? ( + + ) : ( + + )} +
+ {restoreResult.message} + {restoreResult.success && ( +
+ +
+ )} +
+
+
+ )} + + {/* Warning */} +
+
+ +
+ Restore replaces all data.{' '} + Your current database and files will be completely replaced. A restart is required after restore. +
+
+
- {/* Modals */} - {showBackupModal && ( - setShowBackupModal(false)} - onExport={async (categories) => { - setShowBackupModal(false); + {/* Restore Confirmation Modal */} + {showRestoreConfirm && restoreFile && ( + { + setShowRestoreConfirm(false); + setIsRestoring(true); + setRestoreResult(null); try { - const { blob, filename } = await api.exportBackup(categories); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = filename; - a.click(); - URL.revokeObjectURL(url); - showToast('Backup downloaded successfully'); - } catch { - showToast('Failed to create backup', 'error'); + setOperationStatus('Uploading backup file...'); + const result = await api.importBackup(restoreFile); + setRestoreResult(result); + if (result.success) { + showToast('Backup restored. Please restart Bambuddy.', 'success'); + } else { + showToast(result.message, 'error'); + } + } catch (e) { + const message = e instanceof Error ? e.message : 'Failed to restore backup'; + setRestoreResult({ success: false, message }); + showToast(message, 'error'); + } finally { + setIsRestoring(false); + setOperationStatus(''); + setRestoreFile(null); } }} + onCancel={() => { + setShowRestoreConfirm(false); + setRestoreFile(null); + }} /> )} - {showRestoreModal && ( - setShowRestoreModal(false)} - onRestore={async (file, overwrite) => { - return await api.importBackup(file, overwrite); - }} - onSuccess={() => { - setShowRestoreModal(false); - showToast('Backup restored successfully'); - queryClient.invalidateQueries(); - }} - /> + {/* Blocking overlay during backup/restore operations */} + {(isExporting || isRestoring) && ( +
+
+
+
+
+
+
+
+

+ {isExporting ? 'Creating Backup' : 'Restoring Backup'} +

+

+ {operationStatus || (isExporting ? 'Preparing...' : 'Processing...')} +

+
+
+ +

+ Please do not close this page or navigate away. This operation may take several minutes for large backups. +

+
+
+
+
)}
); From 40d285db2e10463757b6752e74dae8040681e904 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 08:58:28 +0100 Subject: [PATCH 04/15] Add relative path storage for library files to fix backup portability Library files now store paths relative to base_dir instead of absolute paths. This ensures thumbnails and files work correctly after restoring a backup on a different system or with a different data directory. Changes: - Add to_relative_path() and to_absolute_path() helper functions - Update file upload, ZIP extraction, and STL thumbnail generation to store relative paths - Update download, thumbnail, gcode, and delete endpoints to resolve relative paths when accessing files - Add database migration to convert existing absolute paths to relative Co-Authored-By: Claude Opus 4.5 The code is ready for testing. After pushing to the remote host: 1. The migration will run automatically on startup, converting any existing absolute paths 2. New files will be stored with relative paths 3. Thumbnails should display correctly after backup/restore --- backend/app/api/routes/library.py | 83 +- backend/app/core/database.py | 30 + .../{index-CxQsg2-C.js => index-B9KMl1Nj.js} | 892 +++++++++--------- static/assets/index-BMToB_z5.css | 1 + static/assets/index-d5ZW47G8.css | 1 - static/index.html | 4 +- 6 files changed, 536 insertions(+), 475 deletions(-) rename static/assets/{index-CxQsg2-C.js => index-B9KMl1Nj.js} (60%) create mode 100644 static/assets/index-BMToB_z5.css delete mode 100644 static/assets/index-d5ZW47G8.css diff --git a/backend/app/api/routes/library.py b/backend/app/api/routes/library.py index 0c75b63ef..9b69d3a8e 100644 --- a/backend/app/api/routes/library.py +++ b/backend/app/api/routes/library.py @@ -75,6 +75,30 @@ def get_library_thumbnails_dir() -> Path: return thumbnails_dir +def to_relative_path(absolute_path: Path | str) -> str: + """Convert an absolute path to a path relative to base_dir for storage.""" + if not absolute_path: + return "" + abs_path = Path(absolute_path) + base_dir = Path(app_settings.base_dir) + try: + return str(abs_path.relative_to(base_dir)) + except ValueError: + # Path is not under base_dir, return as-is (shouldn't happen normally) + return str(abs_path) + + +def to_absolute_path(relative_path: str | None) -> Path | None: + """Convert a relative path (from database) to an absolute path for file operations.""" + if not relative_path: + return None + # Handle already-absolute paths (for backwards compatibility during migration) + path = Path(relative_path) + if path.is_absolute(): + return path + return Path(app_settings.base_dir) / relative_path + + def calculate_file_hash(file_path: Path) -> str: """Calculate SHA256 hash of a file.""" sha256_hash = hashlib.sha256() @@ -722,15 +746,15 @@ async def upload_file( if generate_stl_thumbnails: thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) - # Create database entry + # Create database entry (store relative paths for portability) library_file = LibraryFile( folder_id=folder_id, filename=filename, - file_path=str(file_path), + file_path=to_relative_path(file_path), file_type=file_type, file_size=len(content), file_hash=file_hash, - thumbnail_path=thumbnail_path, + thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None, file_metadata=metadata if metadata else None, ) db.add(library_file) @@ -958,15 +982,15 @@ async def extract_zip_file( if generate_stl_thumbnails: thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) - # Create database entry + # Create database entry (store relative paths for portability) library_file = LibraryFile( folder_id=target_folder_id, filename=filename, - file_path=str(file_path), + file_path=to_relative_path(file_path), file_type=file_type, file_size=len(file_content), file_hash=file_hash, - thumbnail_path=thumbnail_path, + thumbnail_path=to_relative_path(thumbnail_path) if thumbnail_path else None, file_metadata=metadata if metadata else None, ) db.add(library_file) @@ -1062,9 +1086,9 @@ async def batch_generate_stl_thumbnails( failed = 0 for stl_file in stl_files: - file_path = Path(stl_file.file_path) + file_path = to_absolute_path(stl_file.file_path) - if not file_path.exists(): + if not file_path or not file_path.exists(): results.append( BatchThumbnailResult( file_id=stl_file.id, @@ -1080,8 +1104,8 @@ async def batch_generate_stl_thumbnails( thumbnail_path = generate_stl_thumbnail(file_path, thumbnails_dir) if thumbnail_path: - # Update database - stl_file.thumbnail_path = thumbnail_path + # Update database with relative path + stl_file.thumbnail_path = to_relative_path(thumbnail_path) await db.flush() results.append( BatchThumbnailResult( @@ -1843,10 +1867,12 @@ async def delete_file(file_id: int, db: AsyncSession = Depends(get_db)): # Delete actual files try: - if file.file_path and os.path.exists(file.file_path): - os.remove(file.file_path) - if file.thumbnail_path and os.path.exists(file.thumbnail_path): - os.remove(file.thumbnail_path) + abs_file_path = to_absolute_path(file.file_path) + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if abs_file_path and abs_file_path.exists(): + abs_file_path.unlink() + if abs_thumb_path and abs_thumb_path.exists(): + abs_thumb_path.unlink() except Exception as e: logger.warning(f"Failed to delete file from disk: {e}") @@ -1867,11 +1893,12 @@ async def download_file(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.file_path or not os.path.exists(file.file_path): + abs_path = to_absolute_path(file.file_path) + if not abs_path or not abs_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") return FastAPIFileResponse( - file.file_path, + str(abs_path), filename=file.filename, media_type="application/octet-stream", ) @@ -1886,11 +1913,12 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.thumbnail_path or not os.path.exists(file.thumbnail_path): + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if not abs_thumb_path or not abs_thumb_path.exists(): raise HTTPException(status_code=404, detail="Thumbnail not found") # Detect media type from extension - thumb_ext = os.path.splitext(file.thumbnail_path)[1].lower() + thumb_ext = abs_thumb_path.suffix.lower() media_types = { ".png": "image/png", ".jpg": "image/jpeg", @@ -1900,7 +1928,7 @@ async def get_thumbnail(file_id: int, db: AsyncSession = Depends(get_db)): } media_type = media_types.get(thumb_ext, "image/png") - return FastAPIFileResponse(file.thumbnail_path, media_type=media_type) + return FastAPIFileResponse(str(abs_thumb_path), media_type=media_type) @router.get("/files/{file_id}/gcode") @@ -1912,17 +1940,18 @@ async def get_gcode(file_id: int, db: AsyncSession = Depends(get_db)): if not file: raise HTTPException(status_code=404, detail="File not found") - if not file.file_path or not os.path.exists(file.file_path): + abs_path = to_absolute_path(file.file_path) + if not abs_path or not abs_path.exists(): raise HTTPException(status_code=404, detail="File not found on disk") if file.file_type == "gcode": - return FastAPIFileResponse(file.file_path, media_type="text/plain") + return FastAPIFileResponse(str(abs_path), media_type="text/plain") elif file.file_type == "3mf": # Extract gcode from 3mf import zipfile try: - with zipfile.ZipFile(file.file_path, "r") as zf: + with zipfile.ZipFile(str(abs_path), "r") as zf: # Find gcode file gcode_files = [n for n in zf.namelist() if n.endswith(".gcode")] if not gcode_files: @@ -1973,10 +2002,12 @@ async def bulk_delete(data: BulkDeleteRequest, db: AsyncSession = Depends(get_db file = result.scalar_one_or_none() if file: try: - if file.file_path and os.path.exists(file.file_path): - os.remove(file.file_path) - if file.thumbnail_path and os.path.exists(file.thumbnail_path): - os.remove(file.thumbnail_path) + abs_file_path = to_absolute_path(file.file_path) + abs_thumb_path = to_absolute_path(file.thumbnail_path) + if abs_file_path and abs_file_path.exists(): + abs_file_path.unlink() + if abs_thumb_path and abs_thumb_path.exists(): + abs_thumb_path.unlink() except Exception as e: logger.warning(f"Failed to delete file from disk: {e}") await db.delete(file) diff --git a/backend/app/core/database.py b/backend/app/core/database.py index 688797b79..aa78d1ff9 100644 --- a/backend/app/core/database.py +++ b/backend/app/core/database.py @@ -1014,6 +1014,36 @@ async def run_migrations(conn): except Exception: pass + # Migration: Convert absolute paths to relative paths in library_files table + # This ensures backup/restore portability across different installations + try: + base_dir_str = str(settings.base_dir) + # Ensure we have a trailing slash for clean replacement + if not base_dir_str.endswith("/"): + base_dir_str += "/" + + # Update file_path - remove base_dir prefix from absolute paths + await conn.execute( + text(""" + UPDATE library_files + SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1) + WHERE file_path LIKE :pattern + """), + {"base_dir": base_dir_str, "pattern": base_dir_str + "%"}, + ) + + # Update thumbnail_path - remove base_dir prefix from absolute paths + await conn.execute( + text(""" + UPDATE library_files + SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1) + WHERE thumbnail_path LIKE :pattern + """), + {"base_dir": base_dir_str, "pattern": base_dir_str + "%"}, + ) + except Exception: + pass + async def seed_notification_templates(): """Seed default notification templates if they don't exist.""" diff --git a/static/assets/index-CxQsg2-C.js b/static/assets/index-B9KMl1Nj.js similarity index 60% rename from static/assets/index-CxQsg2-C.js rename to static/assets/index-B9KMl1Nj.js index a85b2e893..67319730f 100644 --- a/static/assets/index-CxQsg2-C.js +++ b/static/assets/index-B9KMl1Nj.js @@ -1,20 +1,20 @@ -function jae(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var N_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function nl(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var IC={exports:{}},yb={};var OU;function Pae(){if(OU)return yb;OU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return yb.Fragment=e,yb.jsx=n,yb.jsxs=n,yb}var LU;function Rae(){return LU||(LU=1,IC.exports=Pae()),IC.exports}var a=Rae(),FC={exports:{}},bn={};var IU;function Dae(){if(IU)return bn;IU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),m=Symbol.iterator;function p(U){return U===null||typeof U!="object"?null:(U=m&&U[m]||U["@@iterator"],typeof U=="function"?U:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},b=Object.assign,y={};function v(U,W,le){this.props=U,this.context=W,this.refs=y,this.updater=le||x}v.prototype.isReactComponent={},v.prototype.setState=function(U,W){if(typeof U!="object"&&typeof U!="function"&&U!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,U,W,"setState")},v.prototype.forceUpdate=function(U){this.updater.enqueueForceUpdate(this,U,"forceUpdate")};function S(){}S.prototype=v.prototype;function N(U,W,le){this.props=U,this.context=W,this.refs=y,this.updater=le||x}var E=N.prototype=new S;E.constructor=N,b(E,v.prototype),E.isPureReactComponent=!0;var C=Array.isArray;function k(){}var T={H:null,A:null,T:null,S:null},L=Object.prototype.hasOwnProperty;function A(U,W,le){var re=le.ref;return{$$typeof:t,type:U,key:W,ref:re!==void 0?re:null,props:le}}function R(U,W){return A(U.type,W,U.props)}function F(U){return typeof U=="object"&&U!==null&&U.$$typeof===t}function D(U){var W={"=":"=0",":":"=2"};return"$"+U.replace(/[=:]/g,function(le){return W[le]})}var $=/\/+/g;function j(U,W){return typeof U=="object"&&U!==null&&U.key!=null?D(""+U.key):W.toString(36)}function G(U){switch(U.status){case"fulfilled":return U.value;case"rejected":throw U.reason;default:switch(typeof U.status=="string"?U.then(k,k):(U.status="pending",U.then(function(W){U.status==="pending"&&(U.status="fulfilled",U.value=W)},function(W){U.status==="pending"&&(U.status="rejected",U.reason=W)})),U.status){case"fulfilled":return U.value;case"rejected":throw U.reason}}throw U}function q(U,W,le,re,de){var fe=typeof U;(fe==="undefined"||fe==="boolean")&&(U=null);var we=!1;if(U===null)we=!0;else switch(fe){case"bigint":case"string":case"number":we=!0;break;case"object":switch(U.$$typeof){case t:case e:we=!0;break;case d:return we=U._init,q(we(U._payload),W,le,re,de)}}if(we)return de=de(U),we=re===""?"."+j(U,0):re,C(de)?(le="",we!=null&&(le=we.replace($,"$&/")+"/"),q(de,W,le,"",function(ke){return ke})):de!=null&&(F(de)&&(de=R(de,le+(de.key==null||U&&U.key===de.key?"":(""+de.key).replace($,"$&/")+"/")+we)),W.push(de)),1;we=0;var _e=re===""?".":re+":";if(C(U))for(var ge=0;ge>>1,ue=q[te];if(0>>1;tei(le,Q))rei(de,le)?(q[te]=de,q[re]=Q,te=re):(q[te]=le,q[W]=Q,te=W);else if(rei(de,Q))q[te]=de,q[re]=Q,te=re;else break e}}return K}function i(q,K){var Q=q.sortIndex-K.sortIndex;return Q!==0?Q:q.id-K.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,m=3,p=!1,x=!1,b=!1,y=!1,v=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,N=typeof setImmediate<"u"?setImmediate:null;function E(q){for(var K=n(u);K!==null;){if(K.callback===null)r(u);else if(K.startTime<=q)r(u),K.sortIndex=K.expirationTime,e(c,K);else break;K=n(u)}}function C(q){if(b=!1,E(q),!x)if(n(c)!==null)x=!0,k||(k=!0,D());else{var K=n(u);K!==null&&G(C,K.startTime-q)}}var k=!1,T=-1,L=5,A=-1;function R(){return y?!0:!(t.unstable_now()-Aq&&R());){var te=f.callback;if(typeof te=="function"){f.callback=null,m=f.priorityLevel;var ue=te(f.expirationTime<=q);if(q=t.unstable_now(),typeof ue=="function"){f.callback=ue,E(q),K=!0;break t}f===n(c)&&r(c),E(q)}else r(c);f=n(c)}if(f!==null)K=!0;else{var U=n(u);U!==null&&G(C,U.startTime-q),K=!1}}break e}finally{f=null,m=Q,p=!1}K=void 0}}finally{K?D():k=!1}}}var D;if(typeof N=="function")D=function(){N(F)};else if(typeof MessageChannel<"u"){var $=new MessageChannel,j=$.port2;$.port1.onmessage=F,D=function(){j.postMessage(null)}}else D=function(){v(F,0)};function G(q,K){T=v(function(){q(t.unstable_now())},K)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(q){q.callback=null},t.unstable_forceFrameRate=function(q){0>q||125te?(q.sortIndex=Q,e(u,q),n(c)===null&&q===n(u)&&(b?(S(T),T=-1):b=!0,G(C,Q-te))):(q.sortIndex=ue,e(c,q),x||p||(x=!0,k||(k=!0,D()))),q},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(q){var K=m;return function(){var Q=m;m=K;try{return q.apply(this,arguments)}finally{m=Q}}}})(BC)),BC}var zU;function Iae(){return zU||(zU=1,zC.exports=Lae()),zC.exports}var $C={exports:{}},Oa={};var BU;function Fae(){if(BU)return Oa;BU=1;var t=cx();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),$C.exports=Fae(),$C.exports}var HU;function Uae(){if(HU)return vb;HU=1;var t=Iae(),e=cx(),n=bq();function r(h){var g="https://react.dev/errors/"+h;if(1ue||(h.current=te[ue],te[ue]=null,ue--)}function le(h,g){ue++,te[ue]=h.current,h.current=g}var re=U(null),de=U(null),fe=U(null),we=U(null);function _e(h,g){switch(le(fe,g),le(de,h),le(re,null),g.nodeType){case 9:case 11:h=(h=g.documentElement)&&(h=h.namespaceURI)?iU(h):0;break;default:if(h=g.tagName,g=g.namespaceURI)g=iU(g),h=aU(g,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}W(re),le(re,h)}function ge(){W(re),W(de),W(fe)}function ke(h){h.memoizedState!==null&&le(we,h);var g=re.current,_=aU(g,h.type);g!==_&&(le(de,h),le(re,_))}function De(h){de.current===h&&(W(re),W(de)),we.current===h&&(W(we),pb._currentValue=Q)}var ne,Be;function z(h){if(ne===void 0)try{throw Error()}catch(_){var g=_.stack.trim().match(/\n( *(at )?)/);ne=g&&g[1]||"",Be=-1<_.stack.indexOf(` +function Aae(t,e){for(var n=0;nr[i]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))r(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const o of s.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerPolicy&&(s.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?s.credentials="include":i.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(i){if(i.ep)return;i.ep=!0;const s=n(i);fetch(i.href,s)}})();var w_=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function nl(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var OT={exports:{}},xb={};var RU;function kae(){if(RU)return xb;RU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.fragment");function n(r,i,s){var o=null;if(s!==void 0&&(o=""+s),i.key!==void 0&&(o=""+i.key),"key"in i){s={};for(var l in i)l!=="key"&&(s[l]=i[l])}else s=i;return i=s.ref,{$$typeof:t,type:r,key:o,ref:i!==void 0?i:null,props:s}}return xb.Fragment=e,xb.jsx=n,xb.jsxs=n,xb}var DU;function jae(){return DU||(DU=1,OT.exports=kae()),OT.exports}var a=jae(),LT={exports:{}},bn={};var OU;function Pae(){if(OU)return bn;OU=1;var t=Symbol.for("react.transitional.element"),e=Symbol.for("react.portal"),n=Symbol.for("react.fragment"),r=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),s=Symbol.for("react.consumer"),o=Symbol.for("react.context"),l=Symbol.for("react.forward_ref"),c=Symbol.for("react.suspense"),u=Symbol.for("react.memo"),d=Symbol.for("react.lazy"),f=Symbol.for("react.activity"),m=Symbol.iterator;function p(U){return U===null||typeof U!="object"?null:(U=m&&U[m]||U["@@iterator"],typeof U=="function"?U:null)}var x={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},y=Object.assign,b={};function v(U,q,oe){this.props=U,this.context=q,this.refs=b,this.updater=oe||x}v.prototype.isReactComponent={},v.prototype.setState=function(U,q){if(typeof U!="object"&&typeof U!="function"&&U!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,U,q,"setState")},v.prototype.forceUpdate=function(U){this.updater.enqueueForceUpdate(this,U,"forceUpdate")};function S(){}S.prototype=v.prototype;function M(U,q,oe){this.props=U,this.context=q,this.refs=b,this.updater=oe||x}var C=M.prototype=new S;C.constructor=M,y(C,v.prototype),C.isPureReactComponent=!0;var E=Array.isArray;function k(){}var T={H:null,A:null,T:null,S:null},L=Object.prototype.hasOwnProperty;function A(U,q,oe){var ne=oe.ref;return{$$typeof:t,type:U,key:q,ref:ne!==void 0?ne:null,props:oe}}function R(U,q){return A(U.type,q,U.props)}function F(U){return typeof U=="object"&&U!==null&&U.$$typeof===t}function D(U){var q={"=":"=0",":":"=2"};return"$"+U.replace(/[=:]/g,function(oe){return q[oe]})}var H=/\/+/g;function j(U,q){return typeof U=="object"&&U!==null&&U.key!=null?D(""+U.key):q.toString(36)}function G(U){switch(U.status){case"fulfilled":return U.value;case"rejected":throw U.reason;default:switch(typeof U.status=="string"?U.then(k,k):(U.status="pending",U.then(function(q){U.status==="pending"&&(U.status="fulfilled",U.value=q)},function(q){U.status==="pending"&&(U.status="rejected",U.reason=q)})),U.status){case"fulfilled":return U.value;case"rejected":throw U.reason}}throw U}function W(U,q,oe,ne,de){var me=typeof U;(me==="undefined"||me==="boolean")&&(U=null);var we=!1;if(U===null)we=!0;else switch(me){case"bigint":case"string":case"number":we=!0;break;case"object":switch(U.$$typeof){case t:case e:we=!0;break;case d:return we=U._init,W(we(U._payload),q,oe,ne,de)}}if(we)return de=de(U),we=ne===""?"."+j(U,0):ne,E(de)?(oe="",we!=null&&(oe=we.replace(H,"$&/")+"/"),W(de,q,oe,"",function(Pe){return Pe})):de!=null&&(F(de)&&(de=R(de,oe+(de.key==null||U&&U.key===de.key?"":(""+de.key).replace(H,"$&/")+"/")+we)),q.push(de)),1;we=0;var _e=ne===""?".":ne+":";if(E(U))for(var ge=0;ge>>1,ue=W[ee];if(0>>1;eei(oe,Q))nei(de,oe)?(W[ee]=de,W[ne]=Q,ee=ne):(W[ee]=oe,W[q]=Q,ee=q);else if(nei(de,Q))W[ee]=de,W[ne]=Q,ee=ne;else break e}}return K}function i(W,K){var Q=W.sortIndex-K.sortIndex;return Q!==0?Q:W.id-K.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var s=performance;t.unstable_now=function(){return s.now()}}else{var o=Date,l=o.now();t.unstable_now=function(){return o.now()-l}}var c=[],u=[],d=1,f=null,m=3,p=!1,x=!1,y=!1,b=!1,v=typeof setTimeout=="function"?setTimeout:null,S=typeof clearTimeout=="function"?clearTimeout:null,M=typeof setImmediate<"u"?setImmediate:null;function C(W){for(var K=n(u);K!==null;){if(K.callback===null)r(u);else if(K.startTime<=W)r(u),K.sortIndex=K.expirationTime,e(c,K);else break;K=n(u)}}function E(W){if(y=!1,C(W),!x)if(n(c)!==null)x=!0,k||(k=!0,D());else{var K=n(u);K!==null&&G(E,K.startTime-W)}}var k=!1,T=-1,L=5,A=-1;function R(){return b?!0:!(t.unstable_now()-AW&&R());){var ee=f.callback;if(typeof ee=="function"){f.callback=null,m=f.priorityLevel;var ue=ee(f.expirationTime<=W);if(W=t.unstable_now(),typeof ue=="function"){f.callback=ue,C(W),K=!0;break t}f===n(c)&&r(c),C(W)}else r(c);f=n(c)}if(f!==null)K=!0;else{var U=n(u);U!==null&&G(E,U.startTime-W),K=!1}}break e}finally{f=null,m=Q,p=!1}K=void 0}}finally{K?D():k=!1}}}var D;if(typeof M=="function")D=function(){M(F)};else if(typeof MessageChannel<"u"){var H=new MessageChannel,j=H.port2;H.port1.onmessage=F,D=function(){j.postMessage(null)}}else D=function(){v(F,0)};function G(W,K){T=v(function(){W(t.unstable_now())},K)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(W){W.callback=null},t.unstable_forceFrameRate=function(W){0>W||125ee?(W.sortIndex=Q,e(u,W),n(c)===null&&W===n(u)&&(y?(S(T),T=-1):y=!0,G(E,Q-ee))):(W.sortIndex=ue,e(c,W),x||p||(x=!0,k||(k=!0,D()))),W},t.unstable_shouldYield=R,t.unstable_wrapCallback=function(W){var K=m;return function(){var Q=m;m=K;try{return W.apply(this,arguments)}finally{m=Q}}}})(UT)),UT}var FU;function Oae(){return FU||(FU=1,FT.exports=Dae()),FT.exports}var zT={exports:{}},Da={};var UU;function Lae(){if(UU)return Da;UU=1;var t=cx();function e(c){var u="https://react.dev/errors/"+c;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(e){console.error(e)}}return t(),zT.exports=Lae(),zT.exports}var BU;function Iae(){if(BU)return bb;BU=1;var t=Oae(),e=cx(),n=pq();function r(h){var g="https://react.dev/errors/"+h;if(1ue||(h.current=ee[ue],ee[ue]=null,ue--)}function oe(h,g){ue++,ee[ue]=h.current,h.current=g}var ne=U(null),de=U(null),me=U(null),we=U(null);function _e(h,g){switch(oe(me,g),oe(de,h),oe(ne,null),g.nodeType){case 9:case 11:h=(h=g.documentElement)&&(h=h.namespaceURI)?nU(h):0;break;default:if(h=g.tagName,g=g.namespaceURI)g=nU(g),h=rU(g,h);else switch(h){case"svg":h=1;break;case"math":h=2;break;default:h=0}}q(ne),oe(ne,h)}function ge(){q(ne),q(de),q(me)}function Pe(h){h.memoizedState!==null&&oe(we,h);var g=ne.current,_=rU(g,h.type);g!==_&&(oe(de,h),oe(ne,_))}function De(h){de.current===h&&(q(ne),q(de)),we.current===h&&(q(we),fb._currentValue=Q)}var ae,Be;function z(h){if(ae===void 0)try{throw Error()}catch(_){var g=_.stack.trim().match(/\n( *(at )?)/);ae=g&&g[1]||"",Be=-1<_.stack.indexOf(` at`)?" ()":-1<_.stack.indexOf("@")?"@unknown:0:0":""}return` -`+ne+h+Be}var X=!1;function Z(h,g){if(!h||X)return"";X=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var M={DetermineComponentFrameRoot:function(){try{if(g){var bt=function(){throw Error()};if(Object.defineProperty(bt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(bt,[])}catch(ft){var ot=ft}Reflect.construct(h,[],bt)}else{try{bt.call()}catch(ft){ot=ft}h.call(bt.prototype)}}else{try{throw Error()}catch(ft){ot=ft}(bt=h())&&typeof bt.catch=="function"&&bt.catch(function(){})}}catch(ft){if(ft&&ot&&typeof ft.stack=="string")return[ft.stack,ot.stack]}return[null,null]}};M.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(M.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(M.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var I=M.DetermineComponentFrameRoot(),J=I[0],ve=I[1];if(J&&ve){var Ue=J.split(` +`+ae+h+Be}var X=!1;function Z(h,g){if(!h||X)return"";X=!0;var _=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var N={DetermineComponentFrameRoot:function(){try{if(g){var bt=function(){throw Error()};if(Object.defineProperty(bt.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(bt,[])}catch(ft){var ot=ft}Reflect.construct(h,[],bt)}else{try{bt.call()}catch(ft){ot=ft}h.call(bt.prototype)}}else{try{throw Error()}catch(ft){ot=ft}(bt=h())&&typeof bt.catch=="function"&&bt.catch(function(){})}}catch(ft){if(ft&&ot&&typeof ft.stack=="string")return[ft.stack,ot.stack]}return[null,null]}};N.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var P=Object.getOwnPropertyDescriptor(N.DetermineComponentFrameRoot,"name");P&&P.configurable&&Object.defineProperty(N.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var I=N.DetermineComponentFrameRoot(),J=I[0],ve=I[1];if(J&&ve){var Ue=J.split(` `),rt=ve.split(` -`);for(P=M=0;MP||Ue[M]!==rt[P]){var mt=` -`+Ue[M].replace(" at new "," at ");return h.displayName&&mt.includes("")&&(mt=mt.replace("",h.displayName)),mt}while(1<=M&&0<=P);break}}}finally{X=!1,Error.prepareStackTrace=_}return(_=h?h.displayName||h.name:"")?z(_):""}function V(h,g){switch(h.tag){case 26:case 27:case 5:return z(h.type);case 16:return z("Lazy");case 13:return h.child!==g&&g!==null?z("Suspense Fallback"):z("Suspense");case 19:return z("SuspenseList");case 0:case 15:return Z(h.type,!1);case 11:return Z(h.type.render,!1);case 1:return Z(h.type,!0);case 31:return z("Activity");default:return""}}function H(h){try{var g="",_=null;do g+=V(h,_),_=h,h=h.return;while(h);return g}catch(M){return` -Error generating stack: `+M.message+` -`+M.stack}}var se=Object.prototype.hasOwnProperty,Te=t.unstable_scheduleCallback,Re=t.unstable_cancelCallback,je=t.unstable_shouldYield,B=t.unstable_requestPaint,O=t.unstable_now,Y=t.unstable_getCurrentPriorityLevel,ee=t.unstable_ImmediatePriority,oe=t.unstable_UserBlockingPriority,xe=t.unstable_NormalPriority,Oe=t.unstable_LowPriority,ze=t.unstable_IdlePriority,He=t.log,Je=t.unstable_setDisableYieldValue,Le=null,ae=null;function Ge(h){if(typeof He=="function"&&Je(h),ae&&typeof ae.setStrictMode=="function")try{ae.setStrictMode(Le,h)}catch{}}var Ne=Math.clz32?Math.clz32:Se,$e=Math.log,ut=Math.LN2;function Se(h){return h>>>=0,h===0?32:31-($e(h)/ut|0)|0}var nt=256,ie=262144,he=4194304;function Ae(h){var g=h&42;if(g!==0)return g;switch(h&-h){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return h&261888;case 262144:case 524288:case 1048576:case 2097152:return h&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return h&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return h}}function be(h,g,_){var M=h.pendingLanes;if(M===0)return 0;var P=0,I=h.suspendedLanes,J=h.pingedLanes;h=h.warmLanes;var ve=M&134217727;return ve!==0?(M=ve&~I,M!==0?P=Ae(M):(J&=ve,J!==0?P=Ae(J):_||(_=ve&~h,_!==0&&(P=Ae(_))))):(ve=M&~I,ve!==0?P=Ae(ve):J!==0?P=Ae(J):_||(_=M&~h,_!==0&&(P=Ae(_)))),P===0?0:g!==0&&g!==P&&(g&I)===0&&(I=P&-P,_=g&-g,I>=_||I===32&&(_&4194048)!==0)?g:P}function Ve(h,g){return(h.pendingLanes&~(h.suspendedLanes&~h.pingedLanes)&g)===0}function pt(h,g){switch(h){case 1:case 2:case 4:case 8:case 64:return g+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return g+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Ct(){var h=he;return he<<=1,(he&62914560)===0&&(he=4194304),h}function jt(h){for(var g=[],_=0;31>_;_++)g.push(h);return g}function Qt(h,g){h.pendingLanes|=g,g!==268435456&&(h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0)}function Ft(h,g,_,M,P,I){var J=h.pendingLanes;h.pendingLanes=_,h.suspendedLanes=0,h.pingedLanes=0,h.warmLanes=0,h.expiredLanes&=_,h.entangledLanes&=_,h.errorRecoveryDisabledLanes&=_,h.shellSuspendCounter=0;var ve=h.entanglements,Ue=h.expirationTimes,rt=h.hiddenUpdates;for(_=J&~_;0<_;){var mt=31-Ne(_),bt=1<"u")return null;try{return h.activeElement||h.body}catch{return h.body}}var Et=/[\n"\\]/g;function $t(h){return h.replace(Et,function(g){return"\\"+g.charCodeAt(0).toString(16)+" "})}function xt(h,g,_,M,P,I,J,ve){h.name="",J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"?h.type=J:h.removeAttribute("type"),g!=null?J==="number"?(g===0&&h.value===""||h.value!=g)&&(h.value=""+sn(g)):h.value!==""+sn(g)&&(h.value=""+sn(g)):J!=="submit"&&J!=="reset"||h.removeAttribute("value"),g!=null?Dn(h,J,sn(g)):_!=null?Dn(h,J,sn(_)):M!=null&&h.removeAttribute("value"),P==null&&I!=null&&(h.defaultChecked=!!I),P!=null&&(h.checked=P&&typeof P!="function"&&typeof P!="symbol"),ve!=null&&typeof ve!="function"&&typeof ve!="symbol"&&typeof ve!="boolean"?h.name=""+sn(ve):h.removeAttribute("name")}function Ut(h,g,_,M,P,I,J,ve){if(I!=null&&typeof I!="function"&&typeof I!="symbol"&&typeof I!="boolean"&&(h.type=I),g!=null||_!=null){if(!(I!=="submit"&&I!=="reset"||g!=null)){un(h);return}_=_!=null?""+sn(_):"",g=g!=null?""+sn(g):_,ve||g===h.value||(h.value=g),h.defaultValue=g}M=M??P,M=typeof M!="function"&&typeof M!="symbol"&&!!M,h.checked=ve?h.checked:!!M,h.defaultChecked=!!M,J!=null&&typeof J!="function"&&typeof J!="symbol"&&typeof J!="boolean"&&(h.name=J),un(h)}function Dn(h,g,_){g==="number"&&Qe(h.ownerDocument)===h||h.defaultValue===""+_||(h.defaultValue=""+_)}function Sn(h,g,_,M){if(h=h.options,g){g={};for(var P=0;P<_.length;P++)g["$"+_[P]]=!0;for(_=0;_"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Uh=!1;if(sl)try{var qu={};Object.defineProperty(qu,"passive",{get:function(){Uh=!0}}),window.addEventListener("test",qu,qu),window.removeEventListener("test",qu,qu)}catch{Uh=!1}var go=null,Rl=null,Wu=null;function Rm(){if(Wu)return Wu;var h,g=Rl,_=g.length,M,P="value"in go?go.value:go.textContent,I=P.length;for(h=0;h<_&&g[h]===P[h];h++);var J=_-h;for(M=1;M<=J&&g[_-M]===P[I-M];M++);return Wu=P.slice(h,1=Lx),d5=" ",h5=!1;function f5(h,g){switch(h){case"keyup":return Jre.indexOf(g.keyCode)!==-1;case"keydown":return g.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function m5(h){return h=h.detail,typeof h=="object"&&"data"in h?h.data:null}var Om=!1;function tie(h,g){switch(h){case"compositionend":return m5(g);case"keypress":return g.which!==32?null:(h5=!0,d5);case"textInput":return h=g.data,h===d5&&h5?null:h;default:return null}}function nie(h,g){if(Om)return h==="compositionend"||!HM&&f5(h,g)?(h=Rm(),Wu=Rl=go=null,Om=!1,h):null;switch(h){case"paste":return null;case"keypress":if(!(g.ctrlKey||g.altKey||g.metaKey)||g.ctrlKey&&g.altKey){if(g.char&&1=g)return{node:_,offset:g-h};h=M}e:{for(;_;){if(_.nextSibling){_=_.nextSibling;break e}_=_.parentNode}_=void 0}_=w5(_)}}function N5(h,g){return h&&g?h===g?!0:h&&h.nodeType===3?!1:g&&g.nodeType===3?N5(h,g.parentNode):"contains"in h?h.contains(g):h.compareDocumentPosition?!!(h.compareDocumentPosition(g)&16):!1:!1}function M5(h){h=h!=null&&h.ownerDocument!=null&&h.ownerDocument.defaultView!=null?h.ownerDocument.defaultView:window;for(var g=Qe(h.document);g instanceof h.HTMLIFrameElement;){try{var _=typeof g.contentWindow.location.href=="string"}catch{_=!1}if(_)h=g.contentWindow;else break;g=Qe(h.document)}return g}function qM(h){var g=h&&h.nodeName&&h.nodeName.toLowerCase();return g&&(g==="input"&&(h.type==="text"||h.type==="search"||h.type==="tel"||h.type==="url"||h.type==="password")||g==="textarea"||h.contentEditable==="true")}var uie=sl&&"documentMode"in document&&11>=document.documentMode,Lm=null,WM=null,zx=null,KM=!1;function E5(h,g,_){var M=_.window===_?_.document:_.nodeType===9?_:_.ownerDocument;KM||Lm==null||Lm!==Qe(M)||(M=Lm,"selectionStart"in M&&qM(M)?M={start:M.selectionStart,end:M.selectionEnd}:(M=(M.ownerDocument&&M.ownerDocument.defaultView||window).getSelection(),M={anchorNode:M.anchorNode,anchorOffset:M.anchorOffset,focusNode:M.focusNode,focusOffset:M.focusOffset}),zx&&Ux(zx,M)||(zx=M,M=c_(WM,"onSelect"),0>=J,P-=J,Il=1<<32-Ne(g)+P|_<Mn?(Hn=Kt,Kt=null):Hn=Kt.sibling;var rr=ot(Ye,Kt,tt[Mn],gt);if(rr===null){Kt===null&&(Kt=Hn);break}h&&Kt&&rr.alternate===null&&g(Ye,Kt),qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr,Kt=Hn}if(Mn===tt.length)return _(Ye,Kt),Gn&&Mc(Ye,Mn),Jt;if(Kt===null){for(;MnMn?(Hn=Kt,Kt=null):Hn=Kt.sibling;var gd=ot(Ye,Kt,rr.value,gt);if(gd===null){Kt===null&&(Kt=Hn);break}h&&Kt&&gd.alternate===null&&g(Ye,Kt),qe=I(gd,qe,Mn),nr===null?Jt=gd:nr.sibling=gd,nr=gd,Kt=Hn}if(rr.done)return _(Ye,Kt),Gn&&Mc(Ye,Mn),Jt;if(Kt===null){for(;!rr.done;Mn++,rr=tt.next())rr=bt(Ye,rr.value,gt),rr!==null&&(qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr);return Gn&&Mc(Ye,Mn),Jt}for(Kt=M(Kt);!rr.done;Mn++,rr=tt.next())rr=ft(Kt,Ye,Mn,rr.value,gt),rr!==null&&(h&&rr.alternate!==null&&Kt.delete(rr.key===null?Mn:rr.key),qe=I(rr,qe,Mn),nr===null?Jt=rr:nr.sibling=rr,nr=rr);return h&&Kt.forEach(function(kae){return g(Ye,kae)}),Gn&&Mc(Ye,Mn),Jt}function vr(Ye,qe,tt,gt){if(typeof tt=="object"&&tt!==null&&tt.type===b&&tt.key===null&&(tt=tt.props.children),typeof tt=="object"&&tt!==null){switch(tt.$$typeof){case p:e:{for(var Jt=tt.key;qe!==null;){if(qe.key===Jt){if(Jt=tt.type,Jt===b){if(qe.tag===7){_(Ye,qe.sibling),gt=P(qe,tt.props.children),gt.return=Ye,Ye=gt;break e}}else if(qe.elementType===Jt||typeof Jt=="object"&&Jt!==null&&Jt.$$typeof===L&&Zh(Jt)===qe.type){_(Ye,qe.sibling),gt=P(qe,tt.props),qx(gt,tt),gt.return=Ye,Ye=gt;break e}_(Ye,qe);break}else g(Ye,qe);qe=qe.sibling}tt.type===b?(gt=Wh(tt.props.children,Ye.mode,gt,tt.key),gt.return=Ye,Ye=gt):(gt=Ev(tt.type,tt.key,tt.props,null,Ye.mode,gt),qx(gt,tt),gt.return=Ye,Ye=gt)}return J(Ye);case x:e:{for(Jt=tt.key;qe!==null;){if(qe.key===Jt)if(qe.tag===4&&qe.stateNode.containerInfo===tt.containerInfo&&qe.stateNode.implementation===tt.implementation){_(Ye,qe.sibling),gt=P(qe,tt.children||[]),gt.return=Ye,Ye=gt;break e}else{_(Ye,qe);break}else g(Ye,qe);qe=qe.sibling}gt=tE(tt,Ye.mode,gt),gt.return=Ye,Ye=gt}return J(Ye);case L:return tt=Zh(tt),vr(Ye,qe,tt,gt)}if(G(tt))return Gt(Ye,qe,tt,gt);if(D(tt)){if(Jt=D(tt),typeof Jt!="function")throw Error(r(150));return tt=Jt.call(tt),an(Ye,qe,tt,gt)}if(typeof tt.then=="function")return vr(Ye,qe,Rv(tt),gt);if(tt.$$typeof===N)return vr(Ye,qe,Av(Ye,tt),gt);Dv(Ye,tt)}return typeof tt=="string"&&tt!==""||typeof tt=="number"||typeof tt=="bigint"?(tt=""+tt,qe!==null&&qe.tag===6?(_(Ye,qe.sibling),gt=P(qe,tt),gt.return=Ye,Ye=gt):(_(Ye,qe),gt=eE(tt,Ye.mode,gt),gt.return=Ye,Ye=gt),J(Ye)):_(Ye,qe)}return function(Ye,qe,tt,gt){try{Gx=0;var Jt=vr(Ye,qe,tt,gt);return Wm=null,Jt}catch(Kt){if(Kt===qm||Kt===jv)throw Kt;var nr=$s(29,Kt,null,Ye.mode);return nr.lanes=gt,nr.return=Ye,nr}finally{}}}var ef=X5(!0),Y5=X5(!1),Ju=!1;function fE(h){h.updateQueue={baseState:h.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function mE(h,g){h=h.updateQueue,g.updateQueue===h&&(g.updateQueue={baseState:h.baseState,firstBaseUpdate:h.firstBaseUpdate,lastBaseUpdate:h.lastBaseUpdate,shared:h.shared,callbacks:null})}function ed(h){return{lane:h,tag:0,payload:null,callback:null,next:null}}function td(h,g,_){var M=h.updateQueue;if(M===null)return null;if(M=M.shared,(or&2)!==0){var P=M.pending;return P===null?g.next=g:(g.next=P.next,P.next=g),M.pending=g,g=Mv(h),R5(h,null,_),g}return Nv(h,M,g,_),Mv(h)}function Wx(h,g,_){if(g=g.updateQueue,g!==null&&(g=g.shared,(_&4194048)!==0)){var M=g.lanes;M&=h.pendingLanes,_|=M,g.lanes=_,qt(h,_)}}function pE(h,g){var _=h.updateQueue,M=h.alternate;if(M!==null&&(M=M.updateQueue,_===M)){var P=null,I=null;if(_=_.firstBaseUpdate,_!==null){do{var J={lane:_.lane,tag:_.tag,payload:_.payload,callback:null,next:null};I===null?P=I=J:I=I.next=J,_=_.next}while(_!==null);I===null?P=I=g:I=I.next=g}else P=I=g;_={baseState:M.baseState,firstBaseUpdate:P,lastBaseUpdate:I,shared:M.shared,callbacks:M.callbacks},h.updateQueue=_;return}h=_.lastBaseUpdate,h===null?_.firstBaseUpdate=g:h.next=g,_.lastBaseUpdate=g}var gE=!1;function Kx(){if(gE){var h=Gm;if(h!==null)throw h}}function Xx(h,g,_,M){gE=!1;var P=h.updateQueue;Ju=!1;var I=P.firstBaseUpdate,J=P.lastBaseUpdate,ve=P.shared.pending;if(ve!==null){P.shared.pending=null;var Ue=ve,rt=Ue.next;Ue.next=null,J===null?I=rt:J.next=rt,J=Ue;var mt=h.alternate;mt!==null&&(mt=mt.updateQueue,ve=mt.lastBaseUpdate,ve!==J&&(ve===null?mt.firstBaseUpdate=rt:ve.next=rt,mt.lastBaseUpdate=Ue))}if(I!==null){var bt=P.baseState;J=0,mt=rt=Ue=null,ve=I;do{var ot=ve.lane&-536870913,ft=ot!==ve.lane;if(ft?($n&ot)===ot:(M&ot)===ot){ot!==0&&ot===Vm&&(gE=!0),mt!==null&&(mt=mt.next={lane:0,tag:ve.tag,payload:ve.payload,callback:null,next:null});e:{var Gt=h,an=ve;ot=g;var vr=_;switch(an.tag){case 1:if(Gt=an.payload,typeof Gt=="function"){bt=Gt.call(vr,bt,ot);break e}bt=Gt;break e;case 3:Gt.flags=Gt.flags&-65537|128;case 0:if(Gt=an.payload,ot=typeof Gt=="function"?Gt.call(vr,bt,ot):Gt,ot==null)break e;bt=f({},bt,ot);break e;case 2:Ju=!0}}ot=ve.callback,ot!==null&&(h.flags|=64,ft&&(h.flags|=8192),ft=P.callbacks,ft===null?P.callbacks=[ot]:ft.push(ot))}else ft={lane:ot,tag:ve.tag,payload:ve.payload,callback:ve.callback,next:null},mt===null?(rt=mt=ft,Ue=bt):mt=mt.next=ft,J|=ot;if(ve=ve.next,ve===null){if(ve=P.shared.pending,ve===null)break;ft=ve,ve=ft.next,ft.next=null,P.lastBaseUpdate=ft,P.shared.pending=null}}while(!0);mt===null&&(Ue=bt),P.baseState=Ue,P.firstBaseUpdate=rt,P.lastBaseUpdate=mt,I===null&&(P.shared.lanes=0),sd|=J,h.lanes=J,h.memoizedState=bt}}function Q5(h,g){if(typeof h!="function")throw Error(r(191,h));h.call(g)}function Z5(h,g){var _=h.callbacks;if(_!==null)for(h.callbacks=null,h=0;h<_.length;h++)Q5(_[h],g)}var Km=U(null),Ov=U(0);function J5(h,g){h=Lc,le(Ov,h),le(Km,g),Lc=h|g.baseLanes}function xE(){le(Ov,Lc),le(Km,Km.current)}function bE(){Lc=Ov.current,W(Km),W(Ov)}var Hs=U(null),So=null;function nd(h){var g=h.alternate;le(gi,gi.current&1),le(Hs,h),So===null&&(g===null||Km.current!==null||g.memoizedState!==null)&&(So=h)}function yE(h){le(gi,gi.current),le(Hs,h),So===null&&(So=h)}function eI(h){h.tag===22?(le(gi,gi.current),le(Hs,h),So===null&&(So=h)):rd()}function rd(){le(gi,gi.current),le(Hs,Hs.current)}function Vs(h){W(Hs),So===h&&(So=null),W(gi)}var gi=U(0);function Lv(h){for(var g=h;g!==null;){if(g.tag===13){var _=g.memoizedState;if(_!==null&&(_=_.dehydrated,_===null||MC(_)||EC(_)))return g}else if(g.tag===19&&(g.memoizedProps.revealOrder==="forwards"||g.memoizedProps.revealOrder==="backwards"||g.memoizedProps.revealOrder==="unstable_legacy-backwards"||g.memoizedProps.revealOrder==="together")){if((g.flags&128)!==0)return g}else if(g.child!==null){g.child.return=g,g=g.child;continue}if(g===h)break;for(;g.sibling===null;){if(g.return===null||g.return===h)return null;g=g.return}g.sibling.return=g.return,g=g.sibling}return null}var Tc=0,wn=null,br=null,Mi=null,Iv=!1,Xm=!1,tf=!1,Fv=0,Yx=0,Ym=null,vie=0;function si(){throw Error(r(321))}function vE(h,g){if(g===null)return!1;for(var _=0;_I?I:8;var J=q.T,ve={};q.T=ve,OE(h,!1,g,_);try{var Ue=P(),rt=q.S;if(rt!==null&&rt(ve,Ue),Ue!==null&&typeof Ue=="object"&&typeof Ue.then=="function"){var mt=yie(Ue,M);Zx(h,g,mt,Ws(h))}else Zx(h,g,M,Ws(h))}catch(bt){Zx(h,g,{then:function(){},status:"rejected",reason:bt},Ws())}finally{K.p=I,J!==null&&ve.types!==null&&(J.types=ve.types),q.T=J}}function Mie(){}function RE(h,g,_,M){if(h.tag!==5)throw Error(r(476));var P=kI(h).queue;AI(h,P,g,Q,_===null?Mie:function(){return jI(h),_(M)})}function kI(h){var g=h.memoizedState;if(g!==null)return g;g={memoizedState:Q,baseState:Q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ac,lastRenderedState:Q},next:null};var _={};return g.next={memoizedState:_,baseState:_,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ac,lastRenderedState:_},next:null},h.memoizedState=g,h=h.alternate,h!==null&&(h.memoizedState=g),g}function jI(h){var g=kI(h);g.next===null&&(g=h.alternate.memoizedState),Zx(h,g.next.queue,{},Ws())}function DE(){return ba(pb)}function PI(){return xi().memoizedState}function RI(){return xi().memoizedState}function Eie(h){for(var g=h.return;g!==null;){switch(g.tag){case 24:case 3:var _=Ws();h=ed(_);var M=td(g,h,_);M!==null&&(ws(M,g,_),Wx(M,g,_)),g={cache:cE()},h.payload=g;return}g=g.return}}function Cie(h,g,_){var M=Ws();_={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null},Vv(h)?OI(g,_):(_=ZM(h,g,_,M),_!==null&&(ws(_,h,M),LI(_,g,M)))}function DI(h,g,_){var M=Ws();Zx(h,g,_,M)}function Zx(h,g,_,M){var P={lane:M,revertLane:0,gesture:null,action:_,hasEagerState:!1,eagerState:null,next:null};if(Vv(h))OI(g,P);else{var I=h.alternate;if(h.lanes===0&&(I===null||I.lanes===0)&&(I=g.lastRenderedReducer,I!==null))try{var J=g.lastRenderedState,ve=I(J,_);if(P.hasEagerState=!0,P.eagerState=ve,Bs(ve,J))return Nv(h,g,P,0),Pr===null&&Sv(),!1}catch{}finally{}if(_=ZM(h,g,P,M),_!==null)return ws(_,h,M),LI(_,g,M),!0}return!1}function OE(h,g,_,M){if(M={lane:2,revertLane:fC(),gesture:null,action:M,hasEagerState:!1,eagerState:null,next:null},Vv(h)){if(g)throw Error(r(479))}else g=ZM(h,_,M,2),g!==null&&ws(g,h,2)}function Vv(h){var g=h.alternate;return h===wn||g!==null&&g===wn}function OI(h,g){Xm=Iv=!0;var _=h.pending;_===null?g.next=g:(g.next=_.next,_.next=g),h.pending=g}function LI(h,g,_){if((_&4194048)!==0){var M=g.lanes;M&=h.pendingLanes,_|=M,g.lanes=_,qt(h,_)}}var Jx={readContext:ba,use:zv,useCallback:si,useContext:si,useEffect:si,useImperativeHandle:si,useLayoutEffect:si,useInsertionEffect:si,useMemo:si,useReducer:si,useRef:si,useState:si,useDebugValue:si,useDeferredValue:si,useTransition:si,useSyncExternalStore:si,useId:si,useHostTransitionStatus:si,useFormState:si,useActionState:si,useOptimistic:si,useMemoCache:si,useCacheRefresh:si};Jx.useEffectEvent=si;var II={readContext:ba,use:zv,useCallback:function(h,g){return Za().memoizedState=[h,g===void 0?null:g],h},useContext:ba,useEffect:vI,useImperativeHandle:function(h,g,_){_=_!=null?_.concat([h]):null,$v(4194308,4,NI.bind(null,g,h),_)},useLayoutEffect:function(h,g){return $v(4194308,4,h,g)},useInsertionEffect:function(h,g){$v(4,2,h,g)},useMemo:function(h,g){var _=Za();g=g===void 0?null:g;var M=h();if(tf){Ge(!0);try{h()}finally{Ge(!1)}}return _.memoizedState=[M,g],M},useReducer:function(h,g,_){var M=Za();if(_!==void 0){var P=_(g);if(tf){Ge(!0);try{_(g)}finally{Ge(!1)}}}else P=g;return M.memoizedState=M.baseState=P,h={pending:null,lanes:0,dispatch:null,lastRenderedReducer:h,lastRenderedState:P},M.queue=h,h=h.dispatch=Cie.bind(null,wn,h),[M.memoizedState,h]},useRef:function(h){var g=Za();return h={current:h},g.memoizedState=h},useState:function(h){h=TE(h);var g=h.queue,_=DI.bind(null,wn,g);return g.dispatch=_,[h.memoizedState,_]},useDebugValue:jE,useDeferredValue:function(h,g){var _=Za();return PE(_,h,g)},useTransition:function(){var h=TE(!1);return h=AI.bind(null,wn,h.queue,!0,!1),Za().memoizedState=h,[!1,h]},useSyncExternalStore:function(h,g,_){var M=wn,P=Za();if(Gn){if(_===void 0)throw Error(r(407));_=_()}else{if(_=g(),Pr===null)throw Error(r(349));($n&127)!==0||iI(M,g,_)}P.memoizedState=_;var I={value:_,getSnapshot:g};return P.queue=I,vI(sI.bind(null,M,I,h),[h]),M.flags|=2048,Qm(9,{destroy:void 0},aI.bind(null,M,I,_,g),null),_},useId:function(){var h=Za(),g=Pr.identifierPrefix;if(Gn){var _=Fl,M=Il;_=(M&~(1<<32-Ne(M)-1)).toString(32)+_,g="_"+g+"R_"+_,_=Fv++,0<_&&(g+="H"+_.toString(32)),g+="_"}else _=vie++,g="_"+g+"r_"+_.toString(32)+"_";return h.memoizedState=g},useHostTransitionStatus:DE,useFormState:pI,useActionState:pI,useOptimistic:function(h){var g=Za();g.memoizedState=g.baseState=h;var _={pending:null,lanes:0,dispatch:null,lastRenderedReducer:null,lastRenderedState:null};return g.queue=_,g=OE.bind(null,wn,!0,_),_.dispatch=g,[h,g]},useMemoCache:ME,useCacheRefresh:function(){return Za().memoizedState=Eie.bind(null,wn)},useEffectEvent:function(h){var g=Za(),_={impl:h};return g.memoizedState=_,function(){if((or&2)!==0)throw Error(r(440));return _.impl.apply(void 0,arguments)}}},LE={readContext:ba,use:zv,useCallback:EI,useContext:ba,useEffect:kE,useImperativeHandle:MI,useInsertionEffect:wI,useLayoutEffect:SI,useMemo:CI,useReducer:Bv,useRef:yI,useState:function(){return Bv(Ac)},useDebugValue:jE,useDeferredValue:function(h,g){var _=xi();return TI(_,br.memoizedState,h,g)},useTransition:function(){var h=Bv(Ac)[0],g=xi().memoizedState;return[typeof h=="boolean"?h:Qx(h),g]},useSyncExternalStore:rI,useId:PI,useHostTransitionStatus:DE,useFormState:gI,useActionState:gI,useOptimistic:function(h,g){var _=xi();return cI(_,br,h,g)},useMemoCache:ME,useCacheRefresh:RI};LE.useEffectEvent=_I;var FI={readContext:ba,use:zv,useCallback:EI,useContext:ba,useEffect:kE,useImperativeHandle:MI,useInsertionEffect:wI,useLayoutEffect:SI,useMemo:CI,useReducer:CE,useRef:yI,useState:function(){return CE(Ac)},useDebugValue:jE,useDeferredValue:function(h,g){var _=xi();return br===null?PE(_,h,g):TI(_,br.memoizedState,h,g)},useTransition:function(){var h=CE(Ac)[0],g=xi().memoizedState;return[typeof h=="boolean"?h:Qx(h),g]},useSyncExternalStore:rI,useId:PI,useHostTransitionStatus:DE,useFormState:bI,useActionState:bI,useOptimistic:function(h,g){var _=xi();return br!==null?cI(_,br,h,g):(_.baseState=h,[h,_.queue.dispatch])},useMemoCache:ME,useCacheRefresh:RI};FI.useEffectEvent=_I;function IE(h,g,_,M){g=h.memoizedState,_=_(M,g),_=_==null?g:f({},g,_),h.memoizedState=_,h.lanes===0&&(h.updateQueue.baseState=_)}var FE={enqueueSetState:function(h,g,_){h=h._reactInternals;var M=Ws(),P=ed(M);P.payload=g,_!=null&&(P.callback=_),g=td(h,P,M),g!==null&&(ws(g,h,M),Wx(g,h,M))},enqueueReplaceState:function(h,g,_){h=h._reactInternals;var M=Ws(),P=ed(M);P.tag=1,P.payload=g,_!=null&&(P.callback=_),g=td(h,P,M),g!==null&&(ws(g,h,M),Wx(g,h,M))},enqueueForceUpdate:function(h,g){h=h._reactInternals;var _=Ws(),M=ed(_);M.tag=2,g!=null&&(M.callback=g),g=td(h,M,_),g!==null&&(ws(g,h,_),Wx(g,h,_))}};function UI(h,g,_,M,P,I,J){return h=h.stateNode,typeof h.shouldComponentUpdate=="function"?h.shouldComponentUpdate(M,I,J):g.prototype&&g.prototype.isPureReactComponent?!Ux(_,M)||!Ux(P,I):!0}function zI(h,g,_,M){h=g.state,typeof g.componentWillReceiveProps=="function"&&g.componentWillReceiveProps(_,M),typeof g.UNSAFE_componentWillReceiveProps=="function"&&g.UNSAFE_componentWillReceiveProps(_,M),g.state!==h&&FE.enqueueReplaceState(g,g.state,null)}function nf(h,g){var _=g;if("ref"in g){_={};for(var M in g)M!=="ref"&&(_[M]=g[M])}if(h=h.defaultProps){_===g&&(_=f({},_));for(var P in h)_[P]===void 0&&(_[P]=h[P])}return _}function BI(h){wv(h)}function $I(h){console.error(h)}function HI(h){wv(h)}function Gv(h,g){try{var _=h.onUncaughtError;_(g.value,{componentStack:g.stack})}catch(M){setTimeout(function(){throw M})}}function VI(h,g,_){try{var M=h.onCaughtError;M(_.value,{componentStack:_.stack,errorBoundary:g.tag===1?g.stateNode:null})}catch(P){setTimeout(function(){throw P})}}function UE(h,g,_){return _=ed(_),_.tag=3,_.payload={element:null},_.callback=function(){Gv(h,g)},_}function GI(h){return h=ed(h),h.tag=3,h}function qI(h,g,_,M){var P=_.type.getDerivedStateFromError;if(typeof P=="function"){var I=M.value;h.payload=function(){return P(I)},h.callback=function(){VI(g,_,M)}}var J=_.stateNode;J!==null&&typeof J.componentDidCatch=="function"&&(h.callback=function(){VI(g,_,M),typeof P!="function"&&(od===null?od=new Set([this]):od.add(this));var ve=M.stack;this.componentDidCatch(M.value,{componentStack:ve!==null?ve:""})})}function Tie(h,g,_,M,P){if(_.flags|=32768,M!==null&&typeof M=="object"&&typeof M.then=="function"){if(g=_.alternate,g!==null&&Hm(g,_,P,!0),_=Hs.current,_!==null){switch(_.tag){case 31:case 13:return So===null?r_():_.alternate===null&&oi===0&&(oi=3),_.flags&=-257,_.flags|=65536,_.lanes=P,M===Pv?_.flags|=16384:(g=_.updateQueue,g===null?_.updateQueue=new Set([M]):g.add(M),uC(h,M,P)),!1;case 22:return _.flags|=65536,M===Pv?_.flags|=16384:(g=_.updateQueue,g===null?(g={transitions:null,markerInstances:null,retryQueue:new Set([M])},_.updateQueue=g):(_=g.retryQueue,_===null?g.retryQueue=new Set([M]):_.add(M)),uC(h,M,P)),!1}throw Error(r(435,_.tag))}return uC(h,M,P),r_(),!1}if(Gn)return g=Hs.current,g!==null?((g.flags&65536)===0&&(g.flags|=256),g.flags|=65536,g.lanes=P,M!==iE&&(h=Error(r(422),{cause:M}),$x(yo(h,_)))):(M!==iE&&(g=Error(r(423),{cause:M}),$x(yo(g,_))),h=h.current.alternate,h.flags|=65536,P&=-P,h.lanes|=P,M=yo(M,_),P=UE(h.stateNode,M,P),pE(h,P),oi!==4&&(oi=2)),!1;var I=Error(r(520),{cause:M});if(I=yo(I,_),ob===null?ob=[I]:ob.push(I),oi!==4&&(oi=2),g===null)return!0;M=yo(M,_),_=g;do{switch(_.tag){case 3:return _.flags|=65536,h=P&-P,_.lanes|=h,h=UE(_.stateNode,M,h),pE(_,h),!1;case 1:if(g=_.type,I=_.stateNode,(_.flags&128)===0&&(typeof g.getDerivedStateFromError=="function"||I!==null&&typeof I.componentDidCatch=="function"&&(od===null||!od.has(I))))return _.flags|=65536,P&=-P,_.lanes|=P,P=GI(P),qI(P,h,_,M),pE(_,P),!1}_=_.return}while(_!==null);return!1}var zE=Error(r(461)),Ei=!1;function ya(h,g,_,M){g.child=h===null?Y5(g,null,_,M):ef(g,h.child,_,M)}function WI(h,g,_,M,P){_=_.render;var I=g.ref;if("ref"in M){var J={};for(var ve in M)ve!=="ref"&&(J[ve]=M[ve])}else J=M;return Yh(g),M=_E(h,g,_,J,I,P),ve=wE(),h!==null&&!Ei?(SE(h,g,P),kc(h,g,P)):(Gn&&ve&&nE(g),g.flags|=1,ya(h,g,M,P),g.child)}function KI(h,g,_,M,P){if(h===null){var I=_.type;return typeof I=="function"&&!JM(I)&&I.defaultProps===void 0&&_.compare===null?(g.tag=15,g.type=I,XI(h,g,I,M,P)):(h=Ev(_.type,null,M,g,g.mode,P),h.ref=g.ref,h.return=g,g.child=h)}if(I=h.child,!KE(h,P)){var J=I.memoizedProps;if(_=_.compare,_=_!==null?_:Ux,_(J,M)&&h.ref===g.ref)return kc(h,g,P)}return g.flags|=1,h=Nc(I,M),h.ref=g.ref,h.return=g,g.child=h}function XI(h,g,_,M,P){if(h!==null){var I=h.memoizedProps;if(Ux(I,M)&&h.ref===g.ref)if(Ei=!1,g.pendingProps=M=I,KE(h,P))(h.flags&131072)!==0&&(Ei=!0);else return g.lanes=h.lanes,kc(h,g,P)}return BE(h,g,_,M,P)}function YI(h,g,_,M){var P=M.children,I=h!==null?h.memoizedState:null;if(h===null&&g.stateNode===null&&(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),M.mode==="hidden"){if((g.flags&128)!==0){if(I=I!==null?I.baseLanes|_:_,h!==null){for(M=g.child=h.child,P=0;M!==null;)P=P|M.lanes|M.childLanes,M=M.sibling;M=P&~I}else M=0,g.child=null;return QI(h,g,I,_,M)}if((_&536870912)!==0)g.memoizedState={baseLanes:0,cachePool:null},h!==null&&kv(g,I!==null?I.cachePool:null),I!==null?J5(g,I):xE(),eI(g);else return M=g.lanes=536870912,QI(h,g,I!==null?I.baseLanes|_:_,_,M)}else I!==null?(kv(g,I.cachePool),J5(g,I),rd(),g.memoizedState=null):(h!==null&&kv(g,null),xE(),rd());return ya(h,g,P,_),g.child}function eb(h,g){return h!==null&&h.tag===22||g.stateNode!==null||(g.stateNode={_visibility:1,_pendingMarkers:null,_retryCache:null,_transitions:null}),g.sibling}function QI(h,g,_,M,P){var I=dE();return I=I===null?null:{parent:Ni._currentValue,pool:I},g.memoizedState={baseLanes:_,cachePool:I},h!==null&&kv(g,null),xE(),eI(g),h!==null&&Hm(h,g,M,!0),g.childLanes=P,null}function qv(h,g){return g=Kv({mode:g.mode,children:g.children},h.mode),g.ref=h.ref,h.child=g,g.return=h,g}function ZI(h,g,_){return ef(g,h.child,null,_),h=qv(g,g.pendingProps),h.flags|=2,Vs(g),g.memoizedState=null,h}function Aie(h,g,_){var M=g.pendingProps,P=(g.flags&128)!==0;if(g.flags&=-129,h===null){if(Gn){if(M.mode==="hidden")return h=qv(g,M),g.lanes=536870912,eb(null,h);if(yE(g),(h=zr)?(h=uU(h,wo),h=h!==null&&h.data==="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:Xu!==null?{id:Il,overflow:Fl}:null,retryLane:536870912,hydrationErrors:null},_=O5(h),_.return=g,g.child=_,xa=g,zr=null)):h=null,h===null)throw Qu(g);return g.lanes=536870912,null}return qv(g,M)}var I=h.memoizedState;if(I!==null){var J=I.dehydrated;if(yE(g),P)if(g.flags&256)g.flags&=-257,g=ZI(h,g,_);else if(g.memoizedState!==null)g.child=h.child,g.flags|=128,g=null;else throw Error(r(558));else if(Ei||Hm(h,g,_,!1),P=(_&h.childLanes)!==0,Ei||P){if(M=Pr,M!==null&&(J=Xn(M,_),J!==0&&J!==I.retryLane))throw I.retryLane=J,qh(h,J),ws(M,h,J),zE;r_(),g=ZI(h,g,_)}else h=I.treeContext,zr=No(J.nextSibling),xa=g,Gn=!0,Yu=null,wo=!1,h!==null&&F5(g,h),g=qv(g,M),g.flags|=4096;return g}return h=Nc(h.child,{mode:M.mode,children:M.children}),h.ref=g.ref,g.child=h,h.return=g,h}function Wv(h,g){var _=g.ref;if(_===null)h!==null&&h.ref!==null&&(g.flags|=4194816);else{if(typeof _!="function"&&typeof _!="object")throw Error(r(284));(h===null||h.ref!==_)&&(g.flags|=4194816)}}function BE(h,g,_,M,P){return Yh(g),_=_E(h,g,_,M,void 0,P),M=wE(),h!==null&&!Ei?(SE(h,g,P),kc(h,g,P)):(Gn&&M&&nE(g),g.flags|=1,ya(h,g,_,P),g.child)}function JI(h,g,_,M,P,I){return Yh(g),g.updateQueue=null,_=nI(g,M,_,P),tI(h),M=wE(),h!==null&&!Ei?(SE(h,g,I),kc(h,g,I)):(Gn&&M&&nE(g),g.flags|=1,ya(h,g,_,I),g.child)}function eF(h,g,_,M,P){if(Yh(g),g.stateNode===null){var I=Um,J=_.contextType;typeof J=="object"&&J!==null&&(I=ba(J)),I=new _(M,I),g.memoizedState=I.state!==null&&I.state!==void 0?I.state:null,I.updater=FE,g.stateNode=I,I._reactInternals=g,I=g.stateNode,I.props=M,I.state=g.memoizedState,I.refs={},fE(g),J=_.contextType,I.context=typeof J=="object"&&J!==null?ba(J):Um,I.state=g.memoizedState,J=_.getDerivedStateFromProps,typeof J=="function"&&(IE(g,_,J,M),I.state=g.memoizedState),typeof _.getDerivedStateFromProps=="function"||typeof I.getSnapshotBeforeUpdate=="function"||typeof I.UNSAFE_componentWillMount!="function"&&typeof I.componentWillMount!="function"||(J=I.state,typeof I.componentWillMount=="function"&&I.componentWillMount(),typeof I.UNSAFE_componentWillMount=="function"&&I.UNSAFE_componentWillMount(),J!==I.state&&FE.enqueueReplaceState(I,I.state,null),Xx(g,M,I,P),Kx(),I.state=g.memoizedState),typeof I.componentDidMount=="function"&&(g.flags|=4194308),M=!0}else if(h===null){I=g.stateNode;var ve=g.memoizedProps,Ue=nf(_,ve);I.props=Ue;var rt=I.context,mt=_.contextType;J=Um,typeof mt=="object"&&mt!==null&&(J=ba(mt));var bt=_.getDerivedStateFromProps;mt=typeof bt=="function"||typeof I.getSnapshotBeforeUpdate=="function",ve=g.pendingProps!==ve,mt||typeof I.UNSAFE_componentWillReceiveProps!="function"&&typeof I.componentWillReceiveProps!="function"||(ve||rt!==J)&&zI(g,I,M,J),Ju=!1;var ot=g.memoizedState;I.state=ot,Xx(g,M,I,P),Kx(),rt=g.memoizedState,ve||ot!==rt||Ju?(typeof bt=="function"&&(IE(g,_,bt,M),rt=g.memoizedState),(Ue=Ju||UI(g,_,Ue,M,ot,rt,J))?(mt||typeof I.UNSAFE_componentWillMount!="function"&&typeof I.componentWillMount!="function"||(typeof I.componentWillMount=="function"&&I.componentWillMount(),typeof I.UNSAFE_componentWillMount=="function"&&I.UNSAFE_componentWillMount()),typeof I.componentDidMount=="function"&&(g.flags|=4194308)):(typeof I.componentDidMount=="function"&&(g.flags|=4194308),g.memoizedProps=M,g.memoizedState=rt),I.props=M,I.state=rt,I.context=J,M=Ue):(typeof I.componentDidMount=="function"&&(g.flags|=4194308),M=!1)}else{I=g.stateNode,mE(h,g),J=g.memoizedProps,mt=nf(_,J),I.props=mt,bt=g.pendingProps,ot=I.context,rt=_.contextType,Ue=Um,typeof rt=="object"&&rt!==null&&(Ue=ba(rt)),ve=_.getDerivedStateFromProps,(rt=typeof ve=="function"||typeof I.getSnapshotBeforeUpdate=="function")||typeof I.UNSAFE_componentWillReceiveProps!="function"&&typeof I.componentWillReceiveProps!="function"||(J!==bt||ot!==Ue)&&zI(g,I,M,Ue),Ju=!1,ot=g.memoizedState,I.state=ot,Xx(g,M,I,P),Kx();var ft=g.memoizedState;J!==bt||ot!==ft||Ju||h!==null&&h.dependencies!==null&&Tv(h.dependencies)?(typeof ve=="function"&&(IE(g,_,ve,M),ft=g.memoizedState),(mt=Ju||UI(g,_,mt,M,ot,ft,Ue)||h!==null&&h.dependencies!==null&&Tv(h.dependencies))?(rt||typeof I.UNSAFE_componentWillUpdate!="function"&&typeof I.componentWillUpdate!="function"||(typeof I.componentWillUpdate=="function"&&I.componentWillUpdate(M,ft,Ue),typeof I.UNSAFE_componentWillUpdate=="function"&&I.UNSAFE_componentWillUpdate(M,ft,Ue)),typeof I.componentDidUpdate=="function"&&(g.flags|=4),typeof I.getSnapshotBeforeUpdate=="function"&&(g.flags|=1024)):(typeof I.componentDidUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=4),typeof I.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=1024),g.memoizedProps=M,g.memoizedState=ft),I.props=M,I.state=ft,I.context=Ue,M=mt):(typeof I.componentDidUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=4),typeof I.getSnapshotBeforeUpdate!="function"||J===h.memoizedProps&&ot===h.memoizedState||(g.flags|=1024),M=!1)}return I=M,Wv(h,g),M=(g.flags&128)!==0,I||M?(I=g.stateNode,_=M&&typeof _.getDerivedStateFromError!="function"?null:I.render(),g.flags|=1,h!==null&&M?(g.child=ef(g,h.child,null,P),g.child=ef(g,null,_,P)):ya(h,g,_,P),g.memoizedState=I.state,h=g.child):h=kc(h,g,P),h}function tF(h,g,_,M){return Kh(),g.flags|=256,ya(h,g,_,M),g.child}var $E={dehydrated:null,treeContext:null,retryLane:0,hydrationErrors:null};function HE(h){return{baseLanes:h,cachePool:V5()}}function VE(h,g,_){return h=h!==null?h.childLanes&~_:0,g&&(h|=qs),h}function nF(h,g,_){var M=g.pendingProps,P=!1,I=(g.flags&128)!==0,J;if((J=I)||(J=h!==null&&h.memoizedState===null?!1:(gi.current&2)!==0),J&&(P=!0,g.flags&=-129),J=(g.flags&32)!==0,g.flags&=-33,h===null){if(Gn){if(P?nd(g):rd(),(h=zr)?(h=uU(h,wo),h=h!==null&&h.data!=="&"?h:null,h!==null&&(g.memoizedState={dehydrated:h,treeContext:Xu!==null?{id:Il,overflow:Fl}:null,retryLane:536870912,hydrationErrors:null},_=O5(h),_.return=g,g.child=_,xa=g,zr=null)):h=null,h===null)throw Qu(g);return EC(h)?g.lanes=32:g.lanes=536870912,null}var ve=M.children;return M=M.fallback,P?(rd(),P=g.mode,ve=Kv({mode:"hidden",children:ve},P),M=Wh(M,P,_,null),ve.return=g,M.return=g,ve.sibling=M,g.child=ve,M=g.child,M.memoizedState=HE(_),M.childLanes=VE(h,J,_),g.memoizedState=$E,eb(null,M)):(nd(g),GE(g,ve))}var Ue=h.memoizedState;if(Ue!==null&&(ve=Ue.dehydrated,ve!==null)){if(I)g.flags&256?(nd(g),g.flags&=-257,g=qE(h,g,_)):g.memoizedState!==null?(rd(),g.child=h.child,g.flags|=128,g=null):(rd(),ve=M.fallback,P=g.mode,M=Kv({mode:"visible",children:M.children},P),ve=Wh(ve,P,_,null),ve.flags|=2,M.return=g,ve.return=g,M.sibling=ve,g.child=M,ef(g,h.child,null,_),M=g.child,M.memoizedState=HE(_),M.childLanes=VE(h,J,_),g.memoizedState=$E,g=eb(null,M));else if(nd(g),EC(ve)){if(J=ve.nextSibling&&ve.nextSibling.dataset,J)var rt=J.dgst;J=rt,M=Error(r(419)),M.stack="",M.digest=J,$x({value:M,source:null,stack:null}),g=qE(h,g,_)}else if(Ei||Hm(h,g,_,!1),J=(_&h.childLanes)!==0,Ei||J){if(J=Pr,J!==null&&(M=Xn(J,_),M!==0&&M!==Ue.retryLane))throw Ue.retryLane=M,qh(h,M),ws(J,h,M),zE;MC(ve)||r_(),g=qE(h,g,_)}else MC(ve)?(g.flags|=192,g.child=h.child,g=null):(h=Ue.treeContext,zr=No(ve.nextSibling),xa=g,Gn=!0,Yu=null,wo=!1,h!==null&&F5(g,h),g=GE(g,M.children),g.flags|=4096);return g}return P?(rd(),ve=M.fallback,P=g.mode,Ue=h.child,rt=Ue.sibling,M=Nc(Ue,{mode:"hidden",children:M.children}),M.subtreeFlags=Ue.subtreeFlags&65011712,rt!==null?ve=Nc(rt,ve):(ve=Wh(ve,P,_,null),ve.flags|=2),ve.return=g,M.return=g,M.sibling=ve,g.child=M,eb(null,M),M=g.child,ve=h.child.memoizedState,ve===null?ve=HE(_):(P=ve.cachePool,P!==null?(Ue=Ni._currentValue,P=P.parent!==Ue?{parent:Ue,pool:Ue}:P):P=V5(),ve={baseLanes:ve.baseLanes|_,cachePool:P}),M.memoizedState=ve,M.childLanes=VE(h,J,_),g.memoizedState=$E,eb(h.child,M)):(nd(g),_=h.child,h=_.sibling,_=Nc(_,{mode:"visible",children:M.children}),_.return=g,_.sibling=null,h!==null&&(J=g.deletions,J===null?(g.deletions=[h],g.flags|=16):J.push(h)),g.child=_,g.memoizedState=null,_)}function GE(h,g){return g=Kv({mode:"visible",children:g},h.mode),g.return=h,h.child=g}function Kv(h,g){return h=$s(22,h,null,g),h.lanes=0,h}function qE(h,g,_){return ef(g,h.child,null,_),h=GE(g,g.pendingProps.children),h.flags|=2,g.memoizedState=null,h}function rF(h,g,_){h.lanes|=g;var M=h.alternate;M!==null&&(M.lanes|=g),oE(h.return,g,_)}function WE(h,g,_,M,P,I){var J=h.memoizedState;J===null?h.memoizedState={isBackwards:g,rendering:null,renderingStartTime:0,last:M,tail:_,tailMode:P,treeForkCount:I}:(J.isBackwards=g,J.rendering=null,J.renderingStartTime=0,J.last=M,J.tail=_,J.tailMode=P,J.treeForkCount=I)}function iF(h,g,_){var M=g.pendingProps,P=M.revealOrder,I=M.tail;M=M.children;var J=gi.current,ve=(J&2)!==0;if(ve?(J=J&1|2,g.flags|=128):J&=1,le(gi,J),ya(h,g,M,_),M=Gn?Bx:0,!ve&&h!==null&&(h.flags&128)!==0)e:for(h=g.child;h!==null;){if(h.tag===13)h.memoizedState!==null&&rF(h,_,g);else if(h.tag===19)rF(h,_,g);else if(h.child!==null){h.child.return=h,h=h.child;continue}if(h===g)break e;for(;h.sibling===null;){if(h.return===null||h.return===g)break e;h=h.return}h.sibling.return=h.return,h=h.sibling}switch(P){case"forwards":for(_=g.child,P=null;_!==null;)h=_.alternate,h!==null&&Lv(h)===null&&(P=_),_=_.sibling;_=P,_===null?(P=g.child,g.child=null):(P=_.sibling,_.sibling=null),WE(g,!1,P,_,I,M);break;case"backwards":case"unstable_legacy-backwards":for(_=null,P=g.child,g.child=null;P!==null;){if(h=P.alternate,h!==null&&Lv(h)===null){g.child=P;break}h=P.sibling,P.sibling=_,_=P,P=h}WE(g,!0,_,null,I,M);break;case"together":WE(g,!1,null,null,void 0,M);break;default:g.memoizedState=null}return g.child}function kc(h,g,_){if(h!==null&&(g.dependencies=h.dependencies),sd|=g.lanes,(_&g.childLanes)===0)if(h!==null){if(Hm(h,g,_,!1),(_&g.childLanes)===0)return null}else return null;if(h!==null&&g.child!==h.child)throw Error(r(153));if(g.child!==null){for(h=g.child,_=Nc(h,h.pendingProps),g.child=_,_.return=g;h.sibling!==null;)h=h.sibling,_=_.sibling=Nc(h,h.pendingProps),_.return=g;_.sibling=null}return g.child}function KE(h,g){return(h.lanes&g)!==0?!0:(h=h.dependencies,!!(h!==null&&Tv(h)))}function kie(h,g,_){switch(g.tag){case 3:_e(g,g.stateNode.containerInfo),Zu(g,Ni,h.memoizedState.cache),Kh();break;case 27:case 5:ke(g);break;case 4:_e(g,g.stateNode.containerInfo);break;case 10:Zu(g,g.type,g.memoizedProps.value);break;case 31:if(g.memoizedState!==null)return g.flags|=128,yE(g),null;break;case 13:var M=g.memoizedState;if(M!==null)return M.dehydrated!==null?(nd(g),g.flags|=128,null):(_&g.child.childLanes)!==0?nF(h,g,_):(nd(g),h=kc(h,g,_),h!==null?h.sibling:null);nd(g);break;case 19:var P=(h.flags&128)!==0;if(M=(_&g.childLanes)!==0,M||(Hm(h,g,_,!1),M=(_&g.childLanes)!==0),P){if(M)return iF(h,g,_);g.flags|=128}if(P=g.memoizedState,P!==null&&(P.rendering=null,P.tail=null,P.lastEffect=null),le(gi,gi.current),M)break;return null;case 22:return g.lanes=0,YI(h,g,_,g.pendingProps);case 24:Zu(g,Ni,h.memoizedState.cache)}return kc(h,g,_)}function aF(h,g,_){if(h!==null)if(h.memoizedProps!==g.pendingProps)Ei=!0;else{if(!KE(h,_)&&(g.flags&128)===0)return Ei=!1,kie(h,g,_);Ei=(h.flags&131072)!==0}else Ei=!1,Gn&&(g.flags&1048576)!==0&&I5(g,Bx,g.index);switch(g.lanes=0,g.tag){case 16:e:{var M=g.pendingProps;if(h=Zh(g.elementType),g.type=h,typeof h=="function")JM(h)?(M=nf(h,M),g.tag=1,g=eF(null,g,h,M,_)):(g.tag=0,g=BE(null,g,h,M,_));else{if(h!=null){var P=h.$$typeof;if(P===E){g.tag=11,g=WI(null,g,h,M,_);break e}else if(P===T){g.tag=14,g=KI(null,g,h,M,_);break e}}throw g=j(h)||h,Error(r(306,g,""))}}return g;case 0:return BE(h,g,g.type,g.pendingProps,_);case 1:return M=g.type,P=nf(M,g.pendingProps),eF(h,g,M,P,_);case 3:e:{if(_e(g,g.stateNode.containerInfo),h===null)throw Error(r(387));M=g.pendingProps;var I=g.memoizedState;P=I.element,mE(h,g),Xx(g,M,null,_);var J=g.memoizedState;if(M=J.cache,Zu(g,Ni,M),M!==I.cache&&lE(g,[Ni],_,!0),Kx(),M=J.element,I.isDehydrated)if(I={element:M,isDehydrated:!1,cache:J.cache},g.updateQueue.baseState=I,g.memoizedState=I,g.flags&256){g=tF(h,g,M,_);break e}else if(M!==P){P=yo(Error(r(424)),g),$x(P),g=tF(h,g,M,_);break e}else{switch(h=g.stateNode.containerInfo,h.nodeType){case 9:h=h.body;break;default:h=h.nodeName==="HTML"?h.ownerDocument.body:h}for(zr=No(h.firstChild),xa=g,Gn=!0,Yu=null,wo=!0,_=Y5(g,null,M,_),g.child=_;_;)_.flags=_.flags&-3|4096,_=_.sibling}else{if(Kh(),M===P){g=kc(h,g,_);break e}ya(h,g,M,_)}g=g.child}return g;case 26:return Wv(h,g),h===null?(_=gU(g.type,null,g.pendingProps,null))?g.memoizedState=_:Gn||(_=g.type,h=g.pendingProps,M=u_(fe.current).createElement(_),M[tr]=g,M[_n]=h,va(M,_,h),Fe(M),g.stateNode=M):g.memoizedState=gU(g.type,h.memoizedProps,g.pendingProps,h.memoizedState),null;case 27:return ke(g),h===null&&Gn&&(M=g.stateNode=fU(g.type,g.pendingProps,fe.current),xa=g,wo=!0,P=zr,dd(g.type)?(CC=P,zr=No(M.firstChild)):zr=P),ya(h,g,g.pendingProps.children,_),Wv(h,g),h===null&&(g.flags|=4194304),g.child;case 5:return h===null&&Gn&&((P=M=zr)&&(M=sae(M,g.type,g.pendingProps,wo),M!==null?(g.stateNode=M,xa=g,zr=No(M.firstChild),wo=!1,P=!0):P=!1),P||Qu(g)),ke(g),P=g.type,I=g.pendingProps,J=h!==null?h.memoizedProps:null,M=I.children,wC(P,I)?M=null:J!==null&&wC(P,J)&&(g.flags|=32),g.memoizedState!==null&&(P=_E(h,g,_ie,null,null,_),pb._currentValue=P),Wv(h,g),ya(h,g,M,_),g.child;case 6:return h===null&&Gn&&((h=_=zr)&&(_=oae(_,g.pendingProps,wo),_!==null?(g.stateNode=_,xa=g,zr=null,h=!0):h=!1),h||Qu(g)),null;case 13:return nF(h,g,_);case 4:return _e(g,g.stateNode.containerInfo),M=g.pendingProps,h===null?g.child=ef(g,null,M,_):ya(h,g,M,_),g.child;case 11:return WI(h,g,g.type,g.pendingProps,_);case 7:return ya(h,g,g.pendingProps,_),g.child;case 8:return ya(h,g,g.pendingProps.children,_),g.child;case 12:return ya(h,g,g.pendingProps.children,_),g.child;case 10:return M=g.pendingProps,Zu(g,g.type,M.value),ya(h,g,M.children,_),g.child;case 9:return P=g.type._context,M=g.pendingProps.children,Yh(g),P=ba(P),M=M(P),g.flags|=1,ya(h,g,M,_),g.child;case 14:return KI(h,g,g.type,g.pendingProps,_);case 15:return XI(h,g,g.type,g.pendingProps,_);case 19:return iF(h,g,_);case 31:return Aie(h,g,_);case 22:return YI(h,g,_,g.pendingProps);case 24:return Yh(g),M=ba(Ni),h===null?(P=dE(),P===null&&(P=Pr,I=cE(),P.pooledCache=I,I.refCount++,I!==null&&(P.pooledCacheLanes|=_),P=I),g.memoizedState={parent:M,cache:P},fE(g),Zu(g,Ni,P)):((h.lanes&_)!==0&&(mE(h,g),Xx(g,null,null,_),Kx()),P=h.memoizedState,I=g.memoizedState,P.parent!==M?(P={parent:M,cache:M},g.memoizedState=P,g.lanes===0&&(g.memoizedState=g.updateQueue.baseState=P),Zu(g,Ni,M)):(M=I.cache,Zu(g,Ni,M),M!==P.cache&&lE(g,[Ni],_,!0))),ya(h,g,g.pendingProps.children,_),g.child;case 29:throw g.pendingProps}throw Error(r(156,g.tag))}function jc(h){h.flags|=4}function XE(h,g,_,M,P){if((g=(h.mode&32)!==0)&&(g=!1),g){if(h.flags|=16777216,(P&335544128)===P)if(h.stateNode.complete)h.flags|=8192;else if(PF())h.flags|=8192;else throw Jh=Pv,hE}else h.flags&=-16777217}function sF(h,g){if(g.type!=="stylesheet"||(g.state.loading&4)!==0)h.flags&=-16777217;else if(h.flags|=16777216,!_U(g))if(PF())h.flags|=8192;else throw Jh=Pv,hE}function Xv(h,g){g!==null&&(h.flags|=4),h.flags&16384&&(g=h.tag!==22?Ct():536870912,h.lanes|=g,tp|=g)}function tb(h,g){if(!Gn)switch(h.tailMode){case"hidden":g=h.tail;for(var _=null;g!==null;)g.alternate!==null&&(_=g),g=g.sibling;_===null?h.tail=null:_.sibling=null;break;case"collapsed":_=h.tail;for(var M=null;_!==null;)_.alternate!==null&&(M=_),_=_.sibling;M===null?g||h.tail===null?h.tail=null:h.tail.sibling=null:M.sibling=null}}function Br(h){var g=h.alternate!==null&&h.alternate.child===h.child,_=0,M=0;if(g)for(var P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags&65011712,M|=P.flags&65011712,P.return=h,P=P.sibling;else for(P=h.child;P!==null;)_|=P.lanes|P.childLanes,M|=P.subtreeFlags,M|=P.flags,P.return=h,P=P.sibling;return h.subtreeFlags|=M,h.childLanes=_,g}function jie(h,g,_){var M=g.pendingProps;switch(rE(g),g.tag){case 16:case 15:case 0:case 11:case 7:case 8:case 12:case 9:case 14:return Br(g),null;case 1:return Br(g),null;case 3:return _=g.stateNode,M=null,h!==null&&(M=h.memoizedState.cache),g.memoizedState.cache!==M&&(g.flags|=2048),Cc(Ni),ge(),_.pendingContext&&(_.context=_.pendingContext,_.pendingContext=null),(h===null||h.child===null)&&($m(g)?jc(g):h===null||h.memoizedState.isDehydrated&&(g.flags&256)===0||(g.flags|=1024,aE())),Br(g),null;case 26:var P=g.type,I=g.memoizedState;return h===null?(jc(g),I!==null?(Br(g),sF(g,I)):(Br(g),XE(g,P,null,M,_))):I?I!==h.memoizedState?(jc(g),Br(g),sF(g,I)):(Br(g),g.flags&=-16777217):(h=h.memoizedProps,h!==M&&jc(g),Br(g),XE(g,P,h,M,_)),null;case 27:if(De(g),_=fe.current,P=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==M&&jc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Br(g),null}h=re.current,$m(g)?U5(g):(h=fU(P,M,_),g.stateNode=h,jc(g))}return Br(g),null;case 5:if(De(g),P=g.type,h!==null&&g.stateNode!=null)h.memoizedProps!==M&&jc(g);else{if(!M){if(g.stateNode===null)throw Error(r(166));return Br(g),null}if(I=re.current,$m(g))U5(g);else{var J=u_(fe.current);switch(I){case 1:I=J.createElementNS("http://www.w3.org/2000/svg",P);break;case 2:I=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;default:switch(P){case"svg":I=J.createElementNS("http://www.w3.org/2000/svg",P);break;case"math":I=J.createElementNS("http://www.w3.org/1998/Math/MathML",P);break;case"script":I=J.createElement("div"),I.innerHTML=" - + +
From 9bd12df6bf7818b18a217abf55efd1e7da398e21 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 09:18:31 +0100 Subject: [PATCH 05/15] Changed tests for new backup module --- CHANGELOG.md | 13 + backend/app/api/routes/settings.py | 2075 +---------------- backend/tests/integration/test_library_api.py | 65 + .../tests/integration/test_settings_api.py | 103 +- 4 files changed, 113 insertions(+), 2143 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20756190c..9e5dad1a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ All notable changes to Bambuddy will be documented in this file. - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15) - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background - Increased default camera FPS from 10 to 15 for smoother video across all camera views +- **Simplified Backup/Restore System**: + - Complete backup now creates a single ZIP file containing the entire database and all data directories + - Includes: database, archives, library files, thumbnails, timelapses, icons, projects, and plate calibration data + - Portable backups: works across different installations and data directories + - Faster backup/restore: direct file copy instead of JSON export/import + - Progress indicator and navigation blocking during backup/restore operations + - Legacy JSON-based backup/restore still available via `/backup-legacy` and `/restore-legacy` endpoints + +### Fixes +- **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems: + - Library now stores relative paths in database for portability + - Automatic migration converts existing absolute paths to relative on startup + - Thumbnails and files now display correctly after restoring backups ## [0.1.6-final] - 2026-01-31 diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 4dd219525..4f746e684 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -1,36 +1,17 @@ import io -import json import zipfile from datetime import datetime from pathlib import Path -from fastapi import APIRouter, Depends, File, Query, UploadFile +from fastapi import APIRouter, Depends, File, UploadFile from fastapi.responses import JSONResponse, StreamingResponse from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from backend.app.core.config import settings as app_settings from backend.app.core.database import get_db -from backend.app.models.api_key import APIKey -from backend.app.models.archive import PrintArchive -from backend.app.models.external_link import ExternalLink -from backend.app.models.filament import Filament -from backend.app.models.github_backup import GitHubBackupConfig -from backend.app.models.group import Group -from backend.app.models.maintenance import MaintenanceHistory, MaintenanceType, PrinterMaintenance -from backend.app.models.notification import NotificationProvider -from backend.app.models.notification_template import NotificationTemplate -from backend.app.models.pending_upload import PendingUpload -from backend.app.models.print_queue import PrintQueueItem -from backend.app.models.printer import Printer -from backend.app.models.project import Project -from backend.app.models.project_bom import ProjectBOMItem from backend.app.models.settings import Settings -from backend.app.models.smart_plug import SmartPlug -from backend.app.models.user import User from backend.app.schemas.settings import AppSettings, AppSettingsUpdate -from backend.app.services.printer_manager import printer_manager -from backend.app.services.spoolman import init_spoolman_client router = APIRouter(prefix="/settings", tags=["settings"]) @@ -295,721 +276,6 @@ async def create_backup(db: AsyncSession = Depends(get_db)): ) -@router.get("/backup-legacy") -async def export_backup_legacy( - db: AsyncSession = Depends(get_db), - include_settings: bool = Query(True, description="Include app settings"), - include_notifications: bool = Query(True, description="Include notification providers"), - include_templates: bool = Query(True, description="Include notification templates"), - include_smart_plugs: bool = Query(True, description="Include smart plugs"), - include_external_links: bool = Query(True, description="Include external sidebar links"), - include_printers: bool = Query(False, description="Include printers (without access codes)"), - include_plate_calibration: bool = Query(False, description="Include plate detection reference images"), - include_filaments: bool = Query(False, description="Include filament inventory"), - include_maintenance: bool = Query( - False, description="Include maintenance types, per-printer settings, and history" - ), - include_print_queue: bool = Query(False, description="Include print queue items"), - include_archives: bool = Query(False, description="Include print archive metadata"), - include_projects: bool = Query(False, description="Include projects with BOM items"), - include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"), - include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"), - include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"), - include_users: bool = Query( - False, description="Include users (passwords not exported - users will need new passwords)" - ), - include_groups: bool = Query(False, description="Include groups and user-group assignments"), - include_github_backup: bool = Query(False, description="Include GitHub backup configuration (token not exported)"), -): - """Export selected data as JSON backup.""" - backup: dict = { - "version": "2.0", - "exported_at": datetime.utcnow().isoformat(), - "included": [], - } - - # Settings - if include_settings: - result = await db.execute(select(Settings)) - db_settings = result.scalars().all() - backup["settings"] = {s.key: s.value for s in db_settings} - backup["included"].append("settings") - - # Notification providers - if include_notifications: - # Build printer ID to serial lookup for cross-system backup - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - result = await db.execute(select(NotificationProvider)) - providers = result.scalars().all() - backup["notification_providers"] = [] - for p in providers: - # Use printer_serial for cross-system compatibility - provider_printer_id = getattr(p, "printer_id", None) - printer_serial = printer_id_to_serial.get(provider_printer_id) if provider_printer_id else None - - backup["notification_providers"].append( - { - "name": p.name, - "provider_type": p.provider_type, - "enabled": p.enabled, - "config": json.loads(p.config) if isinstance(p.config, str) else p.config, - "on_print_start": p.on_print_start, - "on_print_complete": p.on_print_complete, - "on_print_failed": p.on_print_failed, - "on_print_stopped": p.on_print_stopped, - "on_print_progress": p.on_print_progress, - "on_printer_offline": p.on_printer_offline, - "on_printer_error": p.on_printer_error, - "on_filament_low": p.on_filament_low, - "on_maintenance_due": p.on_maintenance_due, - "on_ams_humidity_high": getattr(p, "on_ams_humidity_high", False), - "on_ams_temperature_high": getattr(p, "on_ams_temperature_high", False), - "on_ams_ht_humidity_high": getattr(p, "on_ams_ht_humidity_high", False), - "on_ams_ht_temperature_high": getattr(p, "on_ams_ht_temperature_high", False), - "on_plate_not_empty": getattr(p, "on_plate_not_empty", True), - "on_queue_job_added": getattr(p, "on_queue_job_added", False), - "on_queue_job_assigned": getattr(p, "on_queue_job_assigned", False), - "on_queue_job_started": getattr(p, "on_queue_job_started", False), - "on_queue_job_waiting": getattr(p, "on_queue_job_waiting", True), - "on_queue_job_skipped": getattr(p, "on_queue_job_skipped", True), - "on_queue_job_failed": getattr(p, "on_queue_job_failed", True), - "on_queue_completed": getattr(p, "on_queue_completed", False), - "quiet_hours_enabled": p.quiet_hours_enabled, - "quiet_hours_start": p.quiet_hours_start, - "quiet_hours_end": p.quiet_hours_end, - "daily_digest_enabled": getattr(p, "daily_digest_enabled", False), - "daily_digest_time": getattr(p, "daily_digest_time", None), - "printer_serial": printer_serial, - } - ) - backup["included"].append("notification_providers") - - # Notification templates - if include_templates: - result = await db.execute(select(NotificationTemplate)) - templates = result.scalars().all() - backup["notification_templates"] = [] - for t in templates: - backup["notification_templates"].append( - { - "event_type": t.event_type, - "name": t.name, - "title_template": t.title_template, - "body_template": t.body_template, - "is_default": t.is_default, - } - ) - backup["included"].append("notification_templates") - - # Smart plugs - if include_smart_plugs: - result = await db.execute(select(SmartPlug)) - plugs = result.scalars().all() - backup["smart_plugs"] = [] - - # Build printer ID to serial mapping - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - for plug in plugs: - backup["smart_plugs"].append( - { - "name": plug.name, - "plug_type": plug.plug_type, - "ip_address": plug.ip_address, - "ha_entity_id": plug.ha_entity_id, - "ha_power_entity": plug.ha_power_entity, - "ha_energy_today_entity": plug.ha_energy_today_entity, - "ha_energy_total_entity": plug.ha_energy_total_entity, - # MQTT plug fields (legacy) - "mqtt_topic": plug.mqtt_topic, - "mqtt_multiplier": plug.mqtt_multiplier, - # MQTT power fields - "mqtt_power_topic": plug.mqtt_power_topic, - "mqtt_power_path": plug.mqtt_power_path, - "mqtt_power_multiplier": plug.mqtt_power_multiplier, - # MQTT energy fields - "mqtt_energy_topic": plug.mqtt_energy_topic, - "mqtt_energy_path": plug.mqtt_energy_path, - "mqtt_energy_multiplier": plug.mqtt_energy_multiplier, - # MQTT state fields - "mqtt_state_topic": plug.mqtt_state_topic, - "mqtt_state_path": plug.mqtt_state_path, - "mqtt_state_on_value": plug.mqtt_state_on_value, - "printer_serial": printer_id_to_serial.get(plug.printer_id) if plug.printer_id else None, - "enabled": plug.enabled, - "auto_on": plug.auto_on, - "auto_off": plug.auto_off, - "off_delay_mode": plug.off_delay_mode, - "off_delay_minutes": plug.off_delay_minutes, - "off_temp_threshold": plug.off_temp_threshold, - "username": plug.username, - "password": plug.password, - "power_alert_enabled": plug.power_alert_enabled, - "power_alert_high": plug.power_alert_high, - "power_alert_low": plug.power_alert_low, - "schedule_enabled": plug.schedule_enabled, - "schedule_on_time": plug.schedule_on_time, - "schedule_off_time": plug.schedule_off_time, - "show_in_switchbar": plug.show_in_switchbar, - "show_on_printer_card": plug.show_on_printer_card, - } - ) - backup["included"].append("smart_plugs") - - # External links - if include_external_links: - result = await db.execute(select(ExternalLink).order_by(ExternalLink.sort_order)) - links = result.scalars().all() - backup["external_links"] = [] - icons_dir = app_settings.base_dir / "icons" - for link in links: - link_data = { - "name": link.name, - "url": link.url, - "icon": link.icon, - "sort_order": link.sort_order, - } - # Include custom icon file path if exists - if link.custom_icon: - link_data["custom_icon"] = link.custom_icon - icon_path = icons_dir / link.custom_icon - if icon_path.exists(): - link_data["custom_icon_path"] = f"icons/{link.custom_icon}" - backup["external_links"].append(link_data) - backup["included"].append("external_links") - - # Printers (access codes only included if explicitly requested) - if include_printers: - result = await db.execute(select(Printer)) - printers = result.scalars().all() - backup["printers"] = [] - for printer in printers: - printer_data = { - "name": printer.name, - "serial_number": printer.serial_number, - "ip_address": printer.ip_address, - "model": printer.model, - "location": printer.location, - "nozzle_count": printer.nozzle_count, - "is_active": printer.is_active, - "auto_archive": printer.auto_archive, - "print_hours_offset": printer.print_hours_offset, - "runtime_seconds": printer.runtime_seconds, - "external_camera_url": printer.external_camera_url, - "external_camera_type": printer.external_camera_type, - "external_camera_enabled": printer.external_camera_enabled, - "plate_detection_enabled": printer.plate_detection_enabled, - "plate_detection_roi_x": printer.plate_detection_roi_x, - "plate_detection_roi_y": printer.plate_detection_roi_y, - "plate_detection_roi_w": printer.plate_detection_roi_w, - "plate_detection_roi_h": printer.plate_detection_roi_h, - } - if include_access_codes: - printer_data["access_code"] = printer.access_code - backup["printers"].append(printer_data) - backup["included"].append("printers") - if include_access_codes: - backup["included"].append("access_codes") - - # Plate calibration references (requires include_printers) - if include_printers and include_plate_calibration: - plate_cal_dir = app_settings.plate_calibration_dir - if plate_cal_dir.exists(): - backup["plate_calibration"] = { - "files": [], - "printer_id_to_serial": {}, # Map old printer IDs to serial numbers for restore - } - for cal_file in plate_cal_dir.iterdir(): - if cal_file.is_file(): - backup["plate_calibration"]["files"].append(cal_file.name) - # Extract printer ID from filename (e.g., "printer_1_ref_0.jpg" -> 1) - if cal_file.name.startswith("printer_"): - parts = cal_file.name.split("_") - if len(parts) >= 2 and parts[1].isdigit(): - old_printer_id = int(parts[1]) - if old_printer_id not in backup["plate_calibration"]["printer_id_to_serial"]: - # Look up serial number for this printer ID - backup["plate_calibration"]["printer_id_to_serial"][old_printer_id] = ( - printer_id_to_serial.get(old_printer_id) - ) - if backup["plate_calibration"]["files"]: - backup["included"].append("plate_calibration") - - # Filaments - if include_filaments: - result = await db.execute(select(Filament)) - filaments = result.scalars().all() - backup["filaments"] = [] - for f in filaments: - backup["filaments"].append( - { - "name": f.name, - "type": f.type, - "brand": f.brand, - "color": f.color, - "color_hex": f.color_hex, - "cost_per_kg": f.cost_per_kg, - "spool_weight_g": f.spool_weight_g, - "currency": f.currency, - "density": f.density, - "print_temp_min": f.print_temp_min, - "print_temp_max": f.print_temp_max, - "bed_temp_min": f.bed_temp_min, - "bed_temp_max": f.bed_temp_max, - } - ) - backup["included"].append("filaments") - - # Maintenance types and records - if include_maintenance: - # Maintenance types - result = await db.execute(select(MaintenanceType)) - types = result.scalars().all() - backup["maintenance_types"] = [] - for mt in types: - backup["maintenance_types"].append( - { - "name": mt.name, - "description": mt.description, - "default_interval_hours": mt.default_interval_hours, - "interval_type": mt.interval_type, - "icon": mt.icon, - "is_system": mt.is_system, - } - ) - backup["included"].append("maintenance_types") - - # Printer maintenance settings (per-printer custom intervals, enabled status, last performed) - result = await db.execute(select(PrinterMaintenance)) - printer_maint = result.scalars().all() - backup["printer_maintenance"] = [] - - # Build lookups for printer serial and maintenance type name - printer_id_to_serial: dict[int, str] = {} - maint_type_id_to_name: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - for mt in types: - maint_type_id_to_name[mt.id] = mt.name - - for pm in printer_maint: - backup["printer_maintenance"].append( - { - "printer_serial": printer_id_to_serial.get(pm.printer_id), - "maintenance_type_name": maint_type_id_to_name.get(pm.maintenance_type_id), - "custom_interval_hours": pm.custom_interval_hours, - "custom_interval_type": pm.custom_interval_type, - "enabled": pm.enabled, - "last_performed_at": pm.last_performed_at.isoformat() if pm.last_performed_at else None, - "last_performed_hours": pm.last_performed_hours, - } - ) - backup["included"].append("printer_maintenance") - - # Maintenance history - result = await db.execute(select(MaintenanceHistory)) - history = result.scalars().all() - backup["maintenance_history"] = [] - - # Build printer_maintenance ID to (printer_serial, maint_type_name) mapping - pm_id_to_info: dict[int, tuple[str | None, str | None]] = {} - for pm in printer_maint: - pm_id_to_info[pm.id] = ( - printer_id_to_serial.get(pm.printer_id), - maint_type_id_to_name.get(pm.maintenance_type_id), - ) - - for mh in history: - info = pm_id_to_info.get(mh.printer_maintenance_id, (None, None)) - backup["maintenance_history"].append( - { - "printer_serial": info[0], - "maintenance_type_name": info[1], - "performed_at": mh.performed_at.isoformat() if mh.performed_at else None, - "hours_at_maintenance": mh.hours_at_maintenance, - "notes": mh.notes, - } - ) - backup["included"].append("maintenance_history") - - # Print queue - if include_print_queue: - result = await db.execute(select(PrintQueueItem)) - queue_items = result.scalars().all() - backup["print_queue"] = [] - - # Build lookups - printer_id_to_serial: dict[int, str] = {} - archive_id_to_hash: dict[int, str | None] = {} - project_id_to_name: dict[int, str] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - ar_result = await db.execute(select(PrintArchive)) - for ar in ar_result.scalars().all(): - archive_id_to_hash[ar.id] = ar.content_hash - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_id_to_name[proj.id] = proj.name - - for qi in queue_items: - backup["print_queue"].append( - { - "printer_serial": printer_id_to_serial.get(qi.printer_id) if qi.printer_id else None, - "archive_hash": archive_id_to_hash.get(qi.archive_id), - "project_name": project_id_to_name.get(qi.project_id) if qi.project_id else None, - "position": qi.position, - "scheduled_time": qi.scheduled_time.isoformat() if qi.scheduled_time else None, - "require_previous_success": qi.require_previous_success, - "auto_off_after": qi.auto_off_after, - "manual_start": qi.manual_start, - "ams_mapping": qi.ams_mapping, - "plate_id": qi.plate_id, - "bed_levelling": qi.bed_levelling, - "flow_cali": qi.flow_cali, - "vibration_cali": qi.vibration_cali, - "layer_inspect": qi.layer_inspect, - "timelapse": qi.timelapse, - "use_ams": qi.use_ams, - "status": qi.status, - "started_at": qi.started_at.isoformat() if qi.started_at else None, - "completed_at": qi.completed_at.isoformat() if qi.completed_at else None, - "error_message": qi.error_message, - } - ) - backup["included"].append("print_queue") - - # Collect files for ZIP (icons + archives + project attachments) - backup_files: list[tuple[str, Path]] = [] # (zip_path, local_path) - base_dir = app_settings.base_dir - - # Add external link icon files - if include_external_links and "external_links" in backup: - icons_dir = base_dir / "icons" - for link_data in backup["external_links"]: - if "custom_icon_path" in link_data: - icon_path = icons_dir / link_data["custom_icon"] - if icon_path.exists(): - backup_files.append((link_data["custom_icon_path"], icon_path)) - - # Add plate calibration reference images - if "plate_calibration" in backup: - plate_cal_dir = app_settings.plate_calibration_dir - plate_cal_data = backup["plate_calibration"] - # Support both old list format and new dict format - filenames = plate_cal_data.get("files", []) if isinstance(plate_cal_data, dict) else plate_cal_data - for filename in filenames: - file_path = plate_cal_dir / filename - if file_path.exists(): - backup_files.append((f"plate_calibration/{filename}", file_path)) - - # Print archives with file paths for ZIP - if include_archives: - result = await db.execute(select(PrintArchive)) - archives = result.scalars().all() - backup["archives"] = [] - - # Build project ID to name mapping for archive export - project_id_to_name: dict[int, str] = {} - if include_projects: - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_id_to_name[proj.id] = proj.name - - # Build printer ID to serial mapping for archive export - printer_id_to_serial: dict[int, str] = {} - if include_printers: - printer_result = await db.execute(select(Printer)) - for pr in printer_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - for a in archives: - archive_data = { - "filename": a.filename, - "project_name": project_id_to_name.get(a.project_id) if a.project_id else None, - "printer_serial": printer_id_to_serial.get(a.printer_id) if a.printer_id else None, - "file_size": a.file_size, - "content_hash": a.content_hash, - "print_name": a.print_name, - "print_time_seconds": a.print_time_seconds, - "filament_used_grams": a.filament_used_grams, - "filament_type": a.filament_type, - "filament_color": a.filament_color, - "layer_height": a.layer_height, - "total_layers": a.total_layers, - "nozzle_diameter": a.nozzle_diameter, - "bed_temperature": a.bed_temperature, - "nozzle_temperature": a.nozzle_temperature, - "status": a.status, - "started_at": a.started_at.isoformat() if a.started_at else None, - "completed_at": a.completed_at.isoformat() if a.completed_at else None, - "makerworld_url": a.makerworld_url, - "designer": a.designer, - "external_url": a.external_url, - "is_favorite": a.is_favorite, - "tags": a.tags, - "notes": a.notes, - "cost": a.cost, - "failure_reason": a.failure_reason, - "quantity": a.quantity, - "energy_kwh": a.energy_kwh, - "energy_cost": a.energy_cost, - "extra_data": a.extra_data, - "photos": a.photos, - } - - # Collect file paths for ZIP - if a.file_path: - file_path = base_dir / a.file_path - if file_path.exists(): - archive_data["file_path"] = a.file_path - backup_files.append((a.file_path, file_path)) - - if a.thumbnail_path: - thumb_path = base_dir / a.thumbnail_path - if thumb_path.exists(): - archive_data["thumbnail_path"] = a.thumbnail_path - backup_files.append((a.thumbnail_path, thumb_path)) - - if a.timelapse_path: - timelapse_path = base_dir / a.timelapse_path - if timelapse_path.exists(): - archive_data["timelapse_path"] = a.timelapse_path - backup_files.append((a.timelapse_path, timelapse_path)) - - if a.source_3mf_path: - source_path = base_dir / a.source_3mf_path - if source_path.exists(): - archive_data["source_3mf_path"] = a.source_3mf_path - backup_files.append((a.source_3mf_path, source_path)) - - if a.f3d_path: - f3d_path = base_dir / a.f3d_path - if f3d_path.exists(): - archive_data["f3d_path"] = a.f3d_path - backup_files.append((a.f3d_path, f3d_path)) - - # Include photos - if a.photos: - for photo in a.photos: - photo_path = base_dir / "archive" / "photos" / photo - if photo_path.exists(): - zip_photo_path = f"archive/photos/{photo}" - backup_files.append((zip_photo_path, photo_path)) - - backup["archives"].append(archive_data) - backup["included"].append("archives") - - # Projects with BOM items - if include_projects: - result = await db.execute(select(Project)) - projects = result.scalars().all() - backup["projects"] = [] - - for p in projects: - # Get BOM items for this project - bom_result = await db.execute(select(ProjectBOMItem).where(ProjectBOMItem.project_id == p.id)) - bom_items = bom_result.scalars().all() - - project_data = { - "name": p.name, - "description": p.description, - "color": p.color, - "status": p.status, - "target_count": p.target_count, - "notes": p.notes, - "tags": p.tags, - "due_date": p.due_date.isoformat() if p.due_date else None, - "priority": p.priority, - "budget": p.budget, - "is_template": p.is_template, - "template_source_id": p.template_source_id, - "parent_id": p.parent_id, - "bom_items": [ - { - "name": item.name, - "quantity_needed": item.quantity_needed, - "quantity_acquired": item.quantity_acquired, - "unit_price": item.unit_price, - "sourcing_url": item.sourcing_url, - "stl_filename": item.stl_filename, - "remarks": item.remarks, - "sort_order": item.sort_order, - } - for item in bom_items - ], - } - - # Include attachment files for ZIP - if p.attachments: - project_data["attachments"] = p.attachments - attachments_dir = base_dir / "projects" / str(p.id) / "attachments" - for att in p.attachments: - att_path = attachments_dir / att.get("filename", "") - if att_path.exists(): - zip_path = f"projects/{p.id}/attachments/{att['filename']}" - backup_files.append((zip_path, att_path)) - - backup["projects"].append(project_data) - backup["included"].append("projects") - - # Pending uploads (virtual printer queue mode) - if include_pending_uploads: - result = await db.execute(select(PendingUpload).where(PendingUpload.status == "pending")) - pending_uploads = result.scalars().all() - backup["pending_uploads"] = [] - - for p in pending_uploads: - upload_data = { - "filename": p.filename, - "file_size": p.file_size, - "source_ip": p.source_ip, - "status": p.status, - "tags": p.tags, - "notes": p.notes, - "project_id": p.project_id, - "uploaded_at": p.uploaded_at.isoformat() if p.uploaded_at else None, - } - - # Include the actual file if it exists - if p.file_path: - file_path = Path(p.file_path) - if file_path.exists(): - # Store relative path for ZIP - rel_path = f"pending_uploads/{p.filename}" - upload_data["file_path"] = rel_path - backup_files.append((rel_path, file_path)) - - backup["pending_uploads"].append(upload_data) - backup["included"].append("pending_uploads") - - # API keys (note: key_hash cannot be restored, new keys must be generated) - if include_api_keys: - # Build printer ID to serial mapping for cross-system compatibility - printer_id_to_serial: dict[int, str] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_id_to_serial[pr.id] = pr.serial_number - - result = await db.execute(select(APIKey)) - api_keys = result.scalars().all() - backup["api_keys"] = [] - for key in api_keys: - # Convert printer_ids from list of IDs to list of serials - printer_serials = None - if key.printer_ids: - printer_serials = [ - printer_id_to_serial.get(pid) for pid in key.printer_ids if pid in printer_id_to_serial - ] - - backup["api_keys"].append( - { - "name": key.name, - "key_prefix": key.key_prefix, # For identification only - "can_queue": key.can_queue, - "can_control_printer": key.can_control_printer, - "can_read_status": key.can_read_status, - "printer_serials": printer_serials, # Use serials instead of IDs - "enabled": key.enabled, - "expires_at": key.expires_at.isoformat() if key.expires_at else None, - } - ) - backup["included"].append("api_keys") - - # Users (note: passwords not exported for security - users will need new passwords on import) - if include_users: - result = await db.execute(select(User)) - users = result.scalars().all() - backup["users"] = [] - for user in users: - backup["users"].append( - { - "username": user.username, - "role": user.role, - "is_active": user.is_active, - "groups": [g.name for g in user.groups], - # password_hash intentionally not exported for security - } - ) - backup["included"].append("users") - - # Groups (permission groups) - if include_groups: - result = await db.execute(select(Group)) - groups = result.scalars().all() - backup["groups"] = [] - for group in groups: - backup["groups"].append( - { - "name": group.name, - "description": group.description, - "permissions": group.permissions, - "is_system": group.is_system, - } - ) - backup["included"].append("groups") - - # GitHub backup configuration - if include_github_backup: - result = await db.execute(select(GitHubBackupConfig).limit(1)) - config = result.scalar_one_or_none() - if config: - backup["github_backup"] = { - "repository_url": config.repository_url, - # access_token intentionally not exported for security - "branch": config.branch, - "schedule_enabled": config.schedule_enabled, - "schedule_type": config.schedule_type, - "backup_kprofiles": config.backup_kprofiles, - "backup_cloud_profiles": config.backup_cloud_profiles, - "backup_settings": config.backup_settings, - "enabled": config.enabled, - } - backup["included"].append("github_backup") - - # If there are files to include (icons or archives), create ZIP file - if backup_files: - zip_buffer = io.BytesIO() - with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: - # Add backup.json - zf.writestr("backup.json", json.dumps(backup, indent=2)) - - # Add all backup files (icons, archives, etc.) - added_files = set() - for zip_path, local_path in backup_files: - if zip_path not in added_files and local_path.exists(): - try: - zf.write(local_path, zip_path) - added_files.add(zip_path) - except Exception: - pass # Skip files that can't be read - - zip_buffer.seek(0) - filename = f"bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.zip" - return StreamingResponse( - zip_buffer, - media_type="application/zip", - headers={"Content-Disposition": f"attachment; filename={filename}"}, - ) - - # Otherwise return JSON - return JSONResponse( - content=backup, - headers={ - "Content-Disposition": f"attachment; filename=bambuddy-backup-{datetime.now().strftime('%Y%m%d-%H%M%S')}.json" - }, - ) - - @router.post("/restore") async def restore_backup( file: UploadFile = File(...), @@ -1082,1345 +348,6 @@ async def restore_backup( } -@router.post("/restore-legacy") -async def import_backup_legacy( - file: UploadFile = File(...), - overwrite: bool = Query(False, description="Overwrite existing data instead of skipping duplicates"), - db: AsyncSession = Depends(get_db), -): - """Legacy restore: Restore data from JSON or ZIP backup. By default skips duplicates, set overwrite=true to replace existing.""" - try: - content = await file.read() - base_dir = app_settings.base_dir - files_restored = 0 - # Store plate calibration files for later (need printer ID remapping after printers restored) - plate_cal_files: dict[str, bytes] = {} - - # Check if it's a ZIP file - if file.filename and file.filename.endswith(".zip"): - try: - zip_buffer = io.BytesIO(content) - with zipfile.ZipFile(zip_buffer, "r") as zf: - # Extract backup.json - if "backup.json" not in zf.namelist(): - return {"success": False, "message": "Invalid ZIP: missing backup.json"} - - backup_content = zf.read("backup.json") - backup = json.loads(backup_content.decode("utf-8")) - - # Extract all other files to base_dir - for zip_path in zf.namelist(): - if zip_path == "backup.json": - continue - # Ensure path is safe (no path traversal) - if ".." in zip_path or zip_path.startswith("/"): - continue - # Plate calibration files - store for later processing after printers are restored - if zip_path.startswith("plate_calibration/"): - filename = zip_path.replace("plate_calibration/", "", 1) - if filename: # Skip directory entries - plate_cal_files[filename] = zf.read(zip_path) - continue - target_path = base_dir / zip_path - target_path.parent.mkdir(parents=True, exist_ok=True) - with zf.open(zip_path) as src, open(target_path, "wb") as dst: - dst.write(src.read()) - files_restored += 1 - except zipfile.BadZipFile: - return {"success": False, "message": "Invalid ZIP file"} - else: - backup = json.loads(content.decode("utf-8")) - except json.JSONDecodeError as e: - return {"success": False, "message": f"Invalid JSON: {str(e)}"} - except Exception as e: - return {"success": False, "message": f"Invalid backup file: {str(e)}"} - - restored = { - "settings": 0, - "notification_providers": 0, - "notification_templates": 0, - "smart_plugs": 0, - "external_links": 0, - "printers": 0, - "filaments": 0, - "maintenance_types": 0, - "projects": 0, - "pending_uploads": 0, - "users": 0, - "groups": 0, - "github_backup": 0, - } - skipped = { - "settings": 0, - "notification_providers": 0, - "notification_templates": 0, - "smart_plugs": 0, - "external_links": 0, - "printers": 0, - "filaments": 0, - "maintenance_types": 0, - "archives": 0, - "projects": 0, - "pending_uploads": 0, - "users": 0, - "groups": 0, - "github_backup": 0, - } - skipped_details = { - "notification_providers": [], - "smart_plugs": [], - "external_links": [], - "printers": [], - "filaments": [], - "maintenance_types": [], - "archives": [], - "projects": [], - "pending_uploads": [], - "users": [], - "groups": [], - } - - # Restore settings (always overwrites) - if "settings" in backup: - for key, value in backup["settings"].items(): - # Convert value to proper string format for storage - if isinstance(value, bool): - str_value = "true" if value else "false" - elif value is None: - str_value = "None" - else: - str_value = str(value) - await set_setting(db, key, str_value) - restored["settings"] += 1 - # Flush settings to ensure they're persisted before continuing - await db.flush() - - # Restore printers FIRST (skip or overwrite duplicates by serial_number) - # Nearly everything in the app references printers, so they must be imported first - if "printers" in backup: - for printer_data in backup["printers"]: - result = await db.execute(select(Printer).where(Printer.serial_number == printer_data["serial_number"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.name = printer_data["name"] - existing.ip_address = printer_data["ip_address"] - existing.model = printer_data.get("model") - existing.location = printer_data.get("location") - existing.nozzle_count = printer_data.get("nozzle_count", 1) - existing.auto_archive = printer_data.get("auto_archive", True) - existing.print_hours_offset = printer_data.get("print_hours_offset", 0.0) - existing.runtime_seconds = printer_data.get("runtime_seconds", 0) - - # If backup includes access_code, also update access_code and is_active - backup_access_code = printer_data.get("access_code") - if backup_access_code and backup_access_code != "CHANGE_ME": - existing.access_code = backup_access_code - is_active_val = printer_data.get("is_active", False) - if isinstance(is_active_val, str): - is_active_val = is_active_val.lower() == "true" - existing.is_active = is_active_val - - # Restore external camera settings - existing.external_camera_url = printer_data.get("external_camera_url") - existing.external_camera_type = printer_data.get("external_camera_type") - existing.external_camera_enabled = printer_data.get("external_camera_enabled", False) - - # Restore plate detection settings - existing.plate_detection_enabled = printer_data.get("plate_detection_enabled", False) - existing.plate_detection_roi_x = printer_data.get("plate_detection_roi_x") - existing.plate_detection_roi_y = printer_data.get("plate_detection_roi_y") - existing.plate_detection_roi_w = printer_data.get("plate_detection_roi_w") - existing.plate_detection_roi_h = printer_data.get("plate_detection_roi_h") - - restored["printers"] += 1 - else: - skipped["printers"] += 1 - skipped_details["printers"].append(f"{printer_data['name']} ({printer_data['serial_number']})") - else: - # Use access code from backup if provided, otherwise require manual setup - access_code = printer_data.get("access_code") - has_access_code = access_code and access_code != "CHANGE_ME" - is_active_from_backup = printer_data.get("is_active", False) - # Handle bool or string "true"/"false" - if isinstance(is_active_from_backup, str): - is_active_from_backup = is_active_from_backup.lower() == "true" - - printer = Printer( - name=printer_data["name"], - serial_number=printer_data["serial_number"], - ip_address=printer_data["ip_address"], - access_code=access_code if has_access_code else "CHANGE_ME", - model=printer_data.get("model"), - location=printer_data.get("location"), - nozzle_count=printer_data.get("nozzle_count", 1), - is_active=is_active_from_backup if has_access_code else False, - auto_archive=printer_data.get("auto_archive", True), - print_hours_offset=printer_data.get("print_hours_offset", 0.0), - runtime_seconds=printer_data.get("runtime_seconds", 0), - external_camera_url=printer_data.get("external_camera_url"), - external_camera_type=printer_data.get("external_camera_type"), - external_camera_enabled=printer_data.get("external_camera_enabled", False), - plate_detection_enabled=printer_data.get("plate_detection_enabled", False), - plate_detection_roi_x=printer_data.get("plate_detection_roi_x"), - plate_detection_roi_y=printer_data.get("plate_detection_roi_y"), - plate_detection_roi_w=printer_data.get("plate_detection_roi_w"), - plate_detection_roi_h=printer_data.get("plate_detection_roi_h"), - ) - db.add(printer) - restored["printers"] += 1 - # Flush printers so other sections can look them up - await db.flush() - - # Restore plate calibration files (remap printer IDs based on serial numbers) - if plate_cal_files: - # Build serial_number -> new_printer_id mapping - serial_to_new_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - serial_to_new_id[pr.serial_number] = pr.id - - # Get old_id -> serial mapping from backup (supports both old list format and new dict format) - plate_cal_data = backup.get("plate_calibration", {}) - if isinstance(plate_cal_data, dict): - old_id_to_serial: dict[int, str | None] = { - int(k): v for k, v in plate_cal_data.get("printer_id_to_serial", {}).items() - } - else: - old_id_to_serial = {} - - # Build old_id -> new_id mapping - old_id_to_new_id: dict[int, int] = {} - for old_id, serial in old_id_to_serial.items(): - if serial and serial in serial_to_new_id: - old_id_to_new_id[old_id] = serial_to_new_id[serial] - - app_settings.plate_calibration_dir.mkdir(parents=True, exist_ok=True) - - for filename, file_data in plate_cal_files.items(): - # Parse old printer ID from filename (e.g., "printer_3_ref_0.jpg" -> 3) - new_filename = filename - if filename.startswith("printer_"): - parts = filename.split("_") - if len(parts) >= 2 and parts[1].isdigit(): - old_printer_id = int(parts[1]) - if old_printer_id in old_id_to_new_id: - new_printer_id = old_id_to_new_id[old_printer_id] - # Replace old ID with new ID in filename - new_filename = filename.replace(f"printer_{old_printer_id}_", f"printer_{new_printer_id}_", 1) - - target_path = app_settings.plate_calibration_dir / new_filename - with open(target_path, "wb") as f: - f.write(file_data) - files_restored += 1 - - # Restore notification providers (skip or overwrite duplicates by name) - # Build printer serial to ID lookup (printers were restored first) - if "notification_providers" in backup: - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for provider_data in backup["notification_providers"]: - # Look up printer_id from serial (supports both old printer_id and new printer_serial format) - printer_serial = provider_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else provider_data.get("printer_id") - - result = await db.execute( - select(NotificationProvider).where(NotificationProvider.name == provider_data["name"]) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing provider - existing.provider_type = provider_data["provider_type"] - existing.enabled = provider_data.get("enabled", True) - existing.config = json.dumps(provider_data.get("config", {})) - existing.on_print_start = provider_data.get("on_print_start", False) - existing.on_print_complete = provider_data.get("on_print_complete", True) - existing.on_print_failed = provider_data.get("on_print_failed", True) - existing.on_print_stopped = provider_data.get("on_print_stopped", True) - existing.on_print_progress = provider_data.get("on_print_progress", False) - existing.on_printer_offline = provider_data.get("on_printer_offline", False) - existing.on_printer_error = provider_data.get("on_printer_error", False) - existing.on_filament_low = provider_data.get("on_filament_low", False) - existing.on_maintenance_due = provider_data.get("on_maintenance_due", False) - existing.on_ams_humidity_high = provider_data.get("on_ams_humidity_high", False) - existing.on_ams_temperature_high = provider_data.get("on_ams_temperature_high", False) - existing.on_ams_ht_humidity_high = provider_data.get("on_ams_ht_humidity_high", False) - existing.on_ams_ht_temperature_high = provider_data.get("on_ams_ht_temperature_high", False) - existing.on_plate_not_empty = provider_data.get("on_plate_not_empty", True) - existing.on_queue_job_added = provider_data.get("on_queue_job_added", False) - existing.on_queue_job_assigned = provider_data.get("on_queue_job_assigned", False) - existing.on_queue_job_started = provider_data.get("on_queue_job_started", False) - existing.on_queue_job_waiting = provider_data.get("on_queue_job_waiting", True) - existing.on_queue_job_skipped = provider_data.get("on_queue_job_skipped", True) - existing.on_queue_job_failed = provider_data.get("on_queue_job_failed", True) - existing.on_queue_completed = provider_data.get("on_queue_completed", False) - existing.quiet_hours_enabled = provider_data.get("quiet_hours_enabled", False) - existing.quiet_hours_start = provider_data.get("quiet_hours_start") - existing.quiet_hours_end = provider_data.get("quiet_hours_end") - existing.daily_digest_enabled = provider_data.get("daily_digest_enabled", False) - existing.daily_digest_time = provider_data.get("daily_digest_time") - existing.printer_id = printer_id - restored["notification_providers"] += 1 - else: - skipped["notification_providers"] += 1 - skipped_details["notification_providers"].append(provider_data["name"]) - else: - provider = NotificationProvider( - name=provider_data["name"], - provider_type=provider_data["provider_type"], - enabled=provider_data.get("enabled", True), - config=json.dumps(provider_data.get("config", {})), - on_print_start=provider_data.get("on_print_start", False), - on_print_complete=provider_data.get("on_print_complete", True), - on_print_failed=provider_data.get("on_print_failed", True), - on_print_stopped=provider_data.get("on_print_stopped", True), - on_print_progress=provider_data.get("on_print_progress", False), - on_printer_offline=provider_data.get("on_printer_offline", False), - on_printer_error=provider_data.get("on_printer_error", False), - on_filament_low=provider_data.get("on_filament_low", False), - on_maintenance_due=provider_data.get("on_maintenance_due", False), - on_ams_humidity_high=provider_data.get("on_ams_humidity_high", False), - on_ams_temperature_high=provider_data.get("on_ams_temperature_high", False), - on_ams_ht_humidity_high=provider_data.get("on_ams_ht_humidity_high", False), - on_ams_ht_temperature_high=provider_data.get("on_ams_ht_temperature_high", False), - on_plate_not_empty=provider_data.get("on_plate_not_empty", True), - on_queue_job_added=provider_data.get("on_queue_job_added", False), - on_queue_job_assigned=provider_data.get("on_queue_job_assigned", False), - on_queue_job_started=provider_data.get("on_queue_job_started", False), - on_queue_job_waiting=provider_data.get("on_queue_job_waiting", True), - on_queue_job_skipped=provider_data.get("on_queue_job_skipped", True), - on_queue_job_failed=provider_data.get("on_queue_job_failed", True), - on_queue_completed=provider_data.get("on_queue_completed", False), - quiet_hours_enabled=provider_data.get("quiet_hours_enabled", False), - quiet_hours_start=provider_data.get("quiet_hours_start"), - quiet_hours_end=provider_data.get("quiet_hours_end"), - daily_digest_enabled=provider_data.get("daily_digest_enabled", False), - daily_digest_time=provider_data.get("daily_digest_time"), - printer_id=printer_id, - ) - db.add(provider) - restored["notification_providers"] += 1 - - # Restore notification templates (update existing by event_type) - if "notification_templates" in backup: - for template_data in backup["notification_templates"]: - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.event_type == template_data["event_type"]) - ) - existing = result.scalar_one_or_none() - if existing: - # Update existing template - existing.name = template_data.get("name", existing.name) - existing.title_template = template_data.get("title_template", existing.title_template) - existing.body_template = template_data.get("body_template", existing.body_template) - existing.is_default = template_data.get("is_default", False) - else: - template = NotificationTemplate( - event_type=template_data["event_type"], - name=template_data["name"], - title_template=template_data["title_template"], - body_template=template_data["body_template"], - is_default=template_data.get("is_default", False), - ) - db.add(template) - restored["notification_templates"] += 1 - - # Restore smart plugs (skip or overwrite duplicates by IP) - # Note: Smart plugs reference printers, so printers should be restored first - if "smart_plugs" in backup: - # Build printer serial to ID lookup - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for plug_data in backup["smart_plugs"]: - # Look up printer_id from serial (supports both old printer_id and new printer_serial format) - printer_serial = plug_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else plug_data.get("printer_id") - - # Determine plug type (default to tasmota for backwards compatibility) - plug_type = plug_data.get("plug_type", "tasmota") - - # Find existing plug by IP (Tasmota), entity_id (Home Assistant), or mqtt_topic (MQTT) - existing = None - plug_identifier = None - if plug_type == "homeassistant" and plug_data.get("ha_entity_id"): - result = await db.execute(select(SmartPlug).where(SmartPlug.ha_entity_id == plug_data["ha_entity_id"])) - existing = result.scalar_one_or_none() - plug_identifier = plug_data["ha_entity_id"] - elif plug_type == "mqtt" and (plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic")): - # Check by mqtt_power_topic first (new format), fall back to mqtt_topic (legacy) - power_topic = plug_data.get("mqtt_power_topic") or plug_data.get("mqtt_topic") - result = await db.execute( - select(SmartPlug).where( - (SmartPlug.mqtt_power_topic == power_topic) | (SmartPlug.mqtt_topic == power_topic) - ) - ) - existing = result.scalar_one_or_none() - plug_identifier = power_topic - elif plug_data.get("ip_address"): - result = await db.execute(select(SmartPlug).where(SmartPlug.ip_address == plug_data["ip_address"])) - existing = result.scalar_one_or_none() - plug_identifier = plug_data["ip_address"] - else: - # Skip invalid plug data - continue - - if existing: - if overwrite: - existing.name = plug_data["name"] - existing.plug_type = plug_type - existing.ha_entity_id = plug_data.get("ha_entity_id") - existing.ha_power_entity = plug_data.get("ha_power_entity") - existing.ha_energy_today_entity = plug_data.get("ha_energy_today_entity") - existing.ha_energy_total_entity = plug_data.get("ha_energy_total_entity") - # MQTT fields (legacy) - existing.mqtt_topic = plug_data.get("mqtt_topic") - existing.mqtt_multiplier = plug_data.get("mqtt_multiplier", 1.0) - # MQTT power fields - existing.mqtt_power_topic = plug_data.get("mqtt_power_topic") - existing.mqtt_power_path = plug_data.get("mqtt_power_path") - existing.mqtt_power_multiplier = plug_data.get("mqtt_power_multiplier", 1.0) - # MQTT energy fields - existing.mqtt_energy_topic = plug_data.get("mqtt_energy_topic") - existing.mqtt_energy_path = plug_data.get("mqtt_energy_path") - existing.mqtt_energy_multiplier = plug_data.get("mqtt_energy_multiplier", 1.0) - # MQTT state fields - existing.mqtt_state_topic = plug_data.get("mqtt_state_topic") - existing.mqtt_state_path = plug_data.get("mqtt_state_path") - existing.mqtt_state_on_value = plug_data.get("mqtt_state_on_value") - existing.printer_id = printer_id - existing.enabled = plug_data.get("enabled", True) - existing.auto_on = plug_data.get("auto_on", True) - existing.auto_off = plug_data.get("auto_off", True) - existing.off_delay_mode = plug_data.get("off_delay_mode", "time") - existing.off_delay_minutes = plug_data.get("off_delay_minutes", 5) - existing.off_temp_threshold = plug_data.get("off_temp_threshold", 70) - existing.username = plug_data.get("username") - existing.password = plug_data.get("password") - existing.power_alert_enabled = plug_data.get("power_alert_enabled", False) - existing.power_alert_high = plug_data.get("power_alert_high") - existing.power_alert_low = plug_data.get("power_alert_low") - existing.schedule_enabled = plug_data.get("schedule_enabled", False) - existing.schedule_on_time = plug_data.get("schedule_on_time") - existing.schedule_off_time = plug_data.get("schedule_off_time") - existing.show_in_switchbar = plug_data.get("show_in_switchbar", False) - existing.show_on_printer_card = plug_data.get("show_on_printer_card", True) - restored["smart_plugs"] += 1 - else: - skipped["smart_plugs"] += 1 - skipped_details["smart_plugs"].append(f"{plug_data['name']} ({plug_identifier})") - else: - plug = SmartPlug( - name=plug_data["name"], - plug_type=plug_type, - ip_address=plug_data.get("ip_address"), - ha_entity_id=plug_data.get("ha_entity_id"), - ha_power_entity=plug_data.get("ha_power_entity"), - ha_energy_today_entity=plug_data.get("ha_energy_today_entity"), - ha_energy_total_entity=plug_data.get("ha_energy_total_entity"), - # MQTT fields (legacy) - mqtt_topic=plug_data.get("mqtt_topic"), - mqtt_multiplier=plug_data.get("mqtt_multiplier", 1.0), - # MQTT power fields - mqtt_power_topic=plug_data.get("mqtt_power_topic"), - mqtt_power_path=plug_data.get("mqtt_power_path"), - mqtt_power_multiplier=plug_data.get("mqtt_power_multiplier", 1.0), - # MQTT energy fields - mqtt_energy_topic=plug_data.get("mqtt_energy_topic"), - mqtt_energy_path=plug_data.get("mqtt_energy_path"), - mqtt_energy_multiplier=plug_data.get("mqtt_energy_multiplier", 1.0), - # MQTT state fields - mqtt_state_topic=plug_data.get("mqtt_state_topic"), - mqtt_state_path=plug_data.get("mqtt_state_path"), - mqtt_state_on_value=plug_data.get("mqtt_state_on_value"), - printer_id=printer_id, - enabled=plug_data.get("enabled", True), - auto_on=plug_data.get("auto_on", True), - auto_off=plug_data.get("auto_off", True), - off_delay_mode=plug_data.get("off_delay_mode", "time"), - off_delay_minutes=plug_data.get("off_delay_minutes", 5), - off_temp_threshold=plug_data.get("off_temp_threshold", 70), - username=plug_data.get("username"), - password=plug_data.get("password"), - power_alert_enabled=plug_data.get("power_alert_enabled", False), - power_alert_high=plug_data.get("power_alert_high"), - power_alert_low=plug_data.get("power_alert_low"), - schedule_enabled=plug_data.get("schedule_enabled", False), - schedule_on_time=plug_data.get("schedule_on_time"), - schedule_off_time=plug_data.get("schedule_off_time"), - show_in_switchbar=plug_data.get("show_in_switchbar", False), - show_on_printer_card=plug_data.get("show_on_printer_card", True), - ) - db.add(plug) - restored["smart_plugs"] += 1 - - # Restore external links (skip or overwrite duplicates by name+url) - if "external_links" in backup: - icons_dir = base_dir / "icons" - icons_dir.mkdir(parents=True, exist_ok=True) - - for link_data in backup["external_links"]: - result = await db.execute( - select(ExternalLink).where(ExternalLink.name == link_data["name"], ExternalLink.url == link_data["url"]) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.icon = link_data.get("icon", "link") - existing.sort_order = link_data.get("sort_order", 0) - # Handle custom icon - if link_data.get("custom_icon"): - existing.custom_icon = link_data["custom_icon"] - restored["external_links"] += 1 - else: - skipped["external_links"] += 1 - skipped_details["external_links"].append(link_data["name"]) - else: - link = ExternalLink( - name=link_data["name"], - url=link_data["url"], - icon=link_data.get("icon", "link"), - custom_icon=link_data.get("custom_icon"), - sort_order=link_data.get("sort_order", 0), - ) - db.add(link) - restored["external_links"] += 1 - - # Restore filaments (skip or overwrite duplicates by name+type+brand) - if "filaments" in backup: - for filament_data in backup["filaments"]: - result = await db.execute( - select(Filament).where( - Filament.name == filament_data["name"], - Filament.type == filament_data["type"], - Filament.brand == filament_data.get("brand"), - ) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.color = filament_data.get("color") - existing.color_hex = filament_data.get("color_hex") - existing.cost_per_kg = filament_data.get("cost_per_kg", 25.0) - existing.spool_weight_g = filament_data.get("spool_weight_g", 1000.0) - existing.currency = filament_data.get("currency", "USD") - existing.density = filament_data.get("density") - existing.print_temp_min = filament_data.get("print_temp_min") - existing.print_temp_max = filament_data.get("print_temp_max") - existing.bed_temp_min = filament_data.get("bed_temp_min") - existing.bed_temp_max = filament_data.get("bed_temp_max") - restored["filaments"] += 1 - else: - skipped["filaments"] += 1 - skipped_details["filaments"].append( - f"{filament_data.get('brand', '')} {filament_data['name']} ({filament_data['type']})" - ) - else: - filament = Filament( - name=filament_data["name"], - type=filament_data["type"], - brand=filament_data.get("brand"), - color=filament_data.get("color"), - color_hex=filament_data.get("color_hex"), - cost_per_kg=filament_data.get("cost_per_kg", 25.0), - spool_weight_g=filament_data.get("spool_weight_g", 1000.0), - currency=filament_data.get("currency", "USD"), - density=filament_data.get("density"), - print_temp_min=filament_data.get("print_temp_min"), - print_temp_max=filament_data.get("print_temp_max"), - bed_temp_min=filament_data.get("bed_temp_min"), - bed_temp_max=filament_data.get("bed_temp_max"), - ) - db.add(filament) - restored["filaments"] += 1 - - # Restore maintenance types (skip or overwrite duplicates by name) - if "maintenance_types" in backup: - for mt_data in backup["maintenance_types"]: - result = await db.execute(select(MaintenanceType).where(MaintenanceType.name == mt_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.description = mt_data.get("description") - existing.default_interval_hours = mt_data.get("default_interval_hours", 100.0) - existing.interval_type = mt_data.get("interval_type", "hours") - existing.icon = mt_data.get("icon") - # Don't overwrite is_system - restored["maintenance_types"] += 1 - else: - skipped["maintenance_types"] += 1 - skipped_details["maintenance_types"].append(mt_data["name"]) - else: - mt = MaintenanceType( - name=mt_data["name"], - description=mt_data.get("description"), - default_interval_hours=mt_data.get("default_interval_hours", 100.0), - interval_type=mt_data.get("interval_type", "hours"), - icon=mt_data.get("icon"), - is_system=mt_data.get("is_system", False), - ) - db.add(mt) - restored["maintenance_types"] += 1 - - # Restore printer maintenance settings (per-printer) - if "printer_maintenance" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - maint_type_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - mt_result = await db.execute(select(MaintenanceType)) - for mt in mt_result.scalars().all(): - maint_type_name_to_id[mt.name] = mt.id - - restored["printer_maintenance"] = 0 - skipped["printer_maintenance"] = 0 - skipped_details["printer_maintenance"] = [] - - for pm_data in backup["printer_maintenance"]: - printer_serial = pm_data.get("printer_serial") - maint_type_name = pm_data.get("maintenance_type_name") - - if not printer_serial or not maint_type_name: - continue - - printer_id = printer_serial_to_id.get(printer_serial) - maint_type_id = maint_type_name_to_id.get(maint_type_name) - - if not printer_id or not maint_type_id: - skipped["printer_maintenance"] += 1 - skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") - continue - - # Check if exists - result = await db.execute( - select(PrinterMaintenance).where( - PrinterMaintenance.printer_id == printer_id, - PrinterMaintenance.maintenance_type_id == maint_type_id, - ) - ) - existing = result.scalar_one_or_none() - - if existing: - if overwrite: - existing.custom_interval_hours = pm_data.get("custom_interval_hours") - existing.custom_interval_type = pm_data.get("custom_interval_type") - existing.enabled = pm_data.get("enabled", True) - existing.last_performed_hours = pm_data.get("last_performed_hours", 0.0) - if pm_data.get("last_performed_at"): - existing.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) - restored["printer_maintenance"] += 1 - else: - skipped["printer_maintenance"] += 1 - skipped_details["printer_maintenance"].append(f"{printer_serial}/{maint_type_name}") - else: - pm = PrinterMaintenance( - printer_id=printer_id, - maintenance_type_id=maint_type_id, - custom_interval_hours=pm_data.get("custom_interval_hours"), - custom_interval_type=pm_data.get("custom_interval_type"), - enabled=pm_data.get("enabled", True), - last_performed_hours=pm_data.get("last_performed_hours", 0.0), - ) - if pm_data.get("last_performed_at"): - pm.last_performed_at = datetime.fromisoformat(pm_data["last_performed_at"]) - db.add(pm) - restored["printer_maintenance"] += 1 - - # Restore maintenance history - if "maintenance_history" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - maint_type_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - mt_result = await db.execute(select(MaintenanceType)) - for mt in mt_result.scalars().all(): - maint_type_name_to_id[mt.name] = mt.id - - restored["maintenance_history"] = 0 - skipped["maintenance_history"] = 0 - skipped_details["maintenance_history"] = [] - - for mh_data in backup["maintenance_history"]: - printer_serial = mh_data.get("printer_serial") - maint_type_name = mh_data.get("maintenance_type_name") - - if not printer_serial or not maint_type_name: - continue - - printer_id = printer_serial_to_id.get(printer_serial) - maint_type_id = maint_type_name_to_id.get(maint_type_name) - - if not printer_id or not maint_type_id: - skipped["maintenance_history"] += 1 - continue - - # Find the PrinterMaintenance record - result = await db.execute( - select(PrinterMaintenance).where( - PrinterMaintenance.printer_id == printer_id, - PrinterMaintenance.maintenance_type_id == maint_type_id, - ) - ) - pm = result.scalar_one_or_none() - - if not pm: - skipped["maintenance_history"] += 1 - continue - - # Create history entry (no duplicate check - history is append-only) - mh = MaintenanceHistory( - printer_maintenance_id=pm.id, - hours_at_maintenance=mh_data.get("hours_at_maintenance", 0.0), - notes=mh_data.get("notes"), - ) - if mh_data.get("performed_at"): - mh.performed_at = datetime.fromisoformat(mh_data["performed_at"]) - db.add(mh) - restored["maintenance_history"] += 1 - - # Restore archives (skip duplicates by content_hash - overwrite not supported for archives) - if "archives" in backup: - # Build printer serial to ID mapping - printer_serial_to_id: dict[str, int] = {} - printer_result = await db.execute(select(Printer)) - for pr in printer_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - for archive_data in backup["archives"]: - # Skip if no content_hash or already exists - content_hash = archive_data.get("content_hash") - if content_hash: - result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash)) - existing = result.scalar_one_or_none() - if existing: - skipped["archives"] += 1 - skipped_details["archives"].append(archive_data.get("filename", "Unknown")) - continue - - # Only restore if file exists (from ZIP extraction) - file_path = archive_data.get("file_path") - if file_path and (base_dir / file_path).exists(): - # Look up printer_id from serial - printer_serial = archive_data.get("printer_serial") - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None - - archive = PrintArchive( - filename=archive_data["filename"], - file_path=file_path, - file_size=archive_data.get("file_size", 0), - content_hash=content_hash, - printer_id=printer_id, - thumbnail_path=archive_data.get("thumbnail_path"), - timelapse_path=archive_data.get("timelapse_path"), - source_3mf_path=archive_data.get("source_3mf_path"), - f3d_path=archive_data.get("f3d_path"), - print_name=archive_data.get("print_name"), - print_time_seconds=archive_data.get("print_time_seconds"), - filament_used_grams=archive_data.get("filament_used_grams"), - filament_type=archive_data.get("filament_type"), - filament_color=archive_data.get("filament_color"), - layer_height=archive_data.get("layer_height"), - total_layers=archive_data.get("total_layers"), - nozzle_diameter=archive_data.get("nozzle_diameter"), - bed_temperature=archive_data.get("bed_temperature"), - nozzle_temperature=archive_data.get("nozzle_temperature"), - status=archive_data.get("status", "completed"), - makerworld_url=archive_data.get("makerworld_url"), - designer=archive_data.get("designer"), - external_url=archive_data.get("external_url"), - is_favorite=archive_data.get("is_favorite", False), - tags=archive_data.get("tags"), - notes=archive_data.get("notes"), - cost=archive_data.get("cost"), - failure_reason=archive_data.get("failure_reason"), - quantity=archive_data.get("quantity", 1), - energy_kwh=archive_data.get("energy_kwh"), - energy_cost=archive_data.get("energy_cost"), - extra_data=archive_data.get("extra_data"), - photos=archive_data.get("photos"), - ) - db.add(archive) - restored["archives"] = restored.get("archives", 0) + 1 - - # Restore projects (skip or overwrite duplicates by name) - if "projects" in backup: - for project_data in backup["projects"]: - result = await db.execute(select(Project).where(Project.name == project_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing project - existing.description = project_data.get("description") - existing.color = project_data.get("color") - existing.status = project_data.get("status", "active") - existing.target_count = project_data.get("target_count") - existing.notes = project_data.get("notes") - existing.tags = project_data.get("tags") - existing.priority = project_data.get("priority", "normal") - existing.budget = project_data.get("budget") - existing.is_template = project_data.get("is_template", False) - existing.template_source_id = project_data.get("template_source_id") - existing.parent_id = project_data.get("parent_id") - existing.attachments = project_data.get("attachments") - if project_data.get("due_date"): - existing.due_date = datetime.fromisoformat(project_data["due_date"]) - - # Delete existing BOM items and re-add - await db.execute(ProjectBOMItem.__table__.delete().where(ProjectBOMItem.project_id == existing.id)) - for bom_data in project_data.get("bom_items", []): - bom_item = ProjectBOMItem( - project_id=existing.id, - name=bom_data["name"], - quantity_needed=bom_data.get("quantity_needed", 1), - quantity_acquired=bom_data.get("quantity_acquired", 0), - unit_price=bom_data.get("unit_price"), - sourcing_url=bom_data.get("sourcing_url"), - stl_filename=bom_data.get("stl_filename"), - remarks=bom_data.get("remarks"), - sort_order=bom_data.get("sort_order", 0), - ) - db.add(bom_item) - - restored["projects"] += 1 - else: - skipped["projects"] += 1 - skipped_details["projects"].append(project_data["name"]) - else: - # Create new project - project = Project( - name=project_data["name"], - description=project_data.get("description"), - color=project_data.get("color"), - status=project_data.get("status", "active"), - target_count=project_data.get("target_count"), - notes=project_data.get("notes"), - tags=project_data.get("tags"), - priority=project_data.get("priority", "normal"), - budget=project_data.get("budget"), - is_template=project_data.get("is_template", False), - template_source_id=project_data.get("template_source_id"), - parent_id=project_data.get("parent_id"), - attachments=project_data.get("attachments"), - ) - if project_data.get("due_date"): - project.due_date = datetime.fromisoformat(project_data["due_date"]) - - db.add(project) - await db.flush() # Get the project ID - - # Add BOM items - for bom_data in project_data.get("bom_items", []): - bom_item = ProjectBOMItem( - project_id=project.id, - name=bom_data["name"], - quantity_needed=bom_data.get("quantity_needed", 1), - quantity_acquired=bom_data.get("quantity_acquired", 0), - unit_price=bom_data.get("unit_price"), - sourcing_url=bom_data.get("sourcing_url"), - stl_filename=bom_data.get("stl_filename"), - remarks=bom_data.get("remarks"), - sort_order=bom_data.get("sort_order", 0), - ) - db.add(bom_item) - - restored["projects"] += 1 - - # Link archives to projects by name (after both are restored) - if "archives" in backup and "projects" in backup: - # Build project name to ID mapping - proj_result = await db.execute(select(Project)) - project_name_to_id: dict[str, int] = {} - for proj in proj_result.scalars().all(): - project_name_to_id[proj.name] = proj.id - - # Update archives with project_id - for archive_data in backup["archives"]: - project_name = archive_data.get("project_name") - if project_name and project_name in project_name_to_id: - content_hash = archive_data.get("content_hash") - if content_hash: - result = await db.execute(select(PrintArchive).where(PrintArchive.content_hash == content_hash)) - archive = result.scalar_one_or_none() - if archive: - archive.project_id = project_name_to_id[project_name] - - # Restore print queue (must be after archives and projects) - if "print_queue" in backup: - # Build lookups - printer_serial_to_id: dict[str, int] = {} - archive_hash_to_id: dict[str, int] = {} - project_name_to_id: dict[str, int] = {} - - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - ar_result = await db.execute(select(PrintArchive)) - for ar in ar_result.scalars().all(): - if ar.content_hash: - archive_hash_to_id[ar.content_hash] = ar.id - - proj_result = await db.execute(select(Project)) - for proj in proj_result.scalars().all(): - project_name_to_id[proj.name] = proj.id - - restored["print_queue"] = 0 - skipped["print_queue"] = 0 - skipped_details["print_queue"] = [] - - for qi_data in backup["print_queue"]: - printer_serial = qi_data.get("printer_serial") # Can be None for unassigned items - archive_hash = qi_data.get("archive_hash") - - # Archive is required, but printer can be None (unassigned) - if not archive_hash: - skipped["print_queue"] += 1 - continue - - # Look up printer_id (None if unassigned or printer not found) - printer_id = printer_serial_to_id.get(printer_serial) if printer_serial else None - archive_id = archive_hash_to_id.get(archive_hash) - - # Archive must exist, but printer is optional (unassigned items) - if not archive_id: - skipped["print_queue"] += 1 - skipped_details["print_queue"].append( - f"{printer_serial or 'unassigned'}/{archive_hash[:8] if archive_hash else 'N/A'}" - ) - continue - - # If printer_serial was specified but printer not found, skip - if printer_serial and not printer_id: - skipped["print_queue"] += 1 - skipped_details["print_queue"].append(f"{printer_serial}/{archive_hash[:8]}") - continue - - project_name = qi_data.get("project_name") - project_id = project_name_to_id.get(project_name) if project_name else None - - qi = PrintQueueItem( - printer_id=printer_id, # Can be None for unassigned items - archive_id=archive_id, - project_id=project_id, - position=qi_data.get("position", 0), - require_previous_success=qi_data.get("require_previous_success", False), - auto_off_after=qi_data.get("auto_off_after", False), - manual_start=qi_data.get("manual_start", False), - ams_mapping=qi_data.get("ams_mapping"), - plate_id=qi_data.get("plate_id"), - bed_levelling=qi_data.get("bed_levelling", True), - flow_cali=qi_data.get("flow_cali", False), - vibration_cali=qi_data.get("vibration_cali", True), - layer_inspect=qi_data.get("layer_inspect", False), - timelapse=qi_data.get("timelapse", False), - use_ams=qi_data.get("use_ams", True), - status=qi_data.get("status", "pending"), - error_message=qi_data.get("error_message"), - ) - if qi_data.get("scheduled_time"): - qi.scheduled_time = datetime.fromisoformat(qi_data["scheduled_time"]) - if qi_data.get("started_at"): - qi.started_at = datetime.fromisoformat(qi_data["started_at"]) - if qi_data.get("completed_at"): - qi.completed_at = datetime.fromisoformat(qi_data["completed_at"]) - db.add(qi) - restored["print_queue"] += 1 - - # Restore pending uploads (skip duplicates by filename) - if "pending_uploads" in backup: - # Ensure the pending uploads directory exists - pending_uploads_dir = base_dir / "virtual_printer" / "uploads" - pending_uploads_dir.mkdir(parents=True, exist_ok=True) - - for upload_data in backup["pending_uploads"]: - # Check for existing by filename - result = await db.execute( - select(PendingUpload).where( - PendingUpload.filename == upload_data["filename"], - PendingUpload.status == "pending", - ) - ) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update existing - existing.file_size = upload_data.get("file_size", 0) - existing.source_ip = upload_data.get("source_ip") - existing.tags = upload_data.get("tags") - existing.notes = upload_data.get("notes") - existing.project_id = upload_data.get("project_id") - # Update file path if file was restored from ZIP - if upload_data.get("file_path"): - restored_file = base_dir / upload_data["file_path"] - if restored_file.exists(): - # Move to proper location - target_path = pending_uploads_dir / upload_data["filename"] - if restored_file != target_path: - import shutil - - shutil.move(str(restored_file), str(target_path)) - existing.file_path = str(target_path) - restored["pending_uploads"] += 1 - else: - skipped["pending_uploads"] += 1 - skipped_details["pending_uploads"].append(upload_data["filename"]) - else: - # Determine file path - file_path_str = None - if upload_data.get("file_path"): - restored_file = base_dir / upload_data["file_path"] - if restored_file.exists(): - # Move to proper location - target_path = pending_uploads_dir / upload_data["filename"] - if restored_file != target_path: - import shutil - - shutil.move(str(restored_file), str(target_path)) - file_path_str = str(target_path) - - # Parse uploaded_at - uploaded_at = None - if upload_data.get("uploaded_at"): - try: - uploaded_at = datetime.fromisoformat(upload_data["uploaded_at"].replace("Z", "+00:00")) - except (ValueError, AttributeError): - uploaded_at = datetime.utcnow() - else: - uploaded_at = datetime.utcnow() - - pending = PendingUpload( - filename=upload_data["filename"], - file_path=file_path_str or "", - file_size=upload_data.get("file_size", 0), - source_ip=upload_data.get("source_ip"), - status="pending", - tags=upload_data.get("tags"), - notes=upload_data.get("notes"), - project_id=upload_data.get("project_id"), - uploaded_at=uploaded_at, - ) - db.add(pending) - restored["pending_uploads"] += 1 - - # Restore API keys (generates new keys since we can't restore the hash) - new_api_keys: list[dict] = [] # Track newly generated keys for response - if "api_keys" in backup: - from backend.app.core.auth import generate_api_key - - # Build printer serial to ID mapping - printer_serial_to_id: dict[str, int] = {} - pr_result = await db.execute(select(Printer)) - for pr in pr_result.scalars().all(): - printer_serial_to_id[pr.serial_number] = pr.id - - restored["api_keys"] = 0 - skipped["api_keys"] = 0 - skipped_details["api_keys"] = [] - - for key_data in backup["api_keys"]: - # Check if key with same name already exists - result = await db.execute(select(APIKey).where(APIKey.name == key_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - # Update permissions but keep the existing key - existing.can_queue = key_data.get("can_queue", True) - existing.can_control_printer = key_data.get("can_control_printer", False) - existing.can_read_status = key_data.get("can_read_status", True) - existing.enabled = key_data.get("enabled", True) - if key_data.get("expires_at"): - existing.expires_at = datetime.fromisoformat(key_data["expires_at"]) - # Convert printer serials to IDs - if key_data.get("printer_serials"): - existing.printer_ids = [ - printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id - ] - restored["api_keys"] += 1 - else: - skipped["api_keys"] += 1 - skipped_details["api_keys"].append(key_data["name"]) - else: - # Generate new key - full_key, key_hash, key_prefix = generate_api_key() - - # Convert printer serials to IDs - printer_ids = None - if key_data.get("printer_serials"): - printer_ids = [ - printer_serial_to_id[s] for s in key_data["printer_serials"] if s in printer_serial_to_id - ] - - api_key = APIKey( - name=key_data["name"], - key_hash=key_hash, - key_prefix=key_prefix, - can_queue=key_data.get("can_queue", True), - can_control_printer=key_data.get("can_control_printer", False), - can_read_status=key_data.get("can_read_status", True), - printer_ids=printer_ids, - enabled=key_data.get("enabled", True), - ) - if key_data.get("expires_at"): - api_key.expires_at = datetime.fromisoformat(key_data["expires_at"]) - db.add(api_key) - restored["api_keys"] += 1 - - # Track the new key so user can see it - new_api_keys.append( - { - "name": key_data["name"], - "key": full_key, - "key_prefix": key_prefix, - } - ) - - # Restore groups (before users, so groups exist for assignment) - if "groups" in backup: - for group_data in backup["groups"]: - result = await db.execute(select(Group).where(Group.name == group_data["name"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite and not existing.is_system: - # Update non-system groups - existing.description = group_data.get("description") - existing.permissions = group_data.get("permissions", []) - restored["groups"] += 1 - else: - skipped["groups"] += 1 - skipped_details["groups"].append(group_data["name"]) - else: - group = Group( - name=group_data["name"], - description=group_data.get("description"), - permissions=group_data.get("permissions", []), - is_system=group_data.get("is_system", False), - ) - db.add(group) - restored["groups"] += 1 - - # Flush to ensure groups are persisted before user assignment - await db.flush() - - # Build group name to object lookup for user assignment - group_name_to_obj: dict[str, Group] = {} - result = await db.execute(select(Group)) - for g in result.scalars().all(): - group_name_to_obj[g.name] = g - - # Restore users (note: passwords not included in backup - users will need new passwords) - # Users are skipped by default since they have no passwords; admin must recreate them - new_users: list[str] = [] - if "users" in backup: - from backend.app.core.auth import get_password_hash - - for user_data in backup["users"]: - result = await db.execute(select(User).where(User.username == user_data["username"])) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.role = user_data.get("role", "user") - existing.is_active = user_data.get("is_active", True) - # Assign groups if provided - group_names = user_data.get("groups", []) - if group_names: - existing.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] - # Don't change password - keep existing - restored["users"] += 1 - else: - skipped["users"] += 1 - skipped_details["users"].append(user_data["username"]) - else: - # Create user with a temporary password that must be changed - # Generate a random temporary password - import secrets - - temp_password = secrets.token_urlsafe(16) - user = User( - username=user_data["username"], - password_hash=get_password_hash(temp_password), - role=user_data.get("role", "user"), - is_active=user_data.get("is_active", True), - ) - # Assign groups if provided - group_names = user_data.get("groups", []) - if group_names: - user.groups = [group_name_to_obj[name] for name in group_names if name in group_name_to_obj] - db.add(user) - restored["users"] += 1 - new_users.append(f"{user_data['username']} (temp password: {temp_password})") - - # Restore GitHub backup configuration (note: access_token not included for security) - if "github_backup" in backup: - github_data = backup["github_backup"] - result = await db.execute(select(GitHubBackupConfig).limit(1)) - existing = result.scalar_one_or_none() - if existing: - if overwrite: - existing.repository_url = github_data.get("repository_url", existing.repository_url) - existing.branch = github_data.get("branch", existing.branch) - existing.schedule_enabled = github_data.get("schedule_enabled", existing.schedule_enabled) - existing.schedule_type = github_data.get("schedule_type", existing.schedule_type) - existing.backup_kprofiles = github_data.get("backup_kprofiles", existing.backup_kprofiles) - existing.backup_cloud_profiles = github_data.get( - "backup_cloud_profiles", existing.backup_cloud_profiles - ) - existing.backup_settings = github_data.get("backup_settings", existing.backup_settings) - existing.enabled = github_data.get("enabled", existing.enabled) - # Note: access_token must be re-entered after restore - restored["github_backup"] += 1 - else: - skipped["github_backup"] += 1 - else: - config = GitHubBackupConfig( - repository_url=github_data.get("repository_url", ""), - access_token="", # Must be entered after restore - branch=github_data.get("branch", "main"), - schedule_enabled=github_data.get("schedule_enabled", False), - schedule_type=github_data.get("schedule_type", "daily"), - backup_kprofiles=github_data.get("backup_kprofiles", True), - backup_cloud_profiles=github_data.get("backup_cloud_profiles", True), - backup_settings=github_data.get("backup_settings", False), - enabled=False, # Disabled until token is entered - ) - db.add(config) - restored["github_backup"] += 1 - - await db.commit() - - # If printers were in the backup (restored, updated, or skipped), reconnect all active printers - # This ensures connections are re-established after restore, even if printers were skipped - if "printers" in backup: - # Need fresh query after commit to get proper IDs for newly created printers - result = await db.execute(select(Printer).where(Printer.is_active.is_(True))) - active_printers = result.scalars().all() - for printer in active_printers: - # This will disconnect existing connection (if any) and reconnect - try: - await printer_manager.connect_printer(printer) - except Exception: - pass # Connection failed, but don't fail the restore - - # If settings were restored, check if Spoolman needs to be reconnected - if "settings" in backup: - spoolman_enabled = await get_setting(db, "spoolman_enabled") - spoolman_url = await get_setting(db, "spoolman_url") - if spoolman_enabled and spoolman_enabled.lower() == "true" and spoolman_url: - try: - client = await init_spoolman_client(spoolman_url) - if await client.health_check(): - pass # Connected successfully - except Exception: - pass # Spoolman connection failed, but don't fail the restore - - # Reconfigure virtual printer if settings were restored - try: - from backend.app.services.virtual_printer import virtual_printer_manager - - vp_enabled = await get_setting(db, "virtual_printer_enabled") - vp_access_code = await get_setting(db, "virtual_printer_access_code") - vp_mode = await get_setting(db, "virtual_printer_mode") - vp_model = await get_setting(db, "virtual_printer_model") - - enabled = vp_enabled and vp_enabled.lower() == "true" - access_code = vp_access_code or "" - mode = vp_mode or "immediate" - model = vp_model or "" - - if enabled and access_code: - await virtual_printer_manager.configure( - enabled=True, - access_code=access_code, - mode=mode, - model=model, - ) - elif not enabled and virtual_printer_manager.is_enabled: - await virtual_printer_manager.configure( - enabled=False, - access_code=access_code, - mode=mode, - model=model, - ) - except Exception: - pass # Virtual printer config failed, but don't fail the restore - - # Reconfigure MQTT relay if settings were restored - try: - from backend.app.services.mqtt_relay import mqtt_relay - - mqtt_settings = { - "mqtt_enabled": (await get_setting(db, "mqtt_enabled") or "false") == "true", - "mqtt_broker": await get_setting(db, "mqtt_broker") or "", - "mqtt_port": int(await get_setting(db, "mqtt_port") or "1883"), - "mqtt_username": await get_setting(db, "mqtt_username") or "", - "mqtt_password": await get_setting(db, "mqtt_password") or "", - "mqtt_topic_prefix": await get_setting(db, "mqtt_topic_prefix") or "bambuddy", - "mqtt_use_tls": (await get_setting(db, "mqtt_use_tls") or "false") == "true", - } - await mqtt_relay.configure(mqtt_settings) - except Exception: - pass # MQTT relay config failed, but don't fail the restore - - # Build summary message - restored_parts = [] - for key, count in restored.items(): - if count > 0: - restored_parts.append(f"{count} {key.replace('_', ' ')}") - - if files_restored > 0: - restored_parts.append(f"{files_restored} files") - - skipped_parts = [] - total_skipped = sum(skipped.values()) - for key, count in skipped.items(): - if count > 0: - skipped_parts.append(f"{count} {key.replace('_', ' ')}") - - message_parts = [] - if restored_parts: - message_parts.append(f"Restored: {', '.join(restored_parts)}") - if skipped_parts: - message_parts.append(f"Skipped (already exist): {', '.join(skipped_parts)}") - - response = { - "success": True, - "message": ". ".join(message_parts) if message_parts else "Nothing to restore", - "restored": restored, - "skipped": skipped, - "skipped_details": skipped_details, - "files_restored": files_restored, - "total_skipped": total_skipped, - } - - # Include newly generated API keys if any (so user can see them) - if new_api_keys: - response["new_api_keys"] = new_api_keys - - # Include newly created users with temp passwords (so admin can share them) - if new_users: - response["new_users"] = new_users - - return response - - -# ============================================================================= -# Virtual Printer Settings -# ============================================================================= - - @router.get("/virtual-printer/models") async def get_virtual_printer_models(): """Get available virtual printer models.""" diff --git a/backend/tests/integration/test_library_api.py b/backend/tests/integration/test_library_api.py index 3225eb0c0..84945476f 100644 --- a/backend/tests/integration/test_library_api.py +++ b/backend/tests/integration/test_library_api.py @@ -715,3 +715,68 @@ endsolid cube""" file_ids = {r["file_id"] for r in result["results"]} assert stl_without_thumb1.id in file_ids assert stl_without_thumb2.id in file_ids + + +class TestLibraryPathHelpers: + """Tests for path handling utilities used for backup portability.""" + + def test_to_relative_path_converts_absolute(self): + """Verify absolute paths are converted to relative paths.""" + from backend.app.api.routes.library import to_relative_path + from backend.app.core.config import settings + + base_dir = str(settings.base_dir) + abs_path = f"{base_dir}/archive/library/files/test.3mf" + rel_path = to_relative_path(abs_path) + + assert not rel_path.startswith("/") + assert rel_path == "archive/library/files/test.3mf" + + def test_to_relative_path_handles_path_object(self): + """Verify Path objects are handled correctly.""" + from pathlib import Path + + from backend.app.api.routes.library import to_relative_path + from backend.app.core.config import settings + + abs_path = Path(settings.base_dir) / "archive" / "test.3mf" + rel_path = to_relative_path(abs_path) + + assert not rel_path.startswith("/") + assert rel_path == "archive/test.3mf" + + def test_to_relative_path_returns_empty_for_empty_input(self): + """Verify empty input returns empty string.""" + from backend.app.api.routes.library import to_relative_path + + assert to_relative_path("") == "" + assert to_relative_path(None) == "" + + def test_to_absolute_path_converts_relative(self): + """Verify relative paths are converted to absolute paths.""" + from backend.app.api.routes.library import to_absolute_path + from backend.app.core.config import settings + + rel_path = "archive/library/files/test.3mf" + abs_path = to_absolute_path(rel_path) + + assert abs_path is not None + assert abs_path.is_absolute() + assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf" + + def test_to_absolute_path_handles_already_absolute(self): + """Verify already absolute paths are returned as-is (for backwards compatibility).""" + from backend.app.api.routes.library import to_absolute_path + + abs_path_str = "/data/archive/test.3mf" + result = to_absolute_path(abs_path_str) + + assert result is not None + assert str(result) == abs_path_str + + def test_to_absolute_path_returns_none_for_empty(self): + """Verify None/empty input returns None.""" + from backend.app.api.routes.library import to_absolute_path + + assert to_absolute_path(None) is None + assert to_absolute_path("") is None diff --git a/backend/tests/integration/test_settings_api.py b/backend/tests/integration/test_settings_api.py index 01c69efad..5fe595101 100644 --- a/backend/tests/integration/test_settings_api.py +++ b/backend/tests/integration/test_settings_api.py @@ -393,85 +393,50 @@ class TestSettingsAPI: # Default is False as defined in schema assert isinstance(result["per_printer_mapping_expanded"], bool) - # ======================================================================== - # Backup/Restore tests - # ======================================================================== + +class TestSimplifiedBackupRestore: + """Integration tests for the simplified backup/restore endpoints (ZIP-based). + + Note: Tests that require actual file operations (backup creation) are skipped + because the test suite uses an in-memory database. These tests focus on + validation and error handling which don't require file I/O. + """ @pytest.mark.asyncio @pytest.mark.integration - async def test_backup_includes_external_camera_settings(self, async_client: AsyncClient, printer_factory): - """Verify backup includes external camera settings for printers.""" - # Create a printer with external camera settings - _printer = await printer_factory( - name="Camera Test Printer", - external_camera_url="/dev/video0", - external_camera_type="usb", - external_camera_enabled=True, - ) + async def test_restore_requires_zip_file(self, async_client: AsyncClient): + """Verify restore rejects non-ZIP files.""" + files = {"file": ("backup.txt", b"not a zip file", "text/plain")} + response = await async_client.post("/api/v1/settings/restore", files=files) - # Request backup with printers - response = await async_client.get("/api/v1/settings/backup?include_printers=true") - - assert response.status_code == 200 - backup = response.json() - - # Find the printer in the backup - assert "printers" in backup - printer_data = next((p for p in backup["printers"] if p["name"] == "Camera Test Printer"), None) - assert printer_data is not None - - # Verify external camera fields are included - assert "external_camera_url" in printer_data - assert "external_camera_type" in printer_data - assert "external_camera_enabled" in printer_data - assert printer_data["external_camera_url"] == "/dev/video0" - assert printer_data["external_camera_type"] == "usb" - assert printer_data["external_camera_enabled"] is True + assert response.status_code == 400 + assert "zip" in response.json()["detail"].lower() @pytest.mark.asyncio @pytest.mark.integration - async def test_restore_external_camera_settings_overwrite(self, async_client: AsyncClient, printer_factory): - """Verify restore with overwrite updates external camera settings.""" + async def test_restore_requires_database_in_zip(self, async_client: AsyncClient): + """Verify restore rejects ZIP without database file.""" import io + import zipfile - # Create a printer without camera settings - printer = await printer_factory( - name="Restore Test", - external_camera_url=None, - external_camera_type=None, - external_camera_enabled=False, - ) + # Create a ZIP without bambuddy.db + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("dummy.txt", "dummy content") + zip_buffer.seek(0) - # Create backup data with camera settings - backup_data = { - "version": "1.0", - "included": ["printers"], - "printers": [ - { - "name": "Restore Test", - "serial_number": printer.serial_number, - "ip_address": printer.ip_address, - "external_camera_url": "/dev/video1", - "external_camera_type": "usb", - "external_camera_enabled": True, - } - ], - } + files = {"file": ("backup.zip", zip_buffer.read(), "application/zip")} + response = await async_client.post("/api/v1/settings/restore", files=files) - # Restore with overwrite - import json + assert response.status_code == 400 + assert "missing bambuddy.db" in response.json()["detail"].lower() - files = {"file": ("backup.json", io.BytesIO(json.dumps(backup_data).encode()), "application/json")} - response = await async_client.post("/api/v1/settings/restore?overwrite=true", files=files) + @pytest.mark.asyncio + @pytest.mark.integration + async def test_restore_invalid_zip(self, async_client: AsyncClient): + """Verify restore rejects corrupted ZIP files.""" + files = {"file": ("backup.zip", b"not valid zip content", "application/zip")} + response = await async_client.post("/api/v1/settings/restore", files=files) - assert response.status_code == 200 - result = response.json() - assert result["success"] is True - - # Verify the printer was updated - response = await async_client.get(f"/api/v1/printers/{printer.id}") - assert response.status_code == 200 - updated_printer = response.json() - assert updated_printer["external_camera_url"] == "/dev/video1" - assert updated_printer["external_camera_type"] == "usb" - assert updated_printer["external_camera_enabled"] is True + assert response.status_code == 400 + assert "not a valid zip" in response.json()["detail"].lower() From 63c707b1dfd7036a8340486ee011e8b033d71c19 Mon Sep 17 00:00:00 2001 From: maziggy Date: Sun, 1 Feb 2026 09:28:02 +0100 Subject: [PATCH 06/15] Add Schedule button to archive cards (Issue #208) - Add "Schedule" button next to "Reprint" on archive cards for quick access to print queue functionality - Button respects queue:create permission for restricted users - Previously only accessible via context menu (right-click) Closes #208 --- CHANGELOG.md | 6 ++- frontend/src/pages/ArchivesPage.tsx | 11 +++++ .../{index-B9KMl1Nj.js => index-B5u_lQK-.js} | 44 +++++++++---------- static/index.html | 2 +- 4 files changed, 39 insertions(+), 24 deletions(-) rename static/assets/{index-B9KMl1Nj.js => index-B5u_lQK-.js} (98%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e5dad1a4..efa476641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ All notable changes to Bambuddy will be documented in this file. ## [0.1.7b] - Not released ### Enhancements +- **Schedule Button on Archive Cards** (Issue #208): + - Added "Schedule" button next to "Reprint" on archive cards for quick access to print scheduling + - Previously only available in the context menu (right-click) + - Respects `queue:create` permission for users with restricted access - **Streaming Overlay Improvements** (Issue #164): - **Configurable FPS**: Add `?fps=30` parameter to control camera frame rate (1-30, default 15) - **Status-only mode**: Add `?camera=false` parameter to hide camera and show only status overlay on black background @@ -15,7 +19,7 @@ All notable changes to Bambuddy will be documented in this file. - Portable backups: works across different installations and data directories - Faster backup/restore: direct file copy instead of JSON export/import - Progress indicator and navigation blocking during backup/restore operations - - Legacy JSON-based backup/restore still available via `/backup-legacy` and `/restore-legacy` endpoints + - Removed ~2000 lines of legacy JSON-based backup/restore code ### Fixes - **Library thumbnails missing after restore** - Fixed library files using absolute paths that break after restore on different systems: diff --git a/frontend/src/pages/ArchivesPage.tsx b/frontend/src/pages/ArchivesPage.tsx index a820943ba..222d38891 100644 --- a/frontend/src/pages/ArchivesPage.tsx +++ b/frontend/src/pages/ArchivesPage.tsx @@ -908,6 +908,17 @@ function ArchiveCard({ Reprint + {(archive.status === 'failed' || archive.status === 'aborted') && ( @@ -911,8 +911,8 @@ function ArchiveCard({ size="sm" className="flex-1 min-w-0" onClick={() => setShowReprint(true)} - disabled={!hasPermission('archives:reprint')} - title={!hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined} + disabled={!canModify('archives', 'reprint', archive.created_by_id)} + title={!canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint' : undefined} > Reprint @@ -1006,8 +1006,8 @@ function ArchiveCard({ size="sm" className="min-w-0 p-1 sm:p-1.5" onClick={() => setShowEdit(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'} + disabled={!canModify('archives', 'update', archive.created_by_id)} + title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'} > @@ -1016,8 +1016,8 @@ function ArchiveCard({ size="sm" className="min-w-0 p-1 sm:p-1.5" onClick={() => setShowDeleteConfirm(true)} - disabled={!hasPermission('archives:delete')} - title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} + disabled={!canModify('archives', 'delete', archive.created_by_id)} + title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'} > @@ -1274,7 +1274,7 @@ function ArchiveListRow({ }) { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, canModify } = useAuth(); const [showEdit, setShowEdit] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showReprint, setShowReprint] = useState(false); @@ -1419,8 +1419,8 @@ function ArchiveListRow({ label: 'Print', icon: , onClick: () => setShowReprint(true), - disabled: !hasPermission('archives:reprint'), - title: !hasPermission('archives:reprint') ? 'You do not have permission to reprint' : undefined, + disabled: !canModify('archives', 'reprint', archive.created_by_id), + title: !canModify('archives', 'reprint', archive.created_by_id) ? 'You do not have permission to reprint this archive' : undefined, }, { label: 'Schedule', @@ -1474,8 +1474,8 @@ function ArchiveListRow({ label: 'Scan for Timelapse', icon: , onClick: () => timelapseScanMutation.mutate(), - disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !archive.printer_id || !!archive.timelapse_path || timelapseScanMutation.isPending || !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update archives' : undefined, }, { label: '', divider: true, onClick: () => {} }, { @@ -1491,30 +1491,30 @@ function ArchiveListRow({ source3mfInputRef.current?.click(); } }, - disabled: !archive.source_3mf_path && !hasPermission('archives:update'), - title: !archive.source_3mf_path && !hasPermission('archives:update') ? 'You do not have permission to upload files' : undefined, + disabled: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id), + title: !archive.source_3mf_path && !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to upload files' : undefined, }, ...(archive.source_3mf_path ? [{ label: 'Replace Source 3MF', icon: , onClick: () => source3mfInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Remove Source 3MF', icon: , onClick: () => setShowDeleteSource3mfConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: archive.f3d_path ? 'Replace F3D' : 'Upload F3D', icon: , onClick: () => f3dInputRef.current?.click(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.f3d_path ? [{ label: 'Download F3D', @@ -1531,8 +1531,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteF3dConfirm(true), danger: true, - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }] : []), { label: '', divider: true, onClick: () => {} }, { @@ -1582,15 +1582,15 @@ function ArchiveListRow({ label: archive.is_favorite ? 'Remove from Favorites' : 'Add to Favorites', icon: , onClick: () => favoriteMutation.mutate(), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, { label: 'Edit', icon: , onClick: () => setShowEdit(true), - disabled: !hasPermission('archives:update'), - title: !hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined, + disabled: !canModify('archives', 'update', archive.created_by_id), + title: !canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to update this archive' : undefined, }, ...(archive.project_id && archive.project_name ? [{ label: `Go to Project: ${archive.project_name}`, @@ -1651,8 +1651,8 @@ function ArchiveListRow({ icon: , onClick: () => setShowDeleteConfirm(true), danger: true, - disabled: !hasPermission('archives:delete'), - title: !hasPermission('archives:delete') ? 'You do not have permission to delete archives' : undefined, + disabled: !canModify('archives', 'delete', archive.created_by_id), + title: !canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete this archive' : undefined, }, ]; @@ -1791,8 +1791,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowEdit(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to edit archives' : 'Edit'} + disabled={!canModify('archives', 'update', archive.created_by_id)} + title={!canModify('archives', 'update', archive.created_by_id) ? 'You do not have permission to edit archives' : 'Edit'} > @@ -1800,8 +1800,8 @@ function ArchiveListRow({ variant="ghost" size="sm" onClick={() => setShowDeleteConfirm(true)} - disabled={!hasPermission('archives:delete')} - title={!hasPermission('archives:delete') ? 'You do not have permission to delete archives' : 'Delete'} + disabled={!canModify('archives', 'delete', archive.created_by_id)} + title={!canModify('archives', 'delete', archive.created_by_id) ? 'You do not have permission to delete archives' : 'Delete'} > @@ -2055,7 +2055,7 @@ const collections: { id: Collection; label: string; icon: React.ReactNode }[] = export function ArchivesPage() { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, hasAnyPermission } = useAuth(); const searchInputRef = useRef(null); const [search, setSearch] = useState(''); const [filterPrinter, setFilterPrinter] = useState(() => { @@ -2471,8 +2471,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchTag(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} + disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')} + title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined} > Tags @@ -2481,8 +2481,8 @@ export function ArchivesPage() { variant="secondary" size="sm" onClick={() => setShowBatchProject(true)} - disabled={!hasPermission('archives:update')} - title={!hasPermission('archives:update') ? 'You do not have permission to update archives' : undefined} + disabled={!hasAnyPermission('archives:update_own', 'archives:update_all')} + title={!hasAnyPermission('archives:update_own', 'archives:update_all') ? 'You do not have permission to update archives' : undefined} > Project @@ -2490,8 +2490,8 @@ export function ArchivesPage() { {file.file_type === 'stl' && ( )} diff --git a/frontend/src/pages/QueuePage.tsx b/frontend/src/pages/QueuePage.tsx index 3c739a6ce..9763fce2f 100644 --- a/frontend/src/pages/QueuePage.tsx +++ b/frontend/src/pages/QueuePage.tsx @@ -280,6 +280,7 @@ function SortableQueueItem({ isSelected = false, onToggleSelect, hasPermission, + canModify, }: { item: PrintQueueItem; position?: number; @@ -293,6 +294,7 @@ function SortableQueueItem({ isSelected?: boolean; onToggleSelect?: () => void; hasPermission: (permission: Permission) => boolean; + canModify: (resource: 'queue' | 'archives' | 'library', action: 'update' | 'delete' | 'reprint', createdById: number | null | undefined) => boolean; }) { const canReorder = hasPermission('queue:reorder'); const { @@ -525,8 +527,8 @@ function SortableQueueItem({ variant="ghost" size="sm" onClick={onEdit} - disabled={!hasPermission('queue:update')} - title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : 'Edit'} + disabled={!canModify('queue', 'update', item.created_by_id)} + title={!canModify('queue', 'update', item.created_by_id) ? 'You do not have permission to edit this queue item' : 'Edit'} > @@ -534,8 +536,8 @@ function SortableQueueItem({ variant="ghost" size="sm" onClick={onCancel} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : 'Cancel'} + disabled={!canModify('queue', 'delete', item.created_by_id)} + title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to cancel this queue item' : 'Cancel'} className="text-red-400 hover:text-red-300 hover:bg-red-500/10" > @@ -558,8 +560,8 @@ function SortableQueueItem({ variant="ghost" size="sm" onClick={onRemove} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to remove queue items' : 'Remove'} + disabled={!canModify('queue', 'delete', item.created_by_id)} + title={!canModify('queue', 'delete', item.created_by_id) ? 'You do not have permission to remove this queue item' : 'Remove'} > @@ -574,7 +576,7 @@ function SortableQueueItem({ export function QueuePage() { const queryClient = useQueryClient(); const { showToast } = useToast(); - const { hasPermission } = useAuth(); + const { hasPermission, hasAnyPermission, canModify } = useAuth(); const [filterPrinter, setFilterPrinter] = useState(null); const [filterStatus, setFilterStatus] = useState(''); const [showClearHistoryConfirm, setShowClearHistoryConfirm] = useState(false); @@ -928,8 +930,8 @@ export function QueuePage() { variant="secondary" size="sm" onClick={() => setShowClearHistoryConfirm(true)} - disabled={!hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to clear history' : undefined} + disabled={!hasPermission('queue:delete_all')} + title={!hasPermission('queue:delete_all') ? 'You do not have permission to clear all history' : undefined} > Clear History @@ -970,6 +972,7 @@ export function QueuePage() { onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} + canModify={canModify} /> ))} @@ -1039,8 +1042,8 @@ export function QueuePage() { size="sm" onClick={() => setShowBulkEditModal(true)} className="flex items-center gap-2 text-bambu-green hover:text-bambu-green-light" - disabled={!hasPermission('queue:update')} - title={!hasPermission('queue:update') ? 'You do not have permission to edit queue items' : undefined} + disabled={!hasAnyPermission('queue:update_own', 'queue:update_all')} + title={!hasAnyPermission('queue:update_own', 'queue:update_all') ? 'You do not have permission to edit queue items' : undefined} > Edit Selected @@ -1050,8 +1053,8 @@ export function QueuePage() { size="sm" onClick={() => bulkCancelMutation.mutate(selectedItems)} className="flex items-center gap-2 text-red-400 hover:text-red-300" - disabled={bulkCancelMutation.isPending || !hasPermission('queue:delete')} - title={!hasPermission('queue:delete') ? 'You do not have permission to cancel queue items' : undefined} + disabled={bulkCancelMutation.isPending || !hasAnyPermission('queue:delete_own', 'queue:delete_all')} + title={!hasAnyPermission('queue:delete_own', 'queue:delete_all') ? 'You do not have permission to cancel queue items' : undefined} > Cancel Selected @@ -1085,6 +1088,7 @@ export function QueuePage() { isSelected={selectedItems.includes(item.id)} onToggleSelect={() => handleToggleSelect(item.id)} hasPermission={hasPermission} + canModify={canModify} /> ))} @@ -1139,6 +1143,7 @@ export function QueuePage() { onStart={() => {}} timeFormat={timeFormat} hasPermission={hasPermission} + canModify={canModify} /> ))} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index 4bfe8f32f..ede57364f 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -95,6 +95,8 @@ export function SettingsPage() { const [showEditUserModal, setShowEditUserModal] = useState(false); const [editingUserId, setEditingUserId] = useState(null); const [deleteUserId, setDeleteUserId] = useState(null); + const [deleteUserItemCounts, setDeleteUserItemCounts] = useState<{ archives: number; queue_items: number; library_files: number } | null>(null); + const [deleteUserLoading, setDeleteUserLoading] = useState(false); const [userFormData, setUserFormData] = useState<{ username: string; password: string; @@ -355,16 +357,33 @@ export function SettingsPage() { }); const deleteUserMutation = useMutation({ - mutationFn: (id: number) => api.deleteUser(id), + mutationFn: ({ id, deleteItems }: { id: number; deleteItems: boolean }) => api.deleteUser(id, deleteItems), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }); showToast('User deleted successfully'); + setDeleteUserId(null); + setDeleteUserItemCounts(null); }, onError: (error: Error) => { showToast(error.message, 'error'); }, }); + // Function to initiate user deletion with item count check + const handleDeleteUserClick = async (userId: number) => { + setDeleteUserId(userId); + setDeleteUserLoading(true); + try { + const counts = await api.getUserItemsCount(userId); + setDeleteUserItemCounts(counts); + } catch { + // If we can't get counts, just proceed without showing item options + setDeleteUserItemCounts({ archives: 0, queue_items: 0, library_files: 0 }); + } finally { + setDeleteUserLoading(false); + } + }; + const createGroupMutation = useMutation({ mutationFn: (data: GroupCreate) => api.createGroup(data), onSuccess: () => { @@ -3488,7 +3507,7 @@ export function SettingsPage() { )} {hasPermission('users:delete') && userItem.id !== user?.id && ( - )} @@ -3892,17 +3911,101 @@ export function SettingsPage() { {/* Delete User Confirmation Modal */} {deleteUserId !== null && ( - { - deleteUserMutation.mutate(deleteUserId); +
{ setDeleteUserId(null); + setDeleteUserItemCounts(null); }} - onCancel={() => setDeleteUserId(null)} - /> + > + e.stopPropagation()} + > + +
+ +

Delete User

+
+
+ + {deleteUserLoading ? ( +
+
+
+ ) : deleteUserItemCounts && (deleteUserItemCounts.archives + deleteUserItemCounts.queue_items + deleteUserItemCounts.library_files > 0) ? ( + <> +

This user has created:

+
    + {deleteUserItemCounts.archives > 0 && ( +
  • {deleteUserItemCounts.archives} archive{deleteUserItemCounts.archives !== 1 ? 's' : ''}
  • + )} + {deleteUserItemCounts.queue_items > 0 && ( +
  • {deleteUserItemCounts.queue_items} queue item{deleteUserItemCounts.queue_items !== 1 ? 's' : ''}
  • + )} + {deleteUserItemCounts.library_files > 0 && ( +
  • {deleteUserItemCounts.library_files} library file{deleteUserItemCounts.library_files !== 1 ? 's' : ''}
  • + )} +
+

What would you like to do with these items?

+
+ + + +
+ + ) : ( + <> +

Are you sure you want to delete this user?

+

This action cannot be undone.

+
+ + +
+ + )} + + +
)} {/* Create/Edit Group Modal */} diff --git a/frontend/src/pages/StatsPage.tsx b/frontend/src/pages/StatsPage.tsx index abd3dac88..8b245a05f 100644 --- a/frontend/src/pages/StatsPage.tsx +++ b/frontend/src/pages/StatsPage.tsx @@ -695,8 +695,8 @@ export function StatsPage() {