storymaps 1.2.2 → 1.2.4

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/README.md CHANGED
@@ -265,7 +265,7 @@ The CLI works with any storymaps.io instance:
265
265
  storymaps init --site storymaps.yourcompany.com
266
266
  ```
267
267
 
268
- See the [main project README](https://github.com/jackgleeson/userstorymaps#self-hosting) for setup instructions.
268
+ See the [main project README](https://github.com/jackgleeson/storymaps.io#self-hosting) for setup instructions.
269
269
 
270
270
  ## Licence
271
271
 
package/lib/core/yaml.js CHANGED
@@ -175,13 +175,15 @@ export function jsonToYamlObj(data) {
175
175
  if (stepTypes[i] === 'partial') return { partial: partialSlugMap[step.partialMapId] };
176
176
  const name = resolvedNames[i];
177
177
  const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
178
- || step.url || step.body || step.points != null;
178
+ || step.body || step.url || step.status || step.points != null || step.tags?.length;
179
179
  if (!hasExtra) return name;
180
180
  const obj = { name };
181
181
  if (step.color && step.color !== DEFAULT_STEP_COLOR) obj.color = step.color;
182
182
  if (step.body) obj.body = step.body;
183
183
  if (step.url) obj.url = step.url;
184
+ if (step.status) obj.status = step.status;
184
185
  if (step.points != null) obj.points = step.points;
186
+ if (step.tags?.length) obj.tags = [...step.tags];
185
187
  return obj;
186
188
  });
187
189
 
@@ -330,9 +332,11 @@ export function yamlObjToJson(obj) {
330
332
  stepNameToIndex[rawName] = jsonSteps.length;
331
333
  const s = { name: displayName, color };
332
334
  if (typeof step === 'object' && step !== null) {
333
- if (step.url) s.url = step.url;
334
335
  if (step.body) s.body = step.body;
336
+ if (step.url) s.url = step.url;
337
+ if (step.status) s.status = step.status;
335
338
  if (step.points != null) s.points = step.points;
339
+ if (step.tags?.length) s.tags = [...step.tags];
336
340
  }
337
341
  jsonSteps.push(s);
338
342
  });
@@ -4,6 +4,114 @@
4
4
  import chalk from 'chalk';
5
5
  import jsyaml from 'js-yaml';
6
6
 
7
+ // Fields compared on cards and steps that exist on both sides
8
+ const FIELDS = ['body', 'color', 'url', 'status', 'points', 'tags'];
9
+
10
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
11
+
12
+ // Slice names can span lines (e.g. "V1:\nBasic House"); keep each summary entry on one line
13
+ const oneLine = (s) => String(s ?? '').replace(/\s*\n\s*/g, ' ');
14
+
15
+ const changedFields = (a, b) =>
16
+ FIELDS.filter(f => JSON.stringify(a?.[f] ?? null) !== JSON.stringify(b?.[f] ?? null));
17
+
18
+ // True when the names both lists share appear in a different order
19
+ const reordered = (oldNames, newNames) => {
20
+ const common = oldNames.filter(n => newNames.includes(n));
21
+ const commonNew = newNames.filter(n => oldNames.includes(n));
22
+ return common.join('\n') !== commonNew.join('\n');
23
+ };
24
+
25
+ // Compare located cards ({ card, group, at }) across the whole map. Cards are
26
+ // paired by name in order, so a card that changes slice or step counts as moved
27
+ // rather than removed and added. Returns per-group summary lines.
28
+ function compareCards(oldItems, newItems) {
29
+ const stats = new Map();
30
+ const statsFor = (group) => {
31
+ if (!stats.has(group)) stats.set(group, { added: 0, removed: 0, moved: 0, updated: 0, fields: new Set() });
32
+ return stats.get(group);
33
+ };
34
+ // Report groups in their new order, then any that only exist on the old side
35
+ newItems.forEach(i => statsFor(i.group));
36
+ oldItems.forEach(i => statsFor(i.group));
37
+
38
+ const byName = (items) => {
39
+ const map = new Map();
40
+ for (const item of items) {
41
+ const name = item.card?.name ?? '';
42
+ if (!map.has(name)) map.set(name, []);
43
+ map.get(name).push(item);
44
+ }
45
+ return map;
46
+ };
47
+ const oldByName = byName(oldItems);
48
+ const newByName = byName(newItems);
49
+
50
+ for (const name of new Set([...oldByName.keys(), ...newByName.keys()])) {
51
+ const o = oldByName.get(name) || [];
52
+ const c = newByName.get(name) || [];
53
+ const paired = Math.min(o.length, c.length);
54
+ for (let i = 0; i < paired; i++) {
55
+ const s = statsFor(c[i].group);
56
+ if (o[i].group !== c[i].group || o[i].at !== c[i].at) s.moved++;
57
+ const fields = changedFields(o[i].card, c[i].card);
58
+ if (fields.length) {
59
+ s.updated++;
60
+ fields.forEach(f => s.fields.add(f));
61
+ }
62
+ }
63
+ c.slice(paired).forEach(item => statsFor(item.group).added++);
64
+ o.slice(paired).forEach(item => statsFor(item.group).removed++);
65
+ }
66
+
67
+ const lines = [];
68
+ for (const [group, s] of stats) {
69
+ // Equal numbers of unmatched adds and removes means the cards were renamed
70
+ if (s.added && s.added === s.removed) {
71
+ lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.added, 'card')} renamed`));
72
+ } else {
73
+ if (s.added) lines.push(chalk.green(` + ${oneLine(group)}: ${plural(s.added, 'card')}`));
74
+ if (s.removed) lines.push(chalk.red(` - ${oneLine(group)}: ${plural(s.removed, 'card')}`));
75
+ }
76
+ if (s.moved) lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.moved, 'card')} moved`));
77
+ if (s.updated) {
78
+ lines.push(chalk.yellow(` ~ ${oneLine(group)}: ${plural(s.updated, 'card')} updated (${[...s.fields].join(', ')})`));
79
+ }
80
+ }
81
+ return lines;
82
+ }
83
+
84
+ function compareLegend(oldLegend, newLegend) {
85
+ const lines = [];
86
+ const key = e => `${e?.color ?? ''}\n${e?.label ?? ''}`;
87
+ const removed = [...oldLegend];
88
+ const added = [];
89
+ for (const entry of newLegend) {
90
+ const i = removed.findIndex(e => key(e) === key(entry));
91
+ if (i >= 0) removed.splice(i, 1);
92
+ else added.push(entry);
93
+ }
94
+ // Same color with a new label is a rename; same label with a new color is a recolor
95
+ for (const entry of [...added]) {
96
+ let i = removed.findIndex(e => e.color === entry.color);
97
+ if (i >= 0) {
98
+ lines.push(chalk.yellow(` ~ legend: ${removed[i].label} → ${entry.label}`));
99
+ } else if ((i = removed.findIndex(e => e.label === entry.label)) >= 0) {
100
+ lines.push(chalk.yellow(` ~ legend: ${entry.label} color changed`));
101
+ } else {
102
+ continue;
103
+ }
104
+ removed.splice(i, 1);
105
+ added.splice(added.indexOf(entry), 1);
106
+ }
107
+ for (const e of added) lines.push(chalk.green(` + legend: ${e.label}`));
108
+ for (const e of removed) lines.push(chalk.red(` - legend: ${e.label}`));
109
+ if (!lines.length && reordered(oldLegend.map(key), newLegend.map(key))) {
110
+ lines.push(chalk.yellow(' ~ legend reordered'));
111
+ }
112
+ return lines;
113
+ }
114
+
7
115
  export function changeSummary(oldYaml, newYaml) {
8
116
  const old = jsyaml.load(oldYaml);
9
117
  const cur = jsyaml.load(newYaml);
@@ -15,13 +123,15 @@ export function changeSummary(oldYaml, newYaml) {
15
123
  }
16
124
 
17
125
  // Steps (skip spacers and partial refs)
18
- const stepName = s => {
19
- if (typeof s === 'string' && s !== 'spacer') return s;
20
- if (typeof s === 'object' && s?.name) return s.name;
126
+ const toStep = s => {
127
+ if (typeof s === 'string' && s !== 'spacer') return { name: s };
128
+ if (typeof s === 'object' && s?.name) return s;
21
129
  return null;
22
130
  };
23
- const oldSteps = (old.steps || []).map(stepName).filter(Boolean);
24
- const newSteps = (cur.steps || []).map(stepName).filter(Boolean);
131
+ const oldStepObjs = (old.steps || []).map(toStep).filter(Boolean);
132
+ const newStepObjs = (cur.steps || []).map(toStep).filter(Boolean);
133
+ const oldSteps = oldStepObjs.map(s => s.name);
134
+ const newSteps = newStepObjs.map(s => s.name);
25
135
  const addedSteps = newSteps.filter(s => !oldSteps.includes(s));
26
136
  const removedSteps = oldSteps.filter(s => !newSteps.includes(s));
27
137
  if (addedSteps.length || removedSteps.length) {
@@ -31,55 +141,74 @@ export function changeSummary(oldYaml, newYaml) {
31
141
  for (const s of addedSteps) lines.push(chalk.green(` + ${s}`));
32
142
  for (const s of removedSteps) lines.push(chalk.red(` - ${s}`));
33
143
  }
144
+ if (reordered(oldSteps, newSteps)) lines.push(chalk.yellow(' ~ steps reordered'));
145
+ const stepFields = new Set();
146
+ let updatedSteps = 0;
147
+ for (const step of newStepObjs) {
148
+ const before = oldStepObjs.find(s => s.name === step.name);
149
+ if (!before) continue;
150
+ const fields = changedFields(before, step);
151
+ if (fields.length) {
152
+ updatedSteps++;
153
+ fields.forEach(f => stepFields.add(f));
154
+ }
155
+ }
156
+ if (updatedSteps) {
157
+ lines.push(chalk.yellow(` ~ steps: ${plural(updatedSteps, 'step')} updated (${[...stepFields].join(', ')})`));
158
+ }
34
159
 
35
160
  // Slices
36
161
  const oldSlices = (old.slices || []).map(s => s.name);
37
162
  const newSlices = (cur.slices || []).map(s => s.name);
38
- for (const s of newSlices.filter(s => !oldSlices.includes(s))) {
39
- lines.push(chalk.green(` + slice: ${s}`));
163
+ // A slice whose name changed in the same position is a rename, so its cards aren't reported as moved
164
+ const sliceRenames = new Map();
165
+ oldSlices.forEach((name, i) => {
166
+ const newName = newSlices[i];
167
+ if (newName !== undefined && !newSlices.includes(name) && !oldSlices.includes(newName)) {
168
+ sliceRenames.set(name, newName);
169
+ }
170
+ });
171
+ const currentName = name => sliceRenames.get(name) ?? name;
172
+ for (const [from, to] of sliceRenames) {
173
+ lines.push(chalk.yellow(` ~ slice: ${oneLine(from)} → ${oneLine(to)}`));
40
174
  }
41
- for (const s of oldSlices.filter(s => !newSlices.includes(s))) {
42
- lines.push(chalk.red(` - slice: ${s}`));
175
+ const renamedTo = new Set(sliceRenames.values());
176
+ for (const s of newSlices.filter(s => !oldSlices.includes(s) && !renamedTo.has(s))) {
177
+ lines.push(chalk.green(` + slice: ${oneLine(s)}`));
178
+ }
179
+ for (const s of oldSlices.filter(s => !newSlices.includes(s) && !sliceRenames.has(s))) {
180
+ lines.push(chalk.red(` - slice: ${oneLine(s)}`));
181
+ }
182
+ if (reordered(oldSlices.map(currentName), newSlices)) lines.push(chalk.yellow(' ~ slices reordered'));
183
+ for (const slice of cur.slices || []) {
184
+ const before = (old.slices || []).find(s => currentName(s.name) === slice.name);
185
+ if (!before) continue;
186
+ if (!!before.collapsed !== !!slice.collapsed || before.closedReason !== slice.closedReason) {
187
+ const state = !slice.collapsed ? 'reopened' : slice.closedReason === 'closed' ? 'closed' : 'completed';
188
+ lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} ${state}`));
189
+ }
43
190
  }
