/*
* User module — task processing workspace logic.
*
* Features:
* • Language selection (EN/FR/NL) — stored in localStorage
* • Category tabs for record navigation — generated from unique categories
* • Full text search within active tab
* • Drag & drop + click-to-upload images
* • Records display category, subcategory, severity, description per language
* • Save as Draft / Final with validation
* • Task data (including images) stored in IndexedDB (no 5 MB localStorage limit)
*
* Validation rules for "Save as Final":
* 1. All records must have a Status value set.
* 2. If Status is "NOK", "TBC", or "ADD work" then:
* - Handled By must have a value
* - Comment must have a value
* - If the record has imageRequired=true, at least one image must be attached
*/
import { openUserDB, loadTaskData, saveOneTaskData, getStorageEstimate } from './user-db.js';
import { optimizeImage } from './images.js';
import { parseExifFromDataUrl } from './exif.js';
import { dbPut, dbGet } from './db.js';
import { STORE_CONFIG } from './constants.js';
/* ── State ──────────────────────────────────────────────────────────────── */
const userState = {
tasks: [],
users: [],
sites: [],
clRecords: [],
clTemplates: [],
templateSettings: { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] },
taskSettings: { projects: [], processes: [] },
taskData: {},
imageRules: null,
currentTaskId: null,
language: 'EN',
activeCategory: null,
searchQuery: '',
unsavedChanges: false
};
/*
* Temporary storage for bulk images per subcategory.
* Key: subcategory name, Value: array of { name, dataUrl, size }
*/
const bulkImagesStore = new Map();
/* ── Persistence (IndexedDB for local cache, server for source of truth) ── */
/*
* Loads all admin entity data from the server bulk endpoint and caches in IndexedDB.
*/
async function loadFromServer() {
try {
const resp = await fetch('/api/v1/admin/all', { headers: { Accept: 'application/json' } });
if (!resp.ok) return;
const data = await resp.json();
await dbPut(STORE_CONFIG, { key: 'admin_all', value: data });
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
userState.taskSettings = data.taskSettings || { projects: [], processes: [] };
userState.users = data.users || [];
userState.sites = data.sites || [];
userState.clRecords = data.clRecords || [];
userState.clTemplates = data.clTemplates || [];
userState.tasks = data.tasks || [];
} catch (err) {
console.warn('Failed to load data from server, using IndexedDB cache', err);
}
}
/*
* Loads from IndexedDB cache (offline fallback).
*/
async function loadFromCache() {
try {
const row = await dbGet(STORE_CONFIG, 'admin_all');
const data = row?.value;
if (!data) return;
userState.templateSettings = data.templateSettings || { categories: [], subCategories: [], severities: [], statuses: [], handledBy: [] };
userState.taskSettings = data.taskSettings || { projects: [], processes: [] };
userState.users = data.users || [];
userState.sites = data.sites || [];
userState.clRecords = data.clRecords || [];
userState.clTemplates = data.clTemplates || [];
userState.tasks = data.tasks || [];
} catch { /* ignore */ }
}
/* ═══════════════════════════════════════════════════════════════════════════
* Initialization
* ═══════════════════════════════════════════════════════════════════════════ */
export async function initUser() {
userState.language = localStorage.getItem('user_language') || 'EN';
await openUserDB();
/* Load admin entity data from server before reading IndexedDB cache. */
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
/* Task data (images etc.) stored in user-specific IndexedDB */
userState.taskData = await loadTaskData();
fetchImageRules(); /* async, non-blocking */
filterTasksByUser();
bindEvents();
renderTaskListView();
renderSidebarTasks();
updateConnectionBadge();
updateSaveIndicator();
updateStorageIndicator();
initLanguageSelect();
ensureLightboxModal();
window.addEventListener('online', updateConnectionBadge);
window.addEventListener('offline', updateConnectionBadge);
}
async function loadAllData() {
if (navigator.onLine) {
await loadFromServer();
} else {
await loadFromCache();
}
userState.taskData = await loadTaskData();
}
function filterTasksByUser() {
const params = new URLSearchParams(window.location.search);
const userId = params.get('userId');
if (userId) {
const uid = Number(userId);
userState.tasks = userState.tasks.filter(t => t.userId === uid);
}
}
function bindEvents() {
document.getElementById('backToListBtn')?.addEventListener('click', showListView);
document.getElementById('saveDraftBtn')?.addEventListener('click', saveDraft);
document.getElementById('saveFinalBtn')?.addEventListener('click', saveFinal);
document.getElementById('showSettingsBtn')?.addEventListener('click', showSettingsView);
document.getElementById('closeSettingsBtn')?.addEventListener('click', closeSettingsView);
document.getElementById('recordSearchInput')?.addEventListener('input', onSearchInput);
/* Mobile sidebar toggle */
const sidebar = document.querySelector('.sidebar-bs');
const backdrop = document.getElementById('sidebarBackdrop');
const menuBtn = document.getElementById('mobileMenuBtn');
if (menuBtn && sidebar && backdrop) {
const openSidebar = () => { sidebar.classList.add('sidebar-open'); backdrop.classList.add('show'); };
const closeSidebar = () => { sidebar.classList.remove('sidebar-open'); backdrop.classList.remove('show'); };
menuBtn.addEventListener('click', openSidebar);
backdrop.addEventListener('click', closeSidebar);
/* Close sidebar when a nav link inside is clicked */
sidebar.querySelectorAll('a, button').forEach(el => el.addEventListener('click', () => { setTimeout(closeSidebar, 150); }));
}
}
function initLanguageSelect() {
const sel = document.getElementById('userLanguageSelect');
if (!sel) return;
sel.value = userState.language;
sel.addEventListener('change', () => {
userState.language = sel.value;
localStorage.setItem('user_language', sel.value);
if (userState.currentTaskId) renderTaskDetail();
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Views
* ═══════════════════════════════════════════════════════════════════════════ */
function hideAllViews() {
document.querySelectorAll('.workspace-view').forEach(v => v.classList.remove('workspace-view-active'));
}
function showSettingsView() {
hideAllViews();
document.getElementById('settingsView').classList.add('workspace-view-active');
}
function closeSettingsView() {
hideAllViews();
if (userState.currentTaskId) {
document.getElementById('taskDetailView').classList.add('workspace-view-active');
} else {
document.getElementById('taskListView').classList.add('workspace-view-active');
}
}
async function showListView() {
userState.currentTaskId = null;
hideAllViews();
document.getElementById('taskListView').classList.add('workspace-view-active');
await loadAllData();
filterTasksByUser();
renderTaskListView();
renderSidebarTasks();
}
function showDetailView(taskId) {
userState.currentTaskId = Number(taskId);
userState.activeCategory = null;
userState.searchQuery = '';
const searchInput = document.getElementById('recordSearchInput');
if (searchInput) searchInput.value = '';
hideAllViews();
document.getElementById('taskDetailView').classList.add('workspace-view-active');
/* If task was reopened and images were stripped, try to re-download from server */
maybeDownloadImages(taskId).then(() => renderTaskDetail());
highlightSidebarTask(taskId);
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task list rendering (main area)
* ═══════════════════════════════════════════════════════════════════════════ */
function renderTaskListView() {
const container = document.getElementById('taskListContainer');
if (!container) return;
if (!userState.tasks.length) {
container.innerHTML = '
No tasks assigned Tasks will appear here once an administrator assigns them to you.
';
return;
}
const rows = userState.tasks.map((task) => {
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const badgeCls = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
return `
${esc(site?.siteCode || '-')}
${esc(tpl?.name || '-')}
${esc(task.project || '-')}
${esc(task.process || '-')}
${esc(task.status || 'pending')}
Open
`;
}).join('');
container.innerHTML = `
Site Template Project Process Status
${rows}
`;
container.querySelectorAll('[data-open-task]').forEach((b) => {
b.addEventListener('click', () => showDetailView(b.dataset.openTask));
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Sidebar task list
* ═══════════════════════════════════════════════════════════════════════════ */
function renderSidebarTasks() {
const container = document.getElementById('taskListSidebar');
const countEl = document.getElementById('taskCount');
if (!container) return;
if (countEl) countEl.textContent = String(userState.tasks.length);
if (!userState.tasks.length) {
container.innerHTML = 'No tasks.
';
return;
}
container.innerHTML = userState.tasks.map((task) => {
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const badgeCls = task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary';
const active = task.id === userState.currentTaskId ? ' report-list-item--active' : '';
return `
${esc(tpl?.name || '-')} · ${esc(task.project || '-')}
`;
}).join('');
container.querySelectorAll('[data-sidebar-task]').forEach((b) => {
b.addEventListener('click', () => showDetailView(Number(b.dataset.sidebarTask)));
});
}
function highlightSidebarTask(taskId) {
document.querySelectorAll('[data-sidebar-task]').forEach((b) => {
b.classList.toggle('report-list-item--active', Number(b.dataset.sidebarTask) === taskId);
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task detail rendering
* ═══════════════════════════════════════════════════════════════════════════ */
function renderTaskDetail() {
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (!task) { showListView(); return; }
const site = userState.sites.find((s) => s.id === task.siteId);
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
/* Header */
document.getElementById('taskDetailEyebrow').textContent = `Task — ${tpl?.name || 'Unknown template'}`;
document.getElementById('taskDetailTitle').textContent = site?.siteCode || 'Unknown site';
document.getElementById('taskDetailSubtitle').textContent = `${task.project || '-'} / ${task.process || '-'}`;
/* Summary cards */
document.getElementById('taskInfoSite').textContent = site?.siteCode || '-';
document.getElementById('taskInfoProject').textContent = task.project || '-';
document.getElementById('taskInfoProcess').textContent = task.process || '-';
const statusEl = document.getElementById('taskInfoStatus');
statusEl.textContent = task.status || 'pending';
statusEl.className = `badge ${task.status === 'final' ? 'bg-success' : task.status === 'draft' ? 'bg-warning text-dark' : 'bg-secondary'}`;
/* Visit date with validation against template range */
const data = getTaskData(task.id);
const visitDateEl = document.getElementById('visitDate');
visitDateEl.value = data.visitDate || '';
if (tpl?.validFrom) visitDateEl.setAttribute('min', tpl.validFrom);
else visitDateEl.removeAttribute('min');
if (tpl?.validTill) visitDateEl.setAttribute('max', tpl.validTill);
else visitDateEl.removeAttribute('max');
/* Bind date change to check range */
visitDateEl.onchange = () => {
validateVisitDate(visitDateEl, tpl);
markUnsaved();
};
/* Records — build category tabs then render */
renderCategoryTabs(task, tpl);
renderTaskRecords(task, tpl, data);
/* Lock form if task is final */
applyTaskLock(task);
/* Hide validation panel */
document.getElementById('taskValidationPanel').style.display = 'none';
}
function getTaskData(taskId) {
if (!userState.taskData[taskId]) {
userState.taskData[taskId] = { visitDate: '', records: {} };
}
return userState.taskData[taskId];
}
/* ═══════════════════════════════════════════════════════════════════════════
* Category Tabs
* ═══════════════════════════════════════════════════════════════════════════ */
function renderCategoryTabs(task, tpl) {
const tabsContainer = document.getElementById('recordCategoryTabs');
if (!tabsContainer) return;
const recordIds = tpl?.recordIds || [];
const records = recordIds
.map((rid) => userState.clRecords.find((r) => r.id === rid))
.filter(Boolean);
/* Get unique categories */
const categories = [...new Set(records.map(r => r.category || 'Uncategorized'))].sort();
/* If no active category set, default to "All" */
if (!userState.activeCategory) userState.activeCategory = '__all__';
let html = `
All
`;
categories.forEach(cat => {
html += `
${esc(cat)}
`;
});
tabsContainer.innerHTML = html;
tabsContainer.querySelectorAll('[data-cat]').forEach(btn => {
btn.addEventListener('click', () => {
userState.activeCategory = btn.dataset.cat;
tabsContainer.querySelectorAll('.nav-link').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const taskObj = userState.tasks.find(t => t.id === userState.currentTaskId);
const tplObj = userState.clTemplates.find(t => t.id === taskObj?.templateId);
renderTaskRecords(taskObj, tplObj, getTaskData(userState.currentTaskId));
});
});
}
function onSearchInput(e) {
userState.searchQuery = e.target.value.toLowerCase().trim();
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (!task) return;
const tpl = userState.clTemplates.find(t => t.id === task.templateId);
renderTaskRecords(task, tpl, getTaskData(userState.currentTaskId));
}
/* ═══════════════════════════════════════════════════════════════════════════
* Records rendering per task (with category filter + search)
* ═══════════════════════════════════════════════════════════════════════════ */
function getRecordDescription(rec) {
const lang = userState.language;
if (lang === 'FR' && rec.descriptionFR) return rec.descriptionFR;
if (lang === 'NL' && rec.descriptionNL) return rec.descriptionNL;
return rec.descriptionEN || rec.descriptionFR || rec.descriptionNL || 'No description';
}
function renderTaskRecords(task, tpl, data) {
const container = document.getElementById('taskRecordsContainer');
const countEl = document.getElementById('taskRecordCount');
if (!container) return;
/* Get records assigned to the template */
const recordIds = tpl?.recordIds || [];
let records = recordIds
.map((rid) => userState.clRecords.find((r) => r.id === rid))
.filter(Boolean)
.sort((a, b) => a.sort - b.sort);
/* Filter by active category tab */
if (userState.activeCategory && userState.activeCategory !== '__all__') {
records = records.filter(r => (r.category || 'Uncategorized') === userState.activeCategory);
}
/* Full text search filter */
if (userState.searchQuery) {
const q = userState.searchQuery;
records = records.filter(r => {
const desc = getRecordDescription(r).toLowerCase();
const cat = (r.category || '').toLowerCase();
const sub = (r.subCategory || '').toLowerCase();
const sev = (r.severity || '').toLowerCase();
const sort = String(r.sort);
return desc.includes(q) || cat.includes(q) || sub.includes(q) || sev.includes(q) || sort.includes(q);
});
}
if (countEl) countEl.textContent = `${records.length} record(s)`;
if (!records.length) {
container.innerHTML = 'No records match the current filter.
';
return;
}
/* Status and Handled By options */
const statusOpts = userState.templateSettings.statuses.map((s) => s.value);
const handledOpts = userState.templateSettings.handledBy.map((h) => h.value);
/* Group records by sub-category */
const subCatGroups = new Map();
for (const rec of records) {
const key = rec.subCategory || 'Uncategorized';
if (!subCatGroups.has(key)) subCatGroups.set(key, []);
subCatGroups.get(key).push(rec);
}
let html = '';
for (const [subCat, groupRecs] of subCatGroups) {
const scId = `sc-${subCat.replace(/[^a-zA-Z0-9]/g, '_')}`;
/* Sub-category header with bulk actions */
html += `
Status (all)
— Select —
${statusOpts.map((s) => `${esc(s)} `).join('')}
Handled By (all)
— Select —
${handledOpts.map((h) => `${esc(h)} `).join('')}
Comment (all)
Apply
`;
/* Individual records */
for (const rec of groupRecs) {
const rd = data.records[rec.id] || { status: '', handledBy: '', comment: '', images: [] };
const imgCount = (rd.images || []).length;
const imgRequired = rec.imageRequired ? '
IMG REQUIRED ' : '';
const desc = getRecordDescription(rec);
const category = rec.category || '-';
const subCategory = rec.subCategory || '-';
const severity = rec.severity || '-';
html += `
Category: ${esc(category)}
Sub: ${esc(subCategory)}
Severity: ${esc(severity)}
Status
— Select —
${statusOpts.map((s) => `${esc(s)} `).join('')}
Handled By
— Select —
${handledOpts.map((h) => `${esc(h)} `).join('')}
Comment
`;
}
html += '
'; /* close sub-cat-group */
}
container.innerHTML = html;
/* Bind change events */
container.querySelectorAll('.rec-status').forEach((el) => el.addEventListener('change', onRecordFieldChange));
container.querySelectorAll('.rec-handled').forEach((el) => el.addEventListener('change', onRecordFieldChange));
container.querySelectorAll('.rec-comment').forEach((el) => el.addEventListener('input', onRecordFieldChange));
container.querySelectorAll('.rec-image-input').forEach((el) => el.addEventListener('change', onImageAdd));
container.querySelectorAll('[data-remove-img]').forEach((el) => el.addEventListener('click', onImageRemove));
container.querySelectorAll('[data-lightbox-img]').forEach((el) => el.addEventListener('click', openLightbox));
container.querySelectorAll('[data-load-server-img]').forEach((el) => el.addEventListener('click', onLoadServerImage));
/* Bind bulk image add buttons */
container.querySelectorAll('.bulk-add-images-btn').forEach((btn) => {
const sc = btn.dataset.sc;
const fileInput = btn.parentElement.querySelector('.bulk-image-input');
btn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) handleBulkImageAdd(sc, e.target.files);
e.target.value = '';
});
});
/* Bind bulk apply buttons */
container.querySelectorAll('.bulk-apply-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const sc = btn.dataset.sc;
const panel = btn.closest('.sub-cat-bulk-panel');
const bulkStatus = panel.querySelector('.bulk-status')?.value || '';
const bulkHandled = panel.querySelector('.bulk-handled')?.value || '';
const bulkComment = panel.querySelector('.bulk-comment')?.value || '';
const bulkImages = bulkImagesStore.get(sc) || [];
const recCards = container.querySelectorAll(`.task-record-card[data-sub-cat="${sc}"]`);
const taskData = getTaskData(userState.currentTaskId);
recCards.forEach((card) => {
const recId = Number(card.dataset.recordId);
if (!taskData.records[recId]) taskData.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
if (!taskData.records[recId].images) taskData.records[recId].images = [];
if (bulkStatus) { taskData.records[recId].status = bulkStatus; }
if (bulkHandled) { taskData.records[recId].handledBy = bulkHandled; }
if (bulkComment) { taskData.records[recId].comment = bulkComment; }
/* Append bulk images to each record */
if (bulkImages.length) {
for (const img of bulkImages) {
taskData.records[recId].images.push({ ...img });
}
}
});
/* Clear bulk images after applying */
bulkImagesStore.delete(sc);
markUnsaved();
safeSave();
renderTaskRecords(task, tpl, taskData);
});
});
/* Bind sub-cat header chevron toggle */
container.querySelectorAll('.sub-cat-header').forEach((hdr) => {
const target = hdr.nextElementSibling;
if (target) {
target.addEventListener('show.bs.collapse', () => hdr.querySelector('.sub-cat-chevron')?.classList.add('rotated'));
target.addEventListener('hide.bs.collapse', () => hdr.querySelector('.sub-cat-chevron')?.classList.remove('rotated'));
}
});
/* Bind drag & drop zones */
container.querySelectorAll('.drop-zone').forEach((zone) => {
const fileInput = zone.querySelector('.rec-image-input');
zone.addEventListener('click', () => fileInput.click());
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('border-primary', 'bg-light'); });
zone.addEventListener('dragleave', () => { zone.classList.remove('border-primary', 'bg-light'); });
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('border-primary', 'bg-light');
const files = e.dataTransfer.files;
if (files.length) handleFileDrop(zone.dataset.rec, files);
});
});
}
function renderRecordImages(images, recId) {
if (!images || !images.length) return 'No images attached. ';
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
const locked = isTaskLocked(task);
return images.map((img, idx) => {
if (img.dataUrl) {
return `
×
`;
}
/* Image on server — show clickable placeholder that loads on click */
return `
${esc(img.name || 'uploaded')}
×
`;
}).join('');
}
/* ═══════════════════════════════════════════════════════════════════════════
* Record field change handlers
* ═══════════════════════════════════════════════════════════════════════════ */
function onRecordFieldChange(e) {
const recId = e.target.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
if (e.target.classList.contains('rec-status')) data.records[recId].status = e.target.value;
else if (e.target.classList.contains('rec-handled')) data.records[recId].handledBy = e.target.value;
else if (e.target.classList.contains('rec-comment')) data.records[recId].comment = e.target.value;
data.visitDate = document.getElementById('visitDate').value;
safeSave();
markUnsaved();
}
async function handleFileDrop(recId, files) {
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
data.records[recId].images.push({ name: file.name, dataUrl, size: file.size });
}
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
}
/**
* Handles images added to the bulk operation panel for a subcategory.
* Images are stored temporarily and applied to all records when "Apply" is clicked.
*/
async function handleBulkImageAdd(sc, files) {
if (!bulkImagesStore.has(sc)) bulkImagesStore.set(sc, []);
const images = bulkImagesStore.get(sc);
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
images.push({ name: file.name, dataUrl, size: file.size });
}
renderBulkImagesPreview(sc);
}
/**
* Renders preview thumbnails for bulk images in a subcategory panel.
*/
function renderBulkImagesPreview(sc) {
const preview = document.querySelector(`.bulk-images-preview[data-sc="${sc}"]`);
if (!preview) return;
const images = bulkImagesStore.get(sc) || [];
if (!images.length) {
preview.innerHTML = '';
return;
}
preview.innerHTML = images.map((img, idx) => `
`).join('');
/* Bind remove buttons */
preview.querySelectorAll('[data-remove-bulk-img]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const idx = Number(btn.dataset.removeBulkImg);
const subCat = btn.dataset.sc;
const imgs = bulkImagesStore.get(subCat);
if (imgs) {
imgs.splice(idx, 1);
renderBulkImagesPreview(subCat);
}
});
});
}
async function onImageAdd(e) {
const recId = e.target.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const files = e.target.files;
if (!files.length) return;
const data = getTaskData(userState.currentTaskId);
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
for (const file of files) {
if (!file.type.startsWith('image/')) continue;
const dataUrl = await readFileAsDataUrl(file);
data.records[recId].images.push({ name: file.name, dataUrl, size: file.size });
}
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
e.target.value = '';
}
function onImageRemove(e) {
const btn = e.target.closest('[data-remove-img]');
if (!btn) return;
const idx = Number(btn.dataset.removeImg);
const recId = btn.dataset.rec;
if (!recId || !userState.currentTaskId) return;
const data = getTaskData(userState.currentTaskId);
if (data.records[recId]?.images) {
const img = data.records[recId].images[idx];
/* If this image is on the server, delete it there too */
if (img?.uploadedToServer && img.name && navigator.onLine) {
fetch(`/api/v1/reports/${userState.currentTaskId}/images/${recId}/${encodeURIComponent(img.name)}`, {
method: 'DELETE'
}).catch(() => {});
}
data.records[recId].images.splice(idx, 1);
safeSave();
markUnsaved();
refreshImagesForRecord(recId);
}
}
function refreshImagesForRecord(recId) {
const data = getTaskData(userState.currentTaskId);
const imgContainer = document.querySelector(`.task-record-images[data-rec="${recId}"]`);
if (imgContainer) {
imgContainer.innerHTML = renderRecordImages(data.records[recId]?.images || [], recId);
imgContainer.querySelectorAll('[data-remove-img]').forEach((el) => el.addEventListener('click', onImageRemove));
imgContainer.querySelectorAll('[data-lightbox-img]').forEach((el) => el.addEventListener('click', openLightbox));
imgContainer.querySelectorAll('[data-load-server-img]').forEach((el) => el.addEventListener('click', onLoadServerImage));
}
}
/**
* Loads a single image from the server when user clicks on a server placeholder badge.
* Fetches all images for the report, updates the specific record, and refreshes display.
*/
async function onLoadServerImage(e) {
const el = e.target.closest('[data-load-server-img]');
if (!el) return;
const recId = el.dataset.rec;
const imgIdx = Number(el.dataset.imgIdx);
const taskId = userState.currentTaskId;
if (!taskId || !recId || !navigator.onLine) {
showToast('Cannot load image while offline.', 'error');
return;
}
el.innerHTML = ' Loading...';
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, { headers: { Accept: 'application/json' } });
if (!resp.ok) { showToast('Failed to load images from server.', 'error'); return; }
const imagesByRecord = await resp.json();
const data = getTaskData(taskId);
const serverImages = imagesByRecord[recId];
if (data.records[recId]?.images && serverImages) {
/* Restore dataUrl directly from server response */
for (let idx = 0; idx < data.records[recId].images.length; idx++) {
const img = data.records[recId].images[idx];
if (img.uploadedToServer && !img.dataUrl) {
const srvImg = serverImages.find(s => s.name === img.name) || serverImages[idx];
if (srvImg?.dataUrl) {
data.records[recId].images[idx] = { ...img, dataUrl: srvImg.dataUrl, uploadedToServer: false };
}
}
}
safeSave();
refreshImagesForRecord(recId);
}
} catch (err) {
showToast('Could not load image: ' + err.message, 'error');
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Save as Draft / Final
* ═══════════════════════════════════════════════════════════════════════════ */
async function saveDraft() {
if (!userState.currentTaskId) return;
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (isTaskLocked(task)) { showToast('Task is finalized and cannot be edited.', 'error'); return; }
persistCurrentFormData();
/* Optimize images per policy (same as final, but without renaming) */
await optimizeImagesIfNeeded(task.id);
updateTaskStatus('draft');
markSaved();
uploadReport('draft');
showToast('Saved as draft.', 'success');
}
async function saveFinal() {
if (!userState.currentTaskId) return;
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (isTaskLocked(task)) { showToast('Task is already finalized.', 'error'); return; }
persistCurrentFormData();
/* Validate visit date range */
const tpl = task ? userState.clTemplates.find(t => t.id === task.templateId) : null;
const visitDateEl = document.getElementById('visitDate');
if (!validateVisitDate(visitDateEl, tpl)) {
showToast('Visit date is outside the allowed template range.', 'error');
return;
}
const errors = validateForFinal();
if (errors.length) {
showValidationErrors(errors);
showToast('Validation failed — see issues below.', 'error');
return;
}
/* Optimize images (resize per policy) and rename before uploading */
showToast('Processing images...', 'info');
await processImagesForFinal(task, tpl);
updateTaskStatus('final');
markSaved();
const uploadOk = await uploadReport('final');
if (uploadOk) {
/* Remove image dataUrls from IndexedDB to free space (uploaded to server) */
stripImageDataFromStorage(userState.currentTaskId);
showToast('Saved as final.', 'success');
} else {
showToast('Saved locally as final but upload failed — images kept locally.', 'warning');
}
document.getElementById('taskValidationPanel').style.display = 'none';
}
function persistCurrentFormData() {
const data = getTaskData(userState.currentTaskId);
data.visitDate = document.getElementById('visitDate').value;
/* Also read all current form values from DOM into data.records */
const container = document.getElementById('taskRecordsContainer');
if (container) {
container.querySelectorAll('.rec-status').forEach(el => {
const recId = el.dataset.rec;
if (!data.records[recId]) data.records[recId] = { status: '', handledBy: '', comment: '', images: [] };
data.records[recId].status = el.value;
});
container.querySelectorAll('.rec-handled').forEach(el => {
const recId = el.dataset.rec;
if (data.records[recId]) data.records[recId].handledBy = el.value;
});
container.querySelectorAll('.rec-comment').forEach(el => {
const recId = el.dataset.rec;
if (data.records[recId]) data.records[recId].comment = el.value;
});
}
safeSave();
}
function updateTaskStatus(status) {
/* Update in local state */
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (task) {
task.status = status;
/* Persist status to server via admin tasks API */
const payload = { siteId: task.siteId, userId: task.userId, templateId: task.templateId, project: task.project || '', process: task.process || '', status };
fetch(`/api/v1/admin/tasks/${task.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload)
}).catch((err) => console.warn('Task status sync failed', err));
/* Update IndexedDB cache */
dbGet(STORE_CONFIG, 'admin_all').then((row) => {
const data = row?.value;
if (data) {
const idx = (data.tasks || []).findIndex(t => t.id === task.id);
if (idx >= 0) { data.tasks[idx].status = status; dbPut(STORE_CONFIG, { key: 'admin_all', value: data }).catch(() => {}); }
}
}).catch(() => {});
renderTaskDetail();
renderSidebarTasks();
highlightSidebarTask(userState.currentTaskId);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Validation for "Save as Final"
* ═══════════════════════════════════════════════════════════════════════════ */
function validateForFinal() {
const errors = [];
const task = userState.tasks.find((t) => t.id === userState.currentTaskId);
if (!task) return ['Task not found.'];
const tpl = userState.clTemplates.find((t) => t.id === task.templateId);
const recordIds = tpl?.recordIds || [];
const records = recordIds.map((rid) => userState.clRecords.find((r) => r.id === rid)).filter(Boolean);
const data = getTaskData(task.id);
/* Build lookup from status value to its requirements */
const statusReqs = {};
(userState.templateSettings.statuses || []).forEach((s) => {
statusReqs[s.value] = { requireHandledBy: !!s.requireHandledBy, requireComment: !!s.requireComment };
});
for (const rec of records) {
const rd = data.records[rec.id] || {};
const label = `Record #${rec.sort}`;
if (!rd.status) {
errors.push(`${label}: Status is required.`);
continue;
}
const reqs = statusReqs[rd.status] || {};
if (reqs.requireHandledBy && !rd.handledBy) {
errors.push(`${label}: Handled By is required when Status is "${rd.status}".`);
}
if (reqs.requireComment && !rd.comment?.trim()) {
errors.push(`${label}: Comment is required when Status is "${rd.status}".`);
}
if ((reqs.requireHandledBy || reqs.requireComment) && rec.imageRequired && (!rd.images || rd.images.length === 0)) {
errors.push(`${label}: At least one image is required (status "${rd.status}" + image-required flag).`);
}
}
return errors;
}
function showValidationErrors(errors) {
const panel = document.getElementById('taskValidationPanel');
const list = document.getElementById('taskValidationList');
if (!panel || !list) return;
panel.style.display = '';
list.innerHTML = errors.map((e) => `${esc(e)} `).join('');
}
/* ═══════════════════════════════════════════════════════════════════════════
* Helpers
* ═══════════════════════════════════════════════════════════════════════════ */
function updateConnectionBadge() {
const badge = document.getElementById('connectionBadge');
if (!badge) return;
badge.textContent = navigator.onLine ? 'Online' : 'Offline';
badge.className = `badge ${navigator.onLine ? 'bg-success' : 'bg-warning text-dark'}`;
}
function showToast(message, tone) {
let toast = document.getElementById('userToast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'userToast';
toast.className = 'admin-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.background = tone === 'success' ? '#198754' : tone === 'error' ? '#dc3545' : tone === 'info' ? '#0d6efd' : '#ffc107';
toast.style.color = tone === 'error' || tone === 'success' || tone === 'info' ? '#fff' : '#000';
toast.classList.add('admin-toast-visible');
clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.classList.remove('admin-toast-visible'); }, 3000);
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function esc(text) {
const d = document.createElement('div');
d.textContent = text ?? '';
return d.innerHTML;
}
function formatFileSizeUser(bytes) {
if (!bytes) return '-';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
}
/* ═══════════════════════════════════════════════════════════════════════════
* Safe save — writes task data to IndexedDB
* ═══════════════════════════════════════════════════════════════════════════ */
function safeSave() {
const taskId = userState.currentTaskId;
const data = userState.taskData[taskId];
if (!taskId || !data) return;
/* Async write to IndexedDB — fire and forget with error handling */
saveOneTaskData(taskId, data).catch(err => {
console.error('IndexedDB save failed:', err);
showToast('Failed to save data to storage.', 'error');
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Save status indicator
* ═══════════════════════════════════════════════════════════════════════════ */
function markUnsaved() {
userState.unsavedChanges = true;
updateSaveIndicator();
}
function markSaved() {
userState.unsavedChanges = false;
updateSaveIndicator();
}
function updateSaveIndicator() {
let el = document.getElementById('saveStatusIndicator');
if (!el) {
el = document.createElement('span');
el.id = 'saveStatusIndicator';
el.className = 'badge ms-2';
const header = document.getElementById('taskDetailTitle');
if (header) header.parentNode.insertBefore(el, header.nextSibling);
else return;
}
if (userState.unsavedChanges) {
el.textContent = 'Unsaved changes';
el.className = 'badge bg-warning text-dark ms-2';
el.style.display = '';
} else {
el.textContent = 'All changes saved';
el.className = 'badge bg-success ms-2';
el.style.display = userState.currentTaskId ? '' : 'none';
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Visit date validation
* ═══════════════════════════════════════════════════════════════════════════ */
function validateVisitDate(inputEl, tpl) {
const val = inputEl?.value;
if (!val) return true; /* empty is allowed — final validation will require it if needed */
const dateVal = val;
if (tpl?.validFrom && dateVal < tpl.validFrom) {
inputEl.classList.add('is-invalid');
showToast(`Visit date must be on or after ${tpl.validFrom}`, 'error');
return false;
}
if (tpl?.validTill && dateVal > tpl.validTill) {
inputEl.classList.add('is-invalid');
showToast(`Visit date must be on or before ${tpl.validTill}`, 'error');
return false;
}
inputEl.classList.remove('is-invalid');
return true;
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image lightbox modal
* ═══════════════════════════════════════════════════════════════════════════ */
function ensureLightboxModal() {
if (document.getElementById('imageLightboxModal')) return;
const modal = document.createElement('div');
modal.id = 'imageLightboxModal';
modal.className = 'modal fade';
modal.tabIndex = -1;
modal.innerHTML = ``;
document.body.appendChild(modal);
let rotation = 0;
let scale = 1;
const imgEl = document.getElementById('lightboxImage');
function applyUserTransform() { imgEl.style.transform = `rotate(${rotation}deg) scale(${scale})`; }
document.getElementById('lightboxRotateLeft').addEventListener('click', () => {
rotation = (rotation - 90) % 360;
applyUserTransform();
});
document.getElementById('lightboxRotateRight').addEventListener('click', () => {
rotation = (rotation + 90) % 360;
applyUserTransform();
});
document.getElementById('lightboxZoomIn').addEventListener('click', () => {
scale = Math.min(scale + 0.25, 5);
applyUserTransform();
});
document.getElementById('lightboxZoomOut').addEventListener('click', () => {
scale = Math.max(scale - 0.25, 0.25);
applyUserTransform();
});
modal.addEventListener('hidden.bs.modal', () => { rotation = 0; scale = 1; imgEl.style.transform = ''; });
}
function openLightbox(e) {
const img = e.target;
const modalEl = document.getElementById('imageLightboxModal');
if (!modalEl) return;
document.getElementById('lightboxImage').src = img.src;
document.getElementById('lightboxImage').style.transform = '';
document.getElementById('lightboxTitle').textContent = img.alt || 'Image Preview';
/* Show file name and size */
const recId = img.dataset.rec;
const idx = Number(img.dataset.imgIdx);
const data = getTaskData(userState.currentTaskId);
const imgData = data?.records?.[recId]?.images?.[idx];
const infoEl = document.getElementById('lightboxFileInfo');
if (imgData) {
const parts = [];
if (imgData.name) parts.push(`${esc(imgData.name)} `);
if (imgData.size) parts.push(formatFileSizeUser(imgData.size));
infoEl.innerHTML = parts.join(' — ');
} else {
infoEl.innerHTML = '';
}
const bsModal = bootstrap.Modal.getOrCreateInstance(modalEl);
bsModal.show();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Task locking (disable editing after final)
* ═══════════════════════════════════════════════════════════════════════════ */
function isTaskLocked(task) {
return task?.status === 'final';
}
function applyTaskLock(task) {
const locked = isTaskLocked(task);
const container = document.getElementById('taskRecordsContainer');
const visitDate = document.getElementById('visitDate');
const saveDraftBtn = document.getElementById('saveDraftBtn');
const saveFinalBtn = document.getElementById('saveFinalBtn');
if (visitDate) visitDate.disabled = locked;
if (saveDraftBtn) saveDraftBtn.disabled = locked;
if (saveFinalBtn) saveFinalBtn.disabled = locked;
if (container && locked) {
container.querySelectorAll('select, textarea, input').forEach(el => { el.disabled = true; });
container.querySelectorAll('.drop-zone').forEach(el => { el.style.display = 'none'; });
container.querySelectorAll('.btn-remove').forEach(el => { el.style.display = 'none'; });
}
/* Show locked banner */
let banner = document.getElementById('taskLockedBanner');
if (locked) {
if (!banner) {
banner = document.createElement('div');
banner.id = 'taskLockedBanner';
banner.className = 'alert alert-info d-flex align-items-center gap-2 mb-3';
banner.innerHTML = 'This task is finalized and locked for editing. An administrator can reopen it if changes are needed. ';
const form = document.getElementById('taskProcessingForm');
if (form) form.prepend(banner);
}
banner.style.display = '';
} else if (banner) {
banner.style.display = 'none';
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Strip images from IndexedDB after final save
* ═══════════════════════════════════════════════════════════════════════════ */
function stripImageDataFromStorage(taskId) {
const data = getTaskData(taskId);
if (!data.records) return;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (rd.images && rd.images.length) {
/* Keep metadata (name, size) but remove the heavy dataUrl */
rd.images = rd.images.map(img => ({
name: img.name,
size: img.size,
uploadedToServer: true
/* dataUrl removed to save IndexedDB space */
}));
}
}
safeSave();
updateStorageIndicator();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Download images from server when task is reopened
* ═══════════════════════════════════════════════════════════════════════════ */
async function maybeDownloadImages(taskId) {
const task = userState.tasks.find(t => t.id === taskId);
if (!task) return;
const data = getTaskData(taskId);
if (!data.records) return;
/* Check if any images have uploadedToServer flag but no dataUrl */
let needsDownload = false;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (rd.images?.some(img => img.uploadedToServer && !img.dataUrl)) {
needsDownload = true;
break;
}
}
if (!needsDownload || !navigator.onLine) return;
/* Fetch images (as dataUrls) from the server */
try {
const resp = await fetch(`/api/v1/reports/${taskId}/images`, {
headers: { Accept: 'application/json' }
});
if (!resp.ok) return;
const imagesByRecord = await resp.json();
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
const serverImages = imagesByRecord[recId];
if (rd.images && serverImages) {
/* Restore dataUrl from server response */
for (let idx = 0; idx < rd.images.length; idx++) {
const img = rd.images[idx];
if (img.uploadedToServer && !img.dataUrl) {
const srvImg = serverImages.find(s => s.name === img.name) || serverImages[idx];
if (srvImg?.dataUrl) {
rd.images[idx] = { ...img, dataUrl: srvImg.dataUrl, uploadedToServer: false };
}
}
}
}
}
safeSave();
} catch (err) {
console.warn('Could not download images from server:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Storage utilization indicator (IndexedDB via StorageManager API)
* ═══════════════════════════════════════════════════════════════════════════ */
function updateStorageIndicator() {
let el = document.getElementById('storageIndicator');
if (!el) {
/* Create in the sidebar */
const sidebar = document.querySelector('.sidebar-bs .p-3.border-bottom');
if (!sidebar) return;
el = document.createElement('div');
el.id = 'storageIndicator';
el.className = 'mt-2';
sidebar.appendChild(el);
}
getStorageEstimate(userState.taskData).then(({ usedMB, quotaMB, pct }) => {
const barColor = pct > 80 ? 'bg-danger' : pct > 50 ? 'bg-warning' : 'bg-success';
el.innerHTML = ` Storage: ${usedMB} MB / ${quotaMB} MB (${pct}%)
`;
}).catch(() => {
el.innerHTML = ' Storage: estimate unavailable ';
});
}
/* ═══════════════════════════════════════════════════════════════════════════
* Upload report to database
* ═══════════════════════════════════════════════════════════════════════════ */
async function uploadReport(status) {
if (!navigator.onLine) return false; /* Skip upload when offline */
const task = userState.tasks.find(t => t.id === userState.currentTaskId);
if (!task) return false;
const tpl = userState.clTemplates.find(t => t.id === task.templateId);
const data = getTaskData(task.id);
const payload = {
id: String(task.id),
reportNumber: `RPT-${String(task.id).padStart(8, '0').slice(0, 8).toUpperCase()}`,
templateCode: tpl?.name || 'unknown',
templateVersion: Number(tpl?.version) || 1,
status: status,
answers: {
taskId: task.id,
siteId: task.siteId,
userId: task.userId,
project: task.project,
process: task.process,
visitDate: data.visitDate,
records: data.records
}
};
try {
const resp = await fetch('/api/v1/reports', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(payload)
});
if (!resp.ok) {
console.warn(`Report upload returned ${resp.status}`);
return false;
}
return true;
} catch (err) {
console.warn('Report upload failed:', err.message);
return false;
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image rules (fetch from server)
* ═══════════════════════════════════════════════════════════════════════════ */
async function fetchImageRules() {
if (!navigator.onLine) return;
try {
const resp = await fetch('/api/v1/config/image-rules', { headers: { Accept: 'application/json' } });
if (resp.ok) {
userState.imageRules = await resp.json();
}
} catch (err) {
console.warn('Could not fetch image rules:', err.message);
}
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image optimization (shared between draft and final save).
* Resizes/optimizes images per policy without renaming.
* ═══════════════════════════════════════════════════════════════════════════ */
async function optimizeImagesIfNeeded(taskId) {
const data = getTaskData(taskId);
if (!data.records) return;
const rules = userState.imageRules;
const shouldOptimize = rules && (rules.oversizeBehavior === 'auto_optimize' || rules.oversizeBehavior === 'warn_then_optimize');
if (!shouldOptimize) return;
let changed = false;
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (!rd.images?.length) continue;
for (let i = 0; i < rd.images.length; i++) {
const img = rd.images[i];
if (!img.dataUrl) continue;
if (needsOptimization(img, rules)) {
/* Extract EXIF before optimizing (resize strips EXIF) */
if (!img.exif) img.exif = parseExifFromDataUrl(img.dataUrl) || null;
try {
const file = dataUrlToFile(img.dataUrl, img.name || 'image.jpg');
const result = await optimizeImage(file, rules);
img.dataUrl = await blobToDataUrl(result.blob);
img.size = result.blob.size;
img.width = result.width;
img.height = result.height;
changed = true;
} catch (err) {
console.warn(`Image optimization failed for ${img.name}:`, err.message);
}
} else if (!img.width || !img.height) {
const dims = await getImageDimensions(img.dataUrl);
img.width = dims.width;
img.height = dims.height;
changed = true;
}
}
}
if (changed) safeSave();
}
/* ═══════════════════════════════════════════════════════════════════════════
* Image processing on final save:
* 1. Resize/optimize per image policy (if oversizeBehavior is auto_optimize)
* 2. Extract EXIF metadata before resize
* 3. Rename files: SiteCode_Project_Process_Date_Sort_ImageNumber
* ═══════════════════════════════════════════════════════════════════════════ */
async function processImagesForFinal(task, tpl) {
const data = getTaskData(task.id);
if (!data.records) return;
const site = userState.sites.find(s => s.id === task.siteId);
const siteCode = sanitizeFileName(site?.siteCode || 'SITE');
const project = sanitizeFileName(task.project || 'PROJECT');
const process = sanitizeFileName(task.process || 'PROCESS');
const visitDate = (data.visitDate || new Date().toISOString().slice(0, 10)).replace(/-/g, '');
const recordIds = tpl?.recordIds || [];
const records = recordIds.map(rid => userState.clRecords.find(r => r.id === rid)).filter(Boolean).sort((a, b) => a.sort - b.sort);
const sortMap = {};
records.forEach(r => { sortMap[r.id] = r.sort; });
const rules = userState.imageRules;
const shouldOptimize = rules && (rules.oversizeBehavior === 'auto_optimize' || rules.oversizeBehavior === 'warn_then_optimize');
for (const recId of Object.keys(data.records)) {
const rd = data.records[recId];
if (!rd.images?.length) continue;
const sort = sortMap[recId] != null ? String(sortMap[recId]).padStart(3, '0') : '000';
for (let i = 0; i < rd.images.length; i++) {
const img = rd.images[i];
if (!img.dataUrl) continue;
/* 1. Extract EXIF before any processing (resize strips EXIF) */
img.exif = parseExifFromDataUrl(img.dataUrl) || null;
/* 2. Optimize/resize if policy says so */
if (shouldOptimize && needsOptimization(img, rules)) {
try {
const file = dataUrlToFile(img.dataUrl, img.name || 'image.jpg');
const result = await optimizeImage(file, rules);
/* Convert optimized blob back to dataUrl */
img.dataUrl = await blobToDataUrl(result.blob);
img.size = result.blob.size;
img.width = result.width;
img.height = result.height;
} catch (err) {
console.warn(`Image optimization failed for ${img.name}:`, err.message);
}
} else {
/* Still capture dimensions if not optimizing */
if (!img.width || !img.height) {
const dims = await getImageDimensions(img.dataUrl);
img.width = dims.width;
img.height = dims.height;
}
}
/* 3. Rename file: SiteCode_Project_Process_Date_Sort_ImageNumber */
const imgNum = String(i + 1).padStart(2, '0');
const ext = getExtensionFromDataUrl(img.dataUrl) || getExtension(img.name) || 'jpg';
img.name = `${siteCode}_${project}_${process}_${visitDate}_${sort}_${imgNum}.${ext}`;
}
}
/* Persist to IndexedDB (updated images with new names, sizes, exif) */
safeSave();
}
function needsOptimization(img, rules) {
/* Check if image exceeds file size or dimension limits */
if (rules.maxFileSizeBytes && img.size > rules.maxFileSizeBytes) return true;
if (img.width && rules.maxWidthPx && img.width > rules.maxWidthPx) return true;
if (img.height && rules.maxHeightPx && img.height > rules.maxHeightPx) return true;
/* If we don't know dimensions, attempt optimization to be safe */
if (!img.width && !img.height) return true;
return false;
}
function dataUrlToFile(dataUrl, fileName) {
const [header, base64] = dataUrl.split(',');
const mime = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg';
const binary = atob(base64);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return new File([array], fileName, { type: mime });
}
function blobToDataUrl(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
function getImageDimensions(dataUrl) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({ width: 0, height: 0 });
img.src = dataUrl;
});
}
function sanitizeFileName(str) {
return str.replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
}
function getExtensionFromDataUrl(dataUrl) {
const mime = dataUrl.match(/data:image\/([^;]+)/)?.[1];
if (mime === 'jpeg' || mime === 'jpg') return 'jpg';
if (mime === 'png') return 'png';
if (mime === 'webp') return 'webp';
return mime || null;
}
function getExtension(filename) {
if (!filename) return null;
const parts = filename.split('.');
return parts.length > 1 ? parts.pop().toLowerCase() : null;
}