storymaps 1.2.3 → 1.2.5
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/lib/core/constants.js +22 -0
- package/lib/core/yaml.js +33 -43
- package/lib/util/api.js +10 -1
- package/lib/util/summary.js +189 -46
- package/package.json +5 -1
package/lib/core/constants.js
CHANGED
|
@@ -21,6 +21,28 @@ export const isValidUrl = (url) => {
|
|
|
21
21
|
}
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
+
// Slice goal and target date are optional. The date is stored as YYYY-MM-DD; YAML can parse an
|
|
25
|
+
// unquoted date as a Date, so accept that too. Anything else is dropped.
|
|
26
|
+
export const SLICE_GOAL_MAX_LENGTH = 200;
|
|
27
|
+
|
|
28
|
+
export const normalizeSliceGoal = (value) =>
|
|
29
|
+
typeof value === 'string' ? value.trim().slice(0, SLICE_GOAL_MAX_LENGTH) : '';
|
|
30
|
+
|
|
31
|
+
export const normalizeSliceDate = (value) => {
|
|
32
|
+
if (value instanceof Date && !isNaN(value)) return value.toISOString().slice(0, 10);
|
|
33
|
+
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return '';
|
|
34
|
+
return isNaN(new Date(`${value}T00:00:00Z`)) ? '' : value;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Copies a slice's goal and target date from one object to another, leaving out empty values
|
|
38
|
+
export const copySliceDetails = (from, to) => {
|
|
39
|
+
const goal = normalizeSliceGoal(from.goal);
|
|
40
|
+
const targetDate = normalizeSliceDate(from.targetDate);
|
|
41
|
+
if (goal) to.goal = goal;
|
|
42
|
+
if (targetDate) to.targetDate = targetDate;
|
|
43
|
+
return to;
|
|
44
|
+
};
|
|
45
|
+
|
|
24
46
|
export const CARD_COLORS = {
|
|
25
47
|
red: '#fca5a5',
|
|
26
48
|
rose: '#fecdd3',
|
package/lib/core/yaml.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// YAML ↔ JSON transform + validation
|
|
3
3
|
|
|
4
4
|
import jsyaml from 'js-yaml';
|
|
5
|
-
import { generateId } from './constants.js';
|
|
5
|
+
import { generateId, copySliceDetails } from './constants.js';
|
|
6
6
|
|
|
7
7
|
const DEFAULT_STEP_COLOR = '#86efac';
|
|
8
8
|
const DEFAULT_STORY_COLOR = '#fef08a';
|
|
@@ -54,10 +54,12 @@ export function jsonToYamlObj(data) {
|
|
|
54
54
|
partialSlugMap[pm.id] = slugify(pm.name) || pm.id;
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
-
// Classify each step
|
|
58
|
-
|
|
57
|
+
// Classify each step. A hidden step with a name or cards is written as a normal step so its
|
|
58
|
+
// cards survive (only empty, unnamed hidden steps are spacers).
|
|
59
|
+
const hasCards = (i) => !!(usersArr[i]?.length || activitiesArr[i]?.length || slices.some(s => s.stories?.[i]?.length));
|
|
60
|
+
const stepTypes = steps.map((step, i) => {
|
|
59
61
|
if (step.partialMapId) return 'partial';
|
|
60
|
-
if (step.hidden) return 'spacer';
|
|
62
|
+
if (step.hidden && !step.name && !hasCards(i)) return 'spacer';
|
|
61
63
|
return 'step';
|
|
62
64
|
});
|
|
63
65
|
|
|
@@ -70,42 +72,18 @@ export function jsonToYamlObj(data) {
|
|
|
70
72
|
});
|
|
71
73
|
const duplicateNames = new Set(Object.keys(nameCounts).filter(n => nameCounts[n] > 1));
|
|
72
74
|
|
|
73
|
-
//
|
|
75
|
+
// Number duplicate names (Confirm[0], Confirm[1]) so cards point at the right step.
|
|
76
|
+
// Import strips the number, so the names come back unchanged.
|
|
77
|
+
const counters = {};
|
|
74
78
|
const resolvedNames = steps.map((step, i) => {
|
|
75
79
|
if (stepTypes[i] !== 'step') return null;
|
|
76
80
|
const name = step.name || '';
|
|
77
81
|
if (!duplicateNames.has(name)) return name;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
for (let j = i; j >= 0; j--) {
|
|
82
|
-
if (usersArr[j]?.length) return `${usersArr[j][0].name}: ${name}`;
|
|
83
|
-
}
|
|
84
|
-
return name;
|
|
82
|
+
const idx = counters[name] || 0;
|
|
83
|
+
counters[name] = idx + 1;
|
|
84
|
+
return `${name}[${idx}]`;
|
|
85
85
|
});
|
|
86
86
|
|
|
87
|
-
// Second pass: add :[N] suffix to any still-duplicate resolved names
|
|
88
|
-
const resolvedCounts = {};
|
|
89
|
-
resolvedNames.forEach(n => {
|
|
90
|
-
if (n != null) resolvedCounts[n] = (resolvedCounts[n] || 0) + 1;
|
|
91
|
-
});
|
|
92
|
-
const stillDuplicate = new Set(Object.keys(resolvedCounts).filter(n => resolvedCounts[n] > 1));
|
|
93
|
-
if (stillDuplicate.size) {
|
|
94
|
-
const counters = {};
|
|
95
|
-
for (let i = 0; i < resolvedNames.length; i++) {
|
|
96
|
-
const n = resolvedNames[i];
|
|
97
|
-
if (n == null || !stillDuplicate.has(n)) continue;
|
|
98
|
-
const idx = counters[n] || 0;
|
|
99
|
-
counters[n] = idx + 1;
|
|
100
|
-
const colonPos = n.indexOf(': ');
|
|
101
|
-
if (colonPos >= 0) {
|
|
102
|
-
resolvedNames[i] = n.slice(0, colonPos + 1) + `[${idx}]` + n.slice(colonPos + 1);
|
|
103
|
-
} else {
|
|
104
|
-
resolvedNames[i] = `${n}[${idx}]`;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
87
|
// Build YAML object
|
|
110
88
|
const result = {};
|
|
111
89
|
result.name = data.name || '';
|
|
@@ -175,14 +153,16 @@ export function jsonToYamlObj(data) {
|
|
|
175
153
|
if (stepTypes[i] === 'partial') return { partial: partialSlugMap[step.partialMapId] };
|
|
176
154
|
const name = resolvedNames[i];
|
|
177
155
|
const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
|
|
178
|
-
|| step.url || step.
|
|
156
|
+
|| step.body || step.url || step.status || step.points != null || step.tags?.length;
|
|
179
157
|
if (!hasExtra) return name;
|
|
180
|
-
const
|
|
181
|
-
if (step.color && step.color !== DEFAULT_STEP_COLOR)
|
|
182
|
-
if (step.body)
|
|
183
|
-
if (step.url)
|
|
184
|
-
if (step.
|
|
185
|
-
|
|
158
|
+
const s = { name };
|
|
159
|
+
if (step.color && step.color !== DEFAULT_STEP_COLOR) s.color = step.color;
|
|
160
|
+
if (step.body) s.body = step.body;
|
|
161
|
+
if (step.url) s.url = step.url;
|
|
162
|
+
if (step.status) s.status = step.status;
|
|
163
|
+
if (step.points != null) s.points = step.points;
|
|
164
|
+
if (step.tags?.length) s.tags = step.tags;
|
|
165
|
+
return s;
|
|
186
166
|
});
|
|
187
167
|
|
|
188
168
|
// Legend
|
|
@@ -194,6 +174,7 @@ export function jsonToYamlObj(data) {
|
|
|
194
174
|
const obj = { name: slice.name || '' };
|
|
195
175
|
if (slice.collapsed) obj.collapsed = true;
|
|
196
176
|
if (slice.closedReason) obj.closedReason = slice.closedReason;
|
|
177
|
+
copySliceDetails(slice, obj);
|
|
197
178
|
const stories = {};
|
|
198
179
|
(slice.stories || []).forEach((cards, i) => {
|
|
199
180
|
if (!cards?.length) return;
|
|
@@ -271,6 +252,7 @@ export function validateYaml(obj) {
|
|
|
271
252
|
const label = slice.name || `#${si}`;
|
|
272
253
|
if (slice.stories) {
|
|
273
254
|
for (const [stepName, cards] of Object.entries(slice.stories)) {
|
|
255
|
+
if (!stepName) continue;
|
|
274
256
|
if (!stepNameSet.has(stepName)) {
|
|
275
257
|
errors.push(`Slice "${label}" references unknown step "${stepName}"`);
|
|
276
258
|
}
|
|
@@ -330,9 +312,11 @@ export function yamlObjToJson(obj) {
|
|
|
330
312
|
stepNameToIndex[rawName] = jsonSteps.length;
|
|
331
313
|
const s = { name: displayName, color };
|
|
332
314
|
if (typeof step === 'object' && step !== null) {
|
|
333
|
-
if (step.url) s.url = step.url;
|
|
334
315
|
if (step.body) s.body = step.body;
|
|
316
|
+
if (step.url) s.url = step.url;
|
|
317
|
+
if (step.status) s.status = step.status;
|
|
335
318
|
if (step.points != null) s.points = step.points;
|
|
319
|
+
if (step.tags?.length) s.tags = step.tags;
|
|
336
320
|
}
|
|
337
321
|
jsonSteps.push(s);
|
|
338
322
|
});
|
|
@@ -364,6 +348,7 @@ export function yamlObjToJson(obj) {
|
|
|
364
348
|
const s = { name: slice.name || '', stories };
|
|
365
349
|
if (slice.collapsed) s.collapsed = true;
|
|
366
350
|
if (slice.closedReason) s.closedReason = slice.closedReason;
|
|
351
|
+
copySliceDetails(slice, s);
|
|
367
352
|
return s;
|
|
368
353
|
});
|
|
369
354
|
|
|
@@ -422,7 +407,8 @@ export function yamlObjToJson(obj) {
|
|
|
422
407
|
};
|
|
423
408
|
|
|
424
409
|
if (obj.legend?.length) result.legend = obj.legend;
|
|
425
|
-
|
|
410
|
+
// Keep empty notes so a push with notes: "" clears them (no notes key leaves them alone)
|
|
411
|
+
if (typeof obj.notes === 'string') result.notes = obj.notes;
|
|
426
412
|
if (jsonPartialMaps.length) result.partialMaps = jsonPartialMaps;
|
|
427
413
|
if (obj.backups?.length) result.backups = obj.backups;
|
|
428
414
|
return result;
|
|
@@ -442,6 +428,10 @@ function backboneToYaml(positionalArr, namesByIndex) {
|
|
|
442
428
|
const obj = { name: card.name };
|
|
443
429
|
if (card.body) obj.body = card.body;
|
|
444
430
|
if (card.color) obj.color = card.color;
|
|
431
|
+
if (card.status) obj.status = card.status;
|
|
432
|
+
if (card.points != null) obj.points = card.points;
|
|
433
|
+
if (card.tags?.length) obj.tags = [...card.tags];
|
|
434
|
+
if (card.url) obj.url = card.url;
|
|
445
435
|
obj.step = stepName;
|
|
446
436
|
result.push(obj);
|
|
447
437
|
});
|
package/lib/util/api.js
CHANGED
|
@@ -10,9 +10,18 @@ class ApiError extends Error {
|
|
|
10
10
|
}
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// The server sends errors as JSON ({"error": "..."}), so show just the message
|
|
14
|
+
function errorMessage(body) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(body).error || body;
|
|
17
|
+
} catch {
|
|
18
|
+
return body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
async function handleResponse(res) {
|
|
14
23
|
if (res.ok) return;
|
|
15
|
-
const body = await res.text().catch(() => '');
|
|
24
|
+
const body = errorMessage(await res.text().catch(() => ''));
|
|
16
25
|
switch (res.status) {
|
|
17
26
|
case 404: throw new ApiError('Map not found', 404);
|
|
18
27
|
case 409: throw new ApiError(body || 'Conflict — the remote map has changed. Pull first or use --force.', 409, { conflict: true });
|
package/lib/util/summary.js
CHANGED
|
@@ -3,6 +3,115 @@
|
|
|
3
3
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import jsyaml from 'js-yaml';
|
|
6
|
+
import { normalizeSliceGoal, normalizeSliceDate } from '../core/constants.js';
|
|
7
|
+
|
|
8
|
+
// Fields compared on cards and steps that exist on both sides
|
|
9
|
+
const FIELDS = ['body', 'color', 'url', 'status', 'points', 'tags'];
|
|
10
|
+
|
|
11
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
|
|
12
|
+
|
|
13
|
+
// Slice names can span lines (e.g. "V1:\nBasic House"); keep each summary entry on one line
|
|
14
|
+
const oneLine = (s) => String(s ?? '').replace(/\s*\n\s*/g, ' ');
|
|
15
|
+
|
|
16
|
+
const changedFields = (a, b) =>
|
|
17
|
+
FIELDS.filter(f => JSON.stringify(a?.[f] ?? null) !== JSON.stringify(b?.[f] ?? null));
|
|
18
|
+
|
|
19
|
+
// True when the names both lists share appear in a different order
|
|
20
|
+
const reordered = (oldNames, newNames) => {
|
|
21
|
+
const common = oldNames.filter(n => newNames.includes(n));
|
|
22
|
+
const commonNew = newNames.filter(n => oldNames.includes(n));
|
|
23
|
+
return common.join('\n') !== commonNew.join('\n');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// Compare located cards ({ card, group, at }) across the whole map. Cards are
|
|
27
|
+
// paired by name in order, so a card that changes slice or step counts as moved
|
|
28
|
+
// rather than removed and added. Returns per-group summary lines.
|
|
29
|
+
function compareCards(oldItems, newItems) {
|
|
30
|
+
const stats = new Map();
|
|
31
|
+
const statsFor = (group) => {
|
|
32
|
+
if (!stats.has(group)) stats.set(group, { added: 0, removed: 0, moved: 0, updated: 0, fields: new Set() });
|
|
33
|
+
return stats.get(group);
|
|
34
|
+
};
|
|
35
|
+
// Report groups in their new order, then any that only exist on the old side
|
|
36
|
+
newItems.forEach(i => statsFor(i.group));
|
|
37
|
+
oldItems.forEach(i => statsFor(i.group));
|
|
38
|
+
|
|
39
|
+
const byName = (items) => {
|
|
40
|
+
const map = new Map();
|
|
41
|
+
for (const item of items) {
|
|
42
|
+
const name = item.card?.name ?? '';
|
|
43
|
+
if (!map.has(name)) map.set(name, []);
|
|
44
|
+
map.get(name).push(item);
|
|
45
|
+
}
|
|
46
|
+
return map;
|
|
47
|
+
};
|
|
48
|
+
const oldByName = byName(oldItems);
|
|
49
|
+
const newByName = byName(newItems);
|
|
50
|
+
|
|
51
|
+
for (const name of new Set([...oldByName.keys(), ...newByName.keys()])) {
|
|
52
|
+
const o = oldByName.get(name) || [];
|
|
53
|
+
const c = newByName.get(name) || [];
|
|
54
|
+
const paired = Math.min(o.length, c.length);
|
|
55
|
+
for (let i = 0; i < paired; i++) {
|
|
56
|
+
const s = statsFor(c[i].group);
|
|
57
|
+
if (o[i].group !== c[i].group || o[i].at !== c[i].at) s.moved++;
|
|
58
|
+
const fields = changedFields(o[i].card, c[i].card);
|
|
59
|
+
if (fields.length) {
|
|
60
|
+
s.updated++;
|
|
61
|
+
fields.forEach(f => s.fields.add(f));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
c.slice(paired).forEach(item => statsFor(item.group).added++);
|
|
65
|
+
o.slice(paired).forEach(item => statsFor(item.group).removed++);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = [];
|
|
69
|
+
for (const [group, s] of stats) {
|
|
70
|
+
// Equal numbers of unmatched adds and removes means the cards were renamed
|
|
71
|
+
if (s.added && s.added === s.removed) {
|
|
72
|
+
lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.added, 'card')} renamed`));
|
|
73
|
+
} else {
|
|
74
|
+
if (s.added) lines.push(chalk.green(` + ${oneLine(group)}: ${plural(s.added, 'card')}`));
|
|
75
|
+
if (s.removed) lines.push(chalk.red(` - ${oneLine(group)}: ${plural(s.removed, 'card')}`));
|
|
76
|
+
}
|
|
77
|
+
if (s.moved) lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.moved, 'card')} moved`));
|
|
78
|
+
if (s.updated) {
|
|
79
|
+
lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.updated, 'card')} updated (${[...s.fields].join(', ')})`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return lines;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function compareLegend(oldLegend, newLegend) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
const key = e => `${e?.color ?? ''}\n${e?.label ?? ''}`;
|
|
88
|
+
const removed = [...oldLegend];
|
|
89
|
+
const added = [];
|
|
90
|
+
for (const entry of newLegend) {
|
|
91
|
+
const i = removed.findIndex(e => key(e) === key(entry));
|
|
92
|
+
if (i >= 0) removed.splice(i, 1);
|
|
93
|
+
else added.push(entry);
|
|
94
|
+
}
|
|
95
|
+
// Same color with a new label is a rename; same label with a new color is a recolor
|
|
96
|
+
for (const entry of [...added]) {
|
|
97
|
+
let i = removed.findIndex(e => e.color === entry.color);
|
|
98
|
+
if (i >= 0) {
|
|
99
|
+
lines.push(chalk.yellow(` ~ legend: ${removed[i].label} → ${entry.label}`));
|
|
100
|
+
} else if ((i = removed.findIndex(e => e.label === entry.label)) >= 0) {
|
|
101
|
+
lines.push(chalk.yellow(` ~ legend: ${entry.label} color changed`));
|
|
102
|
+
} else {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
removed.splice(i, 1);
|
|
106
|
+
added.splice(added.indexOf(entry), 1);
|
|
107
|
+
}
|
|
108
|
+
for (const e of added) lines.push(chalk.green(` + legend: ${e.label}`));
|
|
109
|
+
for (const e of removed) lines.push(chalk.red(` - legend: ${e.label}`));
|
|
110
|
+
if (!lines.length && reordered(oldLegend.map(key), newLegend.map(key))) {
|
|
111
|
+
lines.push(chalk.yellow(' ~ legend reordered'));
|
|
112
|
+
}
|
|
113
|
+
return lines;
|
|
114
|
+
}
|
|
6
115
|
|
|
7
116
|
export function changeSummary(oldYaml, newYaml) {
|
|
8
117
|
const old = jsyaml.load(oldYaml);
|
|
@@ -15,13 +124,15 @@ export function changeSummary(oldYaml, newYaml) {
|
|
|
15
124
|
}
|
|
16
125
|
|
|
17
126
|
// Steps (skip spacers and partial refs)
|
|
18
|
-
const
|
|
19
|
-
if (typeof s === 'string' && s !== 'spacer') return s;
|
|
20
|
-
if (typeof s === 'object' && s?.name) return s
|
|
127
|
+
const toStep = s => {
|
|
128
|
+
if (typeof s === 'string' && s !== 'spacer') return { name: s };
|
|
129
|
+
if (typeof s === 'object' && s?.name) return s;
|
|
21
130
|
return null;
|
|
22
131
|
};
|
|
23
|
-
const
|
|
24
|
-
const
|
|
132
|
+
const oldStepObjs = (old.steps || []).map(toStep).filter(Boolean);
|
|
133
|
+
const newStepObjs = (cur.steps || []).map(toStep).filter(Boolean);
|
|
134
|
+
const oldSteps = oldStepObjs.map(s => s.name);
|
|
135
|
+
const newSteps = newStepObjs.map(s => s.name);
|
|
25
136
|
const addedSteps = newSteps.filter(s => !oldSteps.includes(s));
|
|
26
137
|
const removedSteps = oldSteps.filter(s => !newSteps.includes(s));
|
|
27
138
|
if (addedSteps.length || removedSteps.length) {
|
|
@@ -31,55 +142,84 @@ export function changeSummary(oldYaml, newYaml) {
|
|
|
31
142
|
for (const s of addedSteps) lines.push(chalk.green(` + ${s}`));
|
|
32
143
|
for (const s of removedSteps) lines.push(chalk.red(` - ${s}`));
|
|
33
144
|
}
|
|
145
|
+
if (reordered(oldSteps, newSteps)) lines.push(chalk.yellow(' ~ steps reordered'));
|
|
146
|
+
const stepFields = new Set();
|
|
147
|
+
let updatedSteps = 0;
|
|
148
|
+
for (const step of newStepObjs) {
|
|
149
|
+
const before = oldStepObjs.find(s => s.name === step.name);
|
|
150
|
+
if (!before) continue;
|
|
151
|
+
const fields = changedFields(before, step);
|
|
152
|
+
if (fields.length) {
|
|
153
|
+
updatedSteps++;
|
|
154
|
+
fields.forEach(f => stepFields.add(f));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (updatedSteps) {
|
|
158
|
+
lines.push(chalk.yellow(` ~ steps: ${plural(updatedSteps, 'step')} updated (${[...stepFields].join(', ')})`));
|
|
159
|
+
}
|
|
34
160
|
|
|
35
161
|
// Slices
|
|
36
162
|
const oldSlices = (old.slices || []).map(s => s.name);
|
|
37
163
|
const newSlices = (cur.slices || []).map(s => s.name);
|
|
38
|
-
|
|
39
|
-
|
|
164
|
+
// A slice whose name changed in the same position is a rename, so its cards aren't reported as moved
|
|
165
|
+
const sliceRenames = new Map();
|
|
166
|
+
oldSlices.forEach((name, i) => {
|
|
167
|
+
const newName = newSlices[i];
|
|
168
|
+
if (newName !== undefined && !newSlices.includes(name) && !oldSlices.includes(newName)) {
|
|
169
|
+
sliceRenames.set(name, newName);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
const currentName = name => sliceRenames.get(name) ?? name;
|
|
173
|
+
for (const [from, to] of sliceRenames) {
|
|
174
|
+
lines.push(chalk.yellow(` ~ slice: ${oneLine(from)} → ${oneLine(to)}`));
|
|
175
|
+
}
|
|
176
|
+
const renamedTo = new Set(sliceRenames.values());
|
|
177
|
+
for (const s of newSlices.filter(s => !oldSlices.includes(s) && !renamedTo.has(s))) {
|
|
178
|
+
lines.push(chalk.green(` + slice: ${oneLine(s)}`));
|
|
40
179
|
}
|
|
41
|
-
for (const s of oldSlices.filter(s => !newSlices.includes(s))) {
|
|
42
|
-
lines.push(chalk.red(` - slice: ${s}`));
|
|
180
|
+
for (const s of oldSlices.filter(s => !newSlices.includes(s) && !sliceRenames.has(s))) {
|
|
181
|
+
lines.push(chalk.red(` - slice: ${oneLine(s)}`));
|
|
182
|
+
}
|
|
183
|
+
if (reordered(oldSlices.map(currentName), newSlices)) lines.push(chalk.yellow(' ~ slices reordered'));
|
|
184
|
+
for (const slice of cur.slices || []) {
|
|
185
|
+
const before = (old.slices || []).find(s => currentName(s.name) === slice.name);
|
|
186
|
+
if (!before) continue;
|
|
187
|
+
if (!!before.collapsed !== !!slice.collapsed || before.closedReason !== slice.closedReason) {
|
|
188
|
+
const state = !slice.collapsed ? 'reopened' : slice.closedReason === 'closed' ? 'closed' : 'completed';
|
|
189
|
+
lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} ${state}`));
|
|
190
|
+
}
|
|
191
|
+
const [oldGoal, newGoal] = [normalizeSliceGoal(before.goal), normalizeSliceGoal(slice.goal)];
|
|
192
|
+
if (oldGoal !== newGoal) {
|
|
193
|
+
const change = !oldGoal ? 'set' : !newGoal ? 'removed' : 'changed';
|
|
194
|
+
lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} goal ${change}`));
|
|
195
|
+
}
|
|
196
|
+
const [oldDate, newDate] = [normalizeSliceDate(before.targetDate), normalizeSliceDate(slice.targetDate)];
|
|
197
|
+
if (oldDate !== newDate) {
|
|
198
|
+
const change = !oldDate ? `set to ${newDate}` : !newDate ? 'removed' : `moved to ${newDate}`;
|
|
199
|
+
lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} target date ${change}`));
|
|
200
|
+
}
|
|
43
201
|
}
|
|
44
202
|
|
|
45
|
-
//
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
for (const
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
203
|
+
// Cards: users, activities and stories in every slice
|
|
204
|
+
const collectCards = (map, sliceName) => {
|
|
205
|
+
const items = [];
|
|
206
|
+
for (const group of ['users', 'activities']) {
|
|
207
|
+
for (const card of map[group] || []) items.push({ card, group, at: card.step });
|
|
208
|
+
}
|
|
209
|
+
for (const slice of map.slices || []) {
|
|
210
|
+
const group = sliceName(slice.name);
|
|
211
|
+
for (const [step, cards] of Object.entries(slice.stories || {})) {
|
|
212
|
+
if (Array.isArray(cards)) cards.forEach(card => items.push({ card, group, at: step }));
|
|
52
213
|
}
|
|
53
|
-
result[slice.name] = names;
|
|
54
214
|
}
|
|
55
|
-
return
|
|
215
|
+
return items;
|
|
56
216
|
};
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
const newCounts = new Map();
|
|
64
|
-
for (const n of oldNames) oldCounts.set(n, (oldCounts.get(n) || 0) + 1);
|
|
65
|
-
for (const n of newNames) newCounts.set(n, (newCounts.get(n) || 0) + 1);
|
|
66
|
-
const allNames = new Set([...oldCounts.keys(), ...newCounts.keys()]);
|
|
67
|
-
let added = 0, removed = 0;
|
|
68
|
-
for (const n of allNames) {
|
|
69
|
-
const o = oldCounts.get(n) || 0;
|
|
70
|
-
const c = newCounts.get(n) || 0;
|
|
71
|
-
if (c > o) added += c - o;
|
|
72
|
-
if (o > c) removed += o - c;
|
|
73
|
-
}
|
|
74
|
-
if (added === 0 && removed === 0) continue;
|
|
75
|
-
const net = newNames.length - oldNames.length;
|
|
76
|
-
if (net === 0) {
|
|
77
|
-
const word = added === 1 ? 'card' : 'cards';
|
|
78
|
-
lines.push(chalk.yellow(` ~ ${sliceName}: ${added} ${word} renamed`));
|
|
79
|
-
} else {
|
|
80
|
-
if (added) lines.push(chalk.green(` + ${sliceName}: ${added} ${added === 1 ? 'card' : 'cards'}`));
|
|
81
|
-
if (removed) lines.push(chalk.red(` - ${sliceName}: ${removed} ${removed === 1 ? 'card' : 'cards'}`));
|
|
82
|
-
}
|
|
217
|
+
lines.push(...compareCards(collectCards(old, currentName), collectCards(cur, name => name)));
|
|
218
|
+
|
|
219
|
+
lines.push(...compareLegend(old.legend || [], cur.legend || []));
|
|
220
|
+
|
|
221
|
+
if (JSON.stringify(old.partials ?? null) !== JSON.stringify(cur.partials ?? null)) {
|
|
222
|
+
lines.push(chalk.yellow(' ~ partials updated'));
|
|
83
223
|
}
|
|
84
224
|
|
|
85
225
|
// Notes
|
|
@@ -96,9 +236,12 @@ export function changeSummary(oldYaml, newYaml) {
|
|
|
96
236
|
|
|
97
237
|
export function printChangeSummary(oldYaml, newYaml) {
|
|
98
238
|
const lines = changeSummary(oldYaml, newYaml);
|
|
99
|
-
if (lines.length
|
|
239
|
+
if (lines.length) {
|
|
240
|
+
lines.forEach(l => console.log(l));
|
|
241
|
+
} else if (oldYaml === newYaml) {
|
|
100
242
|
console.log(chalk.dim('No changes.'));
|
|
101
243
|
} else {
|
|
102
|
-
|
|
244
|
+
// Something differs that the summary doesn't describe (e.g. card order within a step)
|
|
245
|
+
console.log(chalk.yellow(` ~ changes not shown in summary, run ${chalk.cyan('storymaps diff --full')} to see them`));
|
|
103
246
|
}
|
|
104
247
|
}
|
package/package.json
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "storymaps",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "CLI tool for storymaps.io - import, export, validate, and manage user story maps from the terminal",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"storymaps": "./bin/storymaps.js"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test 'test/unit/**/*.test.js'",
|
|
11
|
+
"test:integration": "node --test 'test/integration/**/*.test.js'"
|
|
12
|
+
},
|
|
9
13
|
"keywords": [
|
|
10
14
|
"story-mapping",
|
|
11
15
|
"user-story",
|