Plan
\n\n \n\n

Plan Ressourcer

${htmlEscape(context.scopeLabel)}
`; const guide=`PLAN 2.0 · RESSOURCEPAKKE START HER Åbn filen: Plan-Ressourcer.html Den viser hele pakken som et lille selvstændigt ressourcebibliotek med søgning, typefilter, lokale mediefiler og klikbare internetlinks. MAPPEN "medier" Her ligger de originale lokale filer, som Plan kunne hente ud af medielageret. FILEN "plan-resources.json" Dette er Plans tekniske katalog med metadata og placeringer. Den er primært beregnet til senere import eller gendannelse i Plan. BRUG SOM HJEMMESIDE Hvis du vil lægge hele den udpakkede mappe på en webserver, kan du omdøbe: Plan-Ressourcer.html til: index.html Behold mappen "medier" ved siden af HTML-filen, så links til de lokale filer fortsat virker. `; entries.push( {name:'Plan-Ressourcer.html',data:browserHtml}, {name:'SÅDAN-BRUGER-DU-PAKKEN.txt',data:guide}, {name:'plan-resources.json',data:JSON.stringify(manifest,null,2)} ); const zipBlob=await makeZip(entries); const filename=`Plan-Ressourcepakke-${safeName(context.scopeLabel).replace(/\s+/g,'-')}-${new Date().toISOString().slice(0,10)}.plan2`; const prepared=this.fileExporter.prepareBlob(zipBlob,filename,'application/zip'); const result=await this.fileExporter.save(prepared); if(result?.status!=='cancelled'){ this.toast(`Ressourcepakken med ${manifest.counts.mediaFiles} filer og ${manifest.counts.webResources} links er sendt til lagring`); } return {...(result||{}),manifest}; }; const originalDownloadResourceItem=App.prototype.downloadResourceItem; App.prototype.downloadResourceItem=async function(item){ if(isWebResource(item)){ this.showResourceLinkExport(item); return true; } return originalDownloadResourceItem.call(this,item); }; App.prototype.renderResourceCatalog=async function(container,mode='session'){ const context=await this.resourceScopeContext(mode); container.replaceChildren();container.className='tn-resource-catalog';container._tnResourceScopeMode=mode; let query='',type='Alle'; const resourceType=item=>clean(item?.type||item?.category)||'Ressource'; const types=['Alle',...[...new Set(context.resources.map(resourceType).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'da'))]; const tools=document.createElement('div'); tools.className='tn-resource-catalog-tools'; tools.innerHTML=`
Eksportér …
`; const summary=document.createElement('section');summary.className='tn-resource-catalog-summary'; const list=document.createElement('div');list.className='tn-resource-catalog-list'; const draw=()=>{ const q=query.trim().toLowerCase(); const shown=context.resources.filter(item=>(type==='Alle'||resourceType(item)===type)&&(!q||[ item.title,item.type,item.category,item.url,item.embedUrl,...(item.locations||[]) ].join(' ').toLowerCase().includes(q))); summary.innerHTML=`${htmlEscape(context.scopeLabel)} ${types.length-1} typer`; const countNode=tools.querySelector('[data-count]'); if(countNode){ countNode.textContent=shown.length===context.resources.length ?`${shown.length} ressourcer` :`${shown.length} af ${context.resources.length}`; } list.replaceChildren(); for(const item of shown){ const row=document.createElement('article');row.className='tn-resource-catalog-row'; const info=document.createElement('div');const locations=(item.locations||[]).join(' · '); info.innerHTML=`${htmlEscape(item.title)} ${htmlEscape(resourceType(item))}${item.usageCount?` · bruges ${item.usageCount} sted${item.usageCount===1?'':'er'}`:''}${item.size?` · ${TN.formatBytes(item.size)}`:''} ${htmlEscape(locations||'Ressourcekatalog')}`; const actions=document.createElement('div');actions.className='tn-resource-catalog-actions'; if(isWebResource(item)){ const copy=document.createElement('button');copy.type='button';copy.textContent='Kopiér link'; copy.onclick=()=>this.copyResourceLink(item); const save=document.createElement('button');save.type='button';save.textContent='Hent link'; save.onclick=()=>this.saveResourceLinkText(item); actions.append(copy,save); }else{ const download=document.createElement('button');download.type='button';download.textContent='Hent original'; download.onclick=()=>this.downloadResourceItem(item); actions.append(download); } if(item.boardId){ const open=document.createElement('button');open.type='button';open.textContent='Åbn tavle'; open.onclick=async()=>{ const board=await this.boardManager.get(item.boardId); if(!board)return this.toast('Tavlen blev ikke fundet'); this.modalManager.closeAll();await this.loadBoard(board); if(item.widgetId)setTimeout(()=>this.selectWidget?.(item.widgetId),80); }; actions.append(open); }else if(resourceUrl(item)){ const open=document.createElement('button');open.type='button';open.textContent='Åbn'; open.onclick=()=>window.open(resourceUrl(item),'_blank','noopener,noreferrer'); actions.append(open); } row.append(info,actions);list.append(row); } if(!shown.length)list.innerHTML='
Ingen ressourcer matcher søgning og filter.
'; }; tools.querySelector('.tn-resource-tools-primary input').addEventListener('input',TN.debounce(event=>{query=event.target.value;draw();},120)); tools.querySelector('.tn-resource-tools-primary select').onchange=event=>{type=event.target.value;draw();}; const exportMenu=tools.querySelector('.tn-resource-export-menu'); const closeExportMenu=()=>{if(exportMenu)exportMenu.open=false;}; tools.querySelector('[data-links]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.showResourceLinksExport(mode); }; tools.querySelector('[data-json]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.downloadResourceBackup(mode); }; tools.querySelector('[data-package]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.exportResourcePackage(mode); }; container.append(tools,summary,list);draw(); }; try{TN.version='0.13.2-resource-package-v63';}catch(_){} })(window.TavleNext); `; const entries=[ {name:'ÅBN DENNE PLANPAKKE.html',data:visitCard}, {name:'data/manifest.json',data:JSON.stringify(manifest,null,2)}, {name:'data/backup.json',data:JSON.stringify(payload)} ]; if(includeApp){ const source=buildCleanArchiveApp(payload,manifest); entries.push({name:'app/Plan.html',data:source}); } const blob=await makeZip(entries); return {blob,manifest}; }; App.prototype.savePlanPackage=async function(payload,{ backupType='backup', title='Plan-pakke', filename='Plan-pakke.plan2', includeApp=includeAppPreference(), notes='' }={}){ const built=await this.buildPlanPackage(payload,{backupType,title,includeApp,notes}); const prepared=this.fileExporter.prepareBlob( built.blob, filename.toLowerCase().endsWith('.plan2')?filename:`${filename.replace(/\.(?:planpkg\.)?zip$/i,'')}.plan2`, 'application/zip' ); const result=await this.fileExporter.save(prepared); return {...(result||{}),manifest:built.manifest,prepared}; }; App.prototype.openPlanPackage=async function(file){ if(file.size>1024*1024*180)throw new Error('Plan-pakken er større end 180 MB og kan ikke åbnes sikkert på denne enhed'); const entries=readZipStoreOnly(await file.arrayBuffer()); const manifestBytes=entries.get('data/manifest.json')||entries.get('manifest.json'); if(!manifestBytes)throw new Error('Filen er ikke en Plan-pakke: manifest.json mangler'); let manifest; try{manifest=JSON.parse(new TextDecoder().decode(manifestBytes));} catch(_){throw new Error('Plan-pakkens manifest kan ikke læses');} if(manifest?.format!==PACKAGE_FORMAT)throw new Error('ZIP-filen er ikke en kendt Plan-pakke'); if(Number(manifest.packageVersion)>PACKAGE_VERSION)throw new Error(`Pakken kræver en nyere Plan-version (pakkeversion ${manifest.packageVersion})`); const payloadPath=clean(manifest.payloadPath)||'data/backup.json'; const payloadBytes=entries.get(payloadPath); if(!payloadBytes)throw new Error(`Plan-pakken mangler ${payloadPath}`); let payload; try{payload=JSON.parse(new TextDecoder().decode(payloadBytes));} catch(_){throw new Error('Plan-pakkens backupdata kan ikke læses');} return {manifest,payload,entries}; }; App.prototype.previewPlanPackageImport=async function(file){ let opened=null,packageError=null; try{opened=await this.openPlanPackage(file);}catch(error){packageError=error;} /* Almindelige mapper/arkiver fra plan-library kan også installeres direkte. Find alle egentlige forloeb.json-filer i ZIP'en, pak dem ud og lad brugeren vælge, når arkivet indeholder mere end ét undervisningsforløb. */ if(!opened){ let rawEntries={}; try{rawEntries=zipEntries(await file.arrayBuffer());} catch(error){throw packageError||error;} const sheetPaths=Object.keys(rawEntries).filter(path=>/\.xlsx$/i.test(path)&&!path.endsWith('/')); if(sheetPaths.length){ const body=document.createElement('div');body.className='tn-plan-package-preview'; body.innerHTML=`
${sheetPaths.length} regneark fundetDe kan importeres som fælles data fra ${TN.escapeHtml(file.name)}
${sheetPaths.map(TN.escapeHtml).join('
')}
`; this.modalManager.open({title:'Importér regneark fra ZIP',body,className:'tn-modal-wide',actions:[{label:'Annuller',run:()=>{}},{label:'Importér alle som fælles data',primary:true,run:async()=>{let count=0;for(const path of sheetPaths){const entry=rawEntries[path];let bytes;if(entry.compression===0)bytes=entry.data;else if(entry.compression===8){const stream=new Blob([entry.data]).stream().pipeThrough(new DecompressionStream('deflate-raw'));bytes=new Uint8Array(await new Response(stream).arrayBuffer());}else throw new Error(`ZIP-filen bruger en ikke-understøttet komprimeringsmetode (${entry.compression})`);const f=new File([bytes],path.split('/').pop(),{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});count+=(await this.importSpreadsheetFileAsCommonData(f)).length;}this.toast(`${count} regneark blev importeret som fælles data`);return true;}}]}); return; } const paths=Object.keys(rawEntries) .filter(path=>/(^|\/)forloeb\.json$/i.test(path)) .filter(path=>!/(^|\/)demo-data\//i.test(path)) .sort((a,b)=>a.localeCompare(b,'da')); const candidates=[]; for(const path of paths){ try{ const text=await inflateEntry(rawEntries[path]); const payload=JSON.parse(text); const info=this.backupManager.inspect(payload); if(info.kind!=='course-package')continue; candidates.push({path,payload,info,title:payload.course?.title||payload.manifest?.courseTitle||path.split('/').slice(-2,-1)[0]||'Undervisningsforløb'}); }catch(error){console.warn('Springer ugyldig forløbsfil over',path,error);} } if(!candidates.length)throw packageError||new Error('ZIP-filen indeholder hverken en Plan-pakke eller et gyldigt forloeb.json'); const openCandidate=async candidate=>{ this.modalManager.closeAll(); const jsonFile=new File([JSON.stringify(candidate.payload)],candidate.path.split('/').pop()||'forloeb.json',{type:'application/json'}); await this.previewJsonImport(jsonFile); }; if(candidates.length===1){ await openCandidate(candidates[0]); return; } const body=document.createElement('div'); body.className='tn-session-list'; body.innerHTML=`
${candidates.length} undervisningsforløb fundetVælg det forløb, der skal installeres fra ${TN.escapeHtml(file.name)}
`; const list=body.querySelector('.tn-course-zip-list'); list.style.display='grid';list.style.gap='8px'; for(const candidate of candidates){ const button=document.createElement('button'); button.className='tn-session-open'; button.style.width='100%';button.style.padding='12px';button.style.border='1px solid var(--tn-line)';button.style.borderRadius='12px';button.style.background='var(--tn-panel2)';button.style.color='var(--tn-ink)'; button.innerHTML=`${TN.escapeHtml(candidate.title)}${TN.escapeHtml([candidate.payload.course?.subject,candidate.payload.course?.schoolYear,`${candidate.info.sessions} sessioner`].filter(Boolean).join(' · '))}${TN.escapeHtml(candidate.path)}`; button.onclick=()=>openCandidate(candidate); list.append(button); } this.modalManager.open({title:'Installér undervisningsforløb fra ZIP',body,className:'tn-modal-wide',actions:[{label:'Annuller',run:()=>{}}]}); return; } const spreadsheetAttachments=[...opened.entries.keys()].filter(path=>/\.(xlsx|xls|csv|tsv)$/i.test(path)&&path!==opened.manifest.payloadPath); const info=this.backupManager.inspect(opened.payload); const body=document.createElement('div'); body.className='tn-plan-package-preview'; body.innerHTML=`
${TN.escapeHtml(opened.manifest.title||'Plan-pakke')} ${TN.escapeHtml(opened.manifest.backupType||'backup')} · pakkeversion ${TN.escapeHtml(opened.manifest.packageVersion)}
Sessioner${info.sessions} Tavler${info.boards} Widgets${info.widgets} Datasæt${info.datasets} Medier${info.media} App medfølger${opened.manifest.containsApp?'Ja · ignoreres ved import':'Nej'}
Plan importerer kun backupdata.
En eventuel appfil i pakken åbnes eller installeres ikke.
`; if(spreadsheetAttachments.length){const note=document.createElement('div');note.className='tn-warning';note.innerHTML=`${spreadsheetAttachments.length} regneark ligger som bilag i ZIP-filen.
De er ikke en del af backup-payloadet og importeres derfor ikke automatisk. Importér dem bagefter som datafiler: ${spreadsheetAttachments.map(TN.escapeHtml).join(', ')}`;body.append(note);} this.modalManager.open({ title:'Plan-pakke fundet', body, actions:[ {label:'Annuller',run:()=>{}}, {label:spreadsheetAttachments.length?'Importér regneark og vis backup':'Vis importindhold',primary:true,run:async()=>{ if(spreadsheetAttachments.length){let imported=0;for(const path of spreadsheetAttachments){if(!/\.xlsx$/i.test(path))continue;const bytes=opened.entries.get(path);const sheetFile=new File([bytes],path.split('/').pop(),{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});imported+=(await this.importSpreadsheetFileAsCommonData(sheetFile)).length;}if(imported)this.toast(`${imported} regneark blev importeret som fælles data`);} this.modalManager.closeAll(); const jsonFile=new File( [JSON.stringify(opened.payload)], `${safeName(opened.manifest.title||file.name)}.json`, {type:'application/json'} ); await this.previewJsonImport(jsonFile); return false; }} ] }); }; const originalHandleFiles=App.prototype.handleFiles; App.prototype.handleFiles=async function(files){ const packages=[],ordinary=[]; for(const file of files||[]){ const lower=String(file.name||'').toLowerCase(); const type=String(file.type||'').toLowerCase(); if(lower.endsWith('.plan2')||lower.endsWith('.zip')||lower.endsWith('.planpkg')||type==='application/zip')packages.push(file); else ordinary.push(file); } for(const file of packages)await this.previewPlanPackageImport(file); if(ordinary.length)return originalHandleFiles.call(this,ordinary); }; const originalRequestImportFiles=App.prototype.requestImportFiles; App.prototype.requestImportFiles=function(){ this.fileInput.accept='.plan2,.zip,.planpkg,.json,.csv,.tsv,.txt,.xlsx,.xls,.ods,.ics,.pdf,.docx,.odt,.html,.htm,.webp,.webm,application/zip,application/x-zip-compressed,application/json,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,image/webp,video/webm,audio/webm,image/*,audio/*,video/*'; this.fileInput.multiple=true; this.fileInput.click(); }; /* Ny ZIP-baseret sikkerhedskopi for de fire primære varianter. */ App.prototype.safetyExport=async function(kind,progress){ let ids=[],label='',payload; const sessions=await this.sessionManager.list(); if(kind==='all'){ ids=sessions.map(session=>session.id);label='Backup++'; }else{ const course=await this.safetyCourse(); if(!course)throw new Error('Der er intet aktuelt undervisningsforløb'); ids=course.sessionIds; label=kind==='course'?'Backup+':kind==='share'?'Delbar Backup':'Skabelon'; } if(!ids.length)throw new Error('Der er ingen sessioner at gemme'); progress?.('Samler Plan-pakke …'); payload=await this.backupManager.exportNext(kind==='all'?null:ids,{includeMedia:true,kind:kind==='all'?'backup++':kind}); if(kind==='share'&&typeof sharedPayload==='function')payload=sharedPayload(payload); if(kind==='template'&&typeof templatePayload==='function')payload=templatePayload(payload); /* Fallback, fordi sharedPayload/templatePayload er lokale i den tidligere modulblok. */ if(kind==='share'&&payload.backupKind!=='share')payload.backupKind='share'; if(kind==='template'&&payload.backupKind!=='template'){ payload={...payload,backupKind:'template',datasets:[],media:[], widgets:(payload.widgets||[]).map(widget=>({...widget,content:{...(widget.content||{})},meta:{...(widget.meta||{}),templatePrepared:true}})) }; } const course=kind==='all'?null:await this.safetyCourse(); const base=kind==='all'?'Plan':safeName(course?.title||'Undervisningsforløb').replace(/\s+/g,'-'); const date=new Date().toISOString().slice(0,10); const filename=kind==='all' ?`Plan-BackupPP-${date}.plan2` :`${base}-${label.replace(/\s+/g,'-').replace(/\+/g,'P')}-${date}.plan2`; progress?.('Åbner Gem i Filer / Del …'); const result=await this.savePlanPackage(payload,{ backupType:kind, title:kind==='all'?'Hele Plan':`${label} · ${course?.title||''}`, filename, includeApp:includeAppPreference() }); if(result?.status==='cancelled'){progress?.('Gemning blev annulleret.');return result;} progress?.('Plan-pakken er sendt til lagring.'); this.toast('Plan-pakken er sendt til Gem i Filer / Del'); return result; }; /* Klassisk Backup++-dialog gemmer også ZIP. */ App.prototype.showBackup=async function(){ const sessions=await this.sessionManager.list(),body=document.createElement('div'); body.className='tn-backup-panel'; body.innerHTML='

Vælg de sessioner, der skal samles i én Plan-pakke.

Beregner størrelse …
'; const list=body.querySelector('.tn-backup-sessions'),estimate=body.querySelector('.tn-backup-estimate'); const appCheck=body.querySelector('[data-app]');appCheck.checked=includeAppPreference(); appCheck.onchange=()=>setIncludeAppPreference(appCheck.checked); for(const session of sessions){ const row=document.createElement('label');row.className='tn-backup-session'; row.innerHTML=`${TN.escapeHtml(session.title)}${TN.escapeHtml(session.id)}`; list.append(row); } const selected=()=>[...list.querySelectorAll('input[type="checkbox"]:checked')].filter(x=>x!==appCheck).map(x=>x.value); const refresh=TN.debounce(async()=>{ const ids=selected();if(!ids.length){estimate.textContent='Vælg mindst én session.';return;} const e=await this.backupManager.estimateExport(ids); estimate.textContent=`${e.counts.sessions} sessioner · ${e.counts.boards} tavler · ${e.counts.widgets} widgets · ${e.counts.datasets} datasæt · ${e.counts.media} medier · estimeret ${TN.formatBytes(e.totalBytes+(appCheck.checked?(document.documentElement.outerHTML.length+e.totalBytes):0))}`; },80); list.onchange=refresh; body.querySelector('[data-all]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=true);refresh();}; body.querySelector('[data-none]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=false);refresh();}; body.querySelector('[data-current]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=x.value===TN.runtime.activeSession.id);refresh();}; body.querySelector('[data-restore]').onclick=()=>this.showRestorePoints(); body.querySelector('[data-media]').onclick=()=>this.showMediaArchive(); body.querySelector('[data-log]').onclick=()=>this.showImportLogs(); refresh(); this.modalManager.open({ title:'Backup++ · Plan-pakke', body,className:'tn-modal-wide', actions:[ {label:'Opret gendannelsespunkt',run:async()=>{await this.createRestorePoint();return false;}}, {label:'Gem Plan-pakke',primary:true,run:async()=>{ const ids=selected();if(!ids.length){this.toast('Vælg mindst én session');return false;} const payload=await this.backupManager.exportNext(ids); const date=new Date().toISOString().slice(0,10); const name=ids.length===1 ?`${safeName(sessions.find(s=>s.id===ids[0])?.title||ids[0]).replace(/\s+/g,'-')}-BackupPP-${date}.plan2` :`Plan-BackupPP-${date}.plan2`; const result=await this.savePlanPackage(payload,{ backupType:'backup++', title:ids.length===1?'Backup++ · '+(sessions.find(s=>s.id===ids[0])?.title||'Session'):'Backup++ · valgte sessioner', filename:name, includeApp:appCheck.checked }); if(result?.status!=='cancelled')this.toast('Backup++ er sendt til lagring'); return false; }} ] }); }; /* Årsforløbspakker gemmes i samme format. */ App.prototype.showCoursePackageExport=function({course,payload,verification}){ const body=document.createElement('div'); body.style.display='grid';body.style.gap='12px'; body.innerHTML=`
Årsforløbspakken er kontrolleret${TN.escapeHtml(course.title)} · ${course.sessionIds.length} sessioner
Sessioner${verification.counts.sessions}Tavler${verification.counts.boards}Widgets${verification.counts.widgets}Datasæt${verification.counts.datasets}Medier${verification.counts.media}
Gem først pakken. Derefter kan det lokale årsforløb arkiveres.
`; let saved=false; const modal=this.modalManager.open({ title:'Årsforløb er klar', body,className:'tn-modal-export', actions:[ {label:'Luk',run:()=>{}}, {label:'Gem Plan-pakke',primary:true,run:async()=>{ const includeApp=body.querySelector('[data-app]').checked;setIncludeAppPreference(includeApp); const filename=`Plan-${safeName(course.title).replace(/\s+/g,'-')}-${safeName(course.schoolYear||new Date().toISOString().slice(0,10)).replace(/\s+/g,'-')}.plan2`; const result=await this.savePlanPackage(payload,{backupType:'course-package',title:course.title,filename,includeApp}); if(result?.status==='cancelled')return false; saved=true;body.querySelector('[data-course-save-state]').innerHTML='Pakken er sendt til lagring. Du kan nu arkivere det lokale årsforløb.'; archiveButton.disabled=false;this.toast('Årsforløbspakken er gemt');return false; }}, {label:'Arkivér lokalt og start nyt',run:()=>{ if(!saved){this.toast('Gem årsforløbspakken først');return false;} this.showNewCourseDialog({archiveCourse:course,packagePayload:payload});return false; }} ] }); const archiveButton=[...modal.footer.querySelectorAll('button')].at(-1); archiveButton.disabled=true; }; /* Fælles data gemmes også som Plan-pakke. */ const oldExportCommonDataBackup=App.prototype.exportCommonDataBackup; App.prototype.exportCommonDataBackup=async function(){ await this.storage.flush?.(); const datasets=(await this.dataManager.listCommon()).map(TN.deepClone); if(!datasets.length){this.toast('Der er ingen fælles data at sikkerhedskopiere');return null;} const mediaIds=this.backupManager.collectMediaIds(datasets),media=[]; for(const id of mediaIds){ const item=await this.storage.get('media',id);if(!item)continue; media.push({...TN.deepClone(item),blob:undefined,originalBlob:undefined, dataUrl:item.blob?await TN.blobToDataUrl(item.blob):item.dataUrl||null, originalDataUrl:item.originalBlob?await TN.blobToDataUrl(item.originalBlob):item.originalDataUrl||null}); } const payload={ format:'plan-common-data-backup',version:1,appVersion:TN.version,schemaVersion:TN.schemaVersion, createdAt:TN.now(),datasets,media, manifest:{datasets:datasets.length,hiddenDatasets:datasets.filter(item=>item.meta?.hiddenFromUser).length,media:media.length,datasetIds:datasets.map(item=>item.id),mediaIds:media.map(item=>item.id)} }; const date=new Date().toISOString().slice(0,10); const result=await this.savePlanPackage(payload,{ backupType:'common-data', title:'Fælles data', filename:`Plan-Faelles-data-${date}.plan2`, includeApp:includeAppPreference() }); if(result?.status!=='cancelled')this.toast(`${datasets.length} fælles datasæt er sendt til lagring`); return result; }; /* Nødbackup bruger samme pakkeformat. */ if(TN.StorageRescueManager?.prototype){ TN.StorageRescueManager.prototype.saveEmergencyBackup=async function({course=null,includeMedia=true,progress=null}={}){ const target=course||await this.activeCourse(); if(!target)throw new Error('Der er intet undervisningsforløb at sikkerhedskopiere'); progress?.('Samler den aktuelle tilstand direkte fra hukommelsen …'); const payload=await this.buildEmergencyPackage(target,{includeMedia,progress}); progress?.('Pakker nødbackup som ZIP …'); const stamp=new Date().toISOString().replace(/[:.]/g,'-'); const result=await this.app.savePlanPackage(payload,{ backupType:includeMedia?'emergency':'emergency-light', title:`Nødbackup · ${target.title}`, filename:`Plan-NOEDBACKUP-${safeName(target.title).replace(/\s+/g,'-')}-${stamp}.plan2`, includeApp:includeAppPreference() }); if(result?.status!=='cancelled')this.app.toast(includeMedia?'Nødbackup med medier er sendt til Filer':'Hurtig nødbackup er sendt til Filer'); return {...result,payload}; }; } /* Tilføj valg af app i Backup og sikkerhed. */ const oldRenderSafetyCenter=App.prototype.renderSafetyCenter; App.prototype.renderSafetyCenter=async function(container){ await oldRenderSafetyCenter.call(this,container); const observer=new MutationObserver(()=>{ const page=container.querySelector('.tn-safety-page'); if(!page||!page.querySelector('[data-export]')||page.querySelector('[data-plan-package-app-option]'))return; const option=document.createElement('label'); option.className='tn-plan-package-options'; option.dataset.planPackageAppOption=''; option.innerHTML=`Medtag en kopi af Plan-appenFrivilligt. Appfilen lægges i pakken, men importeres aldrig.`; option.querySelector('input').onchange=event=>setIncludeAppPreference(event.target.checked); page.insertBefore(option,page.querySelector('.tn-safety-options')); }); observer.observe(container,{childList:true,subtree:true}); setTimeout(()=>{ const page=container.querySelector('.tn-safety-page'); if(page?.querySelector('[data-export]')&&!page.querySelector('[data-plan-package-app-option]')){ const option=document.createElement('label');option.className='tn-plan-package-options';option.dataset.planPackageAppOption=''; option.innerHTML=`Medtag en kopi af Plan-appenFrivilligt. Appfilen lægges i pakken, men importeres aldrig.`; option.querySelector('input').onchange=event=>setIncludeAppPreference(event.target.checked); page.insertBefore(option,page.querySelector('.tn-safety-options')); } },50); }; try{TN.version='0.13.2-plan-package-v68';}catch(_){} })(window.TavleNext); `; const guide=`PLAN 2.0 · RESSOURCEPAKKE START HER Åbn filen: Plan-Ressourcer.html Den viser hele pakken som et lille selvstændigt ressourcebibliotek med søgning, typefilter, lokale mediefiler og klikbare internetlinks. MAPPEN "medier" Her ligger de originale lokale filer, som Plan kunne hente ud af medielageret. FILEN "plan-resources.json" Dette er Plans tekniske katalog med metadata og placeringer. Den er primært beregnet til senere import eller gendannelse i Plan. BRUG SOM HJEMMESIDE Hvis du vil lægge hele den udpakkede mappe på en webserver, kan du omdøbe: Plan-Ressourcer.html til: index.html Behold mappen "medier" ved siden af HTML-filen, så links til de lokale filer fortsat virker. `; entries.push( {name:'Plan-Ressourcer.html',data:browserHtml}, {name:'SÅDAN-BRUGER-DU-PAKKEN.txt',data:guide}, {name:'plan-resources.json',data:JSON.stringify(manifest,null,2)} ); const zipBlob=await makeZip(entries); const filename=`Plan-Ressourcepakke-${safeName(context.scopeLabel).replace(/\s+/g,'-')}-${new Date().toISOString().slice(0,10)}.plan2`; const prepared=this.fileExporter.prepareBlob(zipBlob,filename,'application/zip'); const result=await this.fileExporter.save(prepared); if(result?.status!=='cancelled'){ this.toast(`Ressourcepakken med ${manifest.counts.mediaFiles} filer og ${manifest.counts.webResources} links er sendt til lagring`); } return {...(result||{}),manifest}; }; const originalDownloadResourceItem=App.prototype.downloadResourceItem; App.prototype.downloadResourceItem=async function(item){ if(isWebResource(item)){ this.showResourceLinkExport(item); return true; } return originalDownloadResourceItem.call(this,item); }; App.prototype.renderResourceCatalog=async function(container,mode='session'){ const context=await this.resourceScopeContext(mode); container.replaceChildren();container.className='tn-resource-catalog';container._tnResourceScopeMode=mode; let query='',type='Alle'; const resourceType=item=>clean(item?.type||item?.category)||'Ressource'; const types=['Alle',...[...new Set(context.resources.map(resourceType).filter(Boolean))].sort((a,b)=>a.localeCompare(b,'da'))]; const tools=document.createElement('div'); tools.className='tn-resource-catalog-tools'; tools.innerHTML=`
Eksportér …
`; const summary=document.createElement('section');summary.className='tn-resource-catalog-summary'; const list=document.createElement('div');list.className='tn-resource-catalog-list'; const draw=()=>{ const q=query.trim().toLowerCase(); const shown=context.resources.filter(item=>(type==='Alle'||resourceType(item)===type)&&(!q||[ item.title,item.type,item.category,item.url,item.embedUrl,...(item.locations||[]) ].join(' ').toLowerCase().includes(q))); summary.innerHTML=`${htmlEscape(context.scopeLabel)} ${types.length-1} typer`; const countNode=tools.querySelector('[data-count]'); if(countNode){ countNode.textContent=shown.length===context.resources.length ?`${shown.length} ressourcer` :`${shown.length} af ${context.resources.length}`; } list.replaceChildren(); for(const item of shown){ const row=document.createElement('article');row.className='tn-resource-catalog-row'; const info=document.createElement('div');const locations=(item.locations||[]).join(' · '); info.innerHTML=`${htmlEscape(item.title)} ${htmlEscape(resourceType(item))}${item.usageCount?` · bruges ${item.usageCount} sted${item.usageCount===1?'':'er'}`:''}${item.size?` · ${TN.formatBytes(item.size)}`:''} ${htmlEscape(locations||'Ressourcekatalog')}`; const actions=document.createElement('div');actions.className='tn-resource-catalog-actions'; if(isWebResource(item)){ const copy=document.createElement('button');copy.type='button';copy.textContent='Kopiér link'; copy.onclick=()=>this.copyResourceLink(item); const save=document.createElement('button');save.type='button';save.textContent='Hent link'; save.onclick=()=>this.saveResourceLinkText(item); actions.append(copy,save); }else{ const download=document.createElement('button');download.type='button';download.textContent='Hent original'; download.onclick=()=>this.downloadResourceItem(item); actions.append(download); } if(item.boardId){ const open=document.createElement('button');open.type='button';open.textContent='Åbn tavle'; open.onclick=async()=>{ const board=await this.boardManager.get(item.boardId); if(!board)return this.toast('Tavlen blev ikke fundet'); this.modalManager.closeAll();await this.loadBoard(board); if(item.widgetId)setTimeout(()=>this.selectWidget?.(item.widgetId),80); }; actions.append(open); }else if(resourceUrl(item)){ const open=document.createElement('button');open.type='button';open.textContent='Åbn'; open.onclick=()=>window.open(resourceUrl(item),'_blank','noopener,noreferrer'); actions.append(open); } row.append(info,actions);list.append(row); } if(!shown.length)list.innerHTML='
Ingen ressourcer matcher søgning og filter.
'; }; tools.querySelector('.tn-resource-tools-primary input').addEventListener('input',TN.debounce(event=>{query=event.target.value;draw();},120)); tools.querySelector('.tn-resource-tools-primary select').onchange=event=>{type=event.target.value;draw();}; const exportMenu=tools.querySelector('.tn-resource-export-menu'); const closeExportMenu=()=>{if(exportMenu)exportMenu.open=false;}; tools.querySelector('[data-links]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.showResourceLinksExport(mode); }; tools.querySelector('[data-json]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.downloadResourceBackup(mode); }; tools.querySelector('[data-package]').onclick=async event=>{ event.preventDefault();event.stopPropagation();closeExportMenu(); await this.exportResourcePackage(mode); }; container.append(tools,summary,list);draw(); }; try{TN.version='0.13.2-resource-package-v63';}catch(_){} })(window.TavleNext); `; const entries=[ {name:'ÅBN DENNE PLANPAKKE.html',data:visitCard}, {name:'data/manifest.json',data:JSON.stringify(manifest,null,2)}, {name:'data/backup.json',data:JSON.stringify(payload)} ]; if(includeApp){ const source=buildCleanArchiveApp(payload,manifest); entries.push({name:'app/Plan.html',data:source}); } const blob=await makeZip(entries); return {blob,manifest}; }; App.prototype.savePlanPackage=async function(payload,{ backupType='backup', title='Plan-pakke', filename='Plan-pakke.plan2', includeApp=includeAppPreference(), notes='' }={}){ const built=await this.buildPlanPackage(payload,{backupType,title,includeApp,notes}); const prepared=this.fileExporter.prepareBlob( built.blob, filename.toLowerCase().endsWith('.plan2')?filename:`${filename.replace(/\.(?:planpkg\.)?zip$/i,'')}.plan2`, 'application/zip' ); const result=await this.fileExporter.save(prepared); return {...(result||{}),manifest:built.manifest,prepared}; }; App.prototype.openPlanPackage=async function(file){ if(file.size>1024*1024*180)throw new Error('Plan-pakken er større end 180 MB og kan ikke åbnes sikkert på denne enhed'); const entries=readZipStoreOnly(await file.arrayBuffer()); const manifestBytes=entries.get('data/manifest.json')||entries.get('manifest.json'); if(!manifestBytes)throw new Error('Filen er ikke en Plan-pakke: manifest.json mangler'); let manifest; try{manifest=JSON.parse(new TextDecoder().decode(manifestBytes));} catch(_){throw new Error('Plan-pakkens manifest kan ikke læses');} if(manifest?.format!==PACKAGE_FORMAT)throw new Error('ZIP-filen er ikke en kendt Plan-pakke'); if(Number(manifest.packageVersion)>PACKAGE_VERSION)throw new Error(`Pakken kræver en nyere Plan-version (pakkeversion ${manifest.packageVersion})`); const payloadPath=clean(manifest.payloadPath)||'data/backup.json'; const payloadBytes=entries.get(payloadPath); if(!payloadBytes)throw new Error(`Plan-pakken mangler ${payloadPath}`); let payload; try{payload=JSON.parse(new TextDecoder().decode(payloadBytes));} catch(_){throw new Error('Plan-pakkens backupdata kan ikke læses');} return {manifest,payload,entries}; }; App.prototype.previewPlanPackageImport=async function(file){ let opened=null,packageError=null; try{opened=await this.openPlanPackage(file);}catch(error){packageError=error;} /* Almindelige mapper/arkiver fra plan-library kan også installeres direkte. Find alle egentlige forloeb.json-filer i ZIP'en, pak dem ud og lad brugeren vælge, når arkivet indeholder mere end ét undervisningsforløb. */ if(!opened){ let rawEntries={}; try{rawEntries=zipEntries(await file.arrayBuffer());} catch(error){throw packageError||error;} const sheetPaths=Object.keys(rawEntries).filter(path=>/\.(xlsx|csv|tsv)$/i.test(path)&&!path.endsWith('/')); if(sheetPaths.length){ const body=document.createElement('div');body.className='tn-plan-package-preview'; body.innerHTML=`
${sheetPaths.length} regneark fundetVælg om de skal importeres som fælles data fra ${TN.escapeHtml(file.name)}
Filer i ZIP-arkivet
${sheetPaths.map(TN.escapeHtml).join('
')}
`; this.modalManager.open({title:'Importér regneark fra ZIP',body,className:'tn-modal-wide',actions:[{label:'Annuller',run:()=>{}},{label:'Importér alle som fælles data',primary:true,run:async()=>{let count=0;for(const path of sheetPaths){const entry=rawEntries[path];const bytes=entry.compression===0?entry.data:entry.compression===8?window.pako.inflateRaw(entry.data):null;if(!bytes)throw new Error(`ZIP-filen bruger en ikke-understøttet komprimeringsmetode (${entry.compression})`);const ext=(path.split('.').pop()||'').toLowerCase();if(ext!=='xlsx')throw new Error(`${path.split('/').pop()} skal åbnes direkte, fordi automatisk ZIP-import foreløbig kun understøtter XLSX`);const sheetFile=new File([bytes],path.split('/').pop(),{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});count+=(await this.importSpreadsheetFileAsCommonData(sheetFile)).length;}this.studentScope?.invalidateGroups?.();this.toast(`${count} regneark blev importeret som fælles data`);return true;}}]}); return; } const paths=Object.keys(rawEntries) .filter(path=>/(^|\/)forloeb\.json$/i.test(path)) .filter(path=>!/(^|\/)demo-data\//i.test(path)) .sort((a,b)=>a.localeCompare(b,'da')); const candidates=[]; for(const path of paths){ try{ const text=await inflateEntry(rawEntries[path]); const payload=JSON.parse(text); const info=this.backupManager.inspect(payload); if(info.kind!=='course-package')continue; candidates.push({path,payload,info,title:payload.course?.title||payload.manifest?.courseTitle||path.split('/').slice(-2,-1)[0]||'Undervisningsforløb'}); }catch(error){console.warn('Springer ugyldig forløbsfil over',path,error);} } if(!candidates.length)throw packageError||new Error('ZIP-filen indeholder hverken en Plan-pakke eller et gyldigt forloeb.json'); const openCandidate=async candidate=>{ this.modalManager.closeAll(); const jsonFile=new File([JSON.stringify(candidate.payload)],candidate.path.split('/').pop()||'forloeb.json',{type:'application/json'}); await this.previewJsonImport(jsonFile); }; if(candidates.length===1){ await openCandidate(candidates[0]); return; } const body=document.createElement('div'); body.className='tn-session-list'; body.innerHTML=`
${candidates.length} undervisningsforløb fundetVælg det forløb, der skal installeres fra ${TN.escapeHtml(file.name)}
`; const list=body.querySelector('.tn-course-zip-list'); list.style.display='grid';list.style.gap='8px'; for(const candidate of candidates){ const button=document.createElement('button'); button.className='tn-session-open'; button.style.width='100%';button.style.padding='12px';button.style.border='1px solid var(--tn-line)';button.style.borderRadius='12px';button.style.background='var(--tn-panel2)';button.style.color='var(--tn-ink)'; button.innerHTML=`${TN.escapeHtml(candidate.title)}${TN.escapeHtml([candidate.payload.course?.subject,candidate.payload.course?.schoolYear,`${candidate.info.sessions} sessioner`].filter(Boolean).join(' · '))}${TN.escapeHtml(candidate.path)}`; button.onclick=()=>openCandidate(candidate); list.append(button); } this.modalManager.open({title:'Installér undervisningsforløb fra ZIP',body,className:'tn-modal-wide',actions:[{label:'Annuller',run:()=>{}}]}); return; } const spreadsheetAttachments=[...opened.entries.keys()].filter(path=>/\.(xlsx|xls|csv|tsv)$/i.test(path)&&path!==opened.manifest.payloadPath); const info=this.backupManager.inspect(opened.payload); const body=document.createElement('div'); body.className='tn-plan-package-preview'; body.innerHTML=`
${TN.escapeHtml(opened.manifest.title||'Plan-pakke')} ${TN.escapeHtml(opened.manifest.backupType||'backup')} · pakkeversion ${TN.escapeHtml(opened.manifest.packageVersion)}
Sessioner${info.sessions} Tavler${info.boards} Widgets${info.widgets} Datasæt${info.datasets} Medier${info.media} App medfølger${opened.manifest.containsApp?'Ja · ignoreres ved import':'Nej'}
Plan importerer backupdata og kan desuden hente vedlagte regneark.
En eventuel appfil i pakken åbnes eller installeres ikke.
`; if(spreadsheetAttachments.length){const note=document.createElement('div');note.className='tn-warning';note.innerHTML=`${spreadsheetAttachments.length} regneark fundet i pakken.
${spreadsheetAttachments.map(TN.escapeHtml).join('
')}`;body.append(note);} this.modalManager.open({ title:'Plan-pakke fundet', body, actions:[ {label:'Annuller',run:()=>{}}, {label:spreadsheetAttachments.length?'Importér regneark og vis backup':'Vis importindhold',primary:true,run:async()=>{ if(spreadsheetAttachments.length){let imported=0;for(const path of spreadsheetAttachments){if(!/\.xlsx$/i.test(path))continue;const bytes=opened.entries.get(path);const sheetFile=new File([bytes],path.split('/').pop(),{type:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'});imported+=(await this.importSpreadsheetFileAsCommonData(sheetFile)).length;}this.studentScope?.invalidateGroups?.();this.toast(imported?`${imported} regneark blev importeret som fælles data`:'Pakken indeholdt ingen XLSX-ark, der kunne importeres automatisk');} this.modalManager.closeAll(); const jsonFile=new File( [JSON.stringify(opened.payload)], `${safeName(opened.manifest.title||file.name)}.json`, {type:'application/json'} ); await this.previewJsonImport(jsonFile); return false; }} ] }); }; /* Den tidligere handler ovenfor foretager allerede manifestbaseret ZIP-routing. Undgå at overskrive den med et generelt Planpakke-spor, da det ville sende ressourcepakker til regnearks-/Planpakkeimporten. */ const originalRequestImportFiles=App.prototype.requestImportFiles; App.prototype.requestImportFiles=function(){ this.fileInput.accept='.plan2,.zip,.planpkg,.json,.csv,.tsv,.txt,.xlsx,.xls,.ods,.ics,.pdf,.docx,.odt,.html,.htm,.webp,.webm,application/zip,application/x-zip-compressed,application/json,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.oasis.opendocument.text,image/webp,video/webm,audio/webm,image/*,audio/*,video/*'; this.fileInput.multiple=true; this.fileInput.click(); }; /* Ny ZIP-baseret sikkerhedskopi for de fire primære varianter. */ function plan2ActiveDatasetIds(){try{const roles=JSON.parse(localStorage.getItem('plan:elevadmin:active-models:v1')||'{}')||{};return new Set(Object.values(roles).map(String).filter(Boolean));}catch(_){return new Set();}} function plan2StripActiveDatasources(payload){const ids=plan2ActiveDatasetIds(),removed=(payload.datasets||[]).filter(ds=>ids.has(String(ds.id))||ids.has(String(ds?.meta?.sourceDatasetId)));const removedIds=new Set(removed.map(ds=>String(ds.id)));const portraitIds=new Set(removed.flatMap(ds=>(ds?.meta?.portraitRefs||[]).map(ref=>String(ref?.mediaId||'')).filter(Boolean)));return {...payload,datasets:(payload.datasets||[]).filter(ds=>!removedIds.has(String(ds.id))),media:(payload.media||[]).filter(media=>!portraitIds.has(String(media.id)))};} function plan2PackageCounts(payload){return {sessions:payload.sessions?.length||0,boards:payload.boards?.length||0,widgets:payload.widgets?.length||0,datasets:payload.datasets?.length||0,media:payload.media?.length||0};} function plan2PackageTitle(kind,course){return kind==='all'?'Hele Plan':`${kind==='course'?'Backup+':kind==='share'?'Backup':'Skabelon'} · ${course?.title||'Undervisningsforløb'}`;} /* Kompakt pakkebygger før de fire primære eksporttyper. */ App.prototype.safetyExport=async function(kind,progress){ let ids=[],label='';const sessions=await this.sessionManager.list(); const course=kind==='all'?null:await this.safetyCourse(); if(kind==='all'){ids=sessions.map(session=>session.id);label='Backup++';} else{if(!course)throw new Error('Der er intet aktuelt undervisningsforløb');ids=course.sessionIds;label=kind==='course'?'Backup+':kind==='share'?'Backup':'Skabelon';} if(!ids.length)throw new Error('Der er ingen sessioner at gemme'); progress?.('Beregner pakkens indhold …'); const estimate=await this.backupManager.estimateExport(kind==='all'?null:ids).catch(()=>null); const counts=estimate?.counts||{};const raw=estimate?.totalBytes||0;const estimatedZip=Math.round(raw*.72); const defaultInclude=kind==='all'||kind==='course'; const body=document.createElement('div');body.className='plan2-package-builder'; body.innerHTML=`
${counts.sessions||ids.length}sessioner${counts.boards||0}tavler${counts.widgets||0}widgets${counts.media||0}medier
Indhold: ${TN.formatBytes(raw)}Estimeret ZIP: ca. ${TN.formatBytes(estimatedZip)}
`; const titleInput=body.querySelector('[data-title]'),dataCheck=body.querySelector('[data-data]'),privacy=body.querySelector('[data-privacy]');titleInput.value=plan2PackageTitle(kind,course); const paint=()=>{privacy.className=dataCheck.checked?'plan2-gdpr-warning':'plan2-privacy-safe';privacy.innerHTML=dataCheck.checked?'Indeholder personoplysningerDel kun pakken med personer, der må modtage oplysningerne.':'Datakilder udeladesPakken er bedre egnet som skabelon eller til deling.';};dataCheck.onchange=paint;paint(); return new Promise(resolve=>this.modalManager.open({title:`${label} · Pakkebygger`,body,className:'tn-modal-package-builder',actions:[{label:'Annuller',run:()=>{resolve({status:'cancelled'});return true;}},{label:'Opret pakke',primary:true,run:async()=>{try{progress?.('Samler Plan-pakke …');let payload=await this.backupManager.exportNext(kind==='all'?null:ids,{includeMedia:true,kind:kind==='all'?'backup++':kind});if(kind==='share'&&typeof sharedPayload==='function')payload=sharedPayload(payload);if(kind==='template'&&typeof templatePayload==='function')payload=templatePayload(payload);if(kind==='share'&&payload.backupKind!=='share')payload.backupKind='share';if(kind==='template'&&payload.backupKind!=='template')payload={...payload,backupKind:'template',datasets:[],media:[],widgets:(payload.widgets||[]).map(widget=>({...widget,content:{},meta:{...(widget.meta||{}),templateEmptied:true}}))};if(!dataCheck.checked)payload=plan2StripActiveDatasources(payload);payload.manifest={...(payload.manifest||{}),includesActiveDatasources:dataCheck.checked,privacy:dataCheck.checked?'contains-active-datasources':'datakilder-udeladt'};const date=new Date().toISOString().slice(0,10),base=safeName(titleInput.value||label).replace(/\s+/g,'-'),filename=`${base}-${date}.plan2`;progress?.('Åbner Gem i Filer / Del …');const result=await this.savePlanPackage(payload,{backupType:kind,title:titleInput.value.trim()||plan2PackageTitle(kind,course),filename,includeApp:includeAppPreference()});if(result?.status==='cancelled'){progress?.('Gemning blev annulleret.');resolve(result);return false;}progress?.('Plan-pakken er sendt til lagring.');this.toast('Plan-pakken er sendt til Gem i Filer / Del');resolve(result);return true;}catch(error){console.error(error);this.toast(error?.message||'Pakken kunne ikke oprettes');resolve({status:'error',error});return false;}}}] })); }; /* Klassisk Backup++-dialog gemmer også ZIP. */ App.prototype.showBackup=async function(){ const sessions=await this.sessionManager.list(),body=document.createElement('div'); body.className='tn-backup-panel'; body.innerHTML='

Vælg de sessioner, der skal samles i én Plan-pakke.

Beregner størrelse …
'; const list=body.querySelector('.tn-backup-sessions'),estimate=body.querySelector('.tn-backup-estimate'); const appCheck=body.querySelector('[data-app]');appCheck.checked=includeAppPreference(); appCheck.onchange=()=>setIncludeAppPreference(appCheck.checked); for(const session of sessions){ const row=document.createElement('label');row.className='tn-backup-session'; row.innerHTML=`${TN.escapeHtml(session.title)}${TN.escapeHtml(session.id)}`; list.append(row); } const selected=()=>[...list.querySelectorAll('input[type="checkbox"]:checked')].filter(x=>x!==appCheck).map(x=>x.value); const refresh=TN.debounce(async()=>{ const ids=selected();if(!ids.length){estimate.textContent='Vælg mindst én session.';return;} const e=await this.backupManager.estimateExport(ids); estimate.textContent=`${e.counts.sessions} sessioner · ${e.counts.boards} tavler · ${e.counts.widgets} widgets · ${e.counts.datasets} datasæt · ${e.counts.media} medier · estimeret ${TN.formatBytes(e.totalBytes+(appCheck.checked?(document.documentElement.outerHTML.length+e.totalBytes):0))}`; },80); list.onchange=refresh; body.querySelector('[data-all]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=true);refresh();}; body.querySelector('[data-none]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=false);refresh();}; body.querySelector('[data-current]').onclick=()=>{list.querySelectorAll('input[type="checkbox"]').forEach(x=>x.checked=x.value===TN.runtime.activeSession.id);refresh();}; body.querySelector('[data-restore]').onclick=()=>this.showRestorePoints(); body.querySelector('[data-media]').onclick=()=>this.showMediaArchive(); body.querySelector('[data-log]').onclick=()=>this.showImportLogs(); refresh(); this.modalManager.open({ title:'Backup++ · Plan-pakke', body,className:'tn-modal-wide', actions:[ {label:'Opret gendannelsespunkt',run:async()=>{await this.createRestorePoint();return false;}}, {label:'Gem Plan-pakke',primary:true,run:async()=>{ const ids=selected();if(!ids.length){this.toast('Vælg mindst én session');return false;} const payload=await this.backupManager.exportNext(ids); const date=new Date().toISOString().slice(0,10); const name=ids.length===1 ?`${safeName(sessions.find(s=>s.id===ids[0])?.title||ids[0]).replace(/\s+/g,'-')}-BackupPP-${date}.plan2` :`Plan-BackupPP-${date}.plan2`; const result=await this.savePlanPackage(payload,{ backupType:'backup++', title:ids.length===1?'Backup++ · '+(sessions.find(s=>s.id===ids[0])?.title||'Session'):'Backup++ · valgte sessioner', filename:name, includeApp:appCheck.checked }); if(result?.status!=='cancelled')this.toast('Backup++ er sendt til lagring'); return false; }} ] }); }; /* Årsforløbspakker gemmes i samme format. */ App.prototype.showCoursePackageExport=function({course,payload,verification}){ const body=document.createElement('div'); body.style.display='grid';body.style.gap='12px'; body.innerHTML=`
Årsforløbspakken er kontrolleret${TN.escapeHtml(course.title)} · ${course.sessionIds.length} sessioner
Sessioner${verification.counts.sessions}Tavler${verification.counts.boards}Widgets${verification.counts.widgets}Datasæt${verification.counts.datasets}Medier${verification.counts.media}
Gem først pakken. Derefter kan det lokale årsforløb arkiveres.
`; let saved=false; const modal=this.modalManager.open({ title:'Årsforløb er klar', body,className:'tn-modal-export', actions:[ {label:'Luk',run:()=>{}}, {label:'Gem Plan-pakke',primary:true,run:async()=>{ const includeApp=body.querySelector('[data-app]').checked;setIncludeAppPreference(includeApp); const filename=`Plan-${safeName(course.title).replace(/\s+/g,'-')}-${safeName(course.schoolYear||new Date().toISOString().slice(0,10)).replace(/\s+/g,'-')}.plan2`; const result=await this.savePlanPackage(payload,{backupType:'course-package',title:course.title,filename,includeApp}); if(result?.status==='cancelled')return false; saved=true;body.querySelector('[data-course-save-state]').innerHTML='Pakken er sendt til lagring. Du kan nu arkivere det lokale årsforløb.'; archiveButton.disabled=false;this.toast('Årsforløbspakken er gemt');return false; }}, {label:'Arkivér lokalt og start nyt',run:()=>{ if(!saved){this.toast('Gem årsforløbspakken først');return false;} this.showNewCourseDialog({archiveCourse:course,packagePayload:payload});return false; }} ] }); const archiveButton=[...modal.footer.querySelectorAll('button')].at(-1); archiveButton.disabled=true; }; /* Fælles data gemmes også som Plan-pakke. */ const oldExportCommonDataBackup=App.prototype.exportCommonDataBackup; App.prototype.exportCommonDataBackup=async function(){ await this.storage.flush?.(); const datasets=(await this.dataManager.listCommon()).map(TN.deepClone); if(!datasets.length){this.toast('Der er ingen fælles data at sikkerhedskopiere');return null;} const mediaIds=this.backupManager.collectMediaIds(datasets),media=[]; for(const id of mediaIds){ const item=await this.storage.get('media',id);if(!item)continue; media.push({...TN.deepClone(item),blob:undefined,originalBlob:undefined, dataUrl:item.blob?await TN.blobToDataUrl(item.blob):item.dataUrl||null, originalDataUrl:item.originalBlob?await TN.blobToDataUrl(item.originalBlob):item.originalDataUrl||null}); } const payload={ format:'plan-common-data-backup',version:1,appVersion:TN.version,schemaVersion:TN.schemaVersion, createdAt:TN.now(),datasets,media, manifest:{datasets:datasets.length,hiddenDatasets:datasets.filter(item=>item.meta?.hiddenFromUser).length,media:media.length,datasetIds:datasets.map(item=>item.id),mediaIds:media.map(item=>item.id)} }; const date=new Date().toISOString().slice(0,10); const result=await this.savePlanPackage(payload,{ backupType:'common-data', title:'Fælles data', filename:`Plan-Faelles-data-${date}.plan2`, includeApp:includeAppPreference() }); if(result?.status!=='cancelled')this.toast(`${datasets.length} fælles datasæt er sendt til lagring`); return result; }; /* Nødbackup bruger samme pakkeformat. */ if(TN.StorageRescueManager?.prototype){ TN.StorageRescueManager.prototype.saveEmergencyBackup=async function({course=null,includeMedia=true,progress=null}={}){ const target=course||await this.activeCourse(); if(!target)throw new Error('Der er intet undervisningsforløb at sikkerhedskopiere'); progress?.('Samler den aktuelle tilstand direkte fra hukommelsen …'); const payload=await this.buildEmergencyPackage(target,{includeMedia,progress}); progress?.('Pakker nødbackup som ZIP …'); const stamp=new Date().toISOString().replace(/[:.]/g,'-'); const result=await this.app.savePlanPackage(payload,{ backupType:includeMedia?'emergency':'emergency-light', title:`Nødbackup · ${target.title}`, filename:`Plan-NOEDBACKUP-${safeName(target.title).replace(/\s+/g,'-')}-${stamp}.plan2`, includeApp:includeAppPreference() }); if(result?.status!=='cancelled')this.app.toast(includeMedia?'Nødbackup med medier er sendt til Filer':'Hurtig nødbackup er sendt til Filer'); return {...result,payload}; }; } /* Tilføj valg af app i Backup og sikkerhed. */ const oldRenderSafetyCenter=App.prototype.renderSafetyCenter; App.prototype.renderSafetyCenter=async function(container){ await oldRenderSafetyCenter.call(this,container); const observer=new MutationObserver(()=>{ const page=container.querySelector('.tn-safety-page'); if(!page||!page.querySelector('[data-export]')||page.querySelector('[data-plan-package-app-option]'))return; const option=document.createElement('label'); option.className='tn-plan-package-options'; option.dataset.planPackageAppOption=''; option.innerHTML=`Medtag en kopi af Plan-appenFrivilligt. Appfilen lægges i pakken, men importeres aldrig.`; option.querySelector('input').onchange=event=>setIncludeAppPreference(event.target.checked); page.insertBefore(option,page.querySelector('.tn-safety-options')); }); observer.observe(container,{childList:true,subtree:true}); setTimeout(()=>{ const page=container.querySelector('.tn-safety-page'); if(page?.querySelector('[data-export]')&&!page.querySelector('[data-plan-package-app-option]')){ const option=document.createElement('label');option.className='tn-plan-package-options';option.dataset.planPackageAppOption=''; option.innerHTML=`Medtag en kopi af Plan-appenFrivilligt. Appfilen lægges i pakken, men importeres aldrig.`; option.querySelector('input').onchange=event=>setIncludeAppPreference(event.target.checked); page.insertBefore(option,page.querySelector('.tn-safety-options')); } },50); }; try{TN.version='0.13.2-plan-package-v68';}catch(_){} })(window.TavleNext);