PageSourceSearch

https://ysbcarpentry.ca/wp-content/nrj-offline.js?ver=1784209836

js ysbcarpentry.ca collected 2026-10-07 03:37:19 UTC 57,321 bytes, 1,607 lines download raw bytes

1const FIELDS_KEY='nrj_offline_fields'
2const FILES_KEY='nrj_offline_files'
3const USE_IDB_FILES=true
4const NRJ_ENABLE_DRAFTS=false
5const NRJ_DRAFT_SLOT='draft'
6const NRJ_USER_SLOTS=[1,2,3,4,5]
7
8function nrj_logc(...a){try{console.log('[NRJ-OFFLINE]',...a)}catch(e){}}
9
10function nrj_find_form(){
11    let byBtn=document.getElementById('nrj-save-report-offline')?.closest('form')
12            || document.getElementById('nrj-manage-offline')?.closest('form')
13    if(byBtn)return byBtn
14    let byFF=document.querySelector('form input[name="form_id"]')
15    if(byFF)return byFF.closest('form')
16    return document.querySelector('form')
17}
18
19function nrj_get_form_id(form){
20    let el=form?.querySelector('input[name="form_id"]')
21    return el?String(el.value||''):''
22}
23
24function nrj_ls_key(formId,slot,base){
25    return base+':'+formId+':'+slot
26}
27
28function nrj_get_index(formId){
29    let k=FIELDS_KEY+':index:'+formId
30    let s=localStorage.getItem(k)
31    let out={}
32    out[NRJ_DRAFT_SLOT]=null
33    NRJ_USER_SLOTS.forEach(slot=>{out[slot]=null})
34    if(!s)return out
35    try{
36        let o=JSON.parse(s)||{}
37        out[NRJ_DRAFT_SLOT]=o[NRJ_DRAFT_SLOT]||null
38        NRJ_USER_SLOTS.forEach(slot=>{out[slot]=o[String(slot)]||o[slot]||null})
39    }catch(e){}
40    return out
41}
42
43function nrj_set_index(formId,idx){
44    let k=FIELDS_KEY+':index:'+formId
45    let o={}
46    o[NRJ_DRAFT_SLOT]=idx[NRJ_DRAFT_SLOT]||null
47    NRJ_USER_SLOTS.forEach(slot=>{o[slot]=idx[slot]||null})
48    localStorage.setItem(k,JSON.stringify(o))
49}
50
51
52function nrj_slot_pick_save(idx){
53    for(let slot of NRJ_USER_SLOTS){
54        if(!idx[slot])return slot
55    }
56    let first=NRJ_USER_SLOTS[0]
57    let last=NRJ_USER_SLOTS[NRJ_USER_SLOTS.length-1]
58    let a=window.prompt('All '+NRJ_USER_SLOTS.length+' slots are full. Overwrite slot '+first+'–'+last+'?',String(first))
59    let n=parseInt(a,10)
60    if(!NRJ_USER_SLOTS.includes(n))return null
61    return n
62}
63
64
65function nrj_slot_pick_restore(idx){
66    let filled=[]
67    for(let slot of NRJ_USER_SLOTS){
68        if(idx[slot])filled.push(slot)
69    }
70    if(filled.length===0)return null
71    if(filled.length===1)return filled[0]
72    let first=NRJ_USER_SLOTS[0]
73    let last=NRJ_USER_SLOTS[NRJ_USER_SLOTS.length-1]
74    let a=window.prompt('Restore slot '+first+'–'+last+'?',String(filled[0]))
75    let n=parseInt(a,10)
76    if(!NRJ_USER_SLOTS.includes(n))return null
77    return n
78}
79
80
81async function nrj_manage_delete_slot(formId,slot){
82    await nrj_clear_saved(formId,slot)
83}
84
85async function nrj_restore_from_slot(formId,slot,form,btn,forcedMode=null){
86    let fieldsStr=localStorage.getItem(nrj_ls_key(formId,slot,FIELDS_KEY))
87    let filesObj=await nrj_load_files(formId,slot)
88
89    if(!fieldsStr&&(!filesObj||!Object.keys(filesObj).length)){
90        nrj_toast('No saved report in slot '+slot,false,btn)
91        return false
92    }
93
94    let fields={}
95
96    try{
97        fields=fieldsStr?JSON.parse(fieldsStr):{}
98    }catch(e){
99        fields={}
100    }
101
102    for(let name in fields){
103        let m=name.match(/^item_meta\[(\d+)\]\[(\d+)\]\[(\d+)\]/)
104
105        if(m){
106            let parent=m[1]
107            let row=parseInt(m[2],10)
108            let sec=form.querySelector('#frm_section_'+parent+'-'+row)
109
110            if(!sec){
111                let btnAdd=form.querySelector('.frm_add_form_row[data-parent="'+parent+'"]')
112
113                if(btnAdd){
114                    while(!form.querySelector('#frm_section_'+parent+'-'+row)){
115                        btnAdd.click()
116                        await nrj_wait(250)
117                    }
118                }
119            }
120        }
121    }
122
123    nrj_apply_subset(form,fields,el=>el.type==='radio'||el.type==='checkbox'||el.tagName==='SELECT')
124
125    await nrj_wait(350)
126
127    nrj_apply_subset(form,fields,el=>!(el.type==='radio'||el.type==='checkbox'||el.tagName==='SELECT'))
128
129    await nrj_wait(350)
130
131    nrj_apply_subset(form,fields,el=>true)
132
133    let mode=forcedMode
134        ? forcedMode
135        : (slot===NRJ_DRAFT_SLOT?nrj_prompt_restore_file_mode():'original')
136
137    let restored=await nrj_restore_files_slot(formId,slot,form,mode)
138
139    if(restored===false){
140        nrj_toast('Restore stopped because a file could not be loaded',false,btn)
141        return false
142    }
143
144    if(slot===NRJ_DRAFT_SLOT){
145        if(!forcedMode){
146            nrj_toast('Restored Draft Slot using '+mode+' files',true,btn)
147        }
148
149        return true
150    }
151
152    let del=window.confirm('Delete stored data for slot '+slot+' after restoring?')
153
154    if(del){
155        await nrj_clear_saved(formId,slot)
156        nrj_toast('Restored slot '+slot+' and deleted saved copy',true,btn)
157    }else{
158        nrj_toast('Restored slot '+slot+' (kept saved copy)',true,btn)
159    }
160
161    return true
162}
163async function nrj_restore_files_slot(formId,slot,form,mode='original'){
164    let files=await nrj_load_files(formId,slot)
165
166    if(!files||!Object.keys(files).length){
167        return
168    }
169
170    let keys=Object.keys(files)
171    let batchSize=5
172
173    let restoreField=async key=>{
174        await nrj_ensure_row_for_key(form,key)
175
176        let list=files[key]||[]
177
178        if(mode==='compressed'){
179            let compressed=[]
180
181            for(let it of list){
182                let f=nrj_file_from_buf(it)
183
184                if(!f)continue
185
186                let cf=await nrj_compress_image_file(f)
187                let buf=await nrj_buf_from_file(cf)
188
189                if(buf){
190                    compressed.push({
191                        name:cf.name,
192                        type:cf.type||'',
193                        lastModified:cf.lastModified||Date.now(),
194                        buf
195                    })
196                }
197            }
198
199            list=compressed
200        }
201
202        let dz=nrj_find_dropzone_for_key(form,key)
203
204        if(dz){
205            try{
206                dz.removeAllFiles(true)
207            }catch(e){}
208
209            for(let it of list){
210                let f=nrj_file_from_buf(it)
211
212                if(!f)continue
213
214                let loaded=await nrj_add_dropzone_file_with_retry(
215                    dz,
216                    f,
217                    5
218                )
219
220                if(!loaded){
221                    nrj_upload_error_log('restore_upload_failed',{
222                        form_id:formId,
223                        field_key:key,
224                        file_name:f.name,
225                        file_size:f.size||'',
226                        message:'Failed after 5 restore attempts'
227                    })
228
229                    nrj_toast(
230                        'Could not load '+f.name+' after 5 attempts',
231                        false,
232                        null
233                    )
234
235                    return false
236                }
237            }
238
239            return true
240        }
241
242        let hidden=form.querySelector(
243            'input[type="hidden"][data-frmfile][name="'+CSS.escape(key)+'"]'
244        )
245
246        let field=hidden
247            ? hidden.closest('.frm_form_field')
248            : null
249
250        let input=field
251            ? field.querySelector('input[type="file"]')
252            : null
253
254        if(input){
255            nrj_apply_files(input,list)
256        }
257
258        return true
259    }
260
261    for(let i=0;i<keys.length;i+=batchSize){
262        let batch=keys.slice(i,i+batchSize)
263        let results=await Promise.all(
264            batch.map(key=>restoreField(key))
265        )
266
267        if(results.some(result=>result===false)){
268            return false
269        }
270    }
271
272    return true
273}
274
275async function nrj_add_dropzone_file_with_retry(dz,file,maxRetries=5){
276    let attempt=0
277
278    while(attempt<maxRetries){
279        attempt++
280
281        let result=await new Promise(resolve=>{
282            let finished=false
283
284            let cleanup=()=>{
285                dz.off('success',successHandler)
286                dz.off('error',errorHandler)
287                dz.off('canceled',canceledHandler)
288            }
289
290            let finish=(ok,reason='')=>{
291                if(finished)return
292
293                finished=true
294                cleanup()
295                resolve({ok,reason})
296            }
297
298            let successHandler=uploadedFile=>{
299                if(uploadedFile!==file)return
300                finish(true)
301            }
302
303            let errorHandler=(uploadedFile,message,xhr)=>{
304                if(uploadedFile!==file)return
305
306                let errorMessage=''
307
308                if(typeof message==='string'){
309                    errorMessage=message
310                }else{
311                    try{
312                        errorMessage=JSON.stringify(message||{})
313                    }catch(e){
314                        errorMessage=String(message||'Upload failed')
315                    }
316                }
317
318                if(xhr&&xhr.status){
319                    errorMessage+=' | HTTP '+xhr.status
320                }
321
322                finish(false,errorMessage)
323            }
324
325            let canceledHandler=uploadedFile=>{
326                if(uploadedFile!==file)return
327                finish(false,'Upload canceled')
328            }
329
330            dz.on('success',successHandler)
331            dz.on('error',errorHandler)
332            dz.on('canceled',canceledHandler)
333
334            try{
335                dz.addFile(file)
336            }catch(e){
337                finish(
338                    false,
339                    e&&e.message?e.message:String(e)
340                )
341            }
342        })
343
344        if(result.ok){
345            nrj_logc(
346                'restore upload accepted',
347                file.name,
348                'attempt',
349                attempt
350            )
351
352            return true
353        }
354
355        nrj_logc(
356            'restore upload failed',
357            file.name,
358            'attempt',
359            attempt,
360            result.reason
361        )
362
363        try{
364            dz.removeFile(file)
365        }catch(e){}
366
367        if(attempt<maxRetries){
368            await nrj_wait(1000)
369        }
370    }
371
372    return false
373}
374
375function nrj_idb_clear(store){
376    return nrj_db().then(db=>new Promise((res,rej)=>{
377        try{
378            const tx=db.transaction([store],'readwrite');
379            const st=tx.objectStore(store);
380            const q=st.clear();
381            q.onsuccess=()=>res(true);
382            q.onerror=e=>rej(e);
383            tx.oncomplete=()=>db.close();
384        }catch(e){
385            try{db.close()}catch(_){}
386            res(true);
387        }
388    }));
389}
390
391async function nrj_clear_all_saved(){
392    try{
393        // Remove all LocalStorage entries related to NRJ offline
394        const prefixes=[FIELDS_KEY+':', FILES_KEY+':'];
395        const idxPrefix=FIELDS_KEY+':index:';
396        const toDelete=[];
397        for(let i=0;i<localStorage.length;i++){
398            const k=localStorage.key(i)||'';
399            if(k.startsWith(prefixes[0]) || k.startsWith(prefixes[1]) || k.startsWith(idxPrefix)){
400                toDelete.push(k);
401            }
402        }
403        toDelete.forEach(k=>{ try{localStorage.removeItem(k)}catch(_){}});
404
405        if(USE_IDB_FILES){ try{ await nrj_idb_clear('files') }catch(_){ /* ignore */ } }
406
407        return true;
408    }catch(e){
409        nrj_logc('clear_all_error', e && e.message ? e.message : e);
410        return false;
411    }
412}
413
414
415function nrj_fmt_bytes(b){
416    if(b==null || isNaN(b)) return '—'
417    const u=['B','KB','MB','GB','TB']
418    let i=0, v=Number(b)
419    while(v>=1024 && i<u.length-1){ v/=1024; i++ }
420    return Math.round(v*10)/10+' '+u[i]
421}
422
423async function nrj_get_storage_info(){
424    let lsUsedBytes=0
425    try{
426        for(let i=0;i<localStorage.length;i++){
427            const k=localStorage.key(i)||''
428            const v=localStorage.getItem(k)||''
429            lsUsedBytes += (k.length + v.length) * 2
430        }
431    }catch(e){}
432    let lsQuotaBytes=5*1024*1024
433    let quota=null, usage=null, idbUsage=null
434    try{
435        if(navigator.storage && navigator.storage.estimate){
436            const est=await navigator.storage.estimate()
437            quota=est.quota||null
438            usage=est.usage||null
439            if(est.usageDetails && typeof est.usageDetails.indexedDB==='number'){
440                idbUsage=est.usageDetails.indexedDB
441            }
442        }
443    }catch(e){}
444    return {lsUsedBytes,lsQuotaBytes,quota,usage,idbUsage}
445}
446
447async function nrj_update_storage_info(){
448    const el=document.getElementById('nrj-storage-info')
449    if(!el)return
450    const {lsUsedBytes,lsQuotaBytes,quota,usage}=await nrj_get_storage_info()
451    const lsRemain=Math.max(0,(lsQuotaBytes||0)-(lsUsedBytes||0))
452    const idbRemain=(quota!=null && usage!=null)?Math.max(0,quota-usage):null
453    const lsText='<b>LocalStorage remaining:</b><br>'
454        +nrj_fmt_bytes(lsRemain)+' ('+nrj_fmt_bytes(lsUsedBytes)+' used of '+nrj_fmt_bytes(lsQuotaBytes)+')'
455    const idbText='<b>IndexedDB remaining:</b><br>'
456        +(idbRemain!=null?(nrj_fmt_bytes(idbRemain)+' ('+nrj_fmt_bytes(usage)+' used of '+nrj_fmt_bytes(quota)+')'):'—')
457    el.innerHTML=lsText+'<br>'+idbText
458}
459
460
461
462function nrj_ev(el){
463    el.dispatchEvent(new Event('input',{bubbles:true}))
464    el.dispatchEvent(new Event('change',{bubbles:true}))
465}
466
467function nrj_click(el){try{el.click()}catch(e){}}
468
469function nrj_set(el,val){
470    if(el.type==='checkbox'){
471        let on=Array.isArray(val)?val.includes(el.value):(val===el.value||val===true||val==='1'||String(val).toLowerCase()==='yes')
472        el.checked=!!on
473        if(on)nrj_click(el)
474        nrj_ev(el)
475        return
476    }
477    if(el.type==='radio'){
478        let on=(String(val).toLowerCase()===String(el.value).toLowerCase())
479        el.checked=!!on
480        if(on){nrj_click(el);nrj_ev(el)}
481        return
482    }
483    if(el.tagName==='SELECT'&&el.multiple){
484        [].forEach.call(el.options,o=>o.selected=(Array.isArray(val)&&val.includes(o.value)))
485        nrj_ev(el);return
486    }
487    if(el.tagName==='SELECT'){
488        el.value=Array.isArray(val)?(val[0]??''):(val??'')
489        nrj_ev(el);return
490    }
491    el.value=(Array.isArray(val)?(val[0]??''):(val??''))
492    nrj_ev(el)
493}
494
495function nrj_open_manage_modal(formId){
496    let m=document.getElementById('nrj-offline-modal')
497    if(!m){
498        m=document.createElement('div')
499        m.id='nrj-offline-modal'
500        m.style.position='fixed'
501        m.style.top='0';m.style.left='0';m.style.right='0';m.style.bottom='0'
502        m.style.background='rgba(0,0,0,0.4)'
503        m.style.zIndex='99999'
504        m.innerHTML='<div id="nrj-offline-modal-box" style="background:#fff;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:96vw;max-width:1200px;min-width:320px;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.3);overflow:hidden"></div>'
505        document.body.appendChild(m)
506        m.addEventListener('click',e=>{if(e.target===m)m.remove()})
507    }
508    let form=nrj_find_form()
509    let formTitle=null
510    if(form){
511        let lg=form.querySelector('legend.frm_screen_reader')
512        if(lg){ formTitle=(lg.textContent||'').trim()||null }
513    }
514    nrj_render_manage_modal(formId,formTitle)
515}
516
517
518function nrj_render_manage_modal(formId,formTitle){
519    let box=document.getElementById('nrj-offline-modal-box')
520    if(!box)return
521
522    let idx=nrj_get_index(formId)
523    let rowsHTML=''
524    let actionStyle='font-weight:700;text-transform:uppercase;cursor:pointer;white-space:nowrap;'
525
526    if(NRJ_ENABLE_DRAFTS){
527        let draft=idx[NRJ_DRAFT_SLOT]
528
529        if(draft){
530            let draftText='Saved '+new Date(draft.savedAt).toLocaleString()
531                +' · fields '+(draft.fields||0)
532                +' · files '+(draft.files||0)
533
534            rowsHTML+='<div style="padding-bottom:12px;border-bottom:1px solid #ddd">'
535                +'<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">'
536                +'<div><b>Draft Slot:</b></div>'
537                +'<div style="display:flex;align-items:center;gap:6px;justify-content:flex-end">'
538                +'<span data-act="restore-draft" data-slot="'+NRJ_DRAFT_SLOT+'" role="button" tabindex="0" style="'+actionStyle+'">Restore</span>'
539                +'<span aria-hidden="true">|</span>'
540                +'<span data-act="save-draft-slot" data-slot="'+NRJ_DRAFT_SLOT+'" role="button" tabindex="0" style="'+actionStyle+'">Save to Slot</span>'
541                +'</div>'
542                +'</div>'
543                +'<div style="margin-top:5px;font-size:9px;">'+draftText+'</div>'
544                +'</div>'
545        }else{
546            rowsHTML+='<div style="padding-bottom:12px;border-bottom:1px solid #ddd">'
547                +'<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">'
548                +'<div><b>Draft Slot:</b></div>'
549                +'<div style="font-weight:700;text-transform:uppercase">Empty</div>'
550                +'</div>'
551                +'</div>'
552        }
553    }
554
555    for(let slot of NRJ_USER_SLOTS){
556        let info=idx[slot]
557
558        if(info){
559            let savedText='Saved '+new Date(info.savedAt).toLocaleString()
560                +' · fields '+(info.fields||0)
561                +' · files '+(info.files||0)
562
563            rowsHTML+='<div style="padding-bottom:12px;border-bottom:1px solid #ddd">'
564                +'<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">'
565                +'<div><b>Slot '+slot+':</b></div>'
566                +'<div style="display:flex;align-items:center;gap:6px;justify-content:flex-end">'
567                +'<span data-act="restore" data-slot="'+slot+'" role="button" tabindex="0" style="'+actionStyle+'">Restore</span>'
568                +'<span aria-hidden="true">|</span>'
569                +'<span data-act="delete" data-slot="'+slot+'" role="button" tabindex="0" style="'+actionStyle+'">Delete</span>'
570                +'<span aria-hidden="true">|</span>'
571                +'<span data-act="details" data-slot="'+slot+'" role="button" tabindex="0" style="'+actionStyle+'">Details</span>'
572                +'</div>'
573                +'</div>'
574                +'<div style="margin-top:5px;font-size:9px;">'+savedText+'</div>'
575                +'</div>'
576        }else{
577            rowsHTML+='<div style="padding-bottom:12px;border-bottom:1px solid #ddd">'
578                +'<div style="display:flex;justify-content:space-between;align-items:center;gap:12px">'
579                +'<div><b>Slot '+slot+':</b></div>'
580                +'<div style="font-weight:700;text-transform:uppercase">Empty</div>'
581                +'</div>'
582                +'</div>'
583        }
584    }
585
586    let headerTitle=formTitle
587        ? nrj_htmlesc(formTitle)
588        : 'Form '+nrj_htmlesc(formId)
589
590    box.innerHTML='<div style="padding:14px 16px;font-weight:700;border-bottom:1px solid #eee">'
591        +'Offline Entries for '+headerTitle
592        +'</div>'
593        +'<div id="nrj-offline-slot-list" style="padding:16px;display:grid;gap:12px;max-height:60vh;overflow-y:auto;overscroll-behavior:contain">'
594        +rowsHTML
595        +'</div>'
596        +'<div style="padding:12px 16px;border-top:1px solid #eee;display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">'
597        +'<div style="display:flex;align-items:center;gap:8px;flex:1 1 auto;min-width:240px">'
598        +'<div id="nrj-storage-info" style="font-size:10px;color:#555">Calculating storage…</div>'
599        +'<button type="button" id="nrj-clear-all-storage" class="viewbtn">Clear All Storage</button>'
600        +'</div>'
601        +'<button type="button" id="nrj-offline-close" class="viewbtn">Close</button>'
602        +'</div>'
603
604    box.querySelector('#nrj-offline-close').addEventListener('click',()=>{
605        document.getElementById('nrj-offline-modal')?.remove()
606    })
607
608    let runAction=async el=>{
609        let rawSlot=el.dataset.slot
610        let slot=rawSlot===NRJ_DRAFT_SLOT
611            ? NRJ_DRAFT_SLOT
612            : parseInt(rawSlot,10)
613
614        let act=el.dataset.act
615
616        if(act==='delete'){
617            document.getElementById('nrj-offline-modal')?.remove()
618            await nrj_wait(50)
619
620            let ok=window.confirm(
621                'Are you sure you wish to delete this saved submission (slot '+slot+')?'
622            )
623
624            if(ok){
625                await nrj_manage_delete_slot(formId,slot)
626                nrj_toast(
627                    'Deleted saved submission (slot '+slot+')',
628                    true,
629                    null
630                )
631            }
632        }
633
634        if(act==='restore'){
635            let form=nrj_find_form()
636            if(!form)return
637
638            document.getElementById('nrj-offline-modal')?.remove()
639
640            let dummy=document.createElement('button')
641            await nrj_restore_from_slot(formId,slot,form,dummy)
642        }
643
644        if(act==='restore-draft'){
645            if(!NRJ_ENABLE_DRAFTS)return
646
647            let form=nrj_find_form()
648            if(!form)return
649
650            document.getElementById('nrj-offline-modal')?.remove()
651
652            let dummy=document.createElement('button')
653            await nrj_restore_from_slot(
654                formId,
655                NRJ_DRAFT_SLOT,
656                form,
657                dummy
658            )
659        }
660
661        if(act==='save-draft-slot'){
662            if(!NRJ_ENABLE_DRAFTS)return
663
664            await nrj_save_draft_to_slot(formId,el)
665            document.getElementById('nrj-offline-modal')?.remove()
666        }
667
668        if(act==='details'){
669            document.getElementById('nrj-offline-modal')?.remove()
670            nrj_open_details_modal(formId,slot)
671        }
672    }
673
674    box.querySelectorAll('[data-act]').forEach(el=>{
675        el.addEventListener('click',async()=>{
676            await runAction(el)
677        })
678
679        el.addEventListener('keydown',async e=>{
680            if(e.key!=='Enter'&&e.key!==' ')return
681
682            e.preventDefault()
683            await runAction(el)
684        })
685    })
686
687    const clearAllBtn=box.querySelector('#nrj-clear-all-storage')
688
689    if(clearAllBtn){
690        clearAllBtn.addEventListener('click',async()=>{
691            document.getElementById('nrj-offline-modal')?.remove()
692            await nrj_wait(50)
693
694            const ok=window.confirm(
695                'Clear ALL saved offline entries for this site (all forms and slots)?'
696            )
697
698            if(ok){
699                await nrj_clear_all_saved()
700                nrj_toast(
701                    'Cleared all saved offline data',
702                    true,
703                    null
704                )
705            }
706        })
707    }
708
709    const isMobile=
710        (window.matchMedia&&window.matchMedia('(max-width: 640px)').matches)
711        ||/Android|iPhone|iPad|iPod|Windows Phone|Mobi/i.test(
712            navigator.userAgent||''
713        )
714
715    if(isMobile){
716        let slotList=box.querySelector('#nrj-offline-slot-list')
717
718        if(slotList){
719            slotList.style.maxHeight='55vh'
720        }
721
722        box.querySelectorAll('[data-act]').forEach(el=>{
723            el.style.fontSize='11px'
724        })
725
726        box.querySelectorAll(
727            '#nrj-clear-all-storage,#nrj-offline-close'
728        ).forEach(btn=>{
729            btn.style.fontSize='10px'
730            btn.style.padding='5px 8px'
731        })
732    }
733
734    nrj_update_storage_info()
735}
736
737function nrj_open_details_modal(formId,slot){
738    let m=document.getElementById('nrj-offline-details-modal')
739    if(!m){
740        m=document.createElement('div')
741        m.id='nrj-offline-details-modal'
742        m.style.position='fixed'
743        m.style.top='0';m.style.left='0';m.style.right='0';m.style.bottom='0'
744        m.style.background='rgba(0,0,0,0.4)'
745        m.style.zIndex='99999'
746        m.innerHTML='<div id="nrj-offline-details-box" style="background:#fff;position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);min-width:340px;max-width:92vw;max-height:90vh;overflow:auto;border-radius:10px;box-shadow:0 10px 30px rgba(0,0,0,.3)"></div>'
747        document.body.appendChild(m)
748        m.addEventListener('click',e=>{if(e.target===m)m.remove()})
749    }
750    nrj_render_details_modal(formId,slot)
751}
752
753async function nrj_render_details_modal(formId,slot){
754    let box=document.getElementById('nrj-offline-details-box')
755    if(!box)return
756    let idx=nrj_get_index(formId)
757    let info=idx[slot]||null
758    let savedAt=info?new Date(info.savedAt).toLocaleString():'—'
759
760    let fieldsStr=localStorage.getItem(nrj_ls_key(formId,slot,FIELDS_KEY))||'{}'
761    let fields={}
762    try{fields=JSON.parse(fieldsStr)||{}}catch(e){fields={}}
763
764    let files=await nrj_load_files(formId,slot)
765
766    let fieldRows=''
767    let fieldKeys=Object.keys(fields||{})
768    if(fieldKeys.length){
769        fieldKeys.forEach(k=>{
770            let v=fields[k]
771            let txt=Array.isArray(v)
772                ? (v.length?v.join(', '):'No Data')
773                : ((v==null||String(v).trim()==='')?'No Data':String(v))
774            let label=nrj_field_label_by_key(k)||k
775            let left=nrj_htmlesc(label)+(label!==k?'<div style="font-size:10px;opacity:.7;margin-top:2px">'+nrj_htmlesc(k)+'</div>':'')
776            fieldRows+='<tr><td colspan="2" style="padding:8px 10px;background:rgba(0,16,46,1);color:#fff;font-weight:700">'+left+'</td></tr>'
777                     + '<tr><td colspan="2" style="padding:10px;border-bottom:1px solid #eaeaea;word-break:break-word">'+nrj_htmlesc(txt)+'</td></tr>'
778        })
779    }else{
780        fieldRows+='<tr><td colspan="2" style="padding:8px 10px;background:rgba(0,16,46,1);color:#fff;font-weight:700">Fields</td></tr>'
781                  +'<tr><td colspan="2" style="padding:10px;border-bottom:1px solid #eaeaea;word-break:break-word">No Data</td></tr>'
782    }
783
784    let fileRows=''
785    let fileKeys=Object.keys(files||{})
786    if(fileKeys.length){
787        fileKeys.forEach(k=>{
788            let arr=(files[k]||[])
789            let names=arr.length?arr.map(it=>nrj_htmlesc(it.name)).join(', '):'No Data'
790            let label=nrj_field_label_by_key(k)||k
791            let left=nrj_htmlesc(label)+(label!==k?'<div style="font-size:11px;opacity:.9;margin-top:2px">'+nrj_htmlesc(k)+'</div>':'')
792            fileRows+='<tr><td colspan="2" style="padding:8px 10px;background:rgba(0,16,46,1);color:#fff;font-weight:700">'+left+'</td></tr>'
793                     + '<tr><td colspan="2" style="padding:10px;border-bottom:1px solid #eaeaea;word-break:break-word">'+names+'</td></tr>'
794        })
795    }else{
796        fileRows+='<tr><td colspan="2" style="padding:8px 10px;background:rgba(0,16,46,1);color:#fff;font-weight:700">Files</td></tr>'
797                 +'<tr><td colspan="2" style="padding:10px;border-bottom:1px solid #eaeaea;word-break:break-word">No Data</td></tr>'
798    }
799
800    box.innerHTML='<div style="padding:14px 16px;font-weight:700;border-bottom:1px solid #eee">Entry Details · Form '+nrj_htmlesc(formId)+' · Slot '+slot+'</div>'
801        +'<div style="padding:14px 16px;display:grid;gap:12px">'
802        +'<div>Saved: '+nrj_htmlesc(savedAt)+' · Fields: '+fieldKeys.length+' · Files: '+fileKeys.length+'</div>'
803        +'<div>'
804        +'<div style="font-weight:700;margin:8px 0 6px">Fields</div>'
805        +'<div style="max-height:40vh;overflow:auto;border:1px solid #eee;border-radius:6px">'
806        +'<table style="width:100%;border-collapse:collapse;font-size:13px">'+fieldRows+'</table>'
807        +'</div>'
808        +'</div>'
809        +'<div>'
810        +'<div style="font-weight:700;margin:8px 0 6px">Files</div>'
811        +'<div style="max-height:30vh;overflow:auto;border:1px solid #eee;border-radius:6px">'
812        +'<table style="width:100%;border-collapse:collapse;font-size:13px">'+fileRows+'</table>'
813        +'</div>'
814        +'</div>'
815        +'</div>'
816        +'<div style="padding:12px 16px;border-top:1px solid #eee;text-align:right">'
817        +'<button type="button" id="nrj-details-back" style="margin-right:8px" class="viewbtn">Back</button>'
818        +'<button type="button" id="nrj-details-close" class="viewbtn">Close</button>'
819        +'</div>'
820
821    document.getElementById('nrj-details-close')?.addEventListener('click',()=>{document.getElementById('nrj-offline-details-modal')?.remove()})
822    document.getElementById('nrj-details-back')?.addEventListener('click',()=>{document.getElementById('nrj-offline-details-modal')?.remove();nrj_open_manage_modal(formId)})
823}
824
825function nrj_field_label_by_key(key){
826    try{
827        let parts=[...String(key).matchAll(/\[(\d+)\]/g)].map(m=>m[1])
828        for(let i=parts.length-1;i>=0;i--){
829            let id=parts[i]
830            let el=document.getElementById('frm_field_'+id+'_container')||document.querySelector('[id^="frm_field_'+id+'"][id$="_container"]')
831            if(el){
832                let lbl=el.querySelector('.frm_primary_label')
833                if(lbl){
834                    let t=lbl.textContent||''
835                    return t.trim()
836                }
837            }
838        }
839    }catch(e){}
840    return null
841}
842
843
844function nrj_htmlesc(s){
845    return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;')
846}
847
848
849function nrj_toast(msg,ok,btn){
850    let el=document.createElement('div')
851    el.textContent=msg
852    el.style.position='absolute'
853    el.style.display='inline-block'
854    el.style.padding='6px 12px'
855    el.style.fontWeight='700'
856    el.style.borderRadius='6px'
857    el.style.border='1px solid #000'
858    el.style.background=ok?'#c6f6d5':'#fed7d7'
859    el.style.color='#000'
860    el.style.zIndex='99999'
861    el.style.whiteSpace='nowrap'
862    document.body.appendChild(el)
863    let anchor=document.getElementById('nrj-save-offline-buttons')||null
864    if(anchor){
865        let r=anchor.getBoundingClientRect()
866        let targetX=r.left+window.scrollX+r.width*0.65
867        let w=el.offsetWidth
868        let minL=r.left+window.scrollX
869        let maxL=r.left+window.scrollX+r.width-w
870        let left=Math.max(minL,Math.min(targetX-w/2,maxL))
871        el.style.left=left+'px'
872        el.style.top=(r.top+window.scrollY-8)+'px'
873    }else{
874        let vw=document.documentElement.clientWidth
875        let vh=document.documentElement.clientHeight
876        el.style.left=(vw*0.65)+'px'
877        el.style.top=(vh*0.35)+'px'
878        el.style.transform='translate(-50%,-50%)'
879    }
880    setTimeout(()=>{el.style.transition='opacity .2s';el.style.opacity='0';setTimeout(()=>el.remove(),500)},2500)
881}
882
883function nrj_prompt_restore_file_mode(){
884    let msg='Restore files as compressed images?\n\nRecommended: Compressed files are best when using cellular data and help reduce upload timeouts.\n\nOriginal files are not recommended over cellular networks because they may be large and can cause slow uploads or timeouts.\n\nYour original files remain safely stored in the Draft Slot either way.\n\nPress OK for compressed files.\nPress Cancel for original files.'
885    return window.confirm(msg)?'compressed':'original'
886}
887
888async function nrj_compress_image_file(file,maxWidth=1600,quality=0.72){
889    if(!file||!file.type||!file.type.startsWith('image/'))return file
890    return new Promise(resolve=>{
891        let img=new Image()
892        let reader=new FileReader()
893        reader.onload=function(e){
894            img.src=e.target.result
895        }
896        reader.onerror=function(){
897            resolve(file)
898        }
899        img.onload=function(){
900            let scale=Math.min(1,maxWidth/img.width)
901            let canvas=document.createElement('canvas')
902            canvas.width=Math.round(img.width*scale)
903            canvas.height=Math.round(img.height*scale)
904            let ctx=canvas.getContext('2d')
905            ctx.drawImage(img,0,0,canvas.width,canvas.height)
906            canvas.toBlob(function(blob){
907                if(!blob){
908                    resolve(file)
909                    return
910                }
911                let name=String(file.name||'image').replace(/\.(png|jpg|jpeg|webp)$/i,'')+'.jpg'
912                resolve(new File([blob],name,{type:'image/jpeg',lastModified:Date.now()}))
913            },'image/jpeg',quality)
914        }
915        img.onerror=function(){
916            resolve(file)
917        }
918        reader.readAsDataURL(file)
919    })
920}
921
922function nrj_file_from_buf(it){
923    let f=null
924    try{f=new File([new Uint8Array(it.buf)],it.name,{type:it.type||'',lastModified:it.lastModified||Date.now()})}
925    catch(e){try{f=new Blob([new Uint8Array(it.buf)],{type:it.type||''});f.name=it.name}catch(e2){}}
926    return f
927}
928
929async function nrj_buf_from_file(f){
930    try{if(typeof f.arrayBuffer==='function')return await f.arrayBuffer()}catch(e){}
931    if(f.dataURL){
932        try{let p=f.dataURL.indexOf(','),b=atob(f.dataURL.slice(p+1)),len=b.length,arr=new Uint8Array(len);for(let i=0;i<len;i++)arr[i]=b.charCodeAt(i);return arr.buffer}catch(e2){}
933    }
934    try{return await new Response(f).arrayBuffer()}catch(e3){return null}
935}
936
937function nrj_b64_from_buf(buf){
938    let bin='',bytes=new Uint8Array(buf),len=bytes.byteLength
939    for(let i=0;i<len;i++)bin+=String.fromCharCode(bytes[i])
940    return btoa(bin)
941}
942
943function nrj_buf_from_b64(b64){
944    let bin=atob(b64),len=bin.length,bytes=new Uint8Array(len)
945    for(let i=0;i<len;i++)bytes[i]=bin.charCodeAt(i)
946    return bytes.buffer
947}
948
949function nrj_dz_instances_in_form(form){
950    let out=[]
951    if(window.Dropzone&&Array.isArray(window.Dropzone.instances)){
952        window.Dropzone.instances.forEach(function(d){if(d&&d.element&&form.contains(d.element))out.push(d)})
953    }
954    if(out.length===0){
955        let els=form.querySelectorAll('.frm_dropzone')
956        els.forEach(function(z){try{let d=z.dropzone||(window.Dropzone&&window.Dropzone.forElement?window.Dropzone.forElement(z):null);if(d)out.push(d)}catch(e){}})
957    }
958    return out
959}
960
961function nrj_wait(ms){return new Promise(r=>setTimeout(r,ms))}
962
963async function nrj_clear_saved(formId,slot){
964    if(!formId){
965        localStorage.removeItem(FIELDS_KEY)
966        localStorage.removeItem(FILES_KEY)
967        try{await nrj_idb_del('files',FILES_KEY)}catch(e){}
968        return
969    }
970    let idx=nrj_get_index(formId)
971    let slots=slot?[slot]:[NRJ_DRAFT_SLOT,...NRJ_USER_SLOTS]
972    for(let s of slots){
973        let fk=nrj_ls_key(formId,s,FIELDS_KEY)
974        let sk=nrj_ls_key(formId,s,FILES_KEY)
975        localStorage.removeItem(fk)
976        if(USE_IDB_FILES){
977            try{await nrj_idb_del('files',sk)}catch(e){}
978        }else{
979            localStorage.removeItem(sk)
980        }
981        idx[s]=null
982    }
983    nrj_set_index(formId,idx)
984}
985
986
987function nrj_apply_files(input,list){
988    let applied=false,dt=null
989    try{dt=new DataTransfer()}catch(e){}
990    if(dt){
991        list.forEach(it=>{let f=nrj_file_from_buf(it);if(f){try{dt.items.add(f)}catch(e){}}})
992        try{input.files=dt.files;input.dispatchEvent(new Event('input',{bubbles:true}));input.dispatchEvent(new Event('change',{bubbles:true}));applied=true}catch(e){}
993    }
994    let dzEl=input.closest('.frm_dropzone')||input.parentElement?.querySelector?.('.frm_dropzone')||input.closest('.frm_form_field')?.querySelector?.('.frm_dropzone')||null
995    if(window.Dropzone&&dzEl&&dzEl.dropzone){
996        try{dzEl.dropzone.removeAllFiles(true)}catch(e){}
997        list.forEach(it=>{let f=nrj_file_from_buf(it);if(f){try{dzEl.dropzone.addFile(f);applied=true}catch(e){}}})
998    }
999    return applied
1000}
1001
1002function nrj_encode_files_for_ls(filesObj){
1003    let out={}
1004    Object.keys(filesObj||{}
1004).forEach(name=>{
1005        out[name]=(filesObj[name]||[]).map(it=>({name:it.name,type:it.type||'',lastModified:it.lastModified||Date.now(),b64:nrj_b64_from_buf(it.buf)}))
1006    })
1007    return JSON.stringify(out)
1008}
1009
1010function nrj_decode_files_from_ls(str){
1011    if(!str)return null
1012    let raw=JSON.parse(str),out={}
1013    Object.keys(raw||{}).forEach(name=>{
1014        out[name]=(raw[name]||[]).map(it=>({name:it.name,type:it.type||'',lastModified:it.lastModified||Date.now(),buf:nrj_buf_from_b64(it.b64)}))
1015    })
1016    return out
1017}
1018
1019function nrj_db(){
1020    return new Promise((res,rej)=>{
1021        let r=indexedDB.open('nrjFormCache',2)
1022        r.onupgradeneeded=e=>{
1023            let db=e.target.result
1024            if(!db.objectStoreNames.contains('forms'))db.createObjectStore('forms')
1025            if(!db.objectStoreNames.contains('files'))db.createObjectStore('files')
1026        }
1027        r.onsuccess=e=>res(e.target.result)
1028        r.onerror=e=>rej(e)
1029    })
1030}
1031
1032function nrj_idb_put(store,k,v){
1033    return nrj_db().then(db=>new Promise((res,rej)=>{
1034        let tx=db.transaction([store],'readwrite');let st=tx.objectStore(store);let q=st.put(v,k)
1035        q.onsuccess=()=>res(true);q.onerror=e=>rej(e);tx.oncomplete=()=>db.close()
1036    }))
1037}
1038
1039function nrj_idb_get(store,k){
1040    return nrj_db().then(db=>new Promise((res,rej)=>{
1041        let tx=db.transaction([store],'readonly');let st=tx.objectStore(store);let q=st.get(k)
1042        q.onsuccess=()=>res(q.result||null);q.onerror=e=>rej(e);tx.oncomplete=()=>db.close()
1043    }))
1044}
1045
1046function nrj_idb_del(store,k){
1047    return nrj_db().then(db=>new Promise((res,rej)=>{
1048        let tx=db.transaction([store],'readwrite');let st=tx.objectStore(store);let q=st.delete(k)
1049        q.onsuccess=()=>res(true);q.onerror=e=>rej(e);tx.oncomplete=()=>db.close()
1050    }))
1051}
1052
1053window.nrj_file_cache=window.nrj_file_cache||{}
1054function nrj_cache_put(name,arr){if(!name)return;window.nrj_file_cache[name]=(arr||[]).slice();}
1055function nrj_cache_add(name,item){if(!name||!item)return;let a=window.nrj_file_cache[name]||[];a.push(item);window.nrj_file_cache[name]=a;}
1056
1057function nrj_upload_error_log(eventType,data){
1058    try{
1059        let body=new URLSearchParams()
1060        body.append('action','nrj_upload_error_log')
1061        body.append('event',eventType||'unknown')
1062        body.append('form_id',data&&data.form_id?data.form_id:'')
1063        body.append('field_key',data&&data.field_key?data.field_key:'')
1064        body.append('file_name',data&&data.file_name?data.file_name:'')
1065        body.append('file_size',data&&data.file_size?String(data.file_size):'')
1066        body.append('message',data&&data.message?data.message:'')
1067        body.append('url',window.location.href)
1068        fetch((window.ajaxurl||'/wp-admin/admin-ajax.php'),{
1069            method:'POST',
1070            credentials:'same-origin',
1071            headers:{'Content-Type':'application/x-www-form-urlencoded; charset=UTF-8'},
1072            body:body.toString()
1073        }).catch(()=>{})
1074    }catch(e){}
1075}
1076
1077function nrj_bind_file_sources(form){
1078    let inputs=[...form.querySelectorAll('input[type="file"]')]
1079
1080    inputs.forEach(inp=>{
1081        if(inp.dataset.nrjBound==='1')return
1082
1083        inp.dataset.nrjBound='1'
1084
1085        inp.addEventListener('change',async()=>{
1086            if(NRJ_ENABLE_DRAFTS){
1087                await nrj_save_draft(form)
1088            }
1089        })
1090    })
1091
1092    let dzs=nrj_dz_instances_in_form(form)
1093
1094    dzs.forEach(dz=>{
1095        if(!dz)return
1096        if(dz.__nrjBound)return
1097
1098        dz.__nrjBound=true
1099
1100        let formId=nrj_get_form_id(form)
1101        let info=nrj_resolve_file_key(dz.element)
1102        let key=info&&info.key?info.key:null
1103        let originalTransform=dz.options.transformFile
1104
1105        dz.options.transformFile=function(file,done){
1106            if(!file||!file.type||!file.type.startsWith('image/')){
1107                if(typeof originalTransform==='function'){
1108                    originalTransform.call(dz,file,done)
1109                }else{
1110                    done(file)
1111                }
1112
1113                return
1114            }
1115
1116            nrj_compress_image_file(file).then(compressed=>{
1117                nrj_logc(
1118                    'compressed',
1119                    file.name,
1120                    file.size,
1121                    '=>',
1122                    compressed.size
1123                )
1124
1125                done(compressed)
1126            }).catch(e=>{
1127                let errorMessage=e&&e.message
1128                    ? e.message
1129                    : String(e||'Compression failed')
1130
1131                nrj_upload_error_log('compression_failed',{
1132                    form_id:formId,
1133                    field_key:key,
1134                    file_name:file&&file.name?file.name:'',
1135                    file_size:file&&file.size?file.size:'',
1136                    message:errorMessage
1137                })
1138
1139                done(file)
1140            })
1141        }
1142
1143        dz.on('addedfile',async file=>{
1144            if(!key||!file)return
1145
1146            let buf=await nrj_buf_from_file(file)
1147
1148            if(buf){
1149                nrj_cache_add(key,{
1150                    name:file.name,
1151                    type:file.type||'',
1152                    lastModified:file.lastModified||Date.now(),
1153                    buf
1154                })
1155
1156                if(NRJ_ENABLE_DRAFTS){
1157                    await nrj_save_draft(form)
1158                }
1159            }
1160        })
1161
1162        dz.on('removedfile',async file=>{
1163            if(!key||!file)return
1164
1165            let arr=(window.nrj_file_cache[key]||[]).filter(it=>!(
1166                it.name===file.name&&
1167                (it.lastModified||0)===(file.lastModified||0)
1168            ))
1169
1170            window.nrj_file_cache[key]=arr
1171
1172            if(NRJ_ENABLE_DRAFTS){
1173                await nrj_save_draft(form)
1174            }
1175        })
1176
1177        dz.on('error',function(file,message,xhr){
1178            let errorMessage=''
1179
1180            if(typeof message==='string'){
1181                errorMessage=message
1182            }else{
1183                try{
1184                    errorMessage=JSON.stringify(message||{})
1185                }catch(e){
1186                    errorMessage=String(message||'Upload failed')
1187                }
1188            }
1189
1190            if(xhr&&xhr.status){
1191                errorMessage+=' | HTTP '+xhr.status
1192            }
1193
1194            nrj_upload_error_log('dropzone_error',{
1195                form_id:formId,
1196                field_key:key,
1197                file_name:file&&file.name?file.name:'',
1198                file_size:file&&file.size?file.size:'',
1199                message:errorMessage
1200            })
1201        })
1202
1203        dz.on('canceled',function(file){
1204            nrj_upload_error_log('dropzone_canceled',{
1205                form_id:formId,
1206                field_key:key,
1207                file_name:file&&file.name?file.name:'',
1208                file_size:file&&file.size?file.size:'',
1209                message:'Upload canceled'
1210            })
1211        })
1212
1213        dz.on('timeout',function(file){
1214            nrj_upload_error_log('dropzone_timeout',{
1215                form_id:formId,
1216                field_key:key,
1217                file_name:file&&file.name?file.name:'',
1218                file_size:file&&file.size?file.size:'',
1219                message:'Upload timeout'
1220            })
1221        })
1222    })
1223}
1224
1225function nrj_apply_subset(form,fields,pred){
1226    Object.keys(fields||{}).forEach(name=>{
1227        let nodes=form.querySelectorAll('[name="'+CSS.escape(name)+'"]')
1228        if(!nodes.length)return
1229        if(!pred(nodes[0]))return
1230        let v=fields[name]
1231        if(nodes[0].type==='radio')nodes.forEach(r=>nrj_set(r,v))
1232        else if(nodes[0].type==='checkbox')nodes.forEach(c=>nrj_set(c,v))
1233        else nrj_set(nodes[0],v)
1234    })
1235}
1236
1237async function nrj_collect(form){
1238    let fields={}
1239    form.querySelectorAll('input,select,textarea').forEach(el=>{
1240        if(!el.name||el.disabled)return
1241        if(el.type==='file')return
1242        if(el.type==='checkbox'){fields[el.name]=fields[el.name]||[];if(el.checked)fields[el.name].push(el.value);return}
1243        if(el.type==='radio'){if(el.checked)fields[el.name]=el.value;return}
1244        if(el.tagName==='SELECT'&&el.multiple){fields[el.name]=[].slice.call(el.options).filter(o=>o.selected).map(o=>o.value);return}
1245        fields[el.name]=el.value
1246    })
1247    let files={}
1248    let inputs=[...form.querySelectorAll('input[type="file"]')]
1249    let dzs=nrj_dz_instances_in_form(form)
1250    for(let input of inputs){
1251        let key=input.name
1252        let cached=window.nrj_file_cache[key]
1253        if(cached&&cached.length){files[key]=cached.slice();continue}
1254        if(input.files&&input.files.length){
1255            let arr=[]
1256            for(let f of input.files){
1257                let buf=await nrj_buf_from_file(f)
1258                if(buf){arr.push({name:f.name,type:f.type||'',lastModified:f.lastModified||Date.now(),buf})}
1259            }
1260            if(arr.length)files[key]=arr
1261        }
1262    }
1263    dzs.forEach(dz=>{
1264        let input=dz.element.closest('.frm_form_field')?.querySelector('input[type="file"]')||dz.element.querySelector('input[type="file"]')||null
1265        let key=input?input.name:null
1266        if(!key)return
1267        if(files[key])return
1268        let cached=window.nrj_file_cache[key]
1269        if(cached&&cached.length)files[key]=cached.slice()
1270    })
1271    let n=Object.values(files).reduce((t,a)=>t+(a?a.length:0),0)
1272    return {fields,files,__counts:{total:n}}
1273}
1274
1275async function nrj_collect_fields(form){
1276    let fields={}
1277    form.querySelectorAll('input,select,textarea').forEach(el=>{
1278        if(!el.name||el.disabled)return
1279        if(el.type==='file')return
1280        if(el.type==='checkbox'){fields[el.name]=fields[el.name]||[];if(el.checked)fields[el.name].push(el.value);return}
1281        if(el.type==='radio'){if(el.checked)fields[el.name]=el.value;return}
1282        if(el.tagName==='SELECT'&&el.multiple){fields[el.name]=[].slice.call(el.options).filter(o=>o.selected).map(o=>o.value);return}
1283        fields[el.name]=el.value
1284    })
1285    return fields
1286}
1287
1288
1289async function nrj_collect_files(form){
1290    let files={}
1291    let hiddenKeys=[...form.querySelectorAll('input[type="hidden"][data-frmfile][name]')].map(h=>h.name)
1292    let uniqKeys=[...new Set(hiddenKeys)]
1293    for(let key of uniqKeys){
1294        let cached=window.nrj_file_cache[key]
1295        if(cached&&cached.length){
1296            files[key]=cached.slice()
1297            continue
1298        }
1299        let dz=nrj_find_dropzone_for_key(form,key)
1300        if(dz){
1301            let list=typeof dz.getAcceptedFiles==='function'?dz.getAcceptedFiles():Array.isArray(dz.files)?dz.files:[]
1302            let arr=[]
1303            for(let f of list){
1304                let buf=await nrj_buf_from_file(f)
1305                if(!buf&&f&&f.dataURL){
1306                    try{let p=f.dataURL.indexOf(',');buf=nrj_buf_from_b64(f.dataURL.slice(p+1))}catch(e){}
1307                }
1308                if(buf)arr.push({name:f.name,type:f.type||'',lastModified:f.lastModified||Date.now(),buf})
1309            }
1310            if(arr.length){
1311                files[key]=arr
1312                continue
1313            }
1314        }
1315        let hidden=form.querySelector('input[type="hidden"][data-frmfile][name="'+CSS.escape(key)+'"]')
1316        let field=hidden?hidden.closest('.frm_form_field'):null
1317        let native=field?field.querySelector('input[type="file"]'):null
1318        if(native&&native.files&&native.files.length){
1319            let arr=[]
1320            for(let f of native.files){
1321                let buf=await nrj_buf_from_file(f)
1322                if(buf)arr.push({name:f.name,type:f.type||'',lastModified:f.lastModified||Date.now(),buf})
1323            }
1324            if(arr.length){
1325                files[key]=arr
1326            }
1327        }
1328    }
1329    let total=Object.values(files).reduce((t,a)=>t+(a?a.length:0),0)
1330    return files
1331}
1332
1333
1334
1335async function nrj_save_files(form,formId,slot){
1336    let files=await nrj_collect_files(form)
1337    try{
1338        let payload=nrj_encode_files_for_ls(files)
1339        let key=nrj_ls_key(formId,slot,FILES_KEY)
1340        if(USE_IDB_FILES){
1341            await nrj_idb_put('files',key,payload)
1342        }else{
1343            localStorage.setItem(key,payload)
1344        }
1345    }catch(e){nrj_logc('save:files_error',e&&e.message?e.message:e)}
1346    let total=Object.values(files).reduce((t,a)=>t+(a?a.length:0),0)
1347    return total
1348}
1349
1350async function nrj_save_draft(form){
1351    if(!NRJ_ENABLE_DRAFTS)return false
1352    let formId=nrj_get_form_id(form)
1353    if(!formId)return false
1354    let fields=await nrj_collect_fields(form)
1355    try{
1356        localStorage.setItem(nrj_ls_key(formId,NRJ_DRAFT_SLOT,FIELDS_KEY),JSON.stringify(fields))
1357    }catch(e){
1358        nrj_logc('draft:fields_error',e&&e.message?e.message:e)
1359    }
1360    let total=await nrj_save_files(form,formId,NRJ_DRAFT_SLOT)
1361    let idx=nrj_get_index(formId)
1362    idx[NRJ_DRAFT_SLOT]={savedAt:Date.now(),fields:Object.keys(fields).length,files:total}
1363    nrj_set_index(formId,idx)
1364    return true
1365}
1366
1367async function nrj_save_draft_to_slot(formId,btn){
1368    if(!NRJ_ENABLE_DRAFTS)return false
1369    let fieldsStr=localStorage.getItem(nrj_ls_key(formId,NRJ_DRAFT_SLOT,FIELDS_KEY))
1370    let filesObj=await nrj_load_files(formId,NRJ_DRAFT_SLOT)
1371    if(!fieldsStr && (!filesObj||!Object.keys(filesObj).length)){
1372        nrj_toast('Draft Slot is empty',false,btn)
1373        return false
1374    }
1375    let idx=nrj_get_index(formId)
1376    let slot=nrj_slot_pick_save(idx)
1377    if(!slot){
1378        nrj_toast('Save canceled',false,btn)
1379        return false
1380    }
1381    if(fieldsStr){
1382        localStorage.setItem(nrj_ls_key(formId,slot,FIELDS_KEY),fieldsStr)
1383    }
1384    let total=0
1385    if(filesObj&&Object.keys(filesObj).length){
1386        let payload=nrj_encode_files_for_ls(filesObj)
1387        let key=nrj_ls_key(formId,slot,FILES_KEY)
1388        if(USE_IDB_FILES){
1389            await nrj_idb_put('files',key,payload)
1390        }else{
1391            localStorage.setItem(key,payload)
1392        }
1393        total=Object.values(filesObj).reduce((t,a)=>t+(a?a.length:0),0)
1394    }
1395    let fields={}
1396    try{fields=fieldsStr?JSON.parse(fieldsStr):{}}catch(e){}
1397    idx[slot]={savedAt:Date.now(),fields:Object.keys(fields).length,files:total}
1398    nrj_set_index(formId,idx)
1399    nrj_toast('Draft saved to slot '+slot,true,btn)
1400    return true
1401}
1402
1403async function nrj_check_existing_draft_on_load(form){
1404    if(!NRJ_ENABLE_DRAFTS)return true
1405    if(!form||form.dataset.nrjDraftChecked==='1')return false
1406    form.dataset.nrjDraftChecked='1'
1407    let formId=nrj_get_form_id(form)
1408    if(!formId)return false
1409    let idx=nrj_get_index(formId)
1410    let draft=idx[NRJ_DRAFT_SLOT]
1411    if(!draft)return true
1412    let choice=window.prompt(
1413        'An unfinished draft was found on this device.\n\nType 1 to restore the draft.\nType 2 to save the draft to an offline slot and start a new form.\nType 3 to discard the draft and start a new form.\n\nThe draft will not be overwritten until you choose.',
1414        '1'
1415    )
1416    if(choice==='1'){
1417        await nrj_restore_from_slot(formId,NRJ_DRAFT_SLOT,form,null)
1418        return true
1419    }
1420    if(choice==='2'){
1421        await nrj_save_draft_to_slot(formId,null)
1422        await nrj_clear_saved(formId,NRJ_DRAFT_SLOT)
1423        nrj_toast('Draft saved to slot. Starting new form.',true,null)
1424        return true
1425    }
1426    if(choice==='3'){
1427        let ok=window.confirm('Discard this Draft Slot permanently?')
1428        if(ok){
1429            await nrj_clear_saved(formId,NRJ_DRAFT_SLOT)
1430            nrj_toast('Draft discarded. Starting new form.',true,null)
1431            return true
1432        }
1433    }
1434    nrj_toast('Draft kept safe. Autosave is paused until refresh.',false,null)
1435    return false
1436}
1437
1438function nrj_bind_draft_autosave(form){
1439    if(!NRJ_ENABLE_DRAFTS)return
1440    if(!form||form.dataset.nrjDraftAutosaveBound==='1')return
1441    form.dataset.nrjDraftAutosaveBound='1'
1442    let timer=null
1443    let queue=function(){
1444        clearTimeout(timer)
1445        timer=setTimeout(async function(){
1446            await nrj_save_draft(form)
1447        },1200)
1448    }
1449    form.addEventListener('input',function(e){
1450        if(!e.target||!e.target.name)return
1451        if(e.target.type==='file')return
1452        queue()
1453    })
1454    form.addEventListener('change',function(e){
1455        if(!e.target||!e.target.name)return
1456        if(e.target.type==='file')return
1457        queue()
1458    })
1459}
1460
1461
1462async function nrj_load_files(formId,slot){
1463    let key=nrj_ls_key(formId,slot,FILES_KEY)
1464    if(USE_IDB_FILES){
1465        let raw=await nrj_idb_get('files',key)
1466        if(!raw)return {}
1467        if(typeof raw==='string') return nrj_decode_files_from_ls(raw)||{}
1468        try{ return nrj_decode_files_from_ls(JSON.stringify(raw))||{} }catch(e){ return {} }
1469    }else{
1470        let str=localStorage.getItem(key)
1471        return nrj_decode_files_from_ls(str)||{}
1472    }
1473}
1474
1475
1476function nrj_resolve_file_key(node){
1477    let el=node&&node.element?node.element:node
1478    if(!(el instanceof HTMLElement))return null
1479    let field=el.closest('.frm_form_field')||el.closest('.frm_form_field_container')||el
1480    let hidden=field?field.querySelector('input[type="hidden"][data-frmfile][name]'):null
1481    if(hidden&&hidden.name)return {key:hidden.name,hidden,field}
1482    return null
1483}
1484
1485function nrj_find_dropzone_for_key(form,key){
1486    if(!form||!key)return null
1487    let hidden=form.querySelector('input[type="hidden"][data-frmfile][name="'+CSS.escape(key)+'"]')
1488    if(!hidden)return null
1489    let field=hidden.closest('.frm_form_field')
1490    if(!field)return null
1491    let dzEl=field.querySelector('.frm_dropzone')
1492    if(!dzEl)return null
1493    if(dzEl.dropzone)return dzEl.dropzone
1494    if(window.Dropzone&&typeof window.Dropzone.forElement==='function'){
1495        try{return window.Dropzone.forElement(dzEl)}catch(e){return null}
1496    }
1497    return null
1498}
1499
1500async function nrj_ensure_row_for_key(form,key){
1501    let m=key.match(/^item_meta\[(\d+)\]\[(\d+)\]\[(\d+)\]/)
1502    if(!m)return
1503    let parent=m[1],row=parseInt(m[2],10)
1504    let sec=form.querySelector('#frm_section_'+parent+'-'+row)
1505    if(sec)return
1506    let btnAdd=form.querySelector('.frm_add_form_row[data-parent="'+parent+'"]')
1507    if(!btnAdd)return
1508    while(!form.querySelector('#frm_section_'+parent+'-'+row)){btnAdd.click();await nrj_wait(250)}
1509}
1510
1511
1512async function nrj_save(form,btn){
1513    let formId=nrj_get_form_id(form)
1514    if(!formId){nrj_toast('Missing form_id',false,btn);return false}
1515    let idx=nrj_get_index(formId)
1516    let slot=nrj_slot_pick_save(idx)
1517    if(!slot){nrj_toast('Save canceled',false,btn);return false}
1518    let fields=await nrj_collect_fields(form)
1519    try{localStorage.setItem(nrj_ls_key(formId,slot,FIELDS_KEY),JSON.stringify(fields));}catch(e){nrj_logc('save:fields_error',e)}
1520    let total=await nrj_save_files(form,formId,slot)
1521    idx[slot]={savedAt:Date.now(),fields:Object.keys(fields).length,files:total}
1522    nrj_set_index(formId,idx)
1523    nrj_toast('Saved in slot '+slot+' · files '+total,true,btn)
1524    return true
1525}
1526
1527
1528function nrj_bind(){
1529    if(nrj_bind._t)return
1530    nrj_bind._t=setTimeout(async function(){
1531        nrj_bind._t=null
1532        await nrj_bind_now()
1533    },200)
1534}
1535
1536function nrj_set_pending_submit(formId){
1537    if(!NRJ_ENABLE_DRAFTS)return
1538    if(!formId)return
1539    sessionStorage.setItem('nrj_pending_submit_form_id',formId)
1540    sessionStorage.setItem('nrj_pending_submit_time',String(Date.now()))
1541}
1542
1543async function nrj_check_pending_submit_success(){
1544    if(!NRJ_ENABLE_DRAFTS)return
1545    let formId=sessionStorage.getItem('nrj_pending_submit_form_id')
1546    let submitTime=parseInt(sessionStorage.getItem('nrj_pending_submit_time')||'0',10)
1547    if(!formId||!submitTime)return
1548    let age=Date.now()-submitTime
1549    if(age>120000){
1550        sessionStorage.removeItem('nrj_pending_submit_form_id')
1551        sessionStorage.removeItem('nrj_pending_submit_time')
1552        return
1553    }
1554    let hasErrors=document.querySelector('.frm_error,.frm_error_style,.frm_message_error,.frm_invalid')
1555    let hasSuccess=document.querySelector('.frm_message,.frm_success_style')
1556    if(hasSuccess&&!hasErrors){
1557        await nrj_clear_saved(formId,NRJ_DRAFT_SLOT)
1558        sessionStorage.removeItem('nrj_pending_submit_form_id')
1559        sessionStorage.removeItem('nrj_pending_submit_time')
1560        nrj_toast('Submitted successfully. Draft Slot cleared.',true,null)
1561    }
1562}
1563
1564async function nrj_check_pending_submit_failure(){
1565    if(!NRJ_ENABLE_DRAFTS)return
1566    let formId=sessionStorage.getItem('nrj_pending_submit_form_id')
1567    let submitTime=parseInt(sessionStorage.getItem('nrj_pending_submit_time')||'0',10)
1568    if(!formId||!submitTime)return
1569    let age=Date.now()-submitTime
1570    if(age>120000){
1571        sessionStorage.removeItem('nrj_pending_submit_form_id')
1572        sessionStorage.removeItem('nrj_pending_submit_time')
1573        return
1574    }
1575    let hasErrors=document.querySelector('.frm_error,.frm_error_style,.frm_message_error,.frm_invalid')
1576    let hasSuccess=document.querySelector('.frm_message,.frm_success_style')
1577    if(hasErrors&&!hasSuccess){
1578        let save=window.confirm('Upload failed. Your draft is still safely stored on this device.\n\nWould you also like to save a permanent copy to one of your offline slots?')
1579        if(save){
1580            await nrj_save_draft_to_slot(formId,null)
1581        }
1582        sessionStorage.removeItem('nrj_pending_submit_form_id')
1583        sessionStorage.removeItem('nrj_pending_submit_time')
1584    }
1585}
1586
1587
1588async function nrj_bind_now(){
1589    let saveBtn=document.getElementById('nrj-save-report-offline')
1590    let manageBtn=document.getElementById('nrj-manage-offline')
1591    if(!saveBtn&&!manageBtn)return
1592    let form=(saveBtn&&saveBtn.closest('form'))||(manageBtn&&manageBtn.closest('form'))||nrj_find_form()
1593    if(!form)return
1594    nrj_bind_file_sources(form)
1595    let allowAutosave=await nrj_check_existing_draft_on_load(form)
1596    if(allowAutosave)nrj_bind_draft_autosave(form)
1597    if(saveBtn&&!saveBtn.dataset.nrjBound){saveBtn.dataset.nrjBound='1';saveBtn.addEventListener('click',async()=>{await nrj_save(form,saveBtn)})}
1598    if(manageBtn&&!manageBtn.dataset.nrjBound){manageBtn.dataset.nrjBound='1';manageBtn.addEventListener('click',()=>{let fid=nrj_get_form_id(form);if(fid)nrj_open_manage_modal(fid)})}
1599}
1600
1601document.addEventListener('DOMContentLoaded',function(){
1602    nrj_bind()
1603    nrj_check_pending_submit_success()
1604    nrj_check_pending_submit_failure()
1605})
1606new MutationObserver(nrj_bind).observe(document.documentElement,{childList:true,subtree:true})
1607window.nrj_offline_clear_saved=nrj_clear_saved

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.