diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 52932ae..254d058 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,7 @@ reverse. ## 2026-08-25 +- 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 - The estimated size of an unsized node now prices the remaining work — done sub-packages drop out, and a subtree with everything named done counts as XS diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index ac24f41..916377e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -5288,3 +5288,54 @@ genau der Solo-Test fällt; Schwelle aufgeweicht → genau die drei Schwellen-Tests; Vererbung entfernt → genau der Vererbungs-Test; Teilung entfernt → genau der Mehrfach-Tag-Test; Erledigt-Ausnahme der Pille entfernt → genau der Pillen-Test. + +## D72 — Lokale Dateien öffnen und speichern, in zwei Stufen +Der Notationstext ist das führende Datenformat (D14) — und war zugleich das +Einzige, das den Browser nicht als Datei verlassen konnte, während das +Diagramm zwei Download-Knöpfe hat. D24 hatte den Fall vorgesehen („ein +künftiges Öffnen/Speichern im Editor — dann als `accept`-Filter und +Download-Endung"). Entschieden (Nutzer): **zwei Stufen** — zuerst der +klassische Weg, der in jedem Browser läuft; darauf die File System Access API +für Chromium, die aus „Speichern unter" ein echtes „Speichern" macht. + +**Stufe 1: Datei-Input und Blob-Download, im Dokumenten-Menü.** Zwei Einträge +neben „Neues Dokument": „Datei öffnen…" (verstecktes +``, gelesen per +`file.text()`) und „Als Datei speichern" (Blob + ``, über das +vorhandene `saveBlob()` des Grafikexports). Das Menü ist der richtige Ort: +Beide Aktionen handeln davon, *welcher Text* da ist — wie Anlegen, Umbenennen, +Wiederherstellen (D22). + +**Geöffnet wird als NEUES Dokument, nicht per Namens-Identität.** D23 lässt +denselben `?sourceUrl=`-Link dasselbe Dokument aktualisieren — dort ist die +URL eine echte Adresse. Ein Dateiname ist keine: Zwei verschiedene Dateien +gleichen Namens (`plan.werkbaum` aus zwei Ordnern) überschrieben sich still, +und der stille Fehler ist der schlimmere (D59-Linie). Wer dieselbe Datei +zweimal öffnet, bekommt eben `plan.werkbaum` und `plan.werkbaum (2)` — sichtbar +und harmlos; die echte Datei-Identität bringt erst das Handle der Stufe 2. +Der Dateiname wird der Dokumentname (über `uniqueName`, wie überall). + +**Der Dateiname beim Speichern entsteht aus dem Dokumentnamen** — headless in +`localfile.js` (`saveFileName`, Hausregel D54-Nachtrag 3): verbotene Zeichen +und Pfadtrenner werden zu `-` (URL-Namen aus D23 bleiben so lesbar), führende +Punkte fallen weg (sonst entstünde eine versteckte Datei), die Endung +`.werkbaum` kommt dazu, wenn nicht schon `.werkbaum` oder `.txt` dasteht +(D24: `.txt` bleibt zulässig), leerer Rest fällt auf `plan.werkbaum` zurück. +Gespeichert wird als `text/plain;charset=utf-8` mit LF — die D24-Konvention; +Pad-Dokumente (D31) dürfen ebenso gespeichert werden (der Schreibschutz gilt +dem Textfeld, nicht dem Export). + +**Kein SPEC-Eintrag:** Öffnen/Speichern ist Dokumentverwaltung wie der +Wähler (D22) und die früheren Stände (D54) — Notation und Darstellung des +Plans ändern sich nicht; `llms.md` bleibt unberührt. Die Endungs-Konvention +steht seit D24 in SPEC §12. + +**Nachgemessen** im Browser (echte `File` per DataTransfer — das prüft den +vollständigen Weg samt `file.text()`; der Download mit abgefangenem +Anchor-Klick und zurückgelesenem Blob): Öffnen legt ein drittes Dokument +„probe-plan.werkbaum" an, aktiviert es, Diagramm zeigt dessen 2 Knoten, der +Input ist geleert (dieselbe Datei bleibt erneut wählbar); Speichern liefert +`probe-plan.werkbaum` mit byte-identischem Inhalt. 479 Tests, davon 9 neue in +`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. diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index f6085fc..a2bc003 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -86,7 +86,7 @@ - [^] #ed.fold.preset: One switch folds everything from size M downward (S) %% see D44 - [^] #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.files: Open and save .werkbaum files (S) + - [x] #ed.files: Open and save .werkbaum files (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.percolor: A pastel colour per person (S) @@ -604,8 +604,9 @@ unchanged and already at the top of the list. #ed.files - Open a .werkbaum file and save one. Today the leading data format is the one - thing that cannot leave the browser as a file, while the diagram can. + Open a .werkbaum file and save one, from the document menu. The leading data + format was the one thing that could not leave the browser as a file, while + the diagram always could. #ed.fresh For documents that come from outside, nodes that went to production since diff --git a/frontend/index.html b/frontend/index.html index 5d32b2e..c31c4e7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -74,6 +74,8 @@
+ + diff --git a/frontend/src/app.js b/frontend/src/app.js index 2fa3c45..44e803b 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -7,6 +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'; /* 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. */ @@ -2191,6 +2192,7 @@ const I18N = { docDeleteConfirm:"Dokument „{name}“ löschen?", docRestore:"Original wiederherstellen", docRestoreConfirm:"„{name}“ auf den mitgelieferten Stand zurücksetzen? Eigene Änderungen gehen verloren.", + docOpenFile:"Datei öffnen…", docSaveFile:"Als Datei speichern", copy:"kopieren", copyDone:"kopiert ✓", copyTooltip:"Text in die Zwischenablage kopieren", copyDiagramTooltip:"Diagramm als PNG-Bild in die Zwischenablage kopieren", downloadDiagramTooltip:"Diagramm als SVG-Datei herunterladen (z. B. für LibreOffice: Einfügen → Bild)", @@ -2296,6 +2298,7 @@ const I18N = { docDeleteConfirm:"Delete document “{name}”?", docRestore:"Restore original", docRestoreConfirm:"Reset “{name}” to the shipped version? Your changes will be lost.", + docOpenFile:"Open file…", docSaveFile:"Save as file", copy:"copy", copyDone:"copied ✓", copyTooltip:"Copy text to clipboard", copyDiagramTooltip:"Copy diagram as a PNG image to the clipboard", downloadDiagramTooltip:"Download diagram as an SVG file (e.g. for LibreOffice: Insert → Image)", @@ -2401,6 +2404,7 @@ const I18N = { docDeleteConfirm:"¿Eliminar el documento «{name}»?", docRestore:"Restaurar original", docRestoreConfirm:"¿Restablecer «{name}» a la versión incluida? Tus cambios se perderán.", + docOpenFile:"Abrir archivo…", docSaveFile:"Guardar como archivo", copy:"copiar", copyDone:"copiado ✓", copyTooltip:"Copiar el texto al portapapeles", copyDiagramTooltip:"Copiar el diagrama como imagen PNG al portapapeles", downloadDiagramTooltip:"Descargar el diagrama como archivo SVG (p. ej. para LibreOffice: Insertar → Imagen)", @@ -2506,6 +2510,7 @@ const I18N = { docDeleteConfirm:"Supprimer le document « {name} » ?", docRestore:"Restaurer l’original", docRestoreConfirm:"Réinitialiser « {name} » à la version livrée ? Vos modifications seront perdues.", + docOpenFile:"Ouvrir un fichier…", docSaveFile:"Enregistrer comme fichier", copy:"copier", copyDone:"copié ✓", copyTooltip:"Copier le texte dans le presse-papiers", copyDiagramTooltip:"Copier le diagramme comme image PNG dans le presse-papiers", downloadDiagramTooltip:"Télécharger le diagramme en fichier SVG (p. ex. pour LibreOffice : Insertion → Image)", @@ -2611,6 +2616,7 @@ const I18N = { docDeleteConfirm:"Usunąć dokument „{name}”?", docRestore:"Przywróć oryginał", docRestoreConfirm:"Przywrócić „{name}” do dostarczonej wersji? Twoje zmiany zostaną utracone.", + docOpenFile:"Otwórz plik…", docSaveFile:"Zapisz jako plik", copy:"kopiuj", copyDone:"skopiowano ✓", copyTooltip:"Kopiuj tekst do schowka", copyDiagramTooltip:"Kopiuj diagram jako obraz PNG do schowka", downloadDiagramTooltip:"Pobierz diagram jako plik SVG (np. dla LibreOffice: Wstaw → Obraz)", @@ -2716,6 +2722,7 @@ const I18N = { docDeleteConfirm:"Удалить документ «{name}»?", docRestore:"Восстановить оригинал", docRestoreConfirm:"Вернуть «{name}» к поставляемой версии? Ваши изменения будут потеряны.", + docOpenFile:"Открыть файл…", docSaveFile:"Сохранить как файл", copy:"копировать", copyDone:"скопировано ✓", copyTooltip:"Скопировать текст в буфер обмена", copyDiagramTooltip:"Скопировать диаграмму как изображение PNG в буфер обмена", downloadDiagramTooltip:"Скачать диаграмму как файл SVG (напр. для LibreOffice: Вставка → Изображение)", @@ -2821,6 +2828,7 @@ const I18N = { docDeleteConfirm:"दस्तावेज़ „{name}“ हटाएँ?", docRestore:"मूल पुनर्स्थापित करें", docRestoreConfirm:"„{name}“ को मूल संस्करण पर लौटाएँ? आपके परिवर्तन खो जाएँगे।", + docOpenFile:"फ़ाइल खोलें…", docSaveFile:"फ़ाइल के रूप में सहेजें", copy:"कॉपी करें", copyDone:"कॉपी हो गया ✓", copyTooltip:"टेक्स्ट को क्लिपबोर्ड पर कॉपी करें", copyDiagramTooltip:"आरेख को PNG छवि के रूप में क्लिपबोर्ड पर कॉपी करें", downloadDiagramTooltip:"आरेख को SVG फ़ाइल के रूप में डाउनलोड करें (जैसे LibreOffice: सम्मिलित करें → छवि)", @@ -2926,6 +2934,7 @@ const I18N = { docDeleteConfirm:"删除文档“{name}”?", docRestore:"恢复原始版本", docRestoreConfirm:"将“{name}”重置为随附版本?您的更改将丢失。", + docOpenFile:"打开文件…", docSaveFile:"另存为文件", copy:"复制", copyDone:"已复制 ✓", copyTooltip:"将文本复制到剪贴板", copyDiagramTooltip:"将图表作为 PNG 图片复制到剪贴板", downloadDiagramTooltip:"将图表下载为 SVG 文件(例如用于 LibreOffice:插入 → 图像)", @@ -3031,6 +3040,7 @@ const I18N = { docDeleteConfirm:"ドキュメント「{name}」を削除しますか?", docRestore:"オリジナルを復元", docRestoreConfirm:"「{name}」を同梱版に戻しますか?変更内容は失われます。", + docOpenFile:"ファイルを開く…", docSaveFile:"ファイルとして保存", copy:"コピー", copyDone:"コピーしました ✓", copyTooltip:"テキストをクリップボードにコピー", copyDiagramTooltip:"ダイアグラムを PNG 画像としてクリップボードにコピー", downloadDiagramTooltip:"ダイアグラムを SVG ファイルとしてダウンロード(例:LibreOffice の 挿入 → 画像)", @@ -3845,6 +3855,43 @@ function deleteDoc(){ persistDocs(); closeDocMenu(); } +/* ---------- Lokale Dateien öffnen und speichern (D72, Stufe 1) ---------- + Der klassische Weg, der in jedem Browser läuft: Datei-Input zum Öffnen, + Blob-Download zum Speichern. Geöffnet wird als NEUES Dokument (D22) — eine + Identität „gleicher Dateiname = gleiches Dokument" wäre eine Vermutung, und + zwei verschiedene Dateien gleichen Namens überschrieben sich still. Der + Dateiname wird der Dokumentname; beim Speichern entsteht er daraus zurück + (saveFileName, localfile.js). */ +const fileOpenInput = document.createElement('input'); +fileOpenInput.type = 'file'; +fileOpenInput.accept = FILE_ACCEPT; +fileOpenInput.hidden = true; +document.body.appendChild(fileOpenInput); +fileOpenInput.addEventListener('change', async () => { + const f = fileOpenInput.files && fileOpenInput.files[0]; + fileOpenInput.value = ''; /* dieselbe Datei soll erneut wählbar sein */ + if(!f) return; + let text; + try{ text = await f.text(); }catch(_){ return; } + flushActive(); + const d = { id: uid(), name: uniqueName(f.name), text }; + docs.push(d); + activeId = d.id; + 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(){ + flushActive(); + const d = activeDoc(); + if(!d) return; + 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). */ function initDocs(){ restoring = true; @@ -4176,6 +4223,8 @@ docList.addEventListener('click', e => { /* `newDoc()` zeichnet das Menü selbst neu — es geht direkt ins Umbenennen (D51), und ein zweiter Durchlauf baute das Eingabefeld nur noch einmal auf. */ document.getElementById('docNew').addEventListener('click', e => { e.stopPropagation(); newDoc(); }); +document.getElementById('docOpenFile').addEventListener('click', e => { e.stopPropagation(); openLocalFile(); }); +document.getElementById('docSaveFile').addEventListener('click', e => { e.stopPropagation(); saveLocalFile(); }); document.getElementById('docRename').addEventListener('click', e => { e.stopPropagation(); renameDoc(); }); document.getElementById('docDelete').addEventListener('click', e => { e.stopPropagation(); deleteDoc(); }); document.getElementById('docRestore').addEventListener('click', e => { e.stopPropagation(); restoreDoc(); }); diff --git a/frontend/src/localfile.js b/frontend/src/localfile.js new file mode 100644 index 0000000..75292af --- /dev/null +++ b/frontend/src/localfile.js @@ -0,0 +1,24 @@ +/* Öffnen und Speichern lokaler Dateien (D72). + Hier liegt die entscheidbare Hälfte (Hausregel, D54-Nachtrag 3): welcher + Dateiname aus einem Dokumentnamen wird. Die I/O — Datei-Input, Blob-Download, + ab Stufe 2 die File System Access API — bleibt in app.js. */ + +/* Der accept-Filter des Öffnen-Dialogs (D24: die Endung macht Dateien + zuordenbar; .txt bleibt zulässig — die Endung ist Konvention, kein Vertrag). */ +export const FILE_ACCEPT = '.werkbaum,.txt,text/plain'; + +/* Dateiname für das Speichern, abgeleitet aus dem Dokumentnamen. + Dokumentnamen sind freier Text — bei ?sourceUrl=-Dokumenten sogar die volle + URL (D23). Zeichen, die auf gängigen Dateisystemen verboten sind, werden zu + `-`; die Endung `.werkbaum` kommt dazu, wenn nicht schon `.werkbaum` oder + `.txt` dasteht (D24). Ein leerer Rest fällt auf `plan` zurück. */ +export function saveFileName(docName){ + let base = String(docName == null ? '' : docName) + .replace(/[\u0000-\u001f/\\:*?"<>|]+/g, '-') /* verbotene Zeichen, auch Pfadtrenner */ + .replace(/-{2,}/g, '-') + .replace(/^[-. ]+|[-. ]+$/g, '') /* Ränder: führende Punkte wären versteckte Dateien */ + .trim(); + if(!base) base = 'plan'; + if(/\.(werkbaum|txt)$/i.test(base)) return base; + return base + '.werkbaum'; +} diff --git a/frontend/tests/localfile.test.js b/frontend/tests/localfile.test.js new file mode 100644 index 0000000..529ecba --- /dev/null +++ b/frontend/tests/localfile.test.js @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { saveFileName, FILE_ACCEPT } from '../src/localfile.js'; + +/* Öffnen/Speichern lokaler Dateien (D72): der Dateiname beim Speichern wird + aus dem Dokumentnamen abgeleitet — Endung .werkbaum (D24), verbotene + Zeichen bereinigt, URL-Namen (D23) bleiben lesbar. */ + +describe('saveFileName', () => { + it('gewöhnlicher Name bekommt die Endung .werkbaum', () => { + expect(saveFileName('Mein Plan')).toBe('Mein Plan.werkbaum'); + }); + + it('vorhandene Endung .werkbaum wird nicht verdoppelt', () => { + expect(saveFileName('plan.werkbaum')).toBe('plan.werkbaum'); + }); + + it('.txt bleibt zulässig — die Endung ist Konvention, kein Vertrag (D24)', () => { + expect(saveFileName('notizen.txt')).toBe('notizen.txt'); + expect(saveFileName('notizen.TXT')).toBe('notizen.TXT'); + }); + + it('URL-Namen (D23) werden zu brauchbaren Dateinamen', () => { + expect(saveFileName('https://example.org/p/plan')) + .toBe('https-example.org-p-plan.werkbaum'); + }); + + it('verbotene Zeichen fallen zu einem einzigen Strich zusammen', () => { + expect(saveFileName('ac:d*e?f"g|h\\i')).toBe('a-b-c-d-e-f-g-h-i.werkbaum'); + }); + + it('Leerzeichen und Klammern bleiben — sie sind in Dateinamen erlaubt', () => { + expect(saveFileName('Sprint 15 (Q3)')).toBe('Sprint 15 (Q3).werkbaum'); + }); + + it('führende Punkte fallen weg — sonst entstünde eine versteckte Datei', () => { + expect(saveFileName('.geheim')).toBe('geheim.werkbaum'); + }); + + it('leerer oder unbrauchbarer Name fällt auf plan.werkbaum zurück', () => { + expect(saveFileName('')).toBe('plan.werkbaum'); + expect(saveFileName('...')).toBe('plan.werkbaum'); + expect(saveFileName(null)).toBe('plan.werkbaum'); + }); +}); + +describe('FILE_ACCEPT', () => { + it('nennt die Endung der Notation und lässt .txt zu (D24)', () => { + expect(FILE_ACCEPT).toContain('.werkbaum'); + expect(FILE_ACCEPT).toContain('.txt'); + }); +});