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.
- package/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- 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
|
+
}));
|