Ist eine Maximalgröße eingestellt, ersetzte das im Browser verkleinerte JPEG den Inhalt in der Queue, der Name blieb aber z. B. „.avif"/„.webp" — der Server lehnte das ab. replaceQueuedBlob() zieht die Endung jetzt auf .jpg nach (Galerie, Teilen, Kamera). Beim Verkleinern wird vor dem Zeichnen weiß gefüllt, sonst wurden transparente Flächen im JPEG schwarz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406 lines
20 KiB
JavaScript
406 lines
20 KiB
JavaScript
/* Offline-Queue für Foto-Uploads (Persist-First / Write-Ahead).
|
|
*
|
|
* WICHTIG gegen Datenverlust:
|
|
* Jedes Foto wird vom Aufrufer ZUERST via enqueuePhoto() persistent in IndexedDB
|
|
* geschrieben und ERST DANACH hochgeladen. Ein Item wird nur nach BESTÄTIGTEM
|
|
* Upload (HTTP 2xx) aus der Queue gelöscht. Dadurch überlebt jedes Foto:
|
|
* - fehlendes/schwaches Netz (navigator.onLine lügt bei WLAN ohne Internet)
|
|
* - hängende Uploads (Timeout in api.uploadOrderPhoto)
|
|
* - App-Schließen / OS-Kill mitten im Upload
|
|
*/
|
|
(function () {
|
|
let syncing = false;
|
|
let syncAgain = false; // wurde während eines Laufs erneut angefragt?
|
|
let lastUnsynced = 0; // ALLE noch nicht bestätigten Items (für Beforeunload-Warnung)
|
|
const MAX_ATTEMPTS = 6; // nach so vielen Dauerfehlern → Quarantäne (blockiert Queue nicht)
|
|
|
|
/* Wer ein Foto gerade hochlaedt, belegt es (idb.queueClaim). Die Seite und der Service
|
|
* Worker (Background Sync) arbeiten dieselbe Queue ab — ohne Absprache luden beide
|
|
* dasselbe Foto hoch (Prod-Log 17.09.2026: reihenweise duplicate:true, im Mobilfunk das
|
|
* doppelte Datenvolumen). Die Lease muss laenger halten als der laengste Upload-Timeout
|
|
* (Seite 45 s, Service Worker 60 s), sonst uebernimmt der andere einen LAUFENDEN Upload.
|
|
* Nach Ablauf darf er uebernehmen — eine eingefrorene Seite blockiert ihr Foto nicht ewig.
|
|
* Der Besitzer ist je Seiten-Instanz eindeutig: zwei offene Fenster sind zwei Besitzer. */
|
|
const OWNER = 'page-' + Math.random().toString(36).slice(2, 10);
|
|
const HOLD_OWNER = OWNER + ':resize';
|
|
const LEASE_MS = 90000;
|
|
|
|
/* opts.hold: Das Foto kommt BELEGT in die Queue. Noetig, wenn es gleich noch verkleinert
|
|
* wird — sonst greift sich der Service Worker das Original, waehrend die Seite die kleine
|
|
* Fassung nachschiebt, und im Auftrag liegen zwei verschiedene Dateien (der md5-Abgleich
|
|
* des Servers erkennt sie nicht als Duplikat). Der Aufrufer MUSS danach releaseHold()
|
|
* rufen; vergisst er es oder stirbt die Seite, laeuft die Lease nach 90 s von selbst ab. */
|
|
async function enqueuePhoto(orderId, fileBlob, filename, opts) {
|
|
// Blob → ArrayBuffer für IndexedDB-Speicherung
|
|
const buf = await fileBlob.arrayBuffer();
|
|
const item = {
|
|
type: 'photo',
|
|
order_id: orderId,
|
|
filename,
|
|
mime: fileBlob.type || 'image/jpeg',
|
|
data: buf,
|
|
attempts: 0,
|
|
failed: false,
|
|
created: Date.now(),
|
|
};
|
|
if (opts && opts.hold) {
|
|
item.uploading_since = Date.now();
|
|
item.uploading_by = HOLD_OWNER;
|
|
}
|
|
const id = await idb.queuePush(item);
|
|
await updateBadge();
|
|
emitChange();
|
|
registerBackgroundSync(); // auch ohne offene App nachliefern
|
|
return id;
|
|
}
|
|
|
|
async function releaseHold(id) {
|
|
try {
|
|
await idb.queuePatch(id, (it) => {
|
|
if (it.uploading_by !== HOLD_OWNER) return false; // laengst von jemandem uebernommen
|
|
delete it.uploading_since;
|
|
delete it.uploading_by;
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
|
|
// Blob eines bereits gesicherten Queue-Items ersetzen. Gebraucht fuer das
|
|
// Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt
|
|
// es erst danach. Klappt das Verkleinern nicht, bleibt das Original — nie ein Loch.
|
|
// Per Patch in einer Transaktion: das fruehere "alles lesen, ganzes Item zurueckschreiben"
|
|
// ueberschrieb, was ein anderer in der Zwischenzeit am Eintrag geaendert hatte.
|
|
async function replaceQueuedBlob(id, fileBlob) {
|
|
const buf = await fileBlob.arrayBuffer();
|
|
const it = await idb.queuePatch(id, (x) => {
|
|
x.data = buf;
|
|
x.mime = fileBlob.type || x.mime || 'image/jpeg';
|
|
// Verkleinert = JPEG aus dem Canvas: Endung angleichen. Sonst laege JPEG-Inhalt unter
|
|
// „.avif"/„.webp" in der Queue, und der Server lehnt die Endung ab.
|
|
if (x.mime === 'image/jpeg' && x.filename && !/\.jpe?g$/i.test(x.filename)) {
|
|
x.filename = x.filename.replace(/\.[^.\/]*$/, '') + '.jpg';
|
|
}
|
|
});
|
|
return !!it; // null: schon hochgeladen und geloescht
|
|
}
|
|
|
|
// Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar
|
|
// nicht offen ist (Display aus, App weggewischt). Der Service Worker laedt selbst
|
|
// hoch — die Auth steckt im HttpOnly-Cookie awl_sso und geht bei same-origin
|
|
// automatisch mit. Ohne das lief Nachliefern nur, solange die Seite im
|
|
// Vordergrund war; im Hintergrund friert der Browser Timer ein.
|
|
async function registerBackgroundSync() {
|
|
try {
|
|
if (!('serviceWorker' in navigator) || !('SyncManager' in window)) return false;
|
|
const reg = await navigator.serviceWorker.ready;
|
|
if (!reg.sync) return false;
|
|
await reg.sync.register('photo-queue');
|
|
// Zusaetzlich, falls der Browser es gewaehrt (installierte PWA): ein
|
|
// periodischer Weckruf. Faengt den Fall ab, dass gar kein sync-Ereignis
|
|
// mehr kommt, weil das Geraet lange offline war.
|
|
try {
|
|
if (reg.periodicSync && navigator.permissions) {
|
|
const st = await navigator.permissions.query({ name: 'periodic-background-sync' });
|
|
if (st.state === 'granted') {
|
|
await reg.periodicSync.register('photo-queue', { minInterval: 15 * 60 * 1000 });
|
|
}
|
|
}
|
|
} catch (_) { /* nicht unterstuetzt — der normale Sync reicht */ }
|
|
return true;
|
|
} catch (_) { return false; } // z.B. Berechtigung verweigert — Timer bleiben als Netz
|
|
}
|
|
|
|
function emitChange() {
|
|
try { window.dispatchEvent(new CustomEvent('queue-changed')); } catch (_) {}
|
|
}
|
|
|
|
// Netzfehler / Timeout / Auth / Serverfehler → Item behalten (später erneut versuchen).
|
|
// NUR echte Client-Fehler (400/413/415: falscher Dateityp / zu groß) sind dauerhaft.
|
|
function isTransient(err) {
|
|
if (!err) return true;
|
|
if (err.offline) return true; // api.js: Anfrage kam nicht bis zum Server
|
|
if (err.name === 'AbortError') return true; // Timeout via AbortController
|
|
if (err instanceof TypeError) return true; // fetch: „Failed to fetch"
|
|
if (err.uploadUnconfirmed) return true; // 2xx, aber kein relpath → nicht bestätigt
|
|
if (err.nonJson) return true; // 2xx-HTML von Proxy/Apache → Auth/Proxy-Problem
|
|
// 5xx (Deploy-Neustart, PHP-Fatal, DB-Lock), 429 (Rate-Limit), 408 (Timeout) sind vorübergehend
|
|
if (err.status && (err.status >= 500 || err.status === 429 || err.status === 408)) return true;
|
|
const m = (err.message || '').toLowerCase();
|
|
if (m.includes('fetch') || m.includes('network') || m.includes('authentifiziert')) return true;
|
|
return false;
|
|
}
|
|
|
|
// Screen wachhalten, solange Uploads laufen. Schaltet sich das Display waehrend einer
|
|
// Foto-Serie ab, friert der Browser die laufenden fetch-Aufrufe ein und der Rest der
|
|
// Serie bleibt liegen (real am 28.08.2026: halbe Serie kam nicht an, der zweite Anlauf
|
|
// erzeugte dann Duplikate). Scheitert die Anforderung (Akkusparmodus, alter Browser),
|
|
// laeuft der Sync unveraendert weiter — die Queue faengt den Abbruch ohnehin ab.
|
|
// Referenzzaehler, weil zwei Stellen den Lock brauchen und sich verschachteln:
|
|
// die Auswahl-Schleife (Verkleinern + Sichern) und der Sync selbst. Wer zuerst
|
|
// fertig ist, darf dem anderen den Lock nicht wegnehmen.
|
|
let wakeLock = null;
|
|
let wakeRefs = 0;
|
|
|
|
async function acquireWakeLock() {
|
|
wakeRefs++;
|
|
try {
|
|
if (!navigator.wakeLock || wakeLock) return;
|
|
wakeLock = await navigator.wakeLock.request('screen');
|
|
wakeLock.addEventListener('release', function () { wakeLock = null; });
|
|
} catch (_) { wakeLock = null; }
|
|
}
|
|
|
|
function releaseWakeLock() {
|
|
wakeRefs = Math.max(0, wakeRefs - 1);
|
|
if (wakeRefs > 0) return;
|
|
try { if (wakeLock) wakeLock.release(); } catch (_) {}
|
|
wakeLock = null;
|
|
}
|
|
|
|
// Ein Wake Lock geht verloren, sobald die Seite in den Hintergrund geht (Anruf,
|
|
// App-Wechsel). Kommt sie zurueck und laeuft noch ein Sync, neu anfordern.
|
|
try {
|
|
document.addEventListener('visibilitychange', function () {
|
|
if (document.visibilityState === 'visible' && wakeRefs > 0 && !wakeLock) {
|
|
wakeRefs--; // acquireWakeLock zaehlt gleich wieder hoch
|
|
acquireWakeLock();
|
|
}
|
|
});
|
|
} catch (_) {}
|
|
|
|
async function syncQueue() {
|
|
// Läuft schon einer? Dann nur merken, dass danach nochmal geprüft werden soll
|
|
// (so werden Fotos erfasst, die während eines laufenden Syncs neu dazukamen).
|
|
if (syncing) { syncAgain = true; return; }
|
|
if (!navigator.onLine) return;
|
|
syncing = true;
|
|
await acquireWakeLock();
|
|
try {
|
|
do {
|
|
syncAgain = false;
|
|
// Nur die Ids holen und jedes Foto einzeln belegen: so liegt immer nur EIN
|
|
// Foto im Speicher, und gelesen wird der Stand zum Zeitpunkt des Uploads
|
|
// (also die verkleinerte Fassung, falls sie inzwischen da ist).
|
|
const ids = await idb.queueKeys();
|
|
let stoppedByNetwork = false;
|
|
for (const id of ids) {
|
|
// null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw.
|
|
// vom Verkleinern belegt. Dann nicht anfassen, der naechste Lauf sieht es wieder.
|
|
const it = await idb.queueClaim(id, OWNER, LEASE_MS);
|
|
if (!it) continue;
|
|
if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
|
|
try {
|
|
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
|
|
const res = await api.uploadOrderPhoto(it.order_id, blob, it.filename);
|
|
await idb.queueDelete(it.id);
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('photo-uploaded', {
|
|
detail: {
|
|
queueId: it.id,
|
|
relpath: res && res.relpath,
|
|
duplicate: !!(res && res.duplicate),
|
|
orderId: it.order_id,
|
|
filename: it.filename,
|
|
},
|
|
}));
|
|
} catch (_) {}
|
|
} catch (e) {
|
|
console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e);
|
|
if (isTransient(e)) {
|
|
// Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später.
|
|
// Lease sofort freigeben, sonst wartet der Service Worker 90 s auf nichts.
|
|
try { await idb.queueRelease(it.id); } catch (_) {}
|
|
stoppedByNetwork = true;
|
|
break;
|
|
}
|
|
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
|
|
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
|
|
const msg = (e && e.message) || 'Fehler';
|
|
try {
|
|
await idb.queueRelease(it.id, (x) => {
|
|
x.attempts = (x.attempts || 0) + 1;
|
|
x.last_error = msg;
|
|
if (x.attempts >= MAX_ATTEMPTS) x.failed = true;
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
if (stoppedByNetwork) break;
|
|
} while (syncAgain);
|
|
} finally {
|
|
syncing = false;
|
|
releaseWakeLock();
|
|
await updateBadge();
|
|
emitChange();
|
|
}
|
|
}
|
|
|
|
async function queueCount() {
|
|
try { return (await idb.queueAll()).length; } catch (_) { return 0; }
|
|
}
|
|
|
|
// Alle Queue-Items (für das Recovery-UI: Warteschlange ansehen / erneut senden / teilen)
|
|
async function listQueue() {
|
|
return idb.queueAll().catch(() => []);
|
|
}
|
|
|
|
// Quarantänisierte Items reaktivieren und erneut versuchen
|
|
async function retryFailed() {
|
|
const ids = await idb.queueKeys().catch(() => []);
|
|
for (const id of ids) {
|
|
try {
|
|
await idb.queuePatch(id, (it) => {
|
|
if (!it.failed) return false;
|
|
it.failed = false;
|
|
it.attempts = 0;
|
|
delete it.last_error;
|
|
});
|
|
} catch (_) {}
|
|
}
|
|
await updateBadge();
|
|
emitChange();
|
|
syncQueue();
|
|
}
|
|
|
|
async function updateBadge() {
|
|
const items = await idb.queueAll().catch(() => []);
|
|
const pending = items.filter(i => !i.failed).length;
|
|
const failed = items.filter(i => i.failed).length;
|
|
lastUnsynced = items.length; // failed zählt mit — sie sind ebenfalls noch nicht gesichert
|
|
const badge = document.getElementById('status-badge');
|
|
if (!badge) return;
|
|
if (!navigator.onLine) {
|
|
badge.textContent = items.length ? '🔴 ' + items.length : '🔴';
|
|
badge.title = items.length ? items.length + ' Fotos warten (offline, gesichert)' : 'Offline';
|
|
} else if (pending) {
|
|
badge.textContent = '🟡 ' + pending;
|
|
badge.title = pending + ' Fotos werden hochgeladen…';
|
|
} else if (failed) {
|
|
badge.textContent = '⚠️ ' + failed;
|
|
badge.title = failed + ' Uploads fehlgeschlagen — bitte prüfen';
|
|
} else {
|
|
badge.textContent = '🟢';
|
|
badge.title = 'Online — alle Fotos gesichert';
|
|
}
|
|
}
|
|
|
|
// Der Service Worker meldet, wenn er im Hintergrund etwas hochgeladen hat
|
|
try {
|
|
navigator.serviceWorker.addEventListener('message', (e) => {
|
|
const d = e.data || {};
|
|
if (d.type !== 'photo-uploaded') return;
|
|
updateBadge();
|
|
emitChange();
|
|
try {
|
|
window.dispatchEvent(new CustomEvent('photo-uploaded', {
|
|
detail: { queueId: d.queueId, relpath: d.relpath, orderId: d.orderId,
|
|
filename: d.filename, duplicate: !!d.duplicate },
|
|
}));
|
|
} catch (_) {}
|
|
});
|
|
} catch (_) {}
|
|
|
|
window.addEventListener('online', () => { updateBadge(); syncQueue(); });
|
|
window.addEventListener('offline', updateBadge);
|
|
|
|
// Warnung, wenn die App mit noch nicht hochgeladenen Fotos geschlossen/neu geladen wird.
|
|
// Feuert in einer Hash-Router-SPA NICHT bei normaler In-App-Navigation, nur beim echten Verlassen.
|
|
// lastUnsynced schließt quarantänisierte (failed) Fotos ein — die sind erst recht gefährdet.
|
|
// Nicht beim eigenen Reload nach einem App-Update (index.php setzt __selfReload): die Fotos
|
|
// liegen in IndexedDB, ein Reload verliert nichts — der Browserdialog waere nur Laerm.
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (window.__selfReload) return;
|
|
if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; }
|
|
});
|
|
|
|
// Zurück in den Vordergrund → sofort erneut synchronisieren
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible' && navigator.onLine) syncQueue();
|
|
});
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
updateBadge();
|
|
if (navigator.onLine) syncQueue();
|
|
// Periodischer Sync alle 15s (fängt Items ein, deren erster Versuch scheiterte)
|
|
setInterval(() => { if (navigator.onLine) syncQueue(); }, 15000);
|
|
});
|
|
|
|
/* ======================================================================
|
|
* Datenspiegel — damit die App im Funkloch nicht leer ist
|
|
*
|
|
* Die Queue oben sichert, was RAUS soll (Fotos). Hier geht es um das Gegenstueck:
|
|
* was zuletzt REIN kam. Bis 31.08.2026 hatte die App dafuer gar nichts — ohne Netz
|
|
* stand auf der Auftragsliste die rohe Browsermeldung "Failed to fetch" und sonst
|
|
* nichts. Auf der Baustelle ist schlechtes Netz aber der Normalfall, nicht die
|
|
* Ausnahme (Keller, Neubau, Rohbau ohne Fenster).
|
|
*
|
|
* Bewusst nur LESEN: jede erfolgreiche Antwort wird gespiegelt, bei Netzfehler wird
|
|
* der letzte Stand mit sichtbarem Zeitstempel angezeigt. Keine Schreib-Queue fuer
|
|
* Auftragsdaten — die braeuchte Idempotenz und Konfliktbehandlung (siehe KB #1037).
|
|
*
|
|
* Der Spiegel liegt im kv-Store derselben IndexedDB. Beim Abmelden wird er geloescht,
|
|
* sonst saehe der naechste Benutzer am selben Geraet offline die Kunden und Preise
|
|
* des vorherigen (KB #1041).
|
|
* ====================================================================== */
|
|
const MIRROR_PREFIX = 'mirror:';
|
|
const MIRROR_MAX_DETAILS = 30; // wie viele Auftrags-/Kundendetails aufgehoben werden
|
|
|
|
async function mirrorSave(key, data) {
|
|
const full = MIRROR_PREFIX + key;
|
|
try {
|
|
await idb.set(full, { data, ts: Date.now() });
|
|
} catch (_) {
|
|
return false; // Speicher voll / privater Modus — kein Grund, den Abruf zu versenken
|
|
}
|
|
if (key.indexOf('detail:') === 0) mirrorPrune(full).catch(() => {});
|
|
return true;
|
|
}
|
|
|
|
async function mirrorLoad(key) {
|
|
try {
|
|
const v = await idb.get(MIRROR_PREFIX + key);
|
|
if (!v || typeof v !== 'object' || !('data' in v)) return null;
|
|
return v; // { data, ts }
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/* Nur die Detailseiten wachsen unbegrenzt (ein Eintrag je geoeffnetem Auftrag).
|
|
* Aeltere wegraeumen — den gerade geschriebenen Schluessel dabei ausdruecklich
|
|
* schuetzen: werden zwei Staende in derselben Millisekunde gespeichert, ist die
|
|
* Sortierung nach Zeitstempel nicht eindeutig und es fliegt sonst ausgerechnet
|
|
* der neue Eintrag raus (KB #1037). */
|
|
async function mirrorPrune(keepKey) {
|
|
const all = (await idb.keys().catch(() => []))
|
|
.filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX + 'detail:') === 0);
|
|
if (all.length <= MIRROR_MAX_DETAILS) return;
|
|
|
|
const withTs = [];
|
|
for (const k of all) {
|
|
const v = await idb.get(k).catch(() => null);
|
|
withTs.push({ k, ts: (v && v.ts) || 0 });
|
|
}
|
|
withTs.sort((a, b) => b.ts - a.ts);
|
|
for (const e of withTs.slice(MIRROR_MAX_DETAILS)) {
|
|
if (e.k === keepKey) continue;
|
|
await idb.del(e.k).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/* Beim Abmelden: alles Gespiegelte vom Geraet nehmen. */
|
|
async function mirrorClear() {
|
|
const all = (await idb.keys().catch(() => []))
|
|
.filter(k => typeof k === 'string' && k.indexOf(MIRROR_PREFIX) === 0);
|
|
for (const k of all) await idb.del(k).catch(() => {});
|
|
return all.length;
|
|
}
|
|
|
|
// Laeuft gerade ein Upload? Daran haengt u.a., ob die Seite nach einem App-Update neu
|
|
// laden darf (index.php) — ein Reload risse einen halb gesendeten 8-MB-Upload ab.
|
|
function isSyncing() { return syncing; }
|
|
|
|
window.offline = { enqueuePhoto, releaseHold, syncQueue, isSyncing, updateBadge, queueCount,
|
|
listQueue, retryFailed,
|
|
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync,
|
|
mirrorSave, mirrorLoad, mirrorClear };
|
|
})();
|