feat: Speichern schreibt in die geöffnete Datei zurück (D72-Nachtrag, Stufe 2)

File System Access API, wo vorhanden (Chromium): Öffnen merkt sich das
FileSystemFileHandle (IndexedDB, überlebt den Neustart), Speichern schreibt
in dieselbe Datei; dieselbe Datei öffnet per isSameEntry wieder in dasselbe
Dokument. Firefox/Safari behalten den Stufe-1-Download.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-25 14:31:51 +02:00
co-authored by Claude Fable 5
parent defeaf84d0
commit 2648d72e45
6 changed files with 207 additions and 12 deletions
+1
View File
@@ -19,6 +19,7 @@ reverse.
## 2026-08-25 ## 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 - 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 - 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 - 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
+50
View File
@@ -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 `tests/localfile.test.js`. Werkzeuggrenze wie in D25/D53: Der echte
Dateidialog und der echte Download lassen sich nicht automatisiert auslösen — Dateidialog und der echte Download lassen sich nicht automatisiert auslösen —
geprüft ist alles bis an diese Kante. 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.
+6
View File
@@ -87,6 +87,7 @@
- [^] #ed.snaps: Earlier states of a document, every ten minutes (S) %% only when something changed - [^] #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 - [^] #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: 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: 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.fresh.news: A star in the header, with the last few days (S) %% see D58
+ [?] #ed.percolor: A pastel colour per person (S) + [?] #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 format was the one thing that could not leave the browser as a file, while
the diagram always could. 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 #ed.fresh
For documents that come from outside, nodes that went to production since 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 your last visit get a yellow halo. New means live, not "line added" — a line
+132 -8
View File
@@ -7,7 +7,7 @@ import { padUrls } from './remote.js';
import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js'; import { depFragment, collectIds, matchIds, depIdAt, idLine } from './autocomplete.js';
import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel } import { LS_SNAPS, SNAP_EVERY, parseSnaps, addSnapshot, persistSnaps, snapLabel }
from './snapshots.js'; 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 /* Neuigkeiten (D58): die git-Historie, zur BAUZEIT eingelesen (Vite-Plugin in
vite.config.js). Zur Laufzeit gibt es kein git und keinen Server, der vite.config.js). Zur Laufzeit gibt es kein git und keinen Server, der
nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */ nachliefern könnte (D11/D19). Leer, wo git nicht erreichbar war. */
@@ -3499,7 +3499,14 @@ function restoreDoc(){
persistDocs(); persistDocs();
closeDocMenu(); 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 closeDocMenu(){ renamingId = null; renameIsNew = false; docMenu.hidden = true; docTrigger.setAttribute('aria-expanded', 'false'); }
function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); } function toggleDocMenu(){ docMenu.hidden ? openDocMenu() : closeDocMenu(); }
/* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive /* Beim Wechseln/Anlegen/Löschen zuerst den aktuellen Editortext ins aktive
@@ -3846,6 +3853,7 @@ function deleteDoc(){
if(!d) return; if(!d) return;
if(!window.confirm(t('docDeleteConfirm', {name: d.name}))) 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(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); 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(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 }]; if(!docs.length) docs = [{ id: EXAMPLE_ID, name: EXAMPLE_NAME, text: INITIAL }];
@@ -3873,23 +3881,138 @@ fileOpenInput.addEventListener('change', async () => {
if(!f) return; if(!f) return;
let text; let text;
try{ text = await f.text(); }catch(_){ return; } 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(); flushActive();
const d = { id: uid(), name: uniqueName(f.name), text }; 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); docs.push(d);
activeId = d.id; activeId = d.id;
}
if(handle){ fileHandles.set(activeId, handle); idbPutHandle(activeId, handle); }
foldOverrides.clear(); foldOverrides.clear();
loadActiveIntoEditor(); loadActiveIntoEditor();
persistDocs(); persistDocs();
closeDocMenu(); closeDocMenu();
}); }
function openLocalFile(){ fileOpenInput.click(); } async function openWithPicker(){
/* Speichern über den vorhandenen Blob-Download (saveBlob, Grafikexport): let handle;
UTF-8, LF das Textfeld normalisiert Zeilenenden ohnehin auf \n (D24). */ try{ [handle] = await window.showOpenFilePicker({types: FILE_TYPES}); }
function saveLocalFile(){ 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(); flushActive();
const d = activeDoc(); const d = activeDoc();
if(!d) return; if(!d) return;
if(hasFsAccess){
if(!(await saveToKnownFile(d))) await saveWithPicker(d);
} else {
saveBlob(new Blob([d.text], {type:'text/plain;charset=utf-8'}), saveFileName(d.name)); saveBlob(new Blob([d.text], {type:'text/plain;charset=utf-8'}), saveFileName(d.name));
}
closeDocMenu(); closeDocMenu();
} }
/* Dokumente laden + aktiven Text in den Editor holen (nach applyLang). */ /* 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(); } try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_){ startLang = detectLang(); }
applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */
initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */ 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 */ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */
loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */ loadRemoteSource(); /* ?sourceUrl= / ?etherpad= nachladen (asynchron, D23/D31) */
+6
View File
@@ -22,3 +22,9 @@ export function saveFileName(docName){
if(/\.(werkbaum|txt)$/i.test(base)) return base; if(/\.(werkbaum|txt)$/i.test(base)) return base;
return base + '.werkbaum'; 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']}},
];
+9 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 /* Öffnen/Speichern lokaler Dateien (D72): der Dateiname beim Speichern wird
aus dem Dokumentnamen abgeleitet — Endung .werkbaum (D24), verbotene aus dem Dokumentnamen abgeleitet — Endung .werkbaum (D24), verbotene
@@ -49,3 +49,11 @@ describe('FILE_ACCEPT', () => {
expect(FILE_ACCEPT).toContain('.txt'); 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');
});
});