diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 254d058..6e2ad12 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-25 +- In Chromium browsers, saving writes back to the opened file, and the same file reopens into the same document - Open a local `.werkbaum` file and save the document back as a file, from the document menu - An AI integration recorded as an idea in the plan — edit the tree in a dialogue, with your own API key and `llms.md` as the model's guide - A warning flags the bottleneck when one `@name` carries more than half of the open work on the cheapest path — their tag pills turn amber on the open nodes diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 916377e..66ecfff 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5339,3 +5339,53 @@ Input ist geleert (dieselbe Datei bleibt erneut wählbar); Speichern liefert `tests/localfile.test.js`. Werkzeuggrenze wie in D25/D53: Der echte Dateidialog und der echte Download lassen sich nicht automatisiert auslösen — geprüft ist alles bis an diese Kante. + +**Nachtrag — Stufe 2: die File System Access API macht aus „Speichern unter" +ein „Speichern".** Wo die Picker existieren (`showOpenFilePicker` als +Feature-Detection — Chromium; Firefox und Safari haben sie bewusst nicht), +ändert sich hinter denselben zwei Menü-Einträgen das Verhalten: + +- **Öffnen** liefert ein `FileSystemFileHandle`. Damit gibt es die + Datei-Identität, die Stufe 1 nicht hatte: `isSameEntry` prüft gegen die + gemerkten Handles, **dieselbe Datei öffnet wieder in dasselbe Dokument** + (aktualisiert den Text), eine andere Datei gleichen Namens bleibt ein + eigenes. `adoptFile()` trägt beide Wege — der Stufe-1-Input ruft es ohne + Handle, dann entsteht immer ein neues Dokument wie bisher. +- **Speichern** schreibt mit gemerktem Handle **in dieselbe Datei zurück** + (`createWritable`), ohne Dialog. Ohne Handle fragt `showSaveFilePicker` + (mit `saveFileName()` als Vorschlag) und merkt sich das Ergebnis — der + Komfort greift ab dem zweiten Speichern. Ein **Abbruch** des Dialogs tut + nichts — bewusst auch kein Download hinterher: Wer abbricht, will nicht + woandershin speichern. Der Menü-Eintrag trägt den Dateinamen des Handles + als Tooltip (Dateinamen sind Daten, kein i18n). +- **Handles überleben den Neustart in IndexedDB** — localStorage kann sie + nicht halten (nicht JSON-serialisierbar), IndexedDB kann es (structured + clone). Beim Start werden sie zurückgeholt; verwaiste Einträge (Dokument + gelöscht) und Fremdes ohne `createWritable` räumen sich dabei weg. Nach dem + Neustart steht die Berechtigung auf `prompt` — der Browser fragt beim + ersten Speichern einmal nach (der Menü-Klick ist die nötige Nutzergeste); + verweigert er, entscheidet der Dialog neu. **Alles daran ist Komfort, keine + Pflicht**: Jeder IndexedDB-Fehler wird geschluckt, Speichern funktioniert + dann eben wieder über den Dialog. +- `deleteDoc()` nimmt das Handle mit (Map und IndexedDB) — wie die Stände + (D54): Mit dem Dokument geht, was an ihm hängt. + +**Kein Strg+S** — erwogen und zurückgestellt: Der Browser-Default (Seite +speichern) müsste abgefangen werden, und ohne Handle öffnete die Geste +unvermittelt einen Dialog; wenn, dann als eigene Entscheidung mit +Legenden-Zeile. + +**Nachgemessen** im Browser (echtes Chromium, `hasFsAccess === true`; die +Picker gestubbt — den nativen Dialog kann die Automatisierung nicht bedienen, +die Logik dahinter schon; Werkzeuggrenze wie D52): Öffnen legt das Dokument +mit Handle an, der Speichern-Eintrag trägt den Dateinamen als Tooltip, +Speichern schreibt in place (**0** Dialog-Aufrufe), dieselbe Datei erneut +geöffnet aktualisiert **dasselbe** Dokument (Anzahl unverändert, Text auf +Version 2, Diagramm folgt); ein Dokument ohne Handle bekommt beim ersten +Speichern den Dialog (`suggestedName` korrekt) und beim zweiten nicht mehr +(1 Aufruf, 2 Schreibvorgänge); Abbruch bzw. verweigerte Berechtigung +schreiben nichts und laden nichts herunter. **Nicht messbar** blieb die +IndexedDB-Rundreise über einen Neustart: Stub-Handles überleben den +Structured Clone nicht (`DataCloneError`, planmäßig geschluckt) — echte +Handles sind gerade dafür klonbar; dieser eine Pfad ist Code-Review statt +Messung. 480 Tests. diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index a2bc003..6174722 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -87,6 +87,7 @@ - [^] #ed.snaps: Earlier states of a document, every ten minutes (S) %% only when something changed - [^] #ed.snaps.manual: Save a state by hand, before a larger change (XS) %% ten minutes is the wrong beat for that moment - [x] #ed.files: Open and save .werkbaum files (S) + - [x] #ed.files.inplace: Saving writes back into the opened file (S) %% File System Access, Chromium + [^] #ed.fresh: Show what is new since your last visit (S) - [^] #ed.fresh.news: A star in the header, with the last few days (S) %% see D58 + [?] #ed.percolor: A pastel colour per person (S) @@ -608,6 +609,11 @@ format was the one thing that could not leave the browser as a file, while the diagram always could. +#ed.files.inplace + Where the File System Access API exists (Chromium), opening keeps a file + handle: saving writes back into the same file, and the same file reopens + into the same document. Firefox and Safari keep the plain download. + #ed.fresh For documents that come from outside, nodes that went to production since your last visit get a yellow halo. New means live, not "line added" — a line diff --git a/frontend/src/app.js b/frontend/src/app.js index 44e803b..e41c028 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -7,7 +7,7 @@ import { padUrls } from './remote.js'; import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js'; import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } from './snapshots.js'; -import { FILE_ACCEPT, saveFileName } from './localfile.js'; +import { FILE_ACCEPT, FILE_TYPES, saveFileName } from './localfile.js'; /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in vite.config.js). Zur Laufzeit gibt es kein git — und keinen Server, der nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */ @@ -3499,7 +3499,14 @@ function restoreDoc(){ persistDocs(); closeDocMenu(); } -function openDocMenu(){ renderDocMenu(); updateRestoreBtn(); docMenu.hidden = false; docTrigger.setAttribute('aria-expanded', 'true'); } +function openDocMenu(){ + renderDocMenu(); updateRestoreBtn(); + /* Wohin „Als Datei speichern" schreiben würde: der Dateiname des gemerkten + Handles als Tooltip (D72, Stufe 2) — Dateinamen sind Daten, kein i18n. */ + const h = fileHandles.get(activeId); + document.getElementById('docSaveFile').title = h ? h.name : ''; + docMenu.hidden = false; docTrigger.setAttribute('aria-expanded', 'true'); +} function closeDocMenu(){ renamingId = null; renameIsNew = false; docMenu.hidden = true; docTrigger.setAttribute('aria-expanded', 'false'); } function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); } /* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive @@ -3846,6 +3853,7 @@ function deleteDoc(){ if(!d) return; if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) return; if(padSource && padSource.id === d.id) stopPad(); /* danach gibt es nichts mehr zu holen (D31) */ + if(fileHandles.has(d.id)){ fileHandles.delete(d.id); idbDeleteHandle(d.id); } /* mit dem Dokument geht sein Datei-Handle (D72) */ docs = docs.filter(x => x.id !== d.id); if(snaps[d.id]){ delete snaps[d.id]; persistSnaps(snaps, localStorage); } /* mit dem Dokument gehen seine Stände (D54) */ if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }]; @@ -3873,23 +3881,138 @@ fileOpenInput.addEventListener('change', async () => { if(!f) return; let text; try{ text = await f.text(); }catch(_){ return; } + adoptFile(null, f.name, text); +}); +/* ---------- Stufe 2 (D72-Nachtrag): File System Access API ---------- + Wo die Picker existieren (Chromium), liefert das Öffnen ein + FileSystemFileHandle: Speichern schreibt in DIESELBE Datei zurück, und + dieselbe Datei öffnet wieder in dasselbe Dokument (isSameEntry) — die + Datei-Identität, die Stufe 1 nicht hatte. Firefox/Safari behalten den + Stufe-1-Weg; die Bedienung ist in beiden Fällen dieselbe. */ +const hasFsAccess = typeof window.showOpenFilePicker === 'function'; +const fileHandles = new Map(); /* docId → FileSystemFileHandle */ +/* Handles überleben den Neustart nur in IndexedDB (localStorage kann sie + nicht halten — sie sind nicht JSON-serialisierbar). Alles hier ist Komfort, + keine Pflicht: Scheitert IndexedDB, funktioniert Speichern weiter über den + Dialog — deshalb schlucken die Helfer ihre Fehler. */ +const IDB_STORE = 'fileHandles'; +function idbOpen(){ + return new Promise((res, rej) => { + const rq = indexedDB.open('werkbaum', 1); + rq.onupgradeneeded = () => rq.result.createObjectStore(IDB_STORE); + rq.onsuccess = () => res(rq.result); + rq.onerror = () => rej(rq.error); + }); +} +async function idbHandleOp(mode, fn){ + try{ + const db = await idbOpen(); + const out = await new Promise((res, rej) => { + const tx = db.transaction(IDB_STORE, mode); + const r = fn(tx.objectStore(IDB_STORE)); + tx.oncomplete = () => res(r); + tx.onerror = () => rej(tx.error); + }); + db.close(); + return out; + }catch(_){ return null; } +} +function idbPutHandle(docId, handle){ return idbHandleOp('readwrite', s => { s.put(handle, docId); }); } +function idbDeleteHandle(docId){ return idbHandleOp('readwrite', s => { s.delete(docId); }); } +/* Beim Start die gemerkten Handles zurückholen — nur für Dokumente, die es + noch gibt (verwaiste Einträge räumen sich dabei weg), und nur, was sich wie + ein Handle verhält (defensiv gegen fremden Speicherinhalt). */ +async function idbLoadHandles(){ + const pair = await idbHandleOp('readonly', s => [s.getAllKeys(), s.getAll()]); + if(!pair) return; + const keys = pair[0].result || [], vals = pair[1].result || []; + keys.forEach((k, i) => { + const h = vals[i]; + if(h && typeof h.createWritable === 'function' && docs.some(d => d.id === k)){ + fileHandles.set(k, h); + } else { + idbDeleteHandle(k); + } + }); +} +/* Text übernehmen: dieselbe Datei (Handle-Identität) aktualisiert ihr + Dokument, sonst entsteht ein neues — dieselbe Zweiteilung wie adoptRemote(). */ +async function adoptFile(handle, name, text){ flushActive(); - const d = { id: uid(), name: uniqueName(f.name), text }; - docs.push(d); - activeId = d.id; + let targetId = null; + if(handle){ + for(const [id, h] of fileHandles){ + try{ if(await handle.isSameEntry(h) && docs.some(d => d.id === id)){ targetId = id; break; } } + catch(_){} + } + } + if(targetId){ + docs.find(d => d.id === targetId).text = text; + activeId = targetId; + } else { + const d = { id: uid(), name: uniqueName(name), text }; + docs.push(d); + activeId = d.id; + } + if(handle){ fileHandles.set(activeId, handle); idbPutHandle(activeId, handle); } foldOverrides.clear(); loadActiveIntoEditor(); persistDocs(); closeDocMenu(); -}); -function openLocalFile(){ fileOpenInput.click(); } -/* Speichern über den vorhandenen Blob-Download (saveBlob, Grafikexport): - UTF-8, LF — das Textfeld normalisiert Zeilenenden ohnehin auf \n (D24). */ -function saveLocalFile(){ +} +async function openWithPicker(){ + let handle; + try{ [handle] = await window.showOpenFilePicker({types: FILE_TYPES}); } + catch(_){ return; } /* Abbruch des Dialogs */ + let text; + try{ text = await (await handle.getFile()).text(); }catch(_){ return; } + await adoptFile(handle, handle.name, text); +} +function openLocalFile(){ + if(hasFsAccess) openWithPicker(); + else fileOpenInput.click(); +} +async function writeToHandle(handle, text){ + const w = await handle.createWritable(); + await w.write(text); + await w.close(); +} +/* In das gemerkte Handle zurückschreiben. Nach einem Neustart steht die + Berechtigung auf 'prompt' — requestPermission fragt einmal nach (der + Menü-Klick ist die nötige Nutzergeste). false heißt: kein Handle oder + nicht (mehr) beschreibbar — dann entscheidet der Dialog neu. */ +async function saveToKnownFile(d){ + const h = fileHandles.get(d.id); + if(!h) return false; + try{ + let perm = await h.queryPermission({mode: 'readwrite'}); + if(perm === 'prompt') perm = await h.requestPermission({mode: 'readwrite'}); + if(perm !== 'granted') return false; + await writeToHandle(h, d.text); + return true; + }catch(_){ return false; } +} +async function saveWithPicker(d){ + let handle; + try{ + handle = await window.showSaveFilePicker({suggestedName: saveFileName(d.name), types: FILE_TYPES}); + }catch(_){ return; } /* Abbruch: bewusst kein Download hinterher */ + try{ await writeToHandle(handle, d.text); }catch(_){ return; } + fileHandles.set(d.id, handle); + idbPutHandle(d.id, handle); +} +/* Speichern: mit Handle in dieselbe Datei, sonst Dialog (Stufe 2) bzw. + Blob-Download (Stufe 1). UTF-8, LF — das Textfeld normalisiert Zeilenenden + ohnehin auf \n (D24). */ +async function saveLocalFile(){ flushActive(); const d = activeDoc(); if(!d) return; - saveBlob(new Blob([d.text], {type:'text/plain;charset=utf-8'}), saveFileName(d.name)); + if(hasFsAccess){ + if(!(await saveToKnownFile(d))) await saveWithPicker(d); + } else { + saveBlob(new Blob([d.text], {type:'text/plain;charset=utf-8'}), saveFileName(d.name)); + } closeDocMenu(); } /* Dokumente laden + aktiven Text in den Editor holen (nach applyLang). */ @@ -4444,6 +4567,7 @@ let startLang = 'de'; try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_){ startLang = detectLang(); } applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */ +if(hasFsAccess) idbLoadHandles(); /* gemerkte Datei-Handles zurückholen (D72, Stufe 2) */ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ diff --git a/frontend/src/localfile.js b/frontend/src/localfile.js index 75292af..fa9ef97 100644 --- a/frontend/src/localfile.js +++ b/frontend/src/localfile.js @@ -22,3 +22,9 @@ export function saveFileName(docName){ if(/\.(werkbaum|txt)$/i.test(base)) return base; return base + '.werkbaum'; } + +/* Dialog-Typen der File System Access API (Stufe 2): derselbe Filter wie + FILE_ACCEPT, in der Form von showOpenFilePicker/showSaveFilePicker. */ +export const FILE_TYPES = [ + {description: 'Werkbaum', accept: {'text/plain': ['.werkbaum', '.txt']}}, +]; diff --git a/frontend/tests/localfile.test.js b/frontend/tests/localfile.test.js index 529ecba..688dc68 100644 --- a/frontend/tests/localfile.test.js +++ b/frontend/tests/localfile.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { saveFileName, FILE_ACCEPT } from '../src/localfile.js'; +import { saveFileName, FILE_ACCEPT, FILE_TYPES } from '../src/localfile.js'; /* Öffnen/Speichern lokaler Dateien (D72): der Dateiname beim Speichern wird aus dem Dokumentnamen abgeleitet — Endung .werkbaum (D24), verbotene @@ -49,3 +49,11 @@ describe('FILE_ACCEPT', () => { expect(FILE_ACCEPT).toContain('.txt'); }); }); + +describe('FILE_TYPES (Stufe 2, Picker-Filter)', () => { + it('führt dieselben Endungen wie FILE_ACCEPT', () => { + const exts = FILE_TYPES[0].accept['text/plain']; + expect(exts).toContain('.werkbaum'); + expect(exts).toContain('.txt'); + }); +});