44
191
 
45
- // Detect card-level changes per slice (name-aware, handles renames)
46
- const collectCardNames = (map) => {
47
- const result = {};
48
- for (const slice of (map.slices || [])) {
49
- const names = [];
50
- for (const cards of Object.values(slice.stories || {})) {
51
- if (Array.isArray(cards)) names.push(...cards.map(c => c.name).filter(Boolean));
192
+ // Cards: users, activities and stories in every slice
193
+ const collectCards = (map, sliceName) => {
194
+ const items = [];
195
+ for (const group of ['users', 'activities']) {
196
+ for (const card of map[group] || []) items.push({ card, group, at: card.step });
197
+ }
198
+ for (const slice of map.slices || []) {
199
+ const group = sliceName(slice.name);
200
+ for (const [step, cards] of Object.entries(slice.stories || {})) {
201
+ if (Array.isArray(cards)) cards.forEach(card => items.push({ card, group, at: step }));
52
202
  }
53
- result[slice.name] = names;
54
203
  }
55
- return result;
204
+ return items;
56
205
  };
57
- const oldCards = collectCardNames(old);
58
- const newCards = collectCardNames(cur);
59
- for (const sliceName of new Set([...Object.keys(oldCards), ...Object.keys(newCards)])) {
60
- const oldNames = oldCards[sliceName] || [];
61
- const newNames = newCards[sliceName] || [];
62
- const oldCounts = new Map();
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
- }
206
+ lines.push(...compareCards(collectCards(old, currentName), collectCards(cur, name => name)));
207
+
208
+ lines.push(...compareLegend(old.legend || [], cur.legend || []));
209
+
210
+ if (JSON.stringify(old.partials ?? null) !== JSON.stringify(cur.partials ?? null)) {
211
+ lines.push(chalk.yellow(' ~ partials updated'));
83
212
  }
84
213
 
85
214
  // Notes
@@ -96,9 +225,12 @@ export function changeSummary(oldYaml, newYaml) {
96
225
 
97
226
  export function printChangeSummary(oldYaml, newYaml) {
98
227
  const lines = changeSummary(oldYaml, newYaml);
99
- if (lines.length === 0) {
228
+ if (lines.length) {
229
+ lines.forEach(l => console.log(l));
230
+ } else if (oldYaml === newYaml) {
100
231
  console.log(chalk.dim('No changes.'));
101
232
  } else {
102
- lines.forEach(l => console.log(l));
233
+ // Something differs that the summary doesn't describe (e.g. card order within a step)
234
+ console.log(chalk.yellow(` ~ changes not shown in summary, run ${chalk.cyan('storymaps diff --full')} to see them`));
103
235
  }
104
236
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "storymaps",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
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": {