Compare commits
No commits in common. "292c25187c5f14713e46d84b7d42035e85eabf88" and "aca014af9462a9fa03e74d230476038e11428e20" have entirely different histories.
292c25187c
...
aca014af94
9 changed files with 63 additions and 630 deletions
|
|
@ -27,9 +27,6 @@ Mobile Progressive Web App für die Baustellen-Doku — Foto-Upload, Sprach- und
|
||||||
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
|
- ✅ **Persist-First / Datenverlust-Schutz**: jedes Foto wird beim Auslösen ZUERST in IndexedDB gesichert, Upload erst danach; Queue-Item wird nur nach bestätigtem Upload (HTTP 2xx mit gültigem `relpath`) gelöscht — überlebt fehlendes/schwaches Netz, hängende Uploads (45s-Timeout) und App-Kill. Erkennt 2xx-HTML (abgelaufene Session/Proxy-Loginseite) als Fehler statt als Erfolg
|
||||||
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
|
- ✅ Auto-Sync bei "online", periodisch (15s) und bei App-Fokus; Status-Badge (🟢 alles gesichert / 🟡 lädt hoch / 🔴 offline / ⚠️ fehlgeschlagen)
|
||||||
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
- ✅ Recovery: Tipp auf das Status-Badge öffnet die Warteschlange → erneut senden / teilen; Warnung beim Schließen mit noch ungesicherten Fotos
|
||||||
- ✅ **Seite und Service Worker laden nichts doppelt hoch**: wer ein Foto sendet, belegt es in IndexedDB (Lease, 90 s); der andere überspringt es und übernimmt erst nach Ablauf. Beim Verkleinern bleibt das Foto belegt, bis die kleine Fassung in der Queue liegt — sonst gingen Original **und** kleine Fassung raus. Logik steht zweimal (`lib/idb.js` + `sw.js`), immer zusammen ändern
|
|
||||||
- ✅ **App-Update lädt nicht mitten in der Arbeit neu**: nach einem Deploy wartet der Reload, bis kein Dialog (Kamera, Notiz, Unterschrift) offen ist, nichts getippt wird und kein Upload läuft (`window.appBusy()`); der eigene Reload löst keinen Browser-Dialog aus
|
|
||||||
- ✅ **Wartende Fotos stehen am Auftrag**: Block „⏳ Warten auf Upload (n)" mit Vorschaubildern aus der Warteschlange und Klartext (gesichert auf dem Gerät, geht raus sobald Netz da ist); nach dem Upload zieht die Auftragsseite von selbst nach. Hinweis-Toast beim Schließen der Kamera, wenn noch etwas wartet
|
|
||||||
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304
|
- ✅ Kacheln laden **serverseitige Thumbnails** (`photo.php?size=thumb`) statt der Originale — rund 10 KB statt mehrerer hundert KB je Kachel, parallel und mit `loading="lazy"`, Wiederholaufrufe enden mit 304
|
||||||
- ✅ Foto-Viewer mit Zoom + Swipe
|
- ✅ Foto-Viewer mit Zoom + Swipe
|
||||||
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
- ✅ Foto-Skizze: Annotationen mit Pfeilen, Kreisen, Rechtecken, Text
|
||||||
|
|
|
||||||
148
ROADMAP.md
148
ROADMAP.md
|
|
@ -1,6 +1,6 @@
|
||||||
# ROADMAP — Baustelle PWA
|
# ROADMAP — Baustelle PWA
|
||||||
|
|
||||||
Stand: 2026-09-18 · Gegenstück: `data/bericht` (Modul-Version 1.5.1)
|
Stand: 2026-08-22 · Gegenstück: `data/bericht` (Modul-Version 1.5.1)
|
||||||
|
|
||||||
Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine
|
Die PWA hat kein eigenes Backend — alles läuft über `custom/bericht/api/`. Punkte, die eine
|
||||||
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
|
API-Änderung brauchen, sind mit **[API]** markiert und stehen zusätzlich in der ROADMAP des
|
||||||
|
|
@ -224,152 +224,6 @@ Keine Schreib-Warteschlange für Auftragsdaten. Die bräuchte Idempotenz und
|
||||||
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
|
Konfliktbehandlung (zwei Geräte am selben Auftrag). Für Fotos existiert sie längst
|
||||||
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
|
(Persist-First-Queue), für alles andere gilt: lesen ja, ändern nur mit Netz.
|
||||||
|
|
||||||
## 6. Rauswurf aus dem Auftrag + unsichtbare Warteschlange (gemeldet 2026-09-18)
|
|
||||||
|
|
||||||
Gemeldet von Eddy: am 17.09.2026 gegen 18 Uhr mehrere Fotos in einem Auftrag gemacht,
|
|
||||||
„Fertig" getippt — die App stand danach auf der **Auftragsliste** statt im Auftrag. Wieder
|
|
||||||
in den Auftrag gegangen: **keine Fotos zu sehen**, kein Hinweis, dass sie noch warten.
|
|
||||||
|
|
||||||
### Befund (Prod-Zugriffslog 17.09., Auftrag 111 — kein Raten)
|
|
||||||
|
|
||||||
- 17:30:56 `POST orders.php?action=create` — Auftrag 111 über den ➕-Knopf angelegt. In
|
|
||||||
derselben Sekunde laufen **zwei** Routen gleichzeitig: `orders.php` (Liste) **und**
|
|
||||||
`orders.php?id=111` (Detail). Das ist die Signatur des Fehlers.
|
|
||||||
- 17:40:45 `verify.php` + `orders.php` (Liste) — der Rauswurf. **Kein** `index.php` davor,
|
|
||||||
also kein Reload, kein SW-Update, kein Logout (kein 401), kein Absturz.
|
|
||||||
- 17:40:47 Auftrag 111 erneut geöffnet, `action=photos` liefert eine **leere** Liste;
|
|
||||||
die Uploads kommen erst 17:40:54 und 17:57:25–17:58:22 an.
|
|
||||||
|
|
||||||
**Ursache Rauswurf — History-Race in `openNewOrderModal`:**
|
|
||||||
|
|
||||||
```js
|
|
||||||
closeModal(modal); // -> history.back() (asynchron!)
|
|
||||||
router.go('#/orders/' + res.order.id); // -> location.hash = … (sofort)
|
|
||||||
```
|
|
||||||
|
|
||||||
`history.back()` wird erst nach dem Hash-Wechsel ausgeführt und springt dann **hinter** den
|
|
||||||
neuen Eintrag zurück. Ergebnis: Auf dem Bildschirm steht der Auftrag (seine Route wurde als
|
|
||||||
letzte fertig), in der Adresszeile steht aber `#/orders`. Alles, was danach
|
|
||||||
`router.navigate()` ruft — z. B. „Fertig" in der Kamera — zeichnet die **Auftragsliste**.
|
|
||||||
Nachgestellt in Chromium (18.09.2026): ohne Abwarten endet der Ablauf auf `#/orders`, mit
|
|
||||||
abgewartetem `popstate` auf `#/orders/111`.
|
|
||||||
|
|
||||||
Dieselbe Falle an zwei weiteren Stellen: „Neuen Bericht anlegen" (`#/reports/<id>`) und
|
|
||||||
Lieferschein-Unterschrift (`#/shipments/<id>`). Betroffen ist nur, wer den Auftrag **in
|
|
||||||
derselben Sitzung frisch angelegt** hat — deshalb fiel es nicht früher auf.
|
|
||||||
|
|
||||||
**Ursache „keine Fotos, keine Meldung":** Die Auftragsseite zeigt ausschließlich, was der
|
|
||||||
Server schon hat. Fotos in der Warteschlange erscheinen nirgends außer als Zahl im kleinen
|
|
||||||
Badge oben rechts (🔴 3 / 🟡 3), und die Seite zeichnet sich nach einem im Hintergrund
|
|
||||||
fertig gewordenen Upload nicht neu. Verloren ging nichts — alle Fotos kamen an.
|
|
||||||
|
|
||||||
### Umsetzung (2026-09-18)
|
|
||||||
|
|
||||||
- [x] **Race zentral behoben:** `closeModal()` zählt selbst ausgelöste `history.back()`
|
|
||||||
(`_pendingBacks`, Zähler statt Boolean, KB #1209 — der Boolean verschluckt bei zwei
|
|
||||||
schnell nacheinander geschlossenen Dialogen ein `popstate`), `router.go()` wartet über
|
|
||||||
`window.historySettled()`, bis alle durch sind, und wechselt erst dann den Hash
|
|
||||||
(Schutzzeit 1,5 s, falls ein `popstate` ausbleibt). Alle Aufrufstellen bleiben wie sie
|
|
||||||
sind. `closeModal()` nimmt den History-Eintrag jetzt **vor** dem Cleanup zurück, damit
|
|
||||||
ein navigierender Cleanup den Rücksprung sieht; `pushModal()` wartet ebenfalls.
|
|
||||||
- [x] **Sicherheitsnetz in Kamera und Galerie-Auswahl:** nach „Fertig" ausdrücklich
|
|
||||||
`router.go('#/orders/<id>')` statt `router.navigate()` — gezeichnet wird der Auftrag,
|
|
||||||
für den fotografiert wurde, nicht „was gerade in der Adresszeile steht".
|
|
||||||
- [x] **Wartende Fotos am Auftrag:** Block „⏳ Warten auf Upload (n)" über „Hochgeladene
|
|
||||||
Fotos", mit Vorschaubildern direkt aus der Warteschlange (max. 12, danach „+n") und
|
|
||||||
Klartext („📴 Kein Netz — 3 Fotos sind auf dem Gerät gesichert und werden gesendet,
|
|
||||||
sobald wieder Netz da ist."), fehlgeschlagene rot markiert; Tipp öffnet die Warteschlange.
|
|
||||||
- [x] **Auftragsseite zieht nach:** bei `photo-uploaded` für diesen Auftrag wird nach 2,5 s
|
|
||||||
Ruhe neu gezeichnet (Scrollposition bleibt) — nicht, solange ein Modal offen ist,
|
|
||||||
die Mehrfachauswahl läuft oder eine Sprachnotiz abgespielt wird.
|
|
||||||
- [x] **Hinweis beim Schließen der Kamera:** „📴 Kein Netz — n Fotos auf dem Gerät
|
|
||||||
gesichert, Upload folgt automatisch" bzw. „⏳ n Fotos warten noch auf den Upload".
|
|
||||||
|
|
||||||
### Verifiziert (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62)
|
|
||||||
|
|
||||||
- Ablauf „Modal zu + `router.go`": endet auf `#/orders/62` (vorher `#/orders`)
|
|
||||||
- **Echtes Funkloch** (`context.setOffline(true)`): 3 Fotos gesichert, „Fertig" → bleibt im
|
|
||||||
Auftrag, Offline-Band, Block mit 3 Vorschaubildern und Klartext, Badge 🔴 3
|
|
||||||
- Netz zurück: Upload läuft an, Block verschwindet von selbst, „Hochgeladene Fotos" 2 → 5,
|
|
||||||
Badge 🟢, Warteschlange leer. Testfotos danach wieder entfernt.
|
|
||||||
- **Nicht** getestet: die echte Kamera (`getUserMedia`) am Handy — der Test hat den
|
|
||||||
Kamera-Cleanup nachgestellt, nicht die Kamera selbst bedient.
|
|
||||||
|
|
||||||
### 6.1 Doppel-Uploads: Seite und Service Worker laden dasselbe Foto hoch
|
|
||||||
|
|
||||||
Log 17.09.: mehrere Antworten mit `duplicate:true`. `syncQueue()` (Seite) und `drainQueue()`
|
|
||||||
(Service Worker, Background Sync) lesen dieselbe IndexedDB-Queue und wissen nichts
|
|
||||||
voneinander. Dank md5-Abgleich (Bericht 1.6.0) folgenlos — kostet aber im Mobilfunk das
|
|
||||||
doppelte Datenvolumen, bei 8-MB-Originalen spürbar.
|
|
||||||
|
|
||||||
**Schlimmer, bisher unbemerkt:** Ist im Admin ein Verkleinern eingestellt, sichert die Kamera
|
|
||||||
erst das Original und ersetzt es danach durch die kleine Fassung. Greift der Service Worker
|
|
||||||
dazwischen zu, lädt er das **Original** hoch, die Seite danach die **kleine Fassung** —
|
|
||||||
unterschiedlicher Inhalt, der md5-Abgleich greift nicht, es liegen **zwei Dateien** im Auftrag.
|
|
||||||
|
|
||||||
Umgesetzt (2026-09-18) — Lease je Item in IndexedDB (kein Web Lock: ein eingefrorener Tab
|
|
||||||
hielte den ewig, und genau für den eingefrorenen Tab gibt es den Background Sync):
|
|
||||||
|
|
||||||
- [x] `idb.queueKeys()` / `queuePatch(id, fn)` / `queueClaim(id, owner, leaseMs)` /
|
|
||||||
`queueRelease()` — Lesen-Ändern-Schreiben in **einer** readwrite-Transaktion (über
|
|
||||||
Kontexte hinweg atomar). Gegenstück in `sw.js` (`queueClaim`/`queueRelease`) —
|
|
||||||
**beide Stellen zusammen ändern**, der Worker kann `lib/idb.js` nicht laden (`window`).
|
|
||||||
- [x] `syncQueue()` und `drainQueue()` holen sich jedes Item per Claim; ist es frisch vom
|
|
||||||
anderen belegt, wird es übersprungen (im Worker zählt es als „offen", damit der
|
|
||||||
Browser nachfasst). Lease 90 s (> längster Upload-Timeout 60 s), danach darf der
|
|
||||||
andere übernehmen. Besitzer je Seiten-/Worker-Instanz eindeutig (`page-…` / `sw-…`).
|
|
||||||
Nebeneffekt: es liegt nur noch **ein** Foto gleichzeitig im Speicher statt aller.
|
|
||||||
- [x] Fehlschlag gibt die Lease sofort frei; Versuchszähler/Quarantäne per Patch statt
|
|
||||||
„ganzes Item zurückschreiben" (überschrieb bisher eine parallele Änderung). Ebenso
|
|
||||||
`retryFailed()`.
|
|
||||||
- [x] `enqueuePhoto(…, {hold:true})` + `releaseHold()`: soll verkleinert werden, kommt das
|
|
||||||
Foto **mit** Lease (`page-…:resize`) in die Queue; freigegeben wird im `finally` nach
|
|
||||||
dem Verkleinern. Vergisst es jemand oder stirbt die Seite: Ablauf nach 90 s.
|
|
||||||
- [x] `replaceQueuedBlob()` ersetzt per Patch
|
|
||||||
|
|
||||||
### 6.2 SW-Update lädt mitten in der Arbeit neu
|
|
||||||
|
|
||||||
Beim Test beobachtet: `controllerchange` → `location.reload()` (index.php, Muster KB #201)
|
|
||||||
feuerte, während drei Fotos in der Warteschlange lagen; der Browser zeigte dazu seinen
|
|
||||||
eigenen „Seite verlassen?"-Dialog (`beforeunload`). War am 17.09. **nicht** die Ursache (kein
|
|
||||||
Deploy, kein `index.php` im Log), trifft aber jeden, der nach einem Deploy arbeitet: offene
|
|
||||||
Kamera weg, getippte Notiz weg, halbe Unterschrift weg.
|
|
||||||
|
|
||||||
- [x] `window.appBusy()` in app.js: Modal offen, Mehrfachauswahl, laufender Upload
|
|
||||||
(`offline.isSyncing()`), Eingabefeld im Fokus, laufende Sprachnotiz. **Nicht**
|
|
||||||
beschäftigt: PIN-Sperre — dort ist der beste Moment zum Neuladen, sonst kommt die
|
|
||||||
PIN-Abfrage zweimal. Suchfelder zählen nicht (behalten am Handy den Fokus, der Reload
|
|
||||||
käme nie).
|
|
||||||
- [x] index.php: Reload nur, wenn nicht beschäftigt; sonst alle 3 s und bei jedem
|
|
||||||
Sichtbarkeitswechsel erneut prüfen
|
|
||||||
- [x] Eigener Reload setzt `window.__selfReload` → `beforeunload` warnt dann nicht (die Fotos
|
|
||||||
liegen in IndexedDB, ein Reload verliert nichts). Die Warnung beim echten Schließen bleibt.
|
|
||||||
|
|
||||||
### Verifiziert 6.1 + 6.2 (2026-09-18, lokale Testinstanz, Chromium, Auftrag 62)
|
|
||||||
|
|
||||||
- **Echte Kamera-Oberfläche** mit nachgebildetem Kamerabild (`canvas.captureStream()` statt
|
|
||||||
Hardware), offline: 3× Auslöser, „Fertig" → bleibt im Auftrag, Toast „📴 Kein Netz — 3 Fotos
|
|
||||||
auf dem Gerät gesichert, Upload folgt automatisch", Block mit 3 Vorschaubildern, Badge 🔴 3
|
|
||||||
- **Gleichzeitiger Zugriff:** Netz an, Seite und Service Worker greifen parallel zu — der
|
|
||||||
Worker lud Foto 4 und 6, die Seite Foto 5. Serverlog: **genau 3** `upload_photo`-POSTs für
|
|
||||||
3 Fotos, keine Antwort mit `duplicate:true`
|
|
||||||
- **Verkleinern:** während des Verkleinerns ist das Item von `page-…:resize` belegt; auf dem
|
|
||||||
Server liegt **eine** Datei mit 41.832 Bytes (die kleine Fassung), nicht das 208-KB-Original
|
|
||||||
- **Fremde Lease:** frisch → wird respektiert (kein Upload, bleibt in der Queue);
|
|
||||||
künstlich abgelaufen → wird übernommen und hochgeladen
|
|
||||||
- **Reload:** `controllerchange` bei offenem Notiz-Dialog → 4,5 s kein Reload; Dialog zu →
|
|
||||||
Reload nach 0,7 s, gleicher Auftrag, **kein Browser-Dialog**, obwohl ein Foto in der
|
|
||||||
Warteschlange lag. Konsole ohne Fehler. Testdaten danach wieder entfernt.
|
|
||||||
- **Nicht** getestet: echte Kamera-Hardware und echter Background Sync bei ausgeschaltetem
|
|
||||||
Display am Handy — beides geht nur auf dem Gerät.
|
|
||||||
|
|
||||||
### Offen (beobachtet, nicht Teil dieser Runde)
|
|
||||||
|
|
||||||
- [ ] `idb.queueAll()` lädt **alle** Foto-Blobs in den Speicher — auch `updateBadge()`, das
|
|
||||||
nur zählen will. Bei 20 Originalen à 8 MB sind das 160 MB je Badge-Update. Braucht ein
|
|
||||||
Zähl-Verfahren ohne Werte (Schlüssel + kleiner Meta-Store); Schema-Änderung, weil
|
|
||||||
`sw.js`/`share.html` die DB mit fester Version 1 öffnen.
|
|
||||||
|
|
||||||
## Erledigt
|
## Erledigt
|
||||||
|
|
||||||
### 2026-08-22
|
### 2026-08-22
|
||||||
|
|
|
||||||
31
app.css
31
app.css
|
|
@ -307,37 +307,6 @@ body {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Wartende Fotos am Auftrag — bewusst auffaellig (gelber Rand): das ist der Zustand,
|
|
||||||
in dem man sonst glaubt, die Fotos seien weg. */
|
|
||||||
.pending-section {
|
|
||||||
border: 1px solid #e0af68;
|
|
||||||
background: #2b2820;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.pending-section h3 { color: #e0af68; }
|
|
||||||
.pending-section p { line-height: 1.45; }
|
|
||||||
.pending-grid .pending-thumb { cursor: pointer; }
|
|
||||||
.pending-grid .pending-thumb img { opacity: 0.7; }
|
|
||||||
.pending-grid .pending-mark {
|
|
||||||
position: absolute;
|
|
||||||
right: 4px;
|
|
||||||
bottom: 4px;
|
|
||||||
font-size: 16px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 3px 4px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(0, 0, 0, 0.6);
|
|
||||||
}
|
|
||||||
.pending-grid .pending-thumb.is-failed { outline: 2px solid #f7768e; outline-offset: -2px; }
|
|
||||||
.pending-grid .pending-more {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #e0af68;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Foto-Section Kopfzeile mit Auswahl-Toggle */
|
/* Foto-Section Kopfzeile mit Auswahl-Toggle */
|
||||||
.photo-section-head {
|
.photo-section-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
225
app.js
225
app.js
|
|
@ -39,57 +39,21 @@ function setBack(visible, hash) {
|
||||||
* Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag.
|
* Jedes Modal pusht beim Öffnen einen eigenen History-Eintrag.
|
||||||
* popstate (Android-Back) pop-t den Stack und entfernt das Modal.
|
* popstate (Android-Back) pop-t den Stack und entfernt das Modal.
|
||||||
* Programmatisches Schließen (closeModal) räumt synchron auf und
|
* Programmatisches Schließen (closeModal) räumt synchron auf und
|
||||||
* zählt den eigenen history.back() mit (_pendingBacks), damit dessen
|
* setzt ein Skip-Flag, damit der eigene history.back() kein
|
||||||
* popstate kein doppeltes Cleanup auslöst.
|
* doppeltes Cleanup auslöst.
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
const modalStack = [];
|
const modalStack = [];
|
||||||
// Selbst ausgeloeste history.back(), deren popstate noch aussteht. Zaehler statt Boolean:
|
let _skipNextPopstate = false;
|
||||||
// schliessen sich zwei Dialoge direkt nacheinander, stehen ZWEI popstate aus — ein Boolean
|
|
||||||
// verschluckt nur das erste, das zweite raeumt dann faelschlich den Stapel ab (KB #1209).
|
|
||||||
let _pendingBacks = 0;
|
|
||||||
let _backWaiters = [];
|
|
||||||
let _lastBackAt = 0;
|
let _lastBackAt = 0;
|
||||||
|
|
||||||
function _flushBackWaiters() {
|
|
||||||
const w = _backWaiters;
|
|
||||||
_backWaiters = [];
|
|
||||||
w.forEach(fn => { try { fn(); } catch (_) {} });
|
|
||||||
}
|
|
||||||
|
|
||||||
/* history.back() ist ASYNCHRON. Wer direkt danach den Hash wechselt (closeModal +
|
|
||||||
* router.go), ueberholt den Ruecksprung: der laeuft erst NACH dem Hash-Wechsel und springt
|
|
||||||
* hinter den neuen Eintrag zurueck. Auf dem Bildschirm steht dann der Auftrag, in der
|
|
||||||
* Adresszeile aber '#/orders' — und das naechste router.navigate() (z.B. "Fertig" in der
|
|
||||||
* Kamera) zeichnet die Auftragsliste. Genau so flog Eddy am 17.09.2026 aus dem frisch
|
|
||||||
* angelegten Auftrag 111. router.go() wartet deshalb hierauf, bevor es den Hash setzt.
|
|
||||||
* Die Schutzzeit verhindert, dass ein ausbleibendes popstate die Navigation fuer immer
|
|
||||||
* blockiert (history.back() am Anfang der History ist ein stilles Nichts). */
|
|
||||||
window.historySettled = function () {
|
|
||||||
if (_pendingBacks <= 0) return Promise.resolve();
|
|
||||||
return new Promise(resolve => {
|
|
||||||
_backWaiters.push(resolve);
|
|
||||||
setTimeout(() => {
|
|
||||||
if (_pendingBacks > 0) { _pendingBacks = 0; _flushBackWaiters(); }
|
|
||||||
}, 1500);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
|
// Aktiver Select-Mode-Cleanup: wird von popstate gerufen, damit Android-Back
|
||||||
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
|
// eine laufende Mehrfachauswahl beendet statt die Seite zu verlassen.
|
||||||
let selectModeCleanup = null;
|
let selectModeCleanup = null;
|
||||||
|
|
||||||
function pushModal(el, cleanup) {
|
function pushModal(el, cleanup) {
|
||||||
modalStack.push({ el, cleanup: cleanup || null });
|
modalStack.push({ el, cleanup: cleanup || null });
|
||||||
const push = () => {
|
try {
|
||||||
// Inzwischen schon wieder geschlossen? Dann keinen verwaisten Eintrag anlegen.
|
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
|
||||||
if (!modalStack.some(m => m.el === el)) return;
|
} catch (e) { /* some browsers may block in strict sandboxes */ }
|
||||||
try {
|
|
||||||
history.pushState({ _modal: true, _ts: Date.now() }, '', location.hash);
|
|
||||||
} catch (e) { /* some browsers may block in strict sandboxes */ }
|
|
||||||
};
|
|
||||||
// Laeuft noch ein Ruecksprung eines eben geschlossenen Modals (Bestaetigung zu, naechster
|
|
||||||
// Dialog auf), erst den abwarten — sonst nimmt er den NEUEN Eintrag gleich wieder mit.
|
|
||||||
if (_pendingBacks > 0) window.historySettled().then(push);
|
|
||||||
else push();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeModal(el) {
|
function closeModal(el) {
|
||||||
|
|
@ -101,47 +65,21 @@ function closeModal(el) {
|
||||||
const entry = modalStack[idx];
|
const entry = modalStack[idx];
|
||||||
modalStack.splice(idx, 1);
|
modalStack.splice(idx, 1);
|
||||||
try { entry.el.remove(); } catch {}
|
try { entry.el.remove(); } catch {}
|
||||||
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion. Das passiert VOR
|
try { entry.cleanup && entry.cleanup(); } catch {}
|
||||||
// dem cleanup: navigiert der (Kamera: zurueck in den Auftrag), sieht router.go() den
|
// Eigenen History-Eintrag wieder entfernen, ohne popstate-Recursion
|
||||||
// ausstehenden Ruecksprung und wartet ihn ab, statt von ihm ueberholt zu werden.
|
|
||||||
if (history.state && history.state._modal) {
|
if (history.state && history.state._modal) {
|
||||||
_pendingBacks++;
|
_skipNextPopstate = true;
|
||||||
history.back();
|
history.back();
|
||||||
}
|
}
|
||||||
try { entry.cleanup && entry.cleanup(); } catch {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Darf die Seite JETZT neu laden? Gefragt von index.php, wenn nach einem Deploy ein neuer
|
|
||||||
* Service Worker uebernimmt. Bis 18.09.2026 lud sie dann sofort neu — mitten in der offenen
|
|
||||||
* Kamera, in der getippten Notiz, in der halben Unterschrift. Alles davon ist danach weg.
|
|
||||||
*
|
|
||||||
* Ausdruecklich NICHT beschaeftigt ist die PIN-Sperre: dort ist der beste Moment zum
|
|
||||||
* Neuladen. Wuerde sie als Modal zaehlen, kaeme der Reload direkt NACH der PIN-Eingabe —
|
|
||||||
* und die PIN-Abfrage gleich ein zweites Mal. */
|
|
||||||
window.appBusy = function () {
|
|
||||||
const app = document.getElementById('app');
|
|
||||||
if (app && app.style.visibility === 'hidden') return false; // PIN-Sperre
|
|
||||||
if (modalStack.length > 0 || selectModeCleanup) return true; // Kamera, Notiz, Unterschrift, Auswahl
|
|
||||||
try { if (offline.isSyncing()) return true; } catch (_) {} // halb gesendeter Upload
|
|
||||||
// Es wird gerade getippt. Suchfelder zaehlen nicht: am Handy behalten sie den Fokus, bis
|
|
||||||
// man die Seite verlaesst — der Reload kaeme nie, und ein Suchwort ist kein Verlust.
|
|
||||||
const a = document.activeElement;
|
|
||||||
if (a && /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) && a.type !== 'search') return true;
|
|
||||||
if (Array.from(document.querySelectorAll('audio')).some(x => !x.paused)) return true;
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
function isTopLevelHash(h) {
|
function isTopLevelHash(h) {
|
||||||
const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/';
|
const hh = (h || '').replace(/^#/, '').replace(/\/$/, '') || '/';
|
||||||
return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh);
|
return ['/', '/orders', '/today', '/customers', '/reports', '/settings'].includes(hh);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('popstate', () => {
|
window.addEventListener('popstate', () => {
|
||||||
if (_pendingBacks > 0) {
|
if (_skipNextPopstate) { _skipNextPopstate = false; return; }
|
||||||
_pendingBacks--;
|
|
||||||
if (_pendingBacks === 0) _flushBackWaiters();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (modalStack.length > 0) {
|
if (modalStack.length > 0) {
|
||||||
const top = modalStack.pop();
|
const top = modalStack.pop();
|
||||||
try { top.el.remove(); } catch {}
|
try { top.el.remove(); } catch {}
|
||||||
|
|
@ -852,9 +790,7 @@ router.on('/orders/:id', async (args) => {
|
||||||
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
<button class="btn btn-secondary" id="btn-new-report">📑 Neuen Bericht anlegen…</button>
|
||||||
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
<button class="btn btn-secondary" id="btn-shipments">🚚 Lieferungen</button>
|
||||||
|
|
||||||
<div id="pending-photos" data-order-id="${escapeHtml(String(args.id))}" style="margin-top:16px;"></div>
|
<div class="detail-section" style="margin-top:16px;">
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<div class="photo-section-head">
|
<div class="photo-section-head">
|
||||||
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
<h3 style="margin:0;">Hochgeladene Fotos (${imagePhotos.length})</h3>
|
||||||
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
${(imagePhotos.length + otherDocs.length) ? '<button class="btn btn-small" id="btn-select-mode" title="Mehrfachauswahl">☑ Auswählen</button>' : ''}
|
||||||
|
|
@ -1078,7 +1014,6 @@ router.on('/orders/:id', async (args) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
loadThumbs();
|
loadThumbs();
|
||||||
renderPendingPhotos(); // was noch in der Warteschlange liegt, gehoert sichtbar zum Auftrag
|
|
||||||
|
|
||||||
const camInput = document.getElementById('camera-input');
|
const camInput = document.getElementById('camera-input');
|
||||||
const galInput = document.getElementById('gallery-input');
|
const galInput = document.getElementById('gallery-input');
|
||||||
|
|
@ -1104,10 +1039,8 @@ router.on('/orders/:id', async (args) => {
|
||||||
offline.releaseWakeLock();
|
offline.releaseWakeLock();
|
||||||
}
|
}
|
||||||
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
|
// Nach Upload einfach die Route neu rendern — so werden Select-Mode,
|
||||||
// Click-Handler und Thumbnails sauber neu aufgebaut. Ausdruecklich diesen
|
// Click-Handler und Thumbnails sauber neu aufgebaut.
|
||||||
// Auftrag, nicht den Hash der Adresszeile (siehe Kamera-Cleanup).
|
router.navigate();
|
||||||
router.go('#/orders/' + args.id);
|
|
||||||
announcePendingPhotos(args.id);
|
|
||||||
}
|
}
|
||||||
camInput.addEventListener('change', () => handleFiles(camInput.files));
|
camInput.addEventListener('change', () => handleFiles(camInput.files));
|
||||||
galInput.addEventListener('change', () => handleFiles(galInput.files));
|
galInput.addEventListener('change', () => handleFiles(galInput.files));
|
||||||
|
|
@ -1116,113 +1049,6 @@ router.on('/orders/:id', async (args) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* WARTENDE FOTOS AM AUFTRAG
|
|
||||||
*
|
|
||||||
* Die Auftragsseite zeigte nur, was der Server schon hat. Fotos in der Warteschlange
|
|
||||||
* standen nirgends ausser als Zahl im Badge oben rechts. Wer im Funkloch fotografiert und
|
|
||||||
* danach in den Auftrag schaut, sieht: nichts — und haelt die Fotos fuer verloren
|
|
||||||
* (gemeldet 18.09.2026). Deshalb ein eigener Block mit Vorschaubildern direkt aus der
|
|
||||||
* Warteschlange und einem Satz, der sagt, was los ist.
|
|
||||||
*
|
|
||||||
* Der Block haengt an #pending-photos (data-order-id). Den gibt es nur, solange die
|
|
||||||
* Auftragsseite gezeichnet ist — daran erkennen die Listener unten, ob sie gemeint sind.
|
|
||||||
* ============================================================ */
|
|
||||||
const PENDING_PREVIEW_MAX = 12; // mehr Vorschaubilder kosten nur Speicher
|
|
||||||
let pendingPhotoUrls = [];
|
|
||||||
let pendingRenderTimer = null;
|
|
||||||
let orderRefreshTimer = null;
|
|
||||||
|
|
||||||
async function renderPendingPhotos() {
|
|
||||||
const box = document.getElementById('pending-photos');
|
|
||||||
if (!box) return;
|
|
||||||
const orderId = box.dataset.orderId;
|
|
||||||
const items = (await offline.listQueue().catch(() => []))
|
|
||||||
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId));
|
|
||||||
if (!document.body.contains(box)) return; // Seite wurde inzwischen neu gezeichnet
|
|
||||||
|
|
||||||
pendingPhotoUrls.forEach(u => { try { URL.revokeObjectURL(u); } catch (_) {} });
|
|
||||||
pendingPhotoUrls = [];
|
|
||||||
if (!items.length) { box.innerHTML = ''; return; }
|
|
||||||
|
|
||||||
const failed = items.filter(i => i.failed).length;
|
|
||||||
const waiting = items.length - failed;
|
|
||||||
const fotos = (n) => n + (n === 1 ? ' Foto' : ' Fotos');
|
|
||||||
const lines = [];
|
|
||||||
if (waiting) {
|
|
||||||
lines.push(navigator.onLine === false
|
|
||||||
? '📴 Kein Netz — ' + fotos(waiting) + (waiting === 1 ? ' ist' : ' sind')
|
|
||||||
+ ' auf dem Gerät gesichert und ' + (waiting === 1 ? 'wird' : 'werden')
|
|
||||||
+ ' gesendet, sobald wieder Netz da ist.'
|
|
||||||
: fotos(waiting) + (waiting === 1 ? ' ist' : ' sind') + ' auf dem Gerät gesichert und '
|
|
||||||
+ (waiting === 1 ? 'geht' : 'gehen') + ' raus, sobald die Verbindung steht.');
|
|
||||||
}
|
|
||||||
if (failed) lines.push('⚠️ ' + fotos(failed) + ' konnte' + (failed === 1 ? '' : 'n')
|
|
||||||
+ ' nicht hochgeladen werden — antippen zum Prüfen.');
|
|
||||||
|
|
||||||
const shown = items.slice(0, PENDING_PREVIEW_MAX);
|
|
||||||
const thumbs = shown.map(it => {
|
|
||||||
const url = URL.createObjectURL(new Blob([it.data], { type: it.mime || 'image/jpeg' }));
|
|
||||||
pendingPhotoUrls.push(url);
|
|
||||||
return '<div class="thumb pending-thumb' + (it.failed ? ' is-failed' : '') + '">'
|
|
||||||
+ '<img src="' + url + '" alt="" loading="lazy">'
|
|
||||||
+ '<span class="pending-mark">' + (it.failed ? '⚠️' : '⏳') + '</span></div>';
|
|
||||||
}).join('');
|
|
||||||
const more = items.length - shown.length;
|
|
||||||
|
|
||||||
box.innerHTML = '<div class="detail-section pending-section">'
|
|
||||||
+ '<h3>⏳ Warten auf Upload (' + items.length + ')</h3>'
|
|
||||||
+ lines.map(l => '<p>' + escapeHtml(l) + '</p>').join('')
|
|
||||||
+ '<div class="photo-grid pending-grid">' + thumbs
|
|
||||||
+ (more > 0 ? '<div class="thumb pending-thumb pending-more">+' + more + '</div>' : '')
|
|
||||||
+ '</div></div>';
|
|
||||||
box.querySelector('.pending-section').onclick = () => openUploadQueueModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Nach dem Schliessen der Kamera sagen, was mit den Fotos ist. Ohne den Satz sieht
|
|
||||||
* "gesichert, aber noch nicht gesendet" genauso aus wie "alles erledigt". */
|
|
||||||
async function announcePendingPhotos(orderId) {
|
|
||||||
const n = (await offline.listQueue().catch(() => []))
|
|
||||||
.filter(i => i.type === 'photo' && String(i.order_id) === String(orderId)).length;
|
|
||||||
if (!n) return;
|
|
||||||
const fotos = n + (n === 1 ? ' Foto' : ' Fotos');
|
|
||||||
showToast(navigator.onLine === false
|
|
||||||
? '📴 Kein Netz — ' + fotos + ' auf dem Gerät gesichert, Upload folgt automatisch'
|
|
||||||
: '⏳ ' + fotos + (n === 1 ? ' wartet' : ' warten') + ' noch auf den Upload', 'warn');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPendingPhotosSoon() {
|
|
||||||
if (!document.getElementById('pending-photos')) return;
|
|
||||||
clearTimeout(pendingRenderTimer);
|
|
||||||
pendingRenderTimer = setTimeout(renderPendingPhotos, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Kam ein Foto dieses Auftrags an, die Seite nachziehen — sonst steht es weder unter
|
|
||||||
* "Warten auf Upload" noch unter "Hochgeladene Fotos". Gebuendelt (eine Serie soll nicht
|
|
||||||
* je Foto neu zeichnen) und nur, wenn gerade nichts offen ist, das dabei kaputtginge. */
|
|
||||||
function scheduleOrderRefresh() {
|
|
||||||
clearTimeout(orderRefreshTimer);
|
|
||||||
orderRefreshTimer = setTimeout(async () => {
|
|
||||||
const box = document.getElementById('pending-photos');
|
|
||||||
if (!box) return; // Auftragsseite nicht mehr offen
|
|
||||||
const playing = Array.from(document.querySelectorAll('#main audio')).some(a => !a.paused);
|
|
||||||
if (modalStack.length || selectModeCleanup || playing) { scheduleOrderRefresh(); return; }
|
|
||||||
const y = window.scrollY, my = main().scrollTop;
|
|
||||||
try { await router.navigate('#/orders/' + box.dataset.orderId); } catch (_) {}
|
|
||||||
try { window.scrollTo(0, y); main().scrollTop = my; } catch (_) {}
|
|
||||||
}, 2500);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('photo-uploaded', (e) => {
|
|
||||||
const box = document.getElementById('pending-photos');
|
|
||||||
if (!box || String((e.detail || {}).orderId) !== String(box.dataset.orderId)) return;
|
|
||||||
renderPendingPhotosSoon();
|
|
||||||
scheduleOrderRefresh();
|
|
||||||
});
|
|
||||||
window.addEventListener('queue-changed', renderPendingPhotosSoon);
|
|
||||||
window.addEventListener('online', renderPendingPhotosSoon);
|
|
||||||
window.addEventListener('offline', renderPendingPhotosSoon);
|
|
||||||
|
|
||||||
/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität).
|
/* Fotoeinstellungen aus dem Bericht-Modul (Admin → Fotoqualität).
|
||||||
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
|
* Vorgabe ist Originalgroesse: 0 = gar nicht verkleinern. Der Wert wird lokal
|
||||||
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
|
* zwischengespeichert, damit die Kamera auch offline sofort weiss, was gilt.
|
||||||
|
|
@ -1250,22 +1076,17 @@ async function uploadPhoto(orderId, file) {
|
||||||
// gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
|
// gingen am 28.08.2026 acht Fotos verloren. Jetzt kann nach dem Sichern nichts
|
||||||
// mehr passieren, was das Foto kostet.
|
// mehr passieren, was das Foto kostet.
|
||||||
const name = file.name || ('foto_' + Date.now() + '.jpg');
|
const name = file.name || ('foto_' + Date.now() + '.jpg');
|
||||||
// Wird gleich verkleinert, kommt das Foto BELEGT in die Queue (hold) — sonst laedt der
|
const qid = await offline.enqueuePhoto(orderId, file, name);
|
||||||
// Service Worker das Original hoch, waehrend hier die kleine Fassung entsteht, und im
|
|
||||||
// Auftrag liegen zwei verschiedene Dateien.
|
|
||||||
const willShrink = photoCfg.photo_maxside > 0;
|
|
||||||
const qid = await offline.enqueuePhoto(orderId, file, name, { hold: willShrink });
|
|
||||||
|
|
||||||
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
|
// Verkleinern ist ab hier reine Kuer: nur wenn im Admin eingestellt, und wenn es
|
||||||
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
|
// schiefgeht oder haengt, bleibt schlicht das Original in der Queue.
|
||||||
if (willShrink) {
|
if (photoCfg.photo_maxside > 0) {
|
||||||
try {
|
try {
|
||||||
const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
|
const small = await resizeImage(file, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
|
||||||
if (small && small !== file && small.size < file.size) {
|
if (small && small !== file && small.size < file.size) {
|
||||||
await offline.replaceQueuedBlob(qid, small);
|
await offline.replaceQueuedBlob(qid, small);
|
||||||
}
|
}
|
||||||
} catch (_) { /* Original bleibt gesichert */ }
|
} catch (_) { /* Original bleibt gesichert */ }
|
||||||
finally { await offline.releaseHold(qid); }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (navigator.onLine) showToast('Sende ' + name + '…');
|
if (navigator.onLine) showToast('Sende ' + name + '…');
|
||||||
|
|
@ -3528,14 +3349,8 @@ async function openCameraModal(orderId) {
|
||||||
stopStream();
|
stopStream();
|
||||||
window.removeEventListener('photo-uploaded', onUploaded);
|
window.removeEventListener('photo-uploaded', onUploaded);
|
||||||
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
strip.querySelectorAll('img').forEach(img => { try { URL.revokeObjectURL(img.src); } catch (_) {} });
|
||||||
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip).
|
// Auftragsseite nur neu laden, wenn wirklich Fotos aufgenommen wurden (sonst unnötiger Roundtrip)
|
||||||
// Ausdruecklich DIESEN Auftrag ansteuern, nicht "was gerade in der Adresszeile steht":
|
if (shots > 0) { try { router.navigate(); } catch (_) {} }
|
||||||
// stand dort nach dem History-Race '#/orders', zeichnete router.navigate() die
|
|
||||||
// Auftragsliste und man flog nach "Fertig" aus dem Auftrag (17.09.2026).
|
|
||||||
if (shots > 0) {
|
|
||||||
try { router.go('#/orders/' + orderId); } catch (_) {}
|
|
||||||
announcePendingPhotos(orderId);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
|
modal.querySelector('#cam-close').onclick = () => closeModal(modal);
|
||||||
|
|
@ -3672,14 +3487,12 @@ async function openCameraModal(orderId) {
|
||||||
try {
|
try {
|
||||||
// Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto)
|
// Erst sichern, dann erst ueber Verkleinern nachdenken (siehe uploadPhoto)
|
||||||
const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
|
const name = 'foto_' + Date.now() + '_' + shots + '.jpg';
|
||||||
const willShrink = photoCfg.photo_maxside > 0;
|
const qid = await offline.enqueuePhoto(orderId, raw, name);
|
||||||
const qid = await offline.enqueuePhoto(orderId, raw, name, { hold: willShrink });
|
if (photoCfg.photo_maxside > 0) {
|
||||||
if (willShrink) {
|
|
||||||
try {
|
try {
|
||||||
const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
|
const small = await resizeImage(raw, photoCfg.photo_maxside, 15000, photoCfg.photo_quality / 100);
|
||||||
if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small);
|
if (small && small !== raw && small.size < raw.size) await offline.replaceQueuedBlob(qid, small);
|
||||||
} catch (_) { /* Original bleibt gesichert */ }
|
} catch (_) { /* Original bleibt gesichert */ }
|
||||||
finally { await offline.releaseHold(qid); }
|
|
||||||
}
|
}
|
||||||
thumb.dataset.qid = qid;
|
thumb.dataset.qid = qid;
|
||||||
thumb.classList.remove('saving');
|
thumb.classList.remove('saving');
|
||||||
|
|
|
||||||
25
index.php
25
index.php
|
|
@ -156,28 +156,13 @@ if ('serviceWorker' in navigator) {
|
||||||
ziel.postMessage({ type: 'PRECACHE', urls: urls });
|
ziel.postMessage({ type: 'PRECACHE', urls: urls });
|
||||||
}).catch(function () {});
|
}).catch(function () {});
|
||||||
|
|
||||||
/* Controller-Change → einmal neu laden — aber nicht mitten in der Arbeit.
|
// Controller-Change → einmal neu laden
|
||||||
Frueher kam der Reload sofort: offene Kamera zu, getippte Notiz weg, halbe
|
var reloaded = false;
|
||||||
Unterschrift weg (und lagen noch Fotos in der Warteschlange, zeigte der Browser
|
|
||||||
dazu seinen eigenen "Seite verlassen?"-Dialog). Jetzt fragt die Seite app.js
|
|
||||||
(window.appBusy) und holt den Reload nach, sobald nichts mehr offen ist —
|
|
||||||
spaetestens, wenn die App in den Hintergrund geht und dabei nichts offen ist. */
|
|
||||||
var reloadWanted = false, reloaded = false;
|
|
||||||
function tryReload() {
|
|
||||||
if (!reloadWanted || reloaded) return;
|
|
||||||
var busy = false;
|
|
||||||
try { busy = (typeof window.appBusy === 'function') && window.appBusy(); } catch (_) {}
|
|
||||||
if (busy) return;
|
|
||||||
reloaded = true;
|
|
||||||
window.__selfReload = true; // offline.js: eigener Reload, keine beforeunload-Warnung
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||||
reloadWanted = true;
|
if (reloaded) return;
|
||||||
tryReload();
|
reloaded = true;
|
||||||
|
window.location.reload();
|
||||||
});
|
});
|
||||||
setInterval(tryReload, 3000);
|
|
||||||
document.addEventListener('visibilitychange', tryReload);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
65
lib/idb.js
65
lib/idb.js
|
|
@ -107,68 +107,5 @@
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nur die Ids — wer die Queue abarbeitet, holt die Eintraege danach einzeln. queueAll()
|
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate };
|
||||||
// zieht dagegen jedes Foto komplett in den Speicher.
|
|
||||||
async function queueKeys() {
|
|
||||||
const db = await open();
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
const tx = db.transaction('queue', 'readonly');
|
|
||||||
const r = tx.objectStore('queue').getAllKeys();
|
|
||||||
r.onsuccess = () => res(r.result || []);
|
|
||||||
r.onerror = () => rej(r.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Lesen-Aendern-Schreiben in EINER Transaktion. IndexedDB reiht readwrite-Transaktionen
|
|
||||||
* auf demselben Store ueber alle Kontexte hinweg hintereinander — Seite und Service
|
|
||||||
* Worker koennen sich hier also nicht ins Wort fallen. `fn(item)` aendert den Eintrag an
|
|
||||||
* Ort und Stelle; gibt sie `false` zurueck, wird NICHT geschrieben.
|
|
||||||
* Rueckgabe: der Eintrag nach der Aenderung, oder null (weg bzw. nicht geschrieben). */
|
|
||||||
async function queuePatch(id, fn) {
|
|
||||||
const db = await open();
|
|
||||||
return new Promise((res, rej) => {
|
|
||||||
const tx = db.transaction('queue', 'readwrite');
|
|
||||||
const store = tx.objectStore('queue');
|
|
||||||
let out = null;
|
|
||||||
const r = store.get(id);
|
|
||||||
r.onsuccess = () => {
|
|
||||||
const it = r.result;
|
|
||||||
if (!it) return; // inzwischen hochgeladen und geloescht
|
|
||||||
if (fn(it) === false) return;
|
|
||||||
store.put(it);
|
|
||||||
out = it;
|
|
||||||
};
|
|
||||||
tx.oncomplete = () => res(out);
|
|
||||||
tx.onerror = () => rej(tx.error);
|
|
||||||
tx.onabort = () => rej(tx.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Einen Eintrag fuer den Upload belegen (Lease). Seite und Service Worker arbeiten
|
|
||||||
* dieselbe Queue ab; ohne Absprache laden beide dasselbe Foto hoch (Prod-Log 17.09.2026:
|
|
||||||
* reihenweise duplicate:true). Belegt wird nur, was frei ist oder dessen Lease abgelaufen
|
|
||||||
* ist — eine eingefrorene oder abgeschossene Seite blockiert ihr Foto also nicht ewig.
|
|
||||||
* DIESELBE LOGIK STEHT IN sw.js (queueClaim) — beide Stellen zusammen aendern.
|
|
||||||
* Rueckgabe: der belegte Eintrag, oder null (weg, fehlgeschlagen-markiert oder belegt). */
|
|
||||||
function queueClaim(id, owner, leaseMs) {
|
|
||||||
const now = Date.now();
|
|
||||||
return queuePatch(id, (it) => {
|
|
||||||
if (it.failed) return false;
|
|
||||||
if (it.uploading_since && it.uploading_by !== owner
|
|
||||||
&& (now - it.uploading_since) < leaseMs) return false;
|
|
||||||
it.uploading_since = now;
|
|
||||||
it.uploading_by = owner;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueRelease(id, extra) {
|
|
||||||
return queuePatch(id, (it) => {
|
|
||||||
delete it.uploading_since;
|
|
||||||
delete it.uploading_by;
|
|
||||||
if (extra) extra(it);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.idb = { get, set, del, keys, queuePush, queueAll, queueDelete, queueUpdate,
|
|
||||||
queueKeys, queuePatch, queueClaim, queueRelease };
|
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
108
lib/offline.js
108
lib/offline.js
|
|
@ -14,26 +14,10 @@
|
||||||
let lastUnsynced = 0; // ALLE noch nicht bestätigten Items (für Beforeunload-Warnung)
|
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)
|
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
|
async function enqueuePhoto(orderId, fileBlob, filename) {
|
||||||
* 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
|
// Blob → ArrayBuffer für IndexedDB-Speicherung
|
||||||
const buf = await fileBlob.arrayBuffer();
|
const buf = await fileBlob.arrayBuffer();
|
||||||
const item = {
|
const id = await idb.queuePush({
|
||||||
type: 'photo',
|
type: 'photo',
|
||||||
order_id: orderId,
|
order_id: orderId,
|
||||||
filename,
|
filename,
|
||||||
|
|
@ -42,40 +26,24 @@
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
failed: false,
|
failed: false,
|
||||||
created: Date.now(),
|
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();
|
await updateBadge();
|
||||||
emitChange();
|
emitChange();
|
||||||
registerBackgroundSync(); // auch ohne offene App nachliefern
|
registerBackgroundSync(); // auch ohne offene App nachliefern
|
||||||
return id;
|
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
|
// Blob eines bereits gesicherten Queue-Items ersetzen. Gebraucht fuer das
|
||||||
// Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt
|
// Verkleinern: gesichert wird zuerst das Original, die kleinere Fassung ersetzt
|
||||||
// es erst danach. Klappt das Verkleinern nicht, bleibt das Original — nie ein Loch.
|
// 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) {
|
async function replaceQueuedBlob(id, fileBlob) {
|
||||||
const buf = await fileBlob.arrayBuffer();
|
const items = await idb.queueAll().catch(() => []);
|
||||||
const it = await idb.queuePatch(id, (x) => {
|
const it = items.find(x => x.id === id);
|
||||||
x.data = buf;
|
if (!it) return false; // schon hochgeladen und geloescht
|
||||||
x.mime = fileBlob.type || x.mime || 'image/jpeg';
|
it.data = await fileBlob.arrayBuffer();
|
||||||
});
|
it.mime = fileBlob.type || it.mime || 'image/jpeg';
|
||||||
return !!it; // null: schon hochgeladen und geloescht
|
await idb.queueUpdate(it);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar
|
// Background Sync: der Browser holt die Queue auch dann nach, wenn die App gar
|
||||||
|
|
@ -172,16 +140,10 @@
|
||||||
try {
|
try {
|
||||||
do {
|
do {
|
||||||
syncAgain = false;
|
syncAgain = false;
|
||||||
// Nur die Ids holen und jedes Foto einzeln belegen: so liegt immer nur EIN
|
const items = await idb.queueAll();
|
||||||
// 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;
|
let stoppedByNetwork = false;
|
||||||
for (const id of ids) {
|
for (const it of items) {
|
||||||
// null: schon weg, in Quarantaene — oder gerade vom Service Worker bzw.
|
if (it.failed) continue; // quarantänisiert: behalten, überspringen
|
||||||
// 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; }
|
if (it.type !== 'photo') { await idb.queueDelete(it.id); continue; }
|
||||||
try {
|
try {
|
||||||
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
|
const blob = new Blob([it.data], { type: it.mime || 'image/jpeg' });
|
||||||
|
|
@ -201,22 +163,16 @@
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e);
|
console.warn('[Sync] Item ' + it.id + ' fehlgeschlagen:', e);
|
||||||
if (isTransient(e)) {
|
if (isTransient(e)) {
|
||||||
// Netzproblem → abbrechen; ALLES bleibt erhalten, nächster Versuch später.
|
// 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;
|
stoppedByNetwork = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
|
// Dauerhafter Fehler → Versuchszähler hoch, ggf. Quarantäne,
|
||||||
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
|
// aber weiter mit den nächsten Items (ein „Poison-Item" blockiert nicht mehr die Queue).
|
||||||
const msg = (e && e.message) || 'Fehler';
|
it.attempts = (it.attempts || 0) + 1;
|
||||||
try {
|
it.last_error = (e && e.message) || 'Fehler';
|
||||||
await idb.queueRelease(it.id, (x) => {
|
if (it.attempts >= MAX_ATTEMPTS) it.failed = true;
|
||||||
x.attempts = (x.attempts || 0) + 1;
|
try { await idb.queueUpdate(it); } catch (_) {}
|
||||||
x.last_error = msg;
|
|
||||||
if (x.attempts >= MAX_ATTEMPTS) x.failed = true;
|
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stoppedByNetwork) break;
|
if (stoppedByNetwork) break;
|
||||||
|
|
@ -240,16 +196,14 @@
|
||||||
|
|
||||||
// Quarantänisierte Items reaktivieren und erneut versuchen
|
// Quarantänisierte Items reaktivieren und erneut versuchen
|
||||||
async function retryFailed() {
|
async function retryFailed() {
|
||||||
const ids = await idb.queueKeys().catch(() => []);
|
const items = await idb.queueAll().catch(() => []);
|
||||||
for (const id of ids) {
|
for (const it of items) {
|
||||||
try {
|
if (it.failed) {
|
||||||
await idb.queuePatch(id, (it) => {
|
it.failed = false;
|
||||||
if (!it.failed) return false;
|
it.attempts = 0;
|
||||||
it.failed = false;
|
delete it.last_error;
|
||||||
it.attempts = 0;
|
try { await idb.queueUpdate(it); } catch (_) {}
|
||||||
delete it.last_error;
|
}
|
||||||
});
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
}
|
||||||
await updateBadge();
|
await updateBadge();
|
||||||
emitChange();
|
emitChange();
|
||||||
|
|
@ -300,10 +254,7 @@
|
||||||
// Warnung, wenn die App mit noch nicht hochgeladenen Fotos geschlossen/neu geladen wird.
|
// 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.
|
// 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.
|
// 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) => {
|
window.addEventListener('beforeunload', (e) => {
|
||||||
if (window.__selfReload) return;
|
|
||||||
if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; }
|
if (lastUnsynced > 0) { e.preventDefault(); e.returnValue = ''; return ''; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -390,12 +341,7 @@
|
||||||
return all.length;
|
return all.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Laeuft gerade ein Upload? Daran haengt u.a., ob die Seite nach einem App-Update neu
|
window.offline = { enqueuePhoto, syncQueue, updateBadge, queueCount, listQueue, retryFailed,
|
||||||
// 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,
|
acquireWakeLock, releaseWakeLock, replaceQueuedBlob, registerBackgroundSync,
|
||||||
mirrorSave, mirrorLoad, mirrorClear };
|
mirrorSave, mirrorLoad, mirrorClear };
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -45,18 +45,11 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(hash) {
|
function go(hash) {
|
||||||
// Erst ausstehende history.back() (geschlossene Modals) durchlaufen lassen. Ein
|
if (hash !== window.location.hash) {
|
||||||
// Hash-Wechsel davor wird vom Ruecksprung ueberholt, die Adresse steht danach auf
|
window.location.hash = hash;
|
||||||
// der ALTEN Route, waehrend die neue angezeigt wird (app.js: historySettled).
|
} else {
|
||||||
const settled = (typeof window.historySettled === 'function')
|
navigate(hash);
|
||||||
? window.historySettled() : Promise.resolve();
|
}
|
||||||
settled.then(() => {
|
|
||||||
if (hash !== window.location.hash) {
|
|
||||||
window.location.hash = hash;
|
|
||||||
} else {
|
|
||||||
navigate(hash);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('hashchange', () => navigate());
|
window.addEventListener('hashchange', () => navigate());
|
||||||
|
|
|
||||||
71
sw.js
71
sw.js
|
|
@ -290,71 +290,15 @@ function queueDb() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueIds(db) {
|
function queueRead(db) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(QUEUE_STORE, 'readonly');
|
const tx = db.transaction(QUEUE_STORE, 'readonly');
|
||||||
const req = tx.objectStore(QUEUE_STORE).getAllKeys();
|
const req = tx.objectStore(QUEUE_STORE).getAll();
|
||||||
req.onsuccess = () => resolve(req.result || []);
|
req.onsuccess = () => resolve(req.result || []);
|
||||||
req.onerror = () => reject(req.error);
|
req.onerror = () => reject(req.error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Die Seite arbeitet dieselbe Queue ab (lib/offline.js, syncQueue). Ohne Absprache luden
|
|
||||||
* beide dasselbe Foto hoch — im Prod-Log vom 17.09.2026 reihenweise duplicate:true, im
|
|
||||||
* Mobilfunk also das doppelte Datenvolumen. Deshalb belegt, wer hochlaedt, den Eintrag
|
|
||||||
* (Lease) — in EINER readwrite-Transaktion, die IndexedDB ueber alle Kontexte hinweg
|
|
||||||
* hintereinander ausfuehrt. DIESELBE LOGIK STEHT IN lib/idb.js (queueClaim) — beide
|
|
||||||
* Stellen zusammen aendern.
|
|
||||||
*
|
|
||||||
* Die Lease haelt laenger als der laengste Upload-Timeout (60 s, unten), damit niemand
|
|
||||||
* einen LAUFENDEN Upload uebernimmt; nach Ablauf ist der Eintrag wieder frei — fuer die
|
|
||||||
* eingefrorene oder abgeschossene Seite gibt es den Background Sync ja gerade. */
|
|
||||||
const SW_OWNER = 'sw-' + Math.random().toString(36).slice(2, 10);
|
|
||||||
const LEASE_MS = 90000;
|
|
||||||
|
|
||||||
/* Rueckgabe: { item } wenn belegt, { busy:true } wenn gerade ein anderer dran ist
|
|
||||||
* (Seite laedt hoch oder verkleinert noch), sonst {} (weg, Quarantaene, kein Foto). */
|
|
||||||
function queueClaim(db, id) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
|
||||||
const store = tx.objectStore(QUEUE_STORE);
|
|
||||||
const out = {};
|
|
||||||
const req = store.get(id);
|
|
||||||
req.onsuccess = () => {
|
|
||||||
const it = req.result;
|
|
||||||
if (!it || it.failed || it.type !== 'photo') return;
|
|
||||||
const now = Date.now();
|
|
||||||
if (it.uploading_since && it.uploading_by !== SW_OWNER
|
|
||||||
&& (now - it.uploading_since) < LEASE_MS) { out.busy = true; return; }
|
|
||||||
it.uploading_since = now;
|
|
||||||
it.uploading_by = SW_OWNER;
|
|
||||||
store.put(it);
|
|
||||||
out.item = it;
|
|
||||||
};
|
|
||||||
tx.oncomplete = () => resolve(out);
|
|
||||||
tx.onerror = () => reject(tx.error);
|
|
||||||
tx.onabort = () => reject(tx.error);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueRelease(db, id) {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
|
||||||
const store = tx.objectStore(QUEUE_STORE);
|
|
||||||
const req = store.get(id);
|
|
||||||
req.onsuccess = () => {
|
|
||||||
const it = req.result;
|
|
||||||
if (!it || it.uploading_by !== SW_OWNER) return;
|
|
||||||
delete it.uploading_since;
|
|
||||||
delete it.uploading_by;
|
|
||||||
store.put(it);
|
|
||||||
};
|
|
||||||
tx.oncomplete = () => resolve();
|
|
||||||
tx.onerror = () => resolve(); // Freigeben darf nie den Lauf kippen — die Lease laeuft ohnehin ab
|
|
||||||
tx.onabort = () => resolve();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function queueRemove(db, id) {
|
function queueRemove(db, id) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
const tx = db.transaction(QUEUE_STORE, 'readwrite');
|
||||||
|
|
@ -405,15 +349,11 @@ async function uploadQueued(item) {
|
||||||
|
|
||||||
async function drainQueue() {
|
async function drainQueue() {
|
||||||
const db = await queueDb();
|
const db = await queueDb();
|
||||||
const ids = await queueIds(db);
|
const items = (await queueRead(db)).filter(i => i.type === 'photo' && !i.failed);
|
||||||
if (!ids.length) return;
|
if (!items.length) return;
|
||||||
|
|
||||||
let left = 0;
|
let left = 0;
|
||||||
for (const id of ids) {
|
for (const it of items) {
|
||||||
const claim = await queueClaim(db, id);
|
|
||||||
if (claim.busy) { left++; continue; } // die Seite ist dran — zaehlt als offen, damit
|
|
||||||
if (!claim.item) continue; // der Browser nachfasst, falls sie es nicht schafft
|
|
||||||
const it = claim.item;
|
|
||||||
try {
|
try {
|
||||||
const res = await uploadQueued(it);
|
const res = await uploadQueued(it);
|
||||||
await queueRemove(db, it.id);
|
await queueRemove(db, it.id);
|
||||||
|
|
@ -427,7 +367,6 @@ async function drainQueue() {
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
left++; // bleibt in der Queue, naechster Sync versucht es erneut
|
left++; // bleibt in der Queue, naechster Sync versucht es erneut
|
||||||
await queueRelease(db, it.id); // sofort freigeben, sonst wartet die Seite 90 s
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue