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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SM-15 — Project Export / Import (JSON).
|
|
3
|
+
*
|
|
4
|
+
* Pure, UMD-wrapped helpers for serializing a project snapshot to a JSON
|
|
5
|
+
* export envelope and parsing/validating an uploaded file back into a
|
|
6
|
+
* snapshot. The browser glue (Blob download, <input type=file>, the
|
|
7
|
+
* overwrite-vs-copy dialog and the POST/PUT calls) lives in main.js — these
|
|
8
|
+
* functions carry the logic that's worth unit-testing in isolation.
|
|
9
|
+
*
|
|
10
|
+
* Export envelope:
|
|
11
|
+
* { format: "storymap-project", version: 1, exportedAt?: ISO, snapshot: {...} }
|
|
12
|
+
*
|
|
13
|
+
* Import is lenient: it accepts the envelope OR a bare snapshot (so a file
|
|
14
|
+
* pulled straight from GET /api/projects/:id also imports).
|
|
15
|
+
*/
|
|
16
|
+
(function (root, factory) {
|
|
17
|
+
if (typeof module === "object" && module.exports) {
|
|
18
|
+
module.exports = factory();
|
|
19
|
+
} else {
|
|
20
|
+
(root.STORYMAP = root.STORYMAP || {}).projectIO = factory();
|
|
21
|
+
}
|
|
22
|
+
}(typeof self !== "undefined" ? self : this, function () {
|
|
23
|
+
"use strict";
|
|
24
|
+
|
|
25
|
+
const FORMAT = "storymap-project";
|
|
26
|
+
const VERSION = 1;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Serialize a snapshot into the on-disk export envelope (pretty JSON string).
|
|
30
|
+
* `opts.exportedAt` (ISO string) is included when supplied — kept as a param
|
|
31
|
+
* so the function stays pure/deterministic for tests.
|
|
32
|
+
*/
|
|
33
|
+
function serializeProject(snapshot, opts) {
|
|
34
|
+
opts = opts || {};
|
|
35
|
+
const env = { format: FORMAT, version: VERSION };
|
|
36
|
+
if (opts.exportedAt) env.exportedAt = opts.exportedAt;
|
|
37
|
+
env.snapshot = snapshot;
|
|
38
|
+
return JSON.stringify(env, null, 2);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Suggested download filename for a snapshot: `<sanitized-id>.storymap.json`. */
|
|
42
|
+
function exportFilename(snapshot) {
|
|
43
|
+
const id = (snapshot && snapshot.project && snapshot.project.id) || "project";
|
|
44
|
+
return String(id).replace(/[^a-zA-Z0-9_-]/g, "_") + ".storymap.json";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse + validate imported text. Accepts the export envelope OR a bare
|
|
49
|
+
* snapshot; returns the snapshot. Throws Error with a human message on bad
|
|
50
|
+
* input (caller surfaces it via flashStatus).
|
|
51
|
+
*/
|
|
52
|
+
function parseImport(text) {
|
|
53
|
+
let data;
|
|
54
|
+
try { data = JSON.parse(text); }
|
|
55
|
+
catch (e) { throw new Error("not valid JSON: " + e.message); }
|
|
56
|
+
if (!data || typeof data !== "object") throw new Error("file is not a JSON object");
|
|
57
|
+
const snap = (data.format === FORMAT || data.snapshot) ? data.snapshot : data;
|
|
58
|
+
if (!snap || typeof snap !== "object") throw new Error("no snapshot found in file");
|
|
59
|
+
if (!snap.project || typeof snap.project !== "object"
|
|
60
|
+
|| typeof snap.project.id !== "string" || !snap.project.id) {
|
|
61
|
+
throw new Error("snapshot has no project.id");
|
|
62
|
+
}
|
|
63
|
+
for (const k of ["tickets", "releases", "processSteps"]) {
|
|
64
|
+
if (snap[k] != null && !Array.isArray(snap[k])) {
|
|
65
|
+
throw new Error(k + " must be an array");
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return snap;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Derive a unique `<base>-copy[-N]` id given the set of existing ids. */
|
|
72
|
+
function suggestCopyId(baseId, existingIds) {
|
|
73
|
+
const taken = new Set(existingIds || []);
|
|
74
|
+
if (!taken.has(baseId + "-copy")) return baseId + "-copy";
|
|
75
|
+
let n = 2;
|
|
76
|
+
while (taken.has(baseId + "-copy-" + n)) n++;
|
|
77
|
+
return baseId + "-copy-" + n;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Decide the import plan. mode ∈ "overwrite" | "copy".
|
|
82
|
+
* Returns { targetId, isOverwrite, conflict }.
|
|
83
|
+
* - no conflict → import under the original id (create)
|
|
84
|
+
* - conflict + "overwrite" → replace the existing project
|
|
85
|
+
* - conflict + "copy" → import under a fresh `<id>-copy` id
|
|
86
|
+
*/
|
|
87
|
+
function planImport(snapshot, existingIds, mode) {
|
|
88
|
+
const id = snapshot.project.id;
|
|
89
|
+
const conflict = (existingIds || []).indexOf(id) >= 0;
|
|
90
|
+
if (conflict && mode === "copy") {
|
|
91
|
+
return { targetId: suggestCopyId(id, existingIds), isOverwrite: false, conflict: true };
|
|
92
|
+
}
|
|
93
|
+
return { targetId: id, isOverwrite: conflict, conflict: conflict };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Deep clone of the snapshot with project.id re-pinned to `targetId`. */
|
|
97
|
+
function withProjectId(snapshot, targetId) {
|
|
98
|
+
const next = JSON.parse(JSON.stringify(snapshot));
|
|
99
|
+
next.project = next.project || {};
|
|
100
|
+
next.project.id = targetId;
|
|
101
|
+
return next;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
FORMAT, VERSION,
|
|
106
|
+
serializeProject, exportFilename, parseImport,
|
|
107
|
+
suggestCopyId, planImport, withProjectId
|
|
108
|
+
};
|
|
109
|
+
}));
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SM-216 — JQL autocomplete suggestion engine (Story S5 of epic SM-187).
|
|
3
|
+
*
|
|
4
|
+
* Pure. No DOM. UMD; shared so it's unit-testable in Node and used by the
|
|
5
|
+
* browser dropdown (frontend/js/smartbar-autocomplete.js). Requires the parser
|
|
6
|
+
* (shared/query.js, for tokenize + keyword/operator catalogue) and the engine
|
|
7
|
+
* (shared/query-engine.js, for QUERY_FIELDS — the SINGLE source of fields + the
|
|
8
|
+
* per-field value lists).
|
|
9
|
+
*
|
|
10
|
+
* suggest(input, cursor, snapshot) → [{ insertText, label, kind, replaceStart,
|
|
11
|
+
* replaceEnd }]
|
|
12
|
+
*
|
|
13
|
+
* `kind` ∈ field | operator | value | keyword. The dropdown replaces the text in
|
|
14
|
+
* [replaceStart, replaceEnd] (the in-progress token) with insertText. Context is
|
|
15
|
+
* derived from a small state machine over the tokens to the LEFT of the cursor:
|
|
16
|
+
* at the start / after AND·OR·NOT·'(' → fields; after a field → operators; after
|
|
17
|
+
* an operator → that field's values; after a complete clause → AND·OR·')'·ORDER BY.
|
|
18
|
+
*/
|
|
19
|
+
(function (global, factory) {
|
|
20
|
+
if (typeof module === "object" && module.exports) {
|
|
21
|
+
module.exports = factory(require("./query.js"), require("./query-engine.js"));
|
|
22
|
+
} else {
|
|
23
|
+
const ns = (global.STORYMAP = global.STORYMAP || {});
|
|
24
|
+
ns.queryAutocomplete = factory(ns.query, ns.queryEngine);
|
|
25
|
+
}
|
|
26
|
+
}(typeof window !== "undefined" ? window : globalThis, function (Q, engine) {
|
|
27
|
+
"use strict";
|
|
28
|
+
|
|
29
|
+
const MAX = 20; // enough to show every field with no prefix; values stay scrollable
|
|
30
|
+
const isKw = (t, kw) => t && t.type === "word" && String(t.value).toUpperCase() === kw;
|
|
31
|
+
const opToInsert = { "=": "= ", "!=": "!= ", "~": "~ ", "<": "< ", ">": "> ", "<=": "<= ", ">=": ">= " };
|
|
32
|
+
// The operators offered per field, derived from the field's clauseOps.
|
|
33
|
+
const OP_SUGGEST = { eq: "=", neq: "!=", contains: "~", in: "IN", "not-in": "NOT IN", lt: "<", gt: ">", lte: "<=", gte: ">=" };
|
|
34
|
+
|
|
35
|
+
/** Lenient tokenize of the text up to the cursor — query.tokenize throws on an
|
|
36
|
+
* unterminated string / unexpected char while typing, so fall back to the
|
|
37
|
+
* valid prefix + the in-progress tail as a partial token. */
|
|
38
|
+
function leftTokens(left) {
|
|
39
|
+
try {
|
|
40
|
+
return Q.tokenize(left).filter((t) => t.type !== "eof");
|
|
41
|
+
} catch (e) {
|
|
42
|
+
const pos = typeof e.position === "number" ? e.position : left.length;
|
|
43
|
+
let toks = [];
|
|
44
|
+
try { toks = Q.tokenize(left.slice(0, pos)).filter((t) => t.type !== "eof"); } catch (_e) { toks = []; }
|
|
45
|
+
const tail = left.slice(pos);
|
|
46
|
+
const quoted = tail[0] === '"' || tail[0] === "'";
|
|
47
|
+
toks.push({ type: quoted ? "string" : "word", value: quoted ? tail.slice(1) : tail, start: pos, end: left.length, partial: true });
|
|
48
|
+
return toks;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Walk the preceding tokens → { state, lastField }. */
|
|
53
|
+
function analyze(tokens) {
|
|
54
|
+
let state = "field", lastField = null;
|
|
55
|
+
for (const t of tokens) {
|
|
56
|
+
switch (state) {
|
|
57
|
+
case "field":
|
|
58
|
+
if (t.type === "lparen" || isKw(t, "NOT")) break; // still expecting a field
|
|
59
|
+
if (t.type === "word") { lastField = t.value; state = "operator"; }
|
|
60
|
+
break;
|
|
61
|
+
case "operator":
|
|
62
|
+
if (t.type === "op") state = "value";
|
|
63
|
+
else if (isKw(t, "IN")) state = "list-open";
|
|
64
|
+
else if (isKw(t, "NOT")) state = "notin";
|
|
65
|
+
else state = "value";
|
|
66
|
+
break;
|
|
67
|
+
case "notin":
|
|
68
|
+
if (isKw(t, "IN")) state = "list-open";
|
|
69
|
+
break;
|
|
70
|
+
case "value":
|
|
71
|
+
if (t.type === "word" || t.type === "string") state = "after";
|
|
72
|
+
break;
|
|
73
|
+
case "list-open":
|
|
74
|
+
if (t.type === "lparen") state = "in-list";
|
|
75
|
+
break;
|
|
76
|
+
case "in-list":
|
|
77
|
+
if (t.type === "rparen") state = "after";
|
|
78
|
+
break; // words / commas stay in-list
|
|
79
|
+
case "after":
|
|
80
|
+
if (isKw(t, "AND") || isKw(t, "OR") || isKw(t, "NOT")) state = "field";
|
|
81
|
+
else if (isKw(t, "ORDER")) state = "order-by";
|
|
82
|
+
break;
|
|
83
|
+
case "order-by": if (isKw(t, "BY")) state = "order-field"; break;
|
|
84
|
+
case "order-field": if (t.type === "word") state = "order-dir"; break;
|
|
85
|
+
case "order-dir": state = "done"; break; // a sort direction ends the query → no more suggestions
|
|
86
|
+
default: break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { state: state, lastField: lastField };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function fieldDefs() { return engine.QUERY_FIELDS; }
|
|
93
|
+
function fieldByKey(key) {
|
|
94
|
+
const k = String(key || "").toLowerCase();
|
|
95
|
+
return fieldDefs().find((f) => f.key.toLowerCase() === k) || null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rank(candidates, prefix, kind, replaceStart, replaceEnd) {
|
|
99
|
+
const p = String(prefix || "").toLowerCase();
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const c of candidates) {
|
|
102
|
+
const label = c.label != null ? c.label : c.insertText.trim();
|
|
103
|
+
const hay = String(c.matchOn != null ? c.matchOn : label).toLowerCase();
|
|
104
|
+
if (p && hay.indexOf(p) < 0) continue;
|
|
105
|
+
out.push({
|
|
106
|
+
insertText: c.insertText, label: label, kind: kind,
|
|
107
|
+
replaceStart: replaceStart, replaceEnd: replaceEnd,
|
|
108
|
+
_exact: p && hay.indexOf(p) === 0 ? 0 : 1
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
out.sort((a, b) => (a._exact - b._exact) || a.label.localeCompare(b.label));
|
|
112
|
+
return out.slice(0, MAX).map((s) => { delete s._exact; return s; });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function quoteIfNeeded(v) {
|
|
116
|
+
const s = String(v);
|
|
117
|
+
return /[\s(),"]/.test(s) ? '"' + s.replace(/"/g, '\\"') + '"' : s;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Main entry. */
|
|
121
|
+
function suggest(input, cursor, snapshot) {
|
|
122
|
+
input = String(input == null ? "" : input);
|
|
123
|
+
cursor = typeof cursor === "number" ? cursor : input.length;
|
|
124
|
+
const left = input.slice(0, cursor);
|
|
125
|
+
const toks = leftTokens(left);
|
|
126
|
+
|
|
127
|
+
// The in-progress (partial) token is the last token ending exactly at the
|
|
128
|
+
// cursor; otherwise the cursor sits after whitespace/a delimiter → no prefix.
|
|
129
|
+
let partial = null;
|
|
130
|
+
if (toks.length) {
|
|
131
|
+
const last = toks[toks.length - 1];
|
|
132
|
+
if (last.end === left.length && (last.type === "word" || last.type === "string" || last.partial)) partial = last;
|
|
133
|
+
}
|
|
134
|
+
const preceding = partial ? toks.slice(0, -1) : toks;
|
|
135
|
+
const prefix = partial ? String(partial.value) : "";
|
|
136
|
+
const replaceStart = partial ? partial.start : cursor;
|
|
137
|
+
const replaceEnd = cursor;
|
|
138
|
+
|
|
139
|
+
const ctx = analyze(preceding);
|
|
140
|
+
const field = ctx.lastField ? fieldByKey(ctx.lastField) : null;
|
|
141
|
+
|
|
142
|
+
// FIELD position.
|
|
143
|
+
if (ctx.state === "field" || ctx.state === "order-field") {
|
|
144
|
+
const cands = fieldDefs().map((f) => ({ insertText: f.key + " ", label: f.key, matchOn: f.key + " " + (f.label || "") }));
|
|
145
|
+
if (ctx.state === "field") { cands.push({ insertText: "NOT ", label: "NOT", matchOn: "not" }); cands.push({ insertText: "(", label: "(", matchOn: "(" }); }
|
|
146
|
+
return rank(cands, prefix, "field", replaceStart, replaceEnd);
|
|
147
|
+
}
|
|
148
|
+
// OPERATOR position — only the operators the field actually allows.
|
|
149
|
+
if (ctx.state === "operator") {
|
|
150
|
+
const ops = field ? field.ops : ["eq", "neq", "contains", "in", "not-in", "lt", "gt", "lte", "gte"];
|
|
151
|
+
const cands = ops.map((op) => {
|
|
152
|
+
const sym = OP_SUGGEST[op];
|
|
153
|
+
const insert = opToInsert[sym] != null ? opToInsert[sym] : (sym + " " + (op === "in" || op === "not-in" ? "(" : ""));
|
|
154
|
+
return { insertText: insert, label: sym, matchOn: sym };
|
|
155
|
+
});
|
|
156
|
+
return rank(cands, prefix, "operator", replaceStart, replaceEnd);
|
|
157
|
+
}
|
|
158
|
+
if (ctx.state === "notin") {
|
|
159
|
+
return rank([{ insertText: "IN (", label: "IN", matchOn: "in" }], prefix, "operator", replaceStart, replaceEnd);
|
|
160
|
+
}
|
|
161
|
+
if (ctx.state === "list-open") {
|
|
162
|
+
return rank([{ insertText: "(", label: "(", matchOn: "(" }], prefix, "operator", replaceStart, replaceEnd);
|
|
163
|
+
}
|
|
164
|
+
// VALUE position — field-specific values (enum/ref/multi/link). Free-input
|
|
165
|
+
// fields (text/number/date) offer nothing.
|
|
166
|
+
if (ctx.state === "value" || ctx.state === "in-list") {
|
|
167
|
+
const cands = [];
|
|
168
|
+
if (field && typeof field.values === "function") {
|
|
169
|
+
let vals = [];
|
|
170
|
+
try { vals = field.values(snapshot || {}) || []; } catch (_e) { vals = []; }
|
|
171
|
+
for (const v of vals) cands.push({ insertText: quoteIfNeeded(v) + " ", label: String(v), matchOn: String(v) });
|
|
172
|
+
}
|
|
173
|
+
if (ctx.state === "in-list") cands.push({ insertText: ")", label: ")", matchOn: ")" });
|
|
174
|
+
return rank(cands, prefix, "value", replaceStart, replaceEnd);
|
|
175
|
+
}
|
|
176
|
+
// After a complete clause.
|
|
177
|
+
if (ctx.state === "after") {
|
|
178
|
+
const cands = [
|
|
179
|
+
{ insertText: "AND ", label: "AND", matchOn: "and" },
|
|
180
|
+
{ insertText: "OR ", label: "OR", matchOn: "or" },
|
|
181
|
+
{ insertText: "ORDER BY ", label: "ORDER BY", matchOn: "order by" },
|
|
182
|
+
{ insertText: ")", label: ")", matchOn: ")" }
|
|
183
|
+
];
|
|
184
|
+
return rank(cands, prefix, "keyword", replaceStart, replaceEnd);
|
|
185
|
+
}
|
|
186
|
+
if (ctx.state === "order-by") {
|
|
187
|
+
return rank([{ insertText: "BY ", label: "BY", matchOn: "by" }], prefix, "keyword", replaceStart, replaceEnd);
|
|
188
|
+
}
|
|
189
|
+
if (ctx.state === "order-dir") {
|
|
190
|
+
return rank([{ insertText: "ASC ", label: "ASC", matchOn: "asc" }, { insertText: "DESC ", label: "DESC", matchOn: "desc" }], prefix, "keyword", replaceStart, replaceEnd);
|
|
191
|
+
}
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return { suggest: suggest, _analyze: analyze, _leftTokens: leftTokens };
|
|
196
|
+
}));
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SM-189 — Query engine + field schema (Story S2 of epic SM-187).
|
|
3
|
+
*
|
|
4
|
+
* Pure. No DOM, no I/O. UMD-wrapped; shared by the server (SM-190 MCP
|
|
5
|
+
* query_tickets) and the browser (SM-191 smart-bar) via the
|
|
6
|
+
* frontend/js/query-engine.js symlink. Requires the parser (shared/query.js).
|
|
7
|
+
*
|
|
8
|
+
* The QUERY_FIELDS schema is the SINGLE SOURCE that drives field resolution
|
|
9
|
+
* (here), semantic validation (here), and the autocomplete value-suggestions
|
|
10
|
+
* (SM-216). Each field: { key, label, type, ops, resolve(ticket, ctx),
|
|
11
|
+
* values(snapshot) }. `type` selects the matcher; `ops` is the allow-list of
|
|
12
|
+
* clauseOps the field accepts (validation + autocomplete).
|
|
13
|
+
*
|
|
14
|
+
* Public surface:
|
|
15
|
+
* QUERY_FIELDS — the field definitions (array)
|
|
16
|
+
* fieldsByKey() — lower-cased key → field def
|
|
17
|
+
* buildQueryCtx(snapshot) — resolution indexes (release/step/epic/links)
|
|
18
|
+
* evaluate(ast, ticket, ctx, fields) — per-ticket boolean
|
|
19
|
+
* validateQuery(ast, orderBy, fields) — semantic check → {message,...}|null
|
|
20
|
+
* queryTickets(snapshot, queryOrAst, opts) → { tickets, orderBy?, error? }
|
|
21
|
+
*/
|
|
22
|
+
(function (global, factory) {
|
|
23
|
+
if (typeof module === "object" && module.exports) {
|
|
24
|
+
module.exports = factory(require("./query.js"));
|
|
25
|
+
} else {
|
|
26
|
+
const ns = (global.STORYMAP = global.STORYMAP || {});
|
|
27
|
+
ns.queryEngine = factory(ns.query);
|
|
28
|
+
}
|
|
29
|
+
}(typeof window !== "undefined" ? window : globalThis, function (queryParser) {
|
|
30
|
+
"use strict";
|
|
31
|
+
|
|
32
|
+
const parseQuery = queryParser && queryParser.parseQuery;
|
|
33
|
+
|
|
34
|
+
// -- matchers (per field type) ---------------------------------------------
|
|
35
|
+
|
|
36
|
+
const ci = (x) => String(x == null ? "" : x).toLowerCase();
|
|
37
|
+
|
|
38
|
+
function matchScalar(resolved, clause) {
|
|
39
|
+
if (clause.clauseOp === "neq") return !(resolved != null && ci(resolved) === ci(clause.value));
|
|
40
|
+
if (clause.clauseOp === "not-in") return !(resolved != null && clause.values.some((v) => ci(resolved) === ci(v)));
|
|
41
|
+
if (resolved == null) return false;
|
|
42
|
+
switch (clause.clauseOp) {
|
|
43
|
+
case "eq": return ci(resolved) === ci(clause.value);
|
|
44
|
+
case "contains": return ci(resolved).indexOf(ci(clause.value)) >= 0;
|
|
45
|
+
case "in": return clause.values.some((v) => ci(resolved) === ci(v));
|
|
46
|
+
default: return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// SM-260: ref fields resolve to a SET of acceptable values (name + id, or
|
|
51
|
+
// ticketKey + id for epic). A clause matches if it hits ANY candidate, so
|
|
52
|
+
// `release = r1` (id) and `release = v1.0` (name) both work.
|
|
53
|
+
function matchRef(cands, clause) {
|
|
54
|
+
const arr = (cands || []).filter((x) => x != null).map(String);
|
|
55
|
+
const eqAny = (v) => arr.some((x) => ci(x) === ci(v));
|
|
56
|
+
switch (clause.clauseOp) {
|
|
57
|
+
case "eq": return eqAny(clause.value);
|
|
58
|
+
case "neq": return !eqAny(clause.value);
|
|
59
|
+
case "contains": return arr.some((x) => ci(x).indexOf(ci(clause.value)) >= 0);
|
|
60
|
+
case "in": return (clause.values || []).some(eqAny);
|
|
61
|
+
case "not-in": return !(clause.values || []).some(eqAny);
|
|
62
|
+
default: return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// membership semantics for array-valued fields (labels, linked keys)
|
|
67
|
+
function matchMulti(resolved, clause) {
|
|
68
|
+
const arr = Array.isArray(resolved) ? resolved : [];
|
|
69
|
+
const has = (v) => arr.some((x) => ci(x) === ci(v));
|
|
70
|
+
switch (clause.clauseOp) {
|
|
71
|
+
case "eq": case "contains": return has(clause.value);
|
|
72
|
+
case "neq": return !has(clause.value);
|
|
73
|
+
case "in": return clause.values.some(has);
|
|
74
|
+
case "not-in": return !clause.values.some(has);
|
|
75
|
+
default: return false;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cmp(a, b, op) {
|
|
80
|
+
switch (op) {
|
|
81
|
+
case "eq": return a === b; case "neq": return a !== b;
|
|
82
|
+
case "lt": return a < b; case "gt": return a > b;
|
|
83
|
+
case "lte": return a <= b; case "gte": return a >= b;
|
|
84
|
+
default: return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function matchNumber(resolved, clause) {
|
|
88
|
+
const a = Number(resolved), b = Number(clause.value);
|
|
89
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
90
|
+
return cmp(a, b, clause.clauseOp);
|
|
91
|
+
}
|
|
92
|
+
function matchDate(resolved, clause) {
|
|
93
|
+
const a = Number(resolved), b = Date.parse(clause.value);
|
|
94
|
+
if (Number.isNaN(a) || Number.isNaN(b)) return false;
|
|
95
|
+
return cmp(a, b, clause.clauseOp);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const MATCHERS = { enum: matchScalar, ref: matchScalar, text: matchScalar, multi: matchMulti, number: matchNumber, date: matchDate };
|
|
99
|
+
|
|
100
|
+
// -- helpers for values() + resolve ----------------------------------------
|
|
101
|
+
|
|
102
|
+
function uniq(/* ...arrays */) {
|
|
103
|
+
const seen = new Set(), out = [];
|
|
104
|
+
for (let i = 0; i < arguments.length; i++) {
|
|
105
|
+
for (const v of (arguments[i] || [])) {
|
|
106
|
+
if (v == null || v === "") continue;
|
|
107
|
+
const k = ci(v);
|
|
108
|
+
if (!seen.has(k)) { seen.add(k); out.push(v); }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
const proj = (snap) => (snap && snap.project) || {};
|
|
114
|
+
const liveTickets = (snap) => ((snap && snap.tickets) || []).filter((t) => t && !t.isDeleted);
|
|
115
|
+
function haystack(t) {
|
|
116
|
+
const labels = Array.isArray(t.labels) ? t.labels.join(" ") : "";
|
|
117
|
+
return [t.ticketKey, t.title, t.description, labels].filter((s) => typeof s === "string").join(" ");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const SCALAR_OPS = ["eq", "neq", "in", "not-in"];
|
|
121
|
+
const NUM_OPS = ["eq", "neq", "lt", "gt", "lte", "gte"];
|
|
122
|
+
const MULTI_OPS = ["eq", "neq", "contains", "in", "not-in"];
|
|
123
|
+
|
|
124
|
+
// -- the field schema (single source) --------------------------------------
|
|
125
|
+
|
|
126
|
+
const QUERY_FIELDS = [
|
|
127
|
+
{ key: "type", label: "Type", type: "enum", ops: SCALAR_OPS,
|
|
128
|
+
resolve: (t) => t.type,
|
|
129
|
+
values: (s) => uniq(proj(s).ticketTypes, liveTickets(s).map((t) => t.type)) },
|
|
130
|
+
{ key: "status", label: "Status", type: "enum", ops: SCALAR_OPS,
|
|
131
|
+
resolve: (t) => t.status,
|
|
132
|
+
values: (s) => uniq(((proj(s).workflow || {}).statuses || []).map((x) => x.id || x), liveTickets(s).map((t) => t.status)) },
|
|
133
|
+
{ key: "key", label: "Ticket key", type: "text", ops: ["eq", "neq", "contains", "in", "not-in"],
|
|
134
|
+
resolve: (t) => t.ticketKey,
|
|
135
|
+
values: (s) => liveTickets(s).map((t) => t.ticketKey) },
|
|
136
|
+
{ key: "title", label: "Title", type: "text", ops: ["contains", "eq", "neq"],
|
|
137
|
+
resolve: (t) => t.title, values: () => [] },
|
|
138
|
+
{ key: "description", label: "Description", type: "text", ops: ["contains", "eq", "neq"],
|
|
139
|
+
resolve: (t) => t.description, values: () => [] },
|
|
140
|
+
{ key: "text", label: "Full text", type: "text", ops: ["contains"],
|
|
141
|
+
resolve: (t) => haystack(t), values: () => [] },
|
|
142
|
+
// SM-260: ref fields match by NAME *or* ID (resolve = display name for sort
|
|
143
|
+
// + autocomplete; matchset = every acceptable filter value; known = all
|
|
144
|
+
// valid values in the snapshot, for the strictRefs unknown-value check).
|
|
145
|
+
{ key: "release", label: "Release", type: "ref", ops: SCALAR_OPS,
|
|
146
|
+
resolve: (t, ctx) => { const r = ctx.releaseById.get(t.position && t.position.releaseId); return r ? r.name : null; },
|
|
147
|
+
matchset: (t, ctx) => { const r = ctx.releaseById.get(t.position && t.position.releaseId); return r ? [r.name, r.id] : []; },
|
|
148
|
+
known: (s) => [].concat.apply([], ((s && s.releases) || []).filter((r) => !r.isDeleted).map((r) => [r.name, r.id])),
|
|
149
|
+
values: (s) => ((s && s.releases) || []).filter((r) => !r.isDeleted).map((r) => r.name) },
|
|
150
|
+
{ key: "processStep", label: "Process step", type: "ref", ops: SCALAR_OPS,
|
|
151
|
+
resolve: (t, ctx) => { const p = ctx.stepById.get(t.position && t.position.processStepId); return p ? p.name : null; },
|
|
152
|
+
matchset: (t, ctx) => { const p = ctx.stepById.get(t.position && t.position.processStepId); return p ? [p.name, p.id] : []; },
|
|
153
|
+
known: (s) => [].concat.apply([], ((s && s.processSteps) || []).filter((p) => !p.isDeleted).map((p) => [p.name, p.id])),
|
|
154
|
+
values: (s) => ((s && s.processSteps) || []).filter((p) => !p.isDeleted).map((p) => p.name) },
|
|
155
|
+
{ key: "epic", label: "Epic", type: "ref", ops: SCALAR_OPS,
|
|
156
|
+
resolve: (t, ctx) => ctx.epicKeyByStory.get(t.id) || null,
|
|
157
|
+
matchset: (t, ctx) => [ctx.epicKeyByStory.get(t.id), ctx.epicIdByStory.get(t.id)].filter((x) => x != null),
|
|
158
|
+
known: (s) => [].concat.apply([], liveTickets(s).filter((t) => t.type === "epic").map((t) => [t.ticketKey, t.id])),
|
|
159
|
+
values: (s) => liveTickets(s).filter((t) => t.type === "epic").map((t) => t.ticketKey) },
|
|
160
|
+
{ key: "label", label: "Label", type: "multi", ops: MULTI_OPS,
|
|
161
|
+
resolve: (t) => t.labels || [],
|
|
162
|
+
values: (s) => uniq(proj(s).labels, [].concat.apply([], liveTickets(s).map((t) => t.labels || []))) },
|
|
163
|
+
{ key: "linkedTo", label: "Links to (key)", type: "multi", ops: MULTI_OPS,
|
|
164
|
+
resolve: (t, ctx) => ctx.linkedTo.get(t.id) || [],
|
|
165
|
+
values: (s) => liveTickets(s).map((t) => t.ticketKey) },
|
|
166
|
+
{ key: "linkedFrom", label: "Linked from (key)", type: "multi", ops: MULTI_OPS,
|
|
167
|
+
resolve: (t, ctx) => ctx.linkedFrom.get(t.id) || [],
|
|
168
|
+
values: (s) => liveTickets(s).map((t) => t.ticketKey) },
|
|
169
|
+
{ key: "acCount", label: "# acceptance criteria", type: "number", ops: NUM_OPS,
|
|
170
|
+
resolve: (t) => (t.acceptanceCriteria || []).length, values: () => [] },
|
|
171
|
+
{ key: "created", label: "Created", type: "date", ops: NUM_OPS,
|
|
172
|
+
resolve: (t) => t.createdAt, values: () => [] },
|
|
173
|
+
{ key: "updated", label: "Updated", type: "date", ops: NUM_OPS,
|
|
174
|
+
resolve: (t) => t.updatedAt, values: () => [] }
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
function fieldsByKey() {
|
|
178
|
+
const m = {};
|
|
179
|
+
for (const f of QUERY_FIELDS) m[f.key.toLowerCase()] = f;
|
|
180
|
+
return m;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// -- resolution context ----------------------------------------------------
|
|
184
|
+
|
|
185
|
+
function buildQueryCtx(snapshot) {
|
|
186
|
+
const tickets = (snapshot && snapshot.tickets) || [];
|
|
187
|
+
const keyById = new Map();
|
|
188
|
+
for (const t of tickets) keyById.set(t.id, t.ticketKey);
|
|
189
|
+
const releaseById = new Map(((snapshot && snapshot.releases) || []).map((r) => [r.id, r]));
|
|
190
|
+
const stepById = new Map(((snapshot && snapshot.processSteps) || []).map((p) => [p.id, p]));
|
|
191
|
+
const epicKeyByStory = new Map();
|
|
192
|
+
const epicIdByStory = new Map();
|
|
193
|
+
const linkedTo = new Map();
|
|
194
|
+
const linkedFrom = new Map();
|
|
195
|
+
for (const t of tickets) {
|
|
196
|
+
if (t.isDeleted) continue;
|
|
197
|
+
for (const l of (t.links || [])) {
|
|
198
|
+
if (!l || typeof l.targetTicketId !== "string") continue;
|
|
199
|
+
const lt = l.linkTypeId || l.type;
|
|
200
|
+
const tgtKey = keyById.get(l.targetTicketId);
|
|
201
|
+
if (tgtKey) {
|
|
202
|
+
if (!linkedTo.has(t.id)) linkedTo.set(t.id, []);
|
|
203
|
+
linkedTo.get(t.id).push(tgtKey);
|
|
204
|
+
if (!linkedFrom.has(l.targetTicketId)) linkedFrom.set(l.targetTicketId, []);
|
|
205
|
+
linkedFrom.get(l.targetTicketId).push(t.ticketKey);
|
|
206
|
+
if (lt === "contains" && t.type === "epic") { epicKeyByStory.set(l.targetTicketId, t.ticketKey); epicIdByStory.set(l.targetTicketId, t.id); }
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { snapshot: snapshot, keyById, releaseById, stepById, epicKeyByStory, epicIdByStory, linkedTo, linkedFrom };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// -- evaluate + validate ---------------------------------------------------
|
|
214
|
+
|
|
215
|
+
function evaluate(node, ticket, ctx, fields) {
|
|
216
|
+
if (!node) return true; // null AST = match all
|
|
217
|
+
if (node.op === "AND") return (node.children || []).every((c) => evaluate(c, ticket, ctx, fields));
|
|
218
|
+
if (node.op === "OR") { const cs = node.children || []; return cs.length === 0 || cs.some((c) => evaluate(c, ticket, ctx, fields)); }
|
|
219
|
+
if (node.op === "NOT") return !evaluate(node.child, ticket, ctx, fields);
|
|
220
|
+
if (node.op === "CLAUSE") {
|
|
221
|
+
const f = fields[String(node.field).toLowerCase()];
|
|
222
|
+
if (!f) return false; // unknown field (validation catches first)
|
|
223
|
+
if (f.type === "ref" && typeof f.matchset === "function") return matchRef(f.matchset(ticket, ctx), node);
|
|
224
|
+
const matcher = MATCHERS[f.type] || matchScalar;
|
|
225
|
+
return matcher(f.resolve(ticket, ctx), node);
|
|
226
|
+
}
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Semantic check: unknown field, operator not allowed on the field, unknown
|
|
231
|
+
* ORDER BY field. With strictRefs (SM-260) a positive ref clause (eq/in)
|
|
232
|
+
* whose value matches no known release/step/epic is reported as an error
|
|
233
|
+
* instead of silently returning 0 rows. Returns the first
|
|
234
|
+
* { message, field?, position } or null. */
|
|
235
|
+
function validateQuery(node, orderBy, fields, snapshot, strictRefs) {
|
|
236
|
+
let err = null;
|
|
237
|
+
(function walk(n) {
|
|
238
|
+
if (err || !n) return;
|
|
239
|
+
if (n.op === "AND" || n.op === "OR") { (n.children || []).forEach(walk); return; }
|
|
240
|
+
if (n.op === "NOT") { walk(n.child); return; }
|
|
241
|
+
if (n.op === "CLAUSE") {
|
|
242
|
+
const f = fields[String(n.field).toLowerCase()];
|
|
243
|
+
if (!f) { err = { message: "unknown field '" + n.field + "'", field: n.field, position: 0 }; return; }
|
|
244
|
+
if (f.ops.indexOf(n.clauseOp) < 0) {
|
|
245
|
+
err = { message: "operator '" + n.clauseOp + "' is not allowed on field '" + n.field + "' (allowed: " + f.ops.join(", ") + ")", field: n.field, position: 0 };
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (strictRefs && f.type === "ref" && typeof f.known === "function"
|
|
249
|
+
&& (n.clauseOp === "eq" || n.clauseOp === "in")) {
|
|
250
|
+
const knownSet = new Set((f.known(snapshot) || []).filter((x) => x != null).map((x) => ci(x)));
|
|
251
|
+
const vals = n.clauseOp === "in" ? (n.values || []) : [n.value];
|
|
252
|
+
for (const v of vals) {
|
|
253
|
+
if (!knownSet.has(ci(v))) {
|
|
254
|
+
err = { message: "no " + String(f.label || f.key).toLowerCase() + " matches '" + v + "'", field: n.field, position: 0 };
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
})(node);
|
|
261
|
+
if (!err && orderBy && !fields[String(orderBy.field).toLowerCase()]) {
|
|
262
|
+
err = { message: "unknown ORDER BY field '" + orderBy.field + "'", field: orderBy.field, position: 0 };
|
|
263
|
+
}
|
|
264
|
+
return err;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// -- ordering --------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
function defaultCompare(a, b) {
|
|
270
|
+
const sa = (a.position && a.position.sortOrder) || 0;
|
|
271
|
+
const sb = (b.position && b.position.sortOrder) || 0;
|
|
272
|
+
if (sa !== sb) return sa - sb;
|
|
273
|
+
return String(a.ticketKey || a.id).localeCompare(String(b.ticketKey || b.id));
|
|
274
|
+
}
|
|
275
|
+
function sortTickets(list, orderBy, fields, ctx) {
|
|
276
|
+
const out = list.slice();
|
|
277
|
+
if (!orderBy) { out.sort(defaultCompare); return out; }
|
|
278
|
+
const f = fields[String(orderBy.field).toLowerCase()];
|
|
279
|
+
const dir = orderBy.dir === "desc" ? -1 : 1;
|
|
280
|
+
const numeric = f && (f.type === "number" || f.type === "date");
|
|
281
|
+
out.sort((a, b) => {
|
|
282
|
+
let va = f ? f.resolve(a, ctx) : null, vb = f ? f.resolve(b, ctx) : null;
|
|
283
|
+
let c;
|
|
284
|
+
if (numeric) { c = (Number(va) || 0) - (Number(vb) || 0); }
|
|
285
|
+
else { c = ci(Array.isArray(va) ? va.join(",") : va).localeCompare(ci(Array.isArray(vb) ? vb.join(",") : vb)); }
|
|
286
|
+
if (c !== 0) return dir * c;
|
|
287
|
+
return defaultCompare(a, b); // stable tiebreak
|
|
288
|
+
});
|
|
289
|
+
return out;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// -- top-level -------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Run a query (string or pre-built AST) over a snapshot.
|
|
296
|
+
* Returns { tickets: ordered[], orderBy?, error? }. A parse error (string
|
|
297
|
+
* input) or a semantic error (unknown field / bad operator) yields an empty
|
|
298
|
+
* list + the structured error. opts.orderBy overrides any ORDER BY in the
|
|
299
|
+
* string. opts.includeDeleted (default false) keeps soft-deleted tickets out.
|
|
300
|
+
*/
|
|
301
|
+
function queryTickets(snapshot, queryOrAst, opts) {
|
|
302
|
+
opts = opts || {};
|
|
303
|
+
let ast = null, orderBy = opts.orderBy || null;
|
|
304
|
+
if (typeof queryOrAst === "string") {
|
|
305
|
+
if (!parseQuery) return { tickets: [], error: { message: "parser unavailable", position: 0 } };
|
|
306
|
+
const parsed = parseQuery(queryOrAst);
|
|
307
|
+
if (parsed.error) return { tickets: [], error: parsed.error };
|
|
308
|
+
ast = parsed.ast;
|
|
309
|
+
if (!orderBy && parsed.orderBy) orderBy = parsed.orderBy;
|
|
310
|
+
} else {
|
|
311
|
+
ast = queryOrAst || null;
|
|
312
|
+
}
|
|
313
|
+
// validate + evaluate are recursive; a pathologically deep (hand-built) AST
|
|
314
|
+
// could overflow the stack. Honour the no-throw contract on this path too
|
|
315
|
+
// (the string path is already guarded inside the parser).
|
|
316
|
+
try {
|
|
317
|
+
const fields = fieldsByKey();
|
|
318
|
+
const vErr = validateQuery(ast, orderBy, fields, snapshot, !!opts.strictRefs);
|
|
319
|
+
if (vErr) return { tickets: [], error: vErr };
|
|
320
|
+
const ctx = buildQueryCtx(snapshot);
|
|
321
|
+
const base = ((snapshot && snapshot.tickets) || [])
|
|
322
|
+
.filter((t) => t && (opts.includeDeleted || !t.isDeleted));
|
|
323
|
+
const matched = base.filter((t) => evaluate(ast, t, ctx, fields));
|
|
324
|
+
const ordered = sortTickets(matched, orderBy, fields, ctx);
|
|
325
|
+
return { tickets: ordered, orderBy: orderBy || null, error: null };
|
|
326
|
+
} catch (e) {
|
|
327
|
+
return { tickets: [], error: { message: "query too complex to evaluate", position: 0 } };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return {
|
|
332
|
+
QUERY_FIELDS: QUERY_FIELDS,
|
|
333
|
+
fieldsByKey: fieldsByKey,
|
|
334
|
+
buildQueryCtx: buildQueryCtx,
|
|
335
|
+
evaluate: evaluate,
|
|
336
|
+
validateQuery: validateQuery,
|
|
337
|
+
queryTickets: queryTickets
|
|
338
|
+
};
|
|
339
|
+
}));
|