storymapper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/ARCHITECTURE.md +334 -0
  2. package/LICENSE +201 -0
  3. package/NOTICE +6 -0
  4. package/README.md +239 -0
  5. package/frontend/css/storymap.css +4637 -0
  6. package/frontend/js/adapters.js +423 -0
  7. package/frontend/js/card-animate.js +384 -0
  8. package/frontend/js/core/graph.js +825 -0
  9. package/frontend/js/core.js +3908 -0
  10. package/frontend/js/dialog-ticket-import.js +506 -0
  11. package/frontend/js/dnd.js +322 -0
  12. package/frontend/js/filter.js +215 -0
  13. package/frontend/js/main.js +2499 -0
  14. package/frontend/js/project-io.js +109 -0
  15. package/frontend/js/query-autocomplete.js +196 -0
  16. package/frontend/js/query-engine.js +339 -0
  17. package/frontend/js/query.js +280 -0
  18. package/frontend/js/renderer-card.js +639 -0
  19. package/frontend/js/renderer-dependencies.js +974 -0
  20. package/frontend/js/renderer-kanban.js +505 -0
  21. package/frontend/js/renderer-storymap.js +2530 -0
  22. package/frontend/js/renderer-ticket-editor.js +455 -0
  23. package/frontend/js/renderer-ticket-modal.js +758 -0
  24. package/frontend/js/save-pipeline.js +170 -0
  25. package/frontend/js/smartbar-autocomplete.js +162 -0
  26. package/frontend/js/store.js +197 -0
  27. package/frontend/js/ticket-editor-boot.js +24 -0
  28. package/frontend/js/ticket-form.js +2095 -0
  29. package/frontend/js/ticket-import.js +477 -0
  30. package/frontend/js/ui-shell.js +441 -0
  31. package/frontend/js/view-process-steps.js +233 -0
  32. package/frontend/js/view-requirements.js +361 -0
  33. package/frontend/js/view-settings.js +1864 -0
  34. package/frontend/js/view-table.js +659 -0
  35. package/frontend/js/wheel-pan.js +65 -0
  36. package/frontend/storymap.html +87 -0
  37. package/frontend/ticket-editor.html +29 -0
  38. package/package.json +76 -0
  39. package/server/bus.js +16 -0
  40. package/server/core/graph.js +10 -0
  41. package/server/core.js +10 -0
  42. package/server/identity.js +134 -0
  43. package/server/index.js +283 -0
  44. package/server/ingest.js +212 -0
  45. package/server/mcp.js +2510 -0
  46. package/server/server.js +1599 -0
  47. package/server/slice.js +103 -0
  48. package/server/storage.js +571 -0
  49. package/server/validation.js +225 -0
  50. package/shared/core/graph.js +825 -0
  51. package/shared/core.js +3908 -0
  52. package/shared/project-io.js +109 -0
  53. package/shared/query-autocomplete.js +196 -0
  54. package/shared/query-engine.js +339 -0
  55. package/shared/query.js +280 -0
  56. package/shared/ticket-import.js +477 -0
  57. package/skill/SKILL.md +458 -0
  58. package/skill/reference/anatomy.md +196 -0
  59. package/skill/reference/spec-evolution.md +52 -0
  60. package/skill/reference/tools.md +156 -0
  61. package/skill/reference/workflows.md +203 -0
