storymaps 1.2.4 → 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 +30 -44
- package/lib/util/api.js +10 -1
- package/lib/util/summary.js +11 -0
- 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,41 +72,17 @@ 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;
|
|
85
|
-
});
|
|
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;
|
|
82
|
+
const idx = counters[name] || 0;
|
|
83
|
+
counters[name] = idx + 1;
|
|
84
|
+
return `${name}[${idx}]`;
|
|
91
85
|
});
|
|
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
86
|
|
|
109
87
|
// Build YAML object
|
|
110
88
|
const result = {};
|
|
@@ -177,14 +155,14 @@ export function jsonToYamlObj(data) {
|
|
|
177
155
|
const hasExtra = (step.color && step.color !== DEFAULT_STEP_COLOR)
|
|
178
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.status)
|
|
185
|
-
if (step.points != null)
|
|
186
|
-
if (step.tags?.length)
|
|
187
|
-
return
|
|
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;
|
|
188
166
|
});
|
|
189
167
|
|
|
190
168
|
// Legend
|
|
@@ -196,6 +174,7 @@ export function jsonToYamlObj(data) {
|
|
|
196
174
|
const obj = { name: slice.name || '' };
|
|
197
175
|
if (slice.collapsed) obj.collapsed = true;
|
|
198
176
|
if (slice.closedReason) obj.closedReason = slice.closedReason;
|
|
177
|
+
copySliceDetails(slice, obj);
|
|
199
178
|
const stories = {};
|
|
200
179
|
(slice.stories || []).forEach((cards, i) => {
|
|
201
180
|
if (!cards?.length) return;
|
|
@@ -273,6 +252,7 @@ export function validateYaml(obj) {
|
|
|
273
252
|
const label = slice.name || `#${si}`;
|
|
274
253
|
if (slice.stories) {
|
|
275
254
|
for (const [stepName, cards] of Object.entries(slice.stories)) {
|
|
255
|
+
if (!stepName) continue;
|
|
276
256
|
if (!stepNameSet.has(stepName)) {
|
|
277
257
|
errors.push(`Slice "${label}" references unknown step "${stepName}"`);
|
|
278
258
|
}
|
|
@@ -336,7 +316,7 @@ export function yamlObjToJson(obj) {
|
|
|
336
316
|
if (step.url) s.url = step.url;
|
|
337
317
|
if (step.status) s.status = step.status;
|
|
338
318
|
if (step.points != null) s.points = step.points;
|
|
339
|
-
if (step.tags?.length) s.tags =
|
|
319
|
+
if (step.tags?.length) s.tags = step.tags;
|
|
340
320
|
}
|
|
341
321
|
jsonSteps.push(s);
|
|
342
322
|
});
|
|
@@ -368,6 +348,7 @@ export function yamlObjToJson(obj) {
|
|
|
368
348
|
const s = { name: slice.name || '', stories };
|
|
369
349
|
if (slice.collapsed) s.collapsed = true;
|
|
370
350
|
if (slice.closedReason) s.closedReason = slice.closedReason;
|
|
351
|
+
copySliceDetails(slice, s);
|
|
371
352
|
return s;
|
|
372
353
|
});
|
|
373
354
|
|
|
@@ -426,7 +407,8 @@ export function yamlObjToJson(obj) {
|
|
|
426
407
|
};
|
|
427
408
|
|
|
428
409
|
if (obj.legend?.length) result.legend = obj.legend;
|
|
429
|
-
|
|
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;
|
|
430
412
|
if (jsonPartialMaps.length) result.partialMaps = jsonPartialMaps;
|
|
431
413
|
if (obj.backups?.length) result.backups = obj.backups;
|
|
432
414
|
return result;
|
|
@@ -446,6 +428,10 @@ function backboneToYaml(positionalArr, namesByIndex) {
|
|
|
446
428
|
const obj = { name: card.name };
|
|
447
429
|
if (card.body) obj.body = card.body;
|
|
448
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;
|
|
449
435
|
obj.step = stepName;
|
|
450
436
|
result.push(obj);
|
|
451
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,7 @@
|
|
|
3
3
|
|
|
4
4
|
import chalk from 'chalk';
|
|
5
5
|
import jsyaml from 'js-yaml';
|
|
6
|
+
import { normalizeSliceGoal, normalizeSliceDate } from '../core/constants.js';
|
|
6
7
|
|
|
7
8
|
// Fields compared on cards and steps that exist on both sides
|
|
8
9
|
const FIELDS = ['body', 'color', 'url', 'status', 'points', 'tags'];
|
|
@@ -187,6 +188,16 @@ export function changeSummary(oldYaml, newYaml) {
|
|
|
187
188
|
const state = !slice.collapsed ? 'reopened' : slice.closedReason === 'closed' ? 'closed' : 'completed';
|
|
188
189
|
lines.push(chalk.yellow(` ~ slice: ${oneLine(slice.name)} ${state}`));
|
|
189
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
|
+
}
|
|
190
201
|
}
|
|
191
202
|
|
|
192
203
|
// Cards: users, activities and stories in every slice
|
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",
|