// Generic helpers shared by the main SPA (app.js) and standalone pages
// (login_dda.html). Date-picker enhancement is the only DOM init here and
// is a no-op when no .dda-date-input fields exist.
const API = '';
function escapeHtml(text) {
if (text == null) return '';
return String(text)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
function formatApiError(detail) {
if (!detail) return null;
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
return detail.map((d) => (d && d.msg) || JSON.stringify(d)).join('; ');
}
return String(detail);
}
/** `options.timeoutMs` is opt-in (default: none, i.e. today's unlimited-wait
* behavior) — only pass it for calls that should normally be fast (a single
* PATCH, a small GET), never for long-running work like running a detection,
* which can legitimately take minutes. Without it, a stalled network/DB
* connection leaves the caller's await pending forever, which is what made
* the region-review buttons look like a frozen page instead of a clear
* "request failed" error. */
async function ddaApi(method, path, options = {}) {
const { timeoutMs, ...rest } = options;
const headers = { ...rest.headers };
if (rest.body && !(rest.body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
}
let signal = rest.signal;
let timer = null;
if (timeoutMs) {
const controller = new AbortController();
signal = controller.signal;
timer = setTimeout(() => controller.abort(), timeoutMs);
}
let res;
try {
res = await fetch(API + path, { method, headers, credentials: 'include', ...rest, signal });
} catch (err) {
if (err.name === 'AbortError') {
throw new Error('Request timed out — the server took too long to respond. Please try again.');
}
throw err;
} finally {
if (timer) clearTimeout(timer);
}
const text = await res.text();
let data = null;
try { data = text ? JSON.parse(text) : null; } catch (_) {}
if (!res.ok) throw new Error(formatApiError(data?.detail) || res.statusText || 'Request failed');
return data;
}
function unwrapImagePage(data) {
if (Array.isArray(data)) {
return { items: data, total: data.length, offset: 0, limit: data.length };
}
const items = (data && data.items) || [];
return {
items,
total: Number(data && data.total != null ? data.total : items.length),
offset: Number((data && data.offset) || 0),
limit: Number((data && data.limit) || items.length),
};
}
function deferIdle(fn, timeoutMs) {
const run = () => { try { fn(); } catch (_) { /* page teardown */ } };
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, { timeout: timeoutMs || 1200 });
} else {
setTimeout(run, 0);
}
}
function showDdaError(msg) {
const el = document.getElementById('dda-error');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
}
function hideDdaError() {
document.getElementById('dda-error')?.classList.add('hidden');
}
function showDdaSuccess(msg) {
const el = document.getElementById('dda-success');
if (!el) return;
el.textContent = msg;
el.classList.remove('hidden');
setTimeout(() => el.classList.add('hidden'), 4000);
}
function formatBytes(n) {
if (n >= 1024 ** 3) return (n / 1024 ** 3).toFixed(1) + ' GB';
if (n >= 1024 ** 2) return (n / 1024 ** 2).toFixed(1) + ' MB';
return (n / 1024).toFixed(0) + ' KB';
}
/** Structured metadata rows (path, type/format/size/dims/georef, capture/upload
* info) for a library image dict (see app/dda/tree/image_service.py::image_to_dict).
* Shared by the Image Library viewer and the Change Detection Before/After slots. */
function buildImageMetaHtml(img) {
const esc = (s) => escapeHtml(String(s));
const row = (parts) => {
const filled = parts.filter(Boolean);
return filled.length ? `
${filled.join(' · ')}
` : '';
};
const path = img.breadcrumb || img.nodePath || '';
const place = [img.villageName, img.zoneName].filter(Boolean).join(', ');
const size = typeof formatBytes === 'function' ? formatBytes(img.fileSizeBytes) : '';
const dims = img.width && img.height ? `${img.width}×${img.height}px` : '';
const captureDate = img.captureDate && typeof formatDateIst === 'function'
? formatDateIst(img.captureDate, { withTime: false }) : '';
const uploadedOn = img.uploadedOn && typeof formatDateIst === 'function'
? formatDateIst(img.uploadedOn) : '';
const uploadedBy = img.uploadedBy ? esc(img.uploadedBy) : '';
return [
row([path ? esc(path) : '']),
row([place ? esc(place) : '']),
row([
img.imageType ? esc(img.imageType) : '',
img.source ? esc(img.source) : '',
img.year != null ? `Year ${esc(img.year)}` : '',
img.format ? esc(img.format) : '',
size,
dims,
img.hasGeoref ? 'Georeferenced' : 'Not georeferenced',
]),
row([
captureDate ? `Captured ${captureDate}` : '',
uploadedOn ? `Uploaded ${uploadedOn}${uploadedBy ? ` by ${uploadedBy}` : ''}` : '',
]),
].join('');
}
// --- Current user (shared across chip / initDda / home greeting) ---
let _ddaMePromise = null;
/** Fetch `/api/me` once per page load and cache on `window.ddaMe`. */
function ensureDdaMe() {
if (window.ddaMe) return Promise.resolve(window.ddaMe);
if (_ddaMePromise) return _ddaMePromise;
_ddaMePromise = ddaApi('GET', '/api/me')
.then((me) => {
window.ddaMe = me;
return me;
})
.catch((err) => {
_ddaMePromise = null;
throw err;
});
return _ddaMePromise;
}
// --- Dynamic role permissions (drives show/hide/disable in the UI so it
// always matches whatever's set in the Roles & Users permission matrix,
// without hardcoding any role name in a template or JS file). ---
let _ddaPermissionsPromise = null;
/** Fetch (once, cached for the page's lifetime) the current user's
* per-module permissions: { moduleKey: { view, create, edit, delete } }.
* Call this during page init, before hasPermission()/applyPermissionGating()
* are used. Resolves to {} (nothing permitted) if the request fails, rather
* than throwing — a permission check should fail closed, not break the page. */
function loadMyPermissions() {
if (!_ddaPermissionsPromise) {
_ddaPermissionsPromise = ddaApi('GET', '/api/dda/rbac/me/permissions').catch(() => ({}));
}
return _ddaPermissionsPromise;
}
/** Sync check against an already-resolved permissions map (from
* loadMyPermissions()). */
function hasPermission(permissions, moduleKey, action) {
return !!(permissions && permissions[moduleKey] && permissions[moduleKey][action]);
}
/** Declaratively hides/disables every element carrying
* data-requires-permission="moduleKey:action" based on the resolved
* permissions map, e.g.