diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 33668f9..1d9e25e 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -386,3 +386,45 @@ Dokumente; das war zu grob, sobald man mehrere Dokumente pflegt. Die GUI-Ansichts-Einstellungen (Modus, Zoom, Aufteilung; `werkbaum-ui`) bleiben bewusst **global** über alle Dokumente — pro-Dokument-Ansichtszustand wäre eine spätere Erweiterung. + +## D23 — Notationstext per `?sourceUrl=` von einer URL laden +Der Editor kann den Notationstext aus einer **externen Textdatei** beziehen: +`…/index.html?sourceUrl=https://example.org/plan.txt`. Damit lässt sich ein +Diagramm teilen/verlinken, ohne den Text in die URL zu packen, und die Quelle +kann anderswo (Git, Wiki, Server) gepflegt werden. + +**Die URL ist der Titel.** Das geladene Dokument wird als eigenes Dokument im +Sinne von D22 geführt; sein **Name ist die URL**. Die **id leitet sich aus der +URL ab** (`url:`), damit derselbe Link dieses eine Dokument *aktualisiert*, +statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des Nutzers bleiben +unberührt. Da der Name in der Titelzeile mit Ellipse abgeschnitten wird, steht +die vollständige URL zusätzlich im Tooltip. + +**Die URL ist die Quelle der Wahrheit:** Ist der Parameter gesetzt, wird bei +**jedem** Laden neu geholt und der Dokumenttext überschrieben. Lokale Änderungen +an einem URL-Dokument überleben ein Neuladen also nicht — bewusst, weil +`sourceUrl` eine *Ansicht auf eine entfernte Datei* ist, nicht deren Kopie. (Eine +konfliktbewusste Variante — lokale Änderungen erkennen und behalten — wäre eine +mögliche Erweiterung.) + +**CORS ist die eigentliche Einschränkung.** Der Browser lädt fremde Hosts nur, +wenn die Zielseite `Access-Control-Allow-Origin` sendet. Das tun u. a. +`raw.githubusercontent.com` und GitLab-Raw-Links; ein beliebiger Webserver +oft **nicht**. Scheitert das Laden (CORS, 404, Netz), bleibt der bisherige Stand +stehen und es erscheint eine **Warnung** im Warnbereich (Typ `sourceLoad`, +zeilenlos ⇒ zuoberst), die CORS ausdrücklich als wahrscheinliche Ursache nennt. +Bewusst kein Proxy-Dienst als Ausweg: das würde fremde Inhalte über einen +Dritt-Host leiten und dem Datenschutz-Anspruch aus D20 widersprechen. + +**Verhältnis zu D20 („keine externen Requests"):** Der Grundsatz bleibt — die App +lädt von sich aus **nichts** nach (Schriften inline, kein CDN). Der Request +entsteht **nur**, wenn der Nutzer selbst eine URL angibt, und geht **nur** an +genau diesen Host; `credentials:'omit'` verhindert das Mitsenden von Cookies. +Erlaubt sind nur `http:`/`https:` (relative Angaben werden gegen die Seite +aufgelöst); andere Schemata (`file:`, `data:`, `javascript:`) werden abgewiesen. +Fremder Text ist ungefährlich: Labels werden escaped, und der Parser erkennt als +Knoten-Link ohnehin nur `https?://…` (SPEC §1/§6) — kein `javascript:`-Vektor. + +Nebenbefund: `checkForUpdates()` hängte den Cache-Buster als `location.href + +'?t=…'` an, was mit vorhandenem Query-String ein zweites `?` erzeugt hätte; es +baut die URL nun über `URL`/`searchParams`. diff --git a/docs/SPEC.md b/docs/SPEC.md index 944d825..8a939f4 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -213,6 +213,18 @@ Punkt, sodass eindeutig bleibt, welche Knoten auf dem Pfad **enden**. Linie, abgetönte Kopie und Punkte erscheinen bei aktivem Umschalter auch im Grafikexport. Siehe D18. +### Diagramm aus einer URL laden (`?sourceUrl=`) +Der Editor kann den Notationstext aus einer externen Textdatei beziehen: +`…?sourceUrl=https://example.org/plan.txt` (relative Angaben werden gegen die +Seite aufgelöst; zugelassen sind nur `http`/`https`). Der Text wird als +**eigenes Dokument** geführt, dessen **Name die URL** ist (vollständige URL im +Tooltip); derselbe Link aktualisiert dieses Dokument, statt ein neues anzulegen. +Ist der Parameter gesetzt, wird bei **jedem** Laden neu geholt — die URL ist die +Quelle der Wahrheit, lokale Änderungen daran überleben ein Neuladen nicht. +Scheitert das Laden (häufigster Fall: das Ziel sendet keinen +`Access-Control-Allow-Origin`-Header, außerdem 404/Netzfehler), bleibt der +bisherige Stand stehen und es erscheint eine **Warnung**. Siehe D23. + ### Grafikexport des Diagramms Das Diagramm wird aus der Live-Geometrie in ein eigenständiges SVG (nur Formen + Text, keine externen Ressourcen) nachgezeichnet. Zwei Icon-Schaltflächen: diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index d10c675..045fd7d 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -110,6 +110,17 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der unterdrückt): `renameDoc()` setzt `renamingId`, `renderDocMenu()` rendert dann ein `` (Enter = `commitRename`, Esc = `cancelRename`, Blur = commit). Doc-Namen sind Nutzerdaten und werden **nicht** übersetzt. +- `?sourceUrl=` (D23): `loadFromSourceUrl()` (Aufruf am Ende des Starts, async) + holt eine externe Textdatei und führt sie als Dokument mit `id: 'url:'`, + `name`/`source` = URL. Nur `http(s)`, `credentials:'omit'`; bei jedem Laden wird + neu geholt (URL = Quelle der Wahrheit, lokale Edits daran gehen verloren). + Fehler landen als zeilenlose Warnung `{type:'sourceLoad', url, error}` in + `sourceWarning` — ein **persistenter** Kanal, den `render()` jedem Warnungs-Satz + voranstellt (überlebt Neu-Renderings, bis das Laden gelingt). Hauptstolperfalle + ist **CORS**: fremde Hosts brauchen `Access-Control-Allow-Origin` (raw.github… + ja, beliebiger Webserver oft nicht) — der Warntext nennt das ausdrücklich. + `updateDocName()` setzt für solche Dokumente den vollen URL-Tooltip und muss + deshalb **nach** dem `data-i18n-title`-Durchlauf in `applyLang()` laufen. - Kleiner Bildschirm: `body.mobile` (per `matchMedia`, ≤ 640 px) stapelt Diagramm/Editor mit **stufenlosem** Splitter (kein Snap/Collapse wie auf Desktop): der Gutter-Drag ruft `setMobileDrow()` (klemmt `--drow` zwischen den diff --git a/frontend/src/app.js b/frontend/src/app.js index b744f02..11b2ace 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -33,6 +33,8 @@ const warnBox = document.getElementById('warn'); Pfad) lebt headless in model.js, das HTML-Erzeugen in render.js. Hier bleibt nur der UI-State des Günstigster-Pfad-Toggles (persistiert). */ let cheapPathOn = true; +/* Warnung des ?sourceUrl-Ladens (D23) — zeilenlos und persistent, siehe render(). */ +let sourceWarning = null; /* ---------- Renderer (Anbindung an den DOM) ---------- parse -> Wurzeln filtern (verworfene) -> günstigen Pfad markieren -> @@ -46,8 +48,10 @@ function render(){ } /* Warnungen aus Parser (unbekannte Statuszeichen) + Renderer (gemischte - Gates) zusammenführen, nach Zeile sortiert anzeigen. */ - let warnings = parsed.warnings; + Gates) zusammenführen, nach Zeile sortiert anzeigen. `sourceWarning` + (?sourceUrl nicht ladbar, D23) gehört keiner Zeile und bleibt über + Neu-Renderings bestehen, bis das Laden gelingt. */ + let warnings = sourceWarning ? [sourceWarning].concat(parsed.warnings) : parsed.warnings; if(!roots.length){ out.innerHTML = `
${esc(t('empty'))}
`; @@ -593,6 +597,7 @@ const I18N = { st_idee:"Idee", st_geplant:"geplant", st_arbeit:"in Arbeit", st_durchstich:"Durchstich", st_fertig:"fertig", st_prod:"in Produktion", st_highrisk:"High Risk", st_verworfen:"verworfen", unknownStatusWarn:"Zeile {line}: unbekanntes Statuszeichen „{code}“ — als neutral dargestellt.", + sourceLoadWarn:"„{url}“ konnte nicht geladen werden ({error}). Die Datei muss per http(s) erreichbar sein und CORS erlauben (Access-Control-Allow-Origin).", a11yStatus:"Status: {status}", a11ySize:"Aufwand: {size}", a11ySizeImplicit:"Aufwand: M (angenommen)", a11yTags:"Zuständig: {names}", a11yLink:"verlinkt", hint_indent:"Einrückung (2 Leerzeichen oder Tab) definiert die Hierarchie.", hint_all:"Teilpaket, alle erforderlich", hint_any:"Alternative, eine wählen", @@ -638,6 +643,7 @@ const I18N = { st_idee:"idea", st_geplant:"planned", st_arbeit:"in progress", st_durchstich:"walking skeleton", st_fertig:"done", st_prod:"in production", st_highrisk:"high risk", st_verworfen:"discarded", unknownStatusWarn:"Line {line}: unknown status code “{code}” — shown as neutral.", + sourceLoadWarn:"Could not load “{url}” ({error}). The file must be reachable via http(s) and allow CORS (Access-Control-Allow-Origin).", a11yStatus:"Status: {status}", a11ySize:"Effort: {size}", a11ySizeImplicit:"Effort: M (assumed)", a11yTags:"Assigned: {names}", a11yLink:"has link", hint_indent:"Indentation (2 spaces or a tab) defines the hierarchy.", hint_all:"sub-task, all required", hint_any:"alternative, choose one", @@ -683,6 +689,7 @@ const I18N = { st_idee:"idea", st_geplant:"planificado", st_arbeit:"en curso", st_durchstich:"prototipo funcional", st_fertig:"terminado", st_prod:"en producción", st_highrisk:"alto riesgo", st_verworfen:"descartado", unknownStatusWarn:"Línea {line}: código de estado desconocido «{code}» — mostrado como neutral.", + sourceLoadWarn:"No se pudo cargar «{url}» ({error}). El archivo debe ser accesible por http(s) y permitir CORS (Access-Control-Allow-Origin).", a11yStatus:"Estado: {status}", a11ySize:"Esfuerzo: {size}", a11ySizeImplicit:"Esfuerzo: M (asumido)", a11yTags:"Responsable: {names}", a11yLink:"con enlace", hint_indent:"La sangría (2 espacios o un tabulador) define la jerarquía.", hint_all:"subtarea, todas obligatorias", hint_any:"alternativa, elige una", @@ -728,6 +735,7 @@ const I18N = { st_idee:"idée", st_geplant:"planifié", st_arbeit:"en cours", st_durchstich:"squelette fonctionnel", st_fertig:"terminé", st_prod:"en production", st_highrisk:"risque élevé", st_verworfen:"abandonné", unknownStatusWarn:"Ligne {line} : code de statut inconnu « {code} » — affiché comme neutre.", + sourceLoadWarn:"Impossible de charger « {url} » ({error}). Le fichier doit être accessible en http(s) et autoriser CORS (Access-Control-Allow-Origin).", a11yStatus:"Statut : {status}", a11ySize:"Effort : {size}", a11ySizeImplicit:"Effort : M (supposé)", a11yTags:"Responsable : {names}", a11yLink:"avec lien", hint_indent:"L'indentation (2 espaces ou une tabulation) définit la hiérarchie.", hint_all:"sous-tâche, toutes requises", hint_any:"alternative, en choisir une", @@ -773,6 +781,7 @@ const I18N = { st_idee:"pomysł", st_geplant:"zaplanowane", st_arbeit:"w toku", st_durchstich:"działający szkielet", st_fertig:"gotowe", st_prod:"w produkcji", st_highrisk:"wysokie ryzyko", st_verworfen:"odrzucone", unknownStatusWarn:"Wiersz {line}: nieznany znak statusu „{code}” — pokazany jako neutralny.", + sourceLoadWarn:"Nie udało się wczytać „{url}” ({error}). Plik musi być dostępny przez http(s) i zezwalać na CORS (Access-Control-Allow-Origin).", a11yStatus:"Status: {status}", a11ySize:"Nakład: {size}", a11ySizeImplicit:"Nakład: M (założony)", a11yTags:"Przypisano: {names}", a11yLink:"z linkiem", hint_indent:"Wcięcie (2 spacje lub tabulator) definiuje hierarchię.", hint_all:"podzadanie, wszystkie wymagane", hint_any:"alternatywa, wybierz jedną", @@ -818,6 +827,7 @@ const I18N = { st_idee:"идея", st_geplant:"запланировано", st_arbeit:"в работе", st_durchstich:"сквозной прототип", st_fertig:"готово", st_prod:"в эксплуатации", st_highrisk:"высокий риск", st_verworfen:"отклонено", unknownStatusWarn:"Строка {line}: неизвестный код статуса «{code}» — показан как нейтральный.", + sourceLoadWarn:"Не удалось загрузить «{url}» ({error}). Файл должен быть доступен по http(s) и разрешать CORS (Access-Control-Allow-Origin).", a11yStatus:"Статус: {status}", a11ySize:"Оценка: {size}", a11ySizeImplicit:"Оценка: M (предполагается)", a11yTags:"Ответственные: {names}", a11yLink:"со ссылкой", hint_indent:"Отступ (2 пробела или табуляция) задаёт иерархию.", hint_all:"подзадача, все обязательны", hint_any:"альтернатива, выберите одну", @@ -863,6 +873,7 @@ const I18N = { st_idee:"विचार", st_geplant:"नियोजित", st_arbeit:"प्रगति पर", st_durchstich:"कार्यशील ढाँचा", st_fertig:"पूर्ण", st_prod:"उत्पादन में", st_highrisk:"उच्च जोखिम", st_verworfen:"अस्वीकृत", unknownStatusWarn:"पंक्ति {line}: अज्ञात स्थिति कोड „{code}“ — तटस्थ रूप में दिखाया गया।", + sourceLoadWarn:"„{url}“ लोड नहीं हो सका ({error})। फ़ाइल http(s) से उपलब्ध होनी चाहिए और CORS की अनुमति देनी चाहिए (Access-Control-Allow-Origin)।", a11yStatus:"स्थिति: {status}", a11ySize:"आकार: {size}", a11ySizeImplicit:"आकार: M (अनुमानित)", a11yTags:"जिम्मेदार: {names}", a11yLink:"लिंक सहित", hint_indent:"इंडेंट (2 स्पेस या टैब) पदानुक्रम तय करता है।", hint_all:"उप-कार्य, सभी आवश्यक", hint_any:"विकल्प, एक चुनें", @@ -908,6 +919,7 @@ const I18N = { st_idee:"想法", st_geplant:"已计划", st_arbeit:"进行中", st_durchstich:"可运行骨架", st_fertig:"已完成", st_prod:"已上线", st_highrisk:"高风险", st_verworfen:"已放弃", unknownStatusWarn:"第 {line} 行:未知状态代码“{code}”——显示为中性。", + sourceLoadWarn:"无法加载“{url}”({error})。该文件必须可通过 http(s) 访问并允许 CORS(Access-Control-Allow-Origin)。", a11yStatus:"状态:{status}", a11ySize:"工作量:{size}", a11ySizeImplicit:"工作量:M(假定)", a11yTags:"负责人:{names}", a11yLink:"含链接", hint_indent:"缩进(2 个空格或制表符)定义层级。", hint_all:"子任务,全部必需", hint_any:"备选项,择其一", @@ -953,6 +965,7 @@ const I18N = { st_idee:"アイデア", st_geplant:"計画済み", st_arbeit:"作業中", st_durchstich:"ウォーキングスケルトン", st_fertig:"完了", st_prod:"本番稼働", st_highrisk:"高リスク", st_verworfen:"破棄", unknownStatusWarn:"{line} 行目: 不明なステータス記号「{code}」— 中立として表示。", + sourceLoadWarn:"「{url}」を読み込めませんでした({error})。ファイルは http(s) でアクセス可能で、CORS(Access-Control-Allow-Origin)を許可する必要があります。", a11yStatus:"ステータス: {status}", a11ySize:"規模: {size}", a11ySizeImplicit:"規模: M(想定)", a11yTags:"担当: {names}", a11yLink:"リンクあり", hint_indent:"インデント(スペース2つまたはタブ)で階層を定義します。", hint_all:"サブタスク、すべて必須", hint_any:"選択肢、1つを選ぶ", @@ -1014,6 +1027,7 @@ function applyLang(l){ langMore.setAttribute('aria-expanded', 'true'); } render(); + updateDocName(); /* Titelzeilen-Tooltip nach dem data-i18n-title-Durchlauf setzen */ try{ localStorage.setItem('werkbaum-lang', l); }catch(_){} } const langsel = document.querySelector('.langsel'); @@ -1184,6 +1198,14 @@ const docList = document.getElementById('docList'); function updateDocName(){ const d = activeDoc(); if(docNameEl) docNameEl.textContent = d ? d.name : ''; + /* Aus einer URL geladene Dokumente (D23): die vollständige Quelle in den + Tooltip, da der Name in der Titelzeile mit Ellipse abgeschnitten wird. + Muss nach applyLang erneut laufen — das setzt data-i18n-title zurück. */ + if(docTrigger && d){ + docTrigger.title = d.source + ? d.source + '\n' + t('docSwitchTooltip') + : t('docSwitchTooltip'); + } } let renamingId = null; /* id des gerade inline umbenannten Dokuments (oder null) */ function renderDocMenu(){ @@ -1274,6 +1296,57 @@ function initDocs(){ updateDocName(); render(); } + +/* ---------- ?sourceUrl= — Notationstext von einer URL laden (D23) ---------- + Die Datei wird geholt und als eigenes Dokument geführt, dessen Name die URL + ist. Die id leitet sich aus der URL ab: derselbe Link aktualisiert dieses + Dokument, statt bei jedem Aufruf ein neues anzulegen. Eigene Dokumente des + Nutzers bleiben unberührt. Scheitert das Laden (häufigster Fall: das Ziel + sendet keinen CORS-Header), bleibt der bisherige Stand stehen und es + erscheint eine Warnung. */ +const SOURCE_PARAM = 'sourceUrl'; +function sourceUrlParam(){ + try{ return new URLSearchParams(location.search).get(SOURCE_PARAM); }catch(_){ return null; } +} +async function loadFromSourceUrl(){ + const raw = sourceUrlParam(); + if(!raw) return; + let url; + /* Relative Angaben gegen die Seite auflösen; nur http(s) zulassen (kein + file:/data:/javascript: — die Notation selbst erlaubt ohnehin nur http(s)). */ + try{ url = new URL(raw, location.href); }catch(_){ + /* Fehlerdetail bewusst technisch/englisch wie die Browser-Meldungen + („Failed to fetch", „HTTP 404") — der Rahmentext ist lokalisiert. */ + sourceWarning = {type:'sourceLoad', url: raw, error: 'invalid URL'}; + render(); + return; + } + if(url.protocol !== 'http:' && url.protocol !== 'https:'){ + sourceWarning = {type:'sourceLoad', url: url.href, error: url.protocol}; + render(); + return; + } + try{ + const resp = await fetch(url.href, {cache:'no-store', credentials:'omit'}); + if(!resp.ok) throw new Error('HTTP ' + resp.status); + const text = await resp.text(); + const id = 'url:' + url.href; + flushActive(); /* laufende Bearbeitung nicht verlieren */ + let d = docs.find(x => x.id === id); + if(d){ d.text = text; d.name = url.href; } + else { d = {id, name: url.href, text, source: url.href}; docs.push(d); } + d.source = url.href; + activeId = id; + sourceWarning = null; + loadActiveIntoEditor(); + persistDocs(); + }catch(err){ + /* CORS-Fehler melden sich als „TypeError: Failed to fetch" ohne Details — + der Warntext nennt CORS daher ausdrücklich als wahrscheinliche Ursache. */ + sourceWarning = {type:'sourceLoad', url: url.href, error: (err && err.message) || String(err)}; + render(); + } +} docTrigger.addEventListener('click', e => { /* Desktop: ist der Editor minimiert, stellt ein Klick ihn wieder her (Bubbling zur Titelzeile) statt das Menü zu öffnen. */ @@ -1383,6 +1456,7 @@ try{ startLang = localStorage.getItem('werkbaum-lang') || detectLang(); }catch(_ applyLang(I18N[startLang] ? startLang : 'de'); /* setzt Texte + rendert */ initDocs(); /* Dokumente laden + aktiven Text in den Editor (nach Sprache) */ applyMobile(); /* Mobil-Verhalten (nach Sprache/Restore) anwenden */ +loadFromSourceUrl(); /* ?sourceUrl= nachladen (asynchron, D23) */ /* ---------- Build-Hinweis (Vorschau/Dev + „latest build") ---------- Kennzeichnet einen nicht-produktiven Build mit einem kleinen Symbol samt @@ -1469,8 +1543,12 @@ function hashContent(str){ async function checkForUpdates(){ try { - /* Fetch HTML mit Cache-Busting via Timestamp */ - const resp = await fetch(location.href + '?t=' + new Date().getTime(), { cache: 'no-store' }); + /* Fetch HTML mit Cache-Busting via Timestamp. Über URL/searchParams gebaut, + damit ein bereits vorhandener Query-String (z. B. ?sourceUrl=…, D23) nicht + durch ein angehängtes zweites „?" zerstört wird. */ + const bust = new URL(location.href); + bust.searchParams.set('t', String(new Date().getTime())); + const resp = await fetch(bust.href, { cache: 'no-store' }); if(!resp.ok) throw new Error(`HTTP ${resp.status}`); /* Voller Content-Hash ist die alleinige Wahrheit. ETag/Last-Modified werden diff --git a/frontend/src/warnings.js b/frontend/src/warnings.js index 2e60430..f179381 100644 --- a/frontend/src/warnings.js +++ b/frontend/src/warnings.js @@ -8,7 +8,9 @@ Bekannte Typen: - mixedGate { line, label } — Geschwister mit gemischtem Gate (SPEC §3) - - unknownStatus { line, code } — unbekanntes Statuszeichen (Phase 2) */ + - unknownStatus { line, code } — unbekanntes Statuszeichen (Phase 2) + - sourceLoad { url, error } — ?sourceUrl= nicht ladbar (D23); ohne + Zeilennummer, erscheint dadurch zuoberst */ import { esc } from './render.js'; @@ -20,6 +22,8 @@ export function formatWarning(w, t){ return t('mixedWarn', {line: w.line, label: esc(w.label)}); case 'unknownStatus': return t('unknownStatusWarn', {line: w.line, code: esc(w.code)}); + case 'sourceLoad': + return t('sourceLoadWarn', {url: esc(w.url), error: esc(w.error)}); default: return `${esc(String(w.type))} (${w.line ?? '?'})`; }