@@ -0,0 +1,506 @@
1
+ // SM-289 — Ticket-Import dialog (browser glue over shared/ticket-import.js).
2
+ //
3
+ // Two steps inside ONE showModal: (1) file upload or pasted text, (2) the
4
+ // preview — every row classified as create / update / error (line + reason),
5
+ // a mode toggle (create-only / upsert) that re-plans instantly, and Apply.
6
+ //
7
+ // Apply runs ALL creates/updates through core.ops on a draft snapshot and
8
+ // commits ONCE via store.applySnapshot — one undo step, one save, one WS
9
+ // push. Error rows are never written. Entry points: Project ▸ Import… and
10
+ // the button in the Table view (both call openImportDialog, SM-295).
11
+ //
12
+ // UMD-wrapped: same source runs in the browser AND require()s in Node tests.
13
+ (function (root, factory) {
14
+ if (typeof module === "object" && module.exports) {
15
+ module.exports = factory(require("./core.js"), require("./ticket-import.js"), require("./project-io.js"));
16
+ } else {
17
+ (root.STORYMAP = root.STORYMAP || {}).dialogTicketImport = factory(
18
+ root.STORYMAP && root.STORYMAP.core,
19
+ root.STORYMAP && root.STORYMAP.ticketImport,
20
+ root.STORYMAP && root.STORYMAP.projectIO
21
+ );
22
+ }
23
+ }(typeof self !== "undefined" ? self : this, function (core, ticketImport, projectIO) {
24
+ "use strict";
25
+
26
+ if (!core) throw new Error("dialog-ticket-import: core module missing");
27
+ if (!ticketImport) throw new Error("dialog-ticket-import: ticket-import module missing");
28
+
29
+ /**
30
+ * SM-295: what does the pasted/loaded text contain?
31
+ * - "project": a storymap-project export envelope OR a bare snapshot
32
+ * object ({project, tickets[]}) → whole-project import (SM-15 engine).
33
+ * - "tickets": CSV or a JSON ARRAY of ticket rows → row import (SM-288).
34
+ * A non-array JSON object that is neither is treated as "project" so
35
+ * projectIO.parseImport reports the precise error. Pure.
36
+ */
37
+ function detectImportKind(text) {
38
+ const t = String(text == null ? "" : text).trim();
39
+ if (t.charAt(0) !== "{") return "tickets";
40
+ try {
41
+ const data = JSON.parse(t);
42
+ if (data && (data.format === "storymap-project"
43
+ || (data.snapshot && data.snapshot.project)
44
+ || (data.project && Array.isArray(data.tickets)))) return "project";
45
+ } catch (_e) { /* unparseable object → project branch reports it */ }
46
+ return "project";
47
+ }
48
+
49
+ const IMPORT_DIALOG = {
50
+ DEFAULT_MODE: "upsert",
51
+ // Preview table hard-caps its rendered rows; the summary always counts ALL.
52
+ PREVIEW_MAX_ROWS: 200,
53
+ // SM-296: user-adjusted header mappings persist per project.
54
+ MAPPING_STORAGE_PREFIX: "storymap-import-mapping-",
55
+ };
56
+
57
+ function storage() {
58
+ try {
59
+ if (typeof localStorage !== "undefined") return localStorage;
60
+ if (typeof window !== "undefined" && window.localStorage) return window.localStorage;
61
+ } catch (_e) { /* opaque origin / privacy mode */ }
62
+ return null;
63
+ }
64
+
65
+ /**
66
+ * Read the per-project mapping config: { headers: {}, values: {} }.
67
+ * SM-297 extended the stored shape from a flat header map to
68
+ * { headers, values } — the legacy flat shape is migrated on read.
69
+ */
70
+ function readMappingFromStorage(projectId) {
71
+ const ls = storage();
72
+ if (!ls || !projectId) return { headers: {}, values: {} };
73
+ try {
74
+ const raw = ls.getItem(IMPORT_DIALOG.MAPPING_STORAGE_PREFIX + projectId);
75
+ const data = raw ? JSON.parse(raw) : null;
76
+ if (!data || typeof data !== "object" || Array.isArray(data)) return { headers: {}, values: {} };
77
+ if (data.headers || data.values) {
78
+ return {
79
+ headers: (data.headers && typeof data.headers === "object") ? data.headers : {},
80
+ values: (data.values && typeof data.values === "object") ? data.values : {}
81
+ };
82
+ }
83
+ return { headers: data, values: {} }; // legacy flat header map
84
+ } catch (_e) { return { headers: {}, values: {} }; }
85
+ }
86
+
87
+ function writeMappingToStorage(projectId, headers, values) {
88
+ const ls = storage();
89
+ if (!ls || !projectId) return;
90
+ try {
91
+ ls.setItem(IMPORT_DIALOG.MAPPING_STORAGE_PREFIX + projectId,
92
+ JSON.stringify({ headers: headers || {}, values: values || {} }));
93
+ }
94
+ catch (_e) { /* quota — mapping just won't persist */ }
95
+ }
96
+
97
+ // SM-290: the apply logic moved into the shared engine — the MCP tool and
98
+ // this dialog run the IDENTICAL code (one revision / one undo step each).
99
+ const applyImportPlan = ticketImport.applyImportPlan;
100
+
101
+ function el(tag, attrs, text) {
102
+ const d = document.createElement(tag);
103
+ if (attrs) for (const k in attrs) {
104
+ if (k === "class") d.className = attrs[k];
105
+ else d.setAttribute(k, attrs[k]);
106
+ }
107
+ if (text != null) d.textContent = text;
108
+ return d;
109
+ }
110
+
111
+ function renderPreview(host, plan, state) {
112
+ host.innerHTML = "";
113
+ const sum = el("div", { class: "ti-summary" },
114
+ plan.creates.length + " neu · " + plan.updates.length + " aktualisiert · " + plan.errors.length + " Fehler");
115
+ host.appendChild(sum);
116
+ const table = el("table", { class: "ti-preview" });
117
+ const thead = el("thead");
118
+ const hr = el("tr");
119
+ ["Zeile", "Aktion", "Ticket", "Detail"].forEach((h) => hr.appendChild(el("th", null, h)));
120
+ thead.appendChild(hr);
121
+ table.appendChild(thead);
122
+ const tbody = el("tbody");
123
+ const rows = [];
124
+ // SM-297 (user finding): errors are LINE-oriented — one preview row per
125
+ // CSV line, labelled with the line's own content (title/key), all
126
+ // problems joined as "field: message" (the message carries the value).
127
+ const errByLine = new Map();
128
+ plan.errors.forEach((e) => {
129
+ const arr = errByLine.get(e.line) || [];
130
+ arr.push(e);
131
+ errByLine.set(e.line, arr);
132
+ });
133
+ errByLine.forEach((list, line) => {
134
+ const data = (state && state.rowsByLine && state.rowsByLine.get(line)) || {};
135
+ const label = (data.title && String(data.title).trim())
136
+ || (data.key && String(data.key).trim()) || "(ohne Titel)";
137
+ rows.push({
138
+ line: line, kind: "error", label: label,
139
+ detail: list.map((e) => (e.field ? e.field + ": " : "") + e.message).join(" · ")
140
+ });
141
+ });
142
+ plan.creates.forEach((c) => rows.push({ line: c.line, kind: "create", label: c.ticket.title, detail: c.ticket.type }));
143
+ plan.updates.forEach((u) => rows.push({
144
+ line: u.line, kind: "update", label: u.ticketKey,
145
+ detail: Object.keys(u.patch).length ? Object.keys(u.patch).join(", ") : "keine Änderungen"
146
+ }));
147
+ rows.sort((a, b) => (a.line || 0) - (b.line || 0));
148
+ rows.slice(0, IMPORT_DIALOG.PREVIEW_MAX_ROWS).forEach((r) => {
149
+ const tr = el("tr", { class: "ti-row-" + r.kind });
150
+ tr.appendChild(el("td", null, String(r.line == null ? "" : r.line)));
151
+ tr.appendChild(el("td", null, r.kind));
152
+ tr.appendChild(el("td", null, r.label || ""));
153
+ tr.appendChild(el("td", null, r.detail || ""));
154
+ tbody.appendChild(tr);
155
+ });
156
+ table.appendChild(tbody);
157
+ host.appendChild(table);
158
+ if (rows.length > IMPORT_DIALOG.PREVIEW_MAX_ROWS) {
159
+ host.appendChild(el("div", { class: "ti-truncated" },
160
+ "… " + (rows.length - IMPORT_DIALOG.PREVIEW_MAX_ROWS) + " weitere Zeilen (Zusammenfassung oben zählt alle)"));
161
+ }
162
+ state.applyBtn.disabled = (plan.creates.length + plan.updates.filter((u) => Object.keys(u.patch).length).length) === 0;
163
+ }
164
+
165
+ /** SM-295: project-branch preview — name, id, counts, conflict choice. */
166
+ function renderProjectPreview(host, snap, state) {
167
+ host.innerHTML = "";
168
+ const p = snap.project;
169
+ const box = el("div", { class: "ti-project-preview" });
170
+ box.appendChild(el("div", { class: "ti-summary" },
171
+ "Projekt-Import: „" + (p.name || p.id) + "“ (" + p.id + ")"));
172
+ box.appendChild(el("div", { class: "ti-project-counts" },
173
+ (snap.tickets || []).length + " Tickets · " + (snap.releases || []).length + " Releases · "
174
+ + (snap.processSteps || []).length + " Process Steps"));
175
+ // Review finding: Apply must WAIT for the project list — with a pending
176
+ // list a conflicting id would classify as "new" and the POST/PUT upsert
177
+ // would silently overwrite the existing project.
178
+ if (!state.listResolved) {
179
+ box.appendChild(el("div", { class: "ti-project-counts" }, "Prüfe bestehende Projekte…"));
180
+ host.appendChild(box);
181
+ state.applyBtn.disabled = true;
182
+ return;
183
+ }
184
+ if (state.listFailed) {
185
+ box.appendChild(el("div", { class: "ti-project-conflict" },
186
+ "Konfliktprüfung nicht möglich (Projektliste nicht ladbar) — eine existierende Id würde ÜBERSCHRIEBEN."));
187
+ }
188
+ const conflict = (state.existingIds || []).indexOf(p.id) >= 0;
189
+ state.projectMode = conflict ? "copy" : "new";
190
+ state.copyTargetId = null;
191
+ if (conflict) {
192
+ state.copyTargetId = projectIO.planImport(snap, state.existingIds, "copy").targetId;
193
+ box.appendChild(el("div", { class: "ti-project-conflict" },
194
+ "Ein Projekt mit dieser Id existiert bereits."));
195
+ [["copy", "Als Kopie importieren („" + state.copyTargetId + "“)"],
196
+ ["overwrite", "Bestehendes Projekt ÜBERSCHREIBEN (destruktiv)"]].forEach(([val, label]) => {
197
+ const lab = el("label", { class: "ti-mode-opt" + (val === "overwrite" ? " ti-destructive" : "") });
198
+ const rb = el("input", { type: "radio", name: "ti-project-mode", value: val });
199
+ rb.checked = (val === state.projectMode);
200
+ rb.addEventListener("change", () => { if (rb.checked) state.projectMode = val; });
201
+ lab.appendChild(rb);
202
+ lab.appendChild(el("span", null, " " + label));
203
+ box.appendChild(lab);
204
+ });
205
+ } else {
206
+ box.appendChild(el("div", { class: "ti-project-new" }, "Wird als neues Projekt angelegt und geladen."));
207
+ }
208
+ host.appendChild(box);
209
+ state.applyBtn.disabled = false;
210
+ }
211
+
212
+ /**
213
+ * SM-295: THE unified import dialog. Auto-detects the content kind:
214
+ * project envelope/snapshot → whole-project import (preview + copy/
215
+ * overwrite choice; works WITHOUT a loaded project), CSV/JSON array →
216
+ * row import (preview + mode + one-commit apply; needs ctx.store).
217
+ * ctx: { store?, showModal, flashStatus, actor,
218
+ * listProjects(): Promise<string[]>,
219
+ * onImportProject(snap, targetId, overwrite): Promise }
220
+ */
221
+ function openImportDialog(ctx) {
222
+ const projectId = (ctx.store && ctx.store.get() && ctx.store.get().project && ctx.store.get().project.id) || null;
223
+ const state = {
224
+ text: "", mode: IMPORT_DIALOG.DEFAULT_MODE, plan: null, applyBtn: null,
225
+ kind: null, projectSnap: null, projectMode: "new", copyTargetId: null,
226
+ existingIds: [], listResolved: false, listFailed: false,
227
+ // SM-296/297: per-project persisted mappings + last parse's headers/rows
228
+ headerMapping: readMappingFromStorage(projectId).headers,
229
+ valueMapping: readMappingFromStorage(projectId).values,
230
+ headers: [],
231
+ rowsByLine: new Map()
232
+ };
233
+
234
+ ctx.showModal({
235
+ title: "Import (Projekt-JSON · Tickets-CSV/JSON)",
236
+ actions: [
237
+ { label: "Cancel", onClick: () => {} },
238
+ { label: "Apply import", onClick: async () => {
239
+ // ---- project branch -------------------------------------------
240
+ if (state.kind === "project" && state.projectSnap) {
241
+ const p = state.projectSnap.project;
242
+ const overwrite = state.projectMode === "overwrite";
243
+ const targetId = state.projectMode === "copy" ? state.copyTargetId : p.id;
244
+ try {
245
+ await ctx.onImportProject(state.projectSnap, targetId, overwrite);
246
+ } catch (err) {
247
+ ctx.flashStatus("import failed: " + (err && err.message || err), { kind: "error" });
248
+ return false;
249
+ }
250
+ return; // close (onImportProject flashes its own success)
251
+ }
252
+ // ---- ticket branch --------------------------------------------
253
+ if (!state.plan || !ctx.store) return false; // still on step 1 → keep open
254
+ let r;
255
+ try {
256
+ r = applyImportPlan(ctx.store.get(), state.plan, ctx.actor);
257
+ } catch (err) {
258
+ // e.g. a stale plan (board changed while the dialog was open):
259
+ // surface it — the silent console.error of the modal wrapper
260
+ // would leave the user with a dead Apply button.
261
+ ctx.flashStatus("import failed: " + (err && err.message || err), { kind: "error" });
262
+ return false;
263
+ }
264
+ if (r.created + r.updated === 0) return false; // nothing to write → keep open
265
+ ctx.store.applySnapshot(r.snapshot); // ONE commit → one undo step
266
+ ctx.flashStatus("import: " + r.created + " neu, " + r.updated + " aktualisiert", { kind: "ok" });
267
+ } }
268
+ ],
269
+ onMount: (modal) => {
270
+ const root = el("div", { class: "ti-root" });
271
+ // Apply is the second modal action (data-act="1") — manage disabled.
272
+ state.applyBtn = modal.querySelector('[data-act="1"]') || el("button");
273
+ state.applyBtn.disabled = true;
274
+
275
+ // Step 1: source
276
+ const src = el("div", { class: "ti-source" });
277
+ const file = el("input", { type: "file", accept: ".csv,.json,text/csv,application/json", class: "ti-file" });
278
+ const ta = el("textarea", {
279
+ class: "ti-text", rows: "6",
280
+ placeholder: "CSV (Header-Zeile mit Feld-Keys, wie der Export) oder JSON-Array hier einfügen — oder oben eine Datei wählen."
281
+ });
282
+ src.appendChild(file);
283
+ src.appendChild(ta);
284
+ // Mode toggle
285
+ const modeRow = el("div", { class: "ti-mode" });
286
+ [["create-only", "Nur Neuanlage"], ["upsert", "Upsert (per Ticket-Key aktualisieren)"]].forEach(([val, label]) => {
287
+ const lab = el("label", { class: "ti-mode-opt" });
288
+ const rb = el("input", { type: "radio", name: "ti-mode", value: val });
289
+ rb.checked = (val === state.mode);
290
+ rb.addEventListener("change", () => { if (rb.checked) { state.mode = val; replan(); } });
291
+ lab.appendChild(rb);
292
+ lab.appendChild(el("span", null, " " + label));
293
+ modeRow.appendChild(lab);
294
+ });
295
+ const mappingHost = el("div", { class: "ti-mapping-host" }); // SM-296
296
+ const errHost = el("div", { class: "ti-parse-error" });
297
+ const previewHost = el("div", { class: "ti-preview-host" });
298
+
299
+ // SM-296/298: ONE unified mapping table — a row per CSV column with
300
+ // its field select, and every unknown VALUE of that field nested
301
+ // directly beneath it (indented ↳). Changes re-plan live + persist.
302
+ function renderMappingTable(plan) {
303
+ mappingHost.innerHTML = "";
304
+ // JSON-array imports have NO header row but can still carry unknown
305
+ // VALUES — the value rows must render regardless (review finding on
306
+ // da00399: the early headers-return silently killed the SM-297
307
+ // panel for the JSON branch).
308
+ const hasUnknown = plan && plan.unknownValues && Object.keys(plan.unknownValues).length > 0;
309
+ if (!state.headers.length && !hasUnknown) return;
310
+ const panel = el("div", { class: "ti-mapping" });
311
+ panel.appendChild(el("div", { class: "ti-mapping-title" }, "Spalten- & Werte-Zuordnung"));
312
+ const renderedFields = new Set();
313
+
314
+ function appendValueRows(field) {
315
+ if (!plan || !plan.unknownValues || !plan.unknownValues[field]) return;
316
+ if (renderedFields.has(field)) return;
317
+ renderedFields.add(field);
318
+ const targets = validTargetsFor(field);
319
+ plan.unknownValues[field].forEach((rawVal) => {
320
+ const row = el("label", { class: "ti-mapping-row ti-mapping-value-row" });
321
+ row.appendChild(el("span", { class: "ti-mapping-raw" }, "↳ " + rawVal));
322
+ const sel = el("select", { class: "ti-mapping-select" });
323
+ sel.appendChild(el("option", { value: "" }, "— ignorieren —"));
324
+ targets.forEach((t) => sel.appendChild(el("option", { value: t.value }, t.label)));
325
+ sel.addEventListener("change", () => {
326
+ const next = Object.assign({}, state.valueMapping);
327
+ next[field] = Object.assign({}, next[field]);
328
+ next[field][String(rawVal).trim().toLowerCase()] = sel.value || null;
329
+ state.valueMapping = next;
330
+ writeMappingToStorage(projectId, state.headerMapping, state.valueMapping);
331
+ replan();
332
+ });
333
+ row.appendChild(sel);
334
+ panel.appendChild(row);
335
+ });
336
+ }
337
+
338
+ state.headers.forEach((h) => {
339
+ const row = el("label", { class: "ti-mapping-row ti-mapping-header-row" });
340
+ row.appendChild(el("span", { class: "ti-mapping-raw" }, h.raw || "(leer)"));
341
+ const sel = el("select", { class: "ti-mapping-select" });
342
+ const optIgnore = el("option", { value: "" }, "— ignorieren —");
343
+ sel.appendChild(optIgnore);
344
+ ticketImport.TICKET_IMPORT.FIELDS.forEach((f) => {
345
+ const o = el("option", { value: f }, f);
346
+ sel.appendChild(o);
347
+ });
348
+ sel.value = h.field || "";
349
+ sel.addEventListener("change", () => {
350
+ state.headerMapping = Object.assign({}, state.headerMapping);
351
+ state.headerMapping[String(h.raw).trim().toLowerCase()] = sel.value || null;
352
+ writeMappingToStorage(projectId, state.headerMapping, state.valueMapping);
353
+ replan();
354
+ });
355
+ row.appendChild(sel);
356
+ panel.appendChild(row);
357
+ // unknown VALUES of this column's field sit right below it
358
+ if (h.field) appendValueRows(h.field);
359
+ });
360
+ // safety net: unknown values whose field has no visible column
361
+ Object.keys((plan && plan.unknownValues) || {}).forEach(appendValueRows);
362
+ mappingHost.appendChild(panel);
363
+ }
364
+
365
+ // SM-297: valid mapping targets per field, from the CURRENT project.
366
+ function validTargetsFor(field) {
367
+ const snap = ctx.store ? ctx.store.get() : {};
368
+ if (field === "status") {
369
+ return (((snap.project || {}).workflow || {}).statuses || [])
370
+ .map((s) => ({ value: s.id || s, label: s.name || s.id || s }));
371
+ }
372
+ if (field === "type") return ((snap.project || {}).ticketTypes || []).map((t) => ({ value: t, label: t }));
373
+ // ids as VALUES (rename-stable — a persisted name mapping would go
374
+ // stale on the first release rename; review finding on 2a40a63),
375
+ // names as labels.
376
+ if (field === "release") return (snap.releases || []).filter((r) => !r.isDeleted).map((r) => ({ value: r.id, label: r.name }));
377
+ if (field === "processStep") return (snap.processSteps || []).filter((p) => !p.isDeleted).map((p) => ({ value: p.id, label: p.name }));
378
+ return [];
379
+ }
380
+
381
+ function replan() {
382
+ errHost.textContent = "";
383
+ previewHost.innerHTML = "";
384
+ mappingHost.innerHTML = "";
385
+ state.plan = null;
386
+ state.projectSnap = null;
387
+ state.kind = null;
388
+ state.headers = [];
389
+ state.applyBtn.disabled = true;
390
+ modeRow.style.display = "";
391
+ if (!state.text.trim()) return;
392
+ state.kind = detectImportKind(state.text);
393
+ // ---- project branch: SM-15 engine + NEW preview -----------------
394
+ if (state.kind === "project") {
395
+ modeRow.style.display = "none"; // ticket modes don't apply
396
+ if (!projectIO) { errHost.textContent = "project import unavailable (projectIO missing)"; return; }
397
+ let snap;
398
+ try { snap = projectIO.parseImport(state.text); }
399
+ catch (e) { errHost.textContent = e.message; return; }
400
+ state.projectSnap = snap;
401
+ renderProjectPreview(previewHost, snap, state);
402
+ return;
403
+ }
404
+ // ---- ticket branch ----------------------------------------------
405
+ if (!ctx.store) {
406
+ errHost.textContent = "Ticket-Import braucht ein geladenes Projekt — Projekt-JSON kann jederzeit importiert werden.";
407
+ return;
408
+ }
409
+ const parsed = ticketImport.parseTicketImport(state.text, undefined, { headerMapping: state.headerMapping });
410
+ if (parsed.error) { errHost.textContent = parsed.error; return; }
411
+ state.headers = parsed.headers || [];
412
+ state.rowsByLine = new Map(parsed.rows.map((r) => [r.line, r.data]));
413
+ state.plan = ticketImport.planTicketImport(ctx.store.get(), parsed.rows, state.mode,
414
+ { valueMapping: state.valueMapping });
415
+ renderMappingTable(state.plan);
416
+ renderPreview(previewHost, state.plan, state);
417
+ // SM-296: unused columns are ANNOUNCED, never silently swallowed.
418
+ const unused = state.headers.filter((h) => !h.field).map((h) => h.raw || "(leer)");
419
+ if (unused.length) {
420
+ previewHost.appendChild(el("div", { class: "ti-unused" },
421
+ "Ignorierte Spalten: " + unused.join(", ")));
422
+ }
423
+ }
424
+
425
+ // SM-295: fetch the existing project ids once (conflict detection in
426
+ // the project branch); re-plan when they arrive after a fast paste.
427
+ if (typeof ctx.listProjects === "function") {
428
+ try {
429
+ ctx.listProjects().then((ids) => {
430
+ state.existingIds = ids || [];
431
+ state.listResolved = true;
432
+ if (state.kind === "project") replan();
433
+ }).catch(() => {
434
+ state.listResolved = true;
435
+ state.listFailed = true; // conflict unknown → explicit warning
436
+ if (state.kind === "project") replan();
437
+ });
438
+ } catch (_e) { state.listResolved = true; state.listFailed = true; }
439
+ } else {
440
+ state.listResolved = true;
441
+ state.listFailed = true;
442
+ }
443
+
444
+ ta.addEventListener("input", () => { state.text = ta.value; replan(); });
445
+ file.addEventListener("change", () => {
446
+ const f = file.files && file.files[0];
447
+ if (!f) return;
448
+ f.text().then((t) => { state.text = t; ta.value = t; replan(); })
449
+ .catch((e) => { errHost.textContent = "Datei nicht lesbar: " + e.message; });
450
+ });
451
+
452
+ root.appendChild(src);
453
+ root.appendChild(modeRow);
454
+ // SM-298: ONE scroll container over mapping + errors + preview — it
455
+ // takes the maximum dialog space (the modal is resizable).
456
+ const scroll = el("div", { class: "ti-scroll" });
457
+ scroll.appendChild(mappingHost);
458
+ scroll.appendChild(errHost);
459
+ scroll.appendChild(previewHost);
460
+ root.appendChild(scroll);
461
+ // Insert the body BEFORE the modal action row.
462
+ const actionRow = modal.querySelector(".modal-actions");
463
+ if (actionRow) modal.insertBefore(root, actionRow);
464
+ else modal.appendChild(root);
465
+ // expose for tests
466
+ root._replan = replan;
467
+ root._state = state;
468
+ }
469
+ });
470
+ }
471
+
472
+ /**
473
+ * SM-295: THE unified export chooser — one menu entry, two targets.
474
+ * ctx: { showModal, onExportProject(), onExportTickets() }
475
+ */
476
+ function openExportDialog(ctx) {
477
+ ctx.showModal({
478
+ title: "Export",
479
+ actions: [
480
+ { label: "Cancel", onClick: () => {} },
481
+ { label: "Projekt (JSON)", onClick: () => ctx.onExportProject() },
482
+ { label: "Tickets (CSV)", onClick: () => ctx.onExportTickets() },
483
+ ],
484
+ onMount: (modal) => {
485
+ const root = el("div", { class: "ti-export-info" });
486
+ root.appendChild(el("p", null,
487
+ "Projekt (JSON): vollständiges, re-importierbares Backup — inklusive Workflow, Definitions, Board-Config, Checklisten, Links und Kommentaren."));
488
+ root.appendChild(el("p", null,
489
+ "Tickets (CSV): das aktuelle Tabellen-Ergebnis (Query, Spalten, Sortierung) — für Tabellenkalkulation und Re-Import per Upsert."));
490
+ const actionRow = modal.querySelector(".modal-actions");
491
+ if (actionRow) modal.insertBefore(root, actionRow);
492
+ else modal.appendChild(root);
493
+ }
494
+ });
495
+ }
496
+
497
+ return {
498
+ IMPORT_DIALOG: IMPORT_DIALOG,
499
+ detectImportKind: detectImportKind,
500
+ applyImportPlan: applyImportPlan,
501
+ openImportDialog: openImportDialog,
502
+ openExportDialog: openExportDialog,
503
+ // Back-compat alias (pre-SM-295 name).
504
+ openTicketImportDialog: openImportDialog,
505
+ };
506
+ }));