storymaps 1.3.4 → 1.4.1

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.
@@ -1,34 +1,56 @@
1
1
  // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
- // storymaps lock — password-protect a map
2
+ // storymaps lock — password-protect one map. Locking a whole project is storymaps project lock.
3
3
 
4
- import { createHash } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
5
  import { resolve } from 'node:path';
6
- import { password } from '@inquirer/prompts';
7
6
  import chalk from 'chalk';
7
+ import jsyaml from 'js-yaml';
8
8
  import { resolveTarget } from '../util/identity.js';
9
- import { lockMap } from '../util/api.js';
9
+ import { lockMap, getProject, getMap } from '../util/api.js';
10
+ import { findProjectFolder, setLockedInFile } from '../util/project.js';
11
+ import { askPassword, StopError, PASSWORD_ENV } from '../util/prompt.js';
10
12
 
11
- export async function lockCommand(target, opts) {
12
- const filePath = resolve(opts.file || 'storymap.yml');
13
- let id, baseUrl;
13
+ // The map's name and the project it's in, from its file, or from the server for a link
14
+ async function describe(target, filePath, baseUrl, id, projectId) {
14
15
  try {
15
- ({ id, baseUrl } = await resolveTarget(target, filePath));
16
+ if (!target) {
17
+ const obj = jsyaml.load(await readFile(filePath, 'utf-8'));
18
+ return { name: obj?.name || id, projectId: projectId || obj?.project };
19
+ }
20
+ const { data } = await getMap(baseUrl, id, 'json');
21
+ return { name: data.name || id, projectId };
16
22
  } catch {
17
- console.error(chalk.red('No map ID found. Run "storymaps new" first to reserve an ID, or pass one directly: storymaps lock <id>'));
18
- process.exitCode = 1;
19
- return;
23
+ return { name: id, projectId };
20
24
  }
25
+ }
21
26
 
22
- const pw = await password({ theme: { prefix: '>' }, message: 'Password:' });
23
- const pw2 = await password({ theme: { prefix: '>' }, message: 'Confirm password:' });
24
-
25
- if (pw !== pw2) {
26
- console.error(chalk.red('Passwords do not match.'));
27
- process.exitCode = 1;
28
- return;
27
+ export async function lockCommand(target, opts) {
28
+ if (!target && !opts.file && await findProjectFolder(opts)) {
29
+ throw new StopError('Name a map with -f. To lock the whole project, run storymaps project lock');
30
+ }
31
+ const filePath = resolve(opts.file || 'storymap.yml');
32
+ let id, baseUrl, projectId;
33
+ try {
34
+ ({ id, baseUrl, projectId } = await resolveTarget(target, filePath));
35
+ } catch (err) {
36
+ if (/whole project/.test(err.message)) throw new StopError(`${err.message.split('.')[0]}. To lock the whole project, run storymaps project lock`);
37
+ throw new StopError('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps lock <id>');
29
38
  }
30
39
 
31
- const passwordHash = createHash('sha256').update(pw).digest('hex');
40
+ // STORYMAPS_PASSWORD is the new password, so nothing is asked
41
+ const passwordHash = await askPassword({ confirmTwice: true, noPasswordMessage: `Locking needs a password, and there's no terminal to type it in. Set ${PASSWORD_ENV} for this command.` });
32
42
  await lockMap(baseUrl, id, passwordHash);
33
- console.log(chalk.green(`Map ${id} is now locked.`));
43
+ if (!target) await setLockedInFile(filePath, true);
44
+
45
+ // In a project, say which lock the map ended up with, as the server labels it
46
+ const map = await describe(target, filePath, baseUrl, id, projectId);
47
+ if (map.projectId) {
48
+ try {
49
+ const project = await getProject(baseUrl, map.projectId);
50
+ const label = project.maps.find(m => m.id === id)?.lock;
51
+ if (label === 'project') return console.log(chalk.green(`Locked ${map.name} with the project password.`));
52
+ if (label === 'map' && project.locked) return console.log(chalk.green(`Locked ${map.name} with its own password.`));
53
+ } catch { /* fall through to the plain message */ }
54
+ }
55
+ console.log(chalk.green(`Locked ${map.name}.`));
34
56
  }
@@ -2,22 +2,40 @@
2
2
  // storymaps open — open a map in the default browser
3
3
 
4
4
  import { exec } from 'node:child_process';
5
+ import { readFile } from 'node:fs/promises';
6
+ import jsyaml from 'js-yaml';
5
7
  import { resolve } from 'node:path';
6
8
  import chalk from 'chalk';
7
9
  import { resolveTarget } from '../util/identity.js';
10
+ import { findProjectFolder } from '../util/project.js';
11
+ import { openProject } from './project-sync.js';
8
12
 
9
13
  export async function openCommand(target, opts) {
14
+ // A project folder opens the project at its first map
15
+ if (!target) {
16
+ const ctx = await findProjectFolder(opts);
17
+ if (ctx?.projectId) {
18
+ await openProject(ctx);
19
+ return;
20
+ }
21
+ }
10
22
  const filePath = resolve(opts.file || 'storymap.yml');
11
- let id, baseUrl;
23
+ let id, baseUrl, projectId;
12
24
  try {
13
- ({ id, baseUrl } = await resolveTarget(target, filePath));
25
+ ({ id, baseUrl, projectId } = await resolveTarget(target, filePath));
14
26
  } catch {
15
27
  console.error(chalk.red('No map ID found. Run "storymaps push" to create it on the server, or pass one directly: storymaps open <id>'));
16
28
  process.exitCode = 1;
17
29
  return;
18
30
  }
19
31
 
20
- const url = `${baseUrl}/${id}`;
32
+ // A map in a project opens at its project link, with the project's tabs
33
+ if (!projectId && !target) {
34
+ try {
35
+ projectId = jsyaml.load(await readFile(filePath, 'utf-8'))?.project;
36
+ } catch { /* no file */ }
37
+ }
38
+ const url = projectId ? `${baseUrl}/p/${projectId}/${id}` : `${baseUrl}/${id}`;
21
39
 
22
40
  const platform = process.platform;
23
41
  const cmd = platform === 'darwin' ? 'open'
@@ -0,0 +1,509 @@
1
+ // Storymaps CLI — AGPL-3.0 — see LICENCE for details
2
+ // The commands that work on maps, run on every map in a project folder: pull, push, status,
3
+ // diff, backup, backups, restore and open
4
+
5
+ import { existsSync } from 'node:fs';
6
+ import { readFile, writeFile, rm } from 'node:fs/promises';
7
+ import { exec } from 'node:child_process';
8
+ import { basename } from 'node:path';
9
+ import chalk from 'chalk';
10
+ import { diffLines } from 'diff';
11
+ import { printFullDiff } from '../util/full-diff.js';
12
+ import jsyaml from 'js-yaml';
13
+ import { exportToYaml, importFromYaml, yamlObjToJson } from '../core/yaml.js';
14
+ import {
15
+ getMap, putMap, getProject, addProjectMap, mapExists, getNewId, createBackup, listBackups, ApiError,
16
+ } from '../util/api.js';
17
+ import { readEtag, writeEtag } from '../util/file.js';
18
+ import { changeSummary } from '../util/summary.js';
19
+ import { formatTime } from '../util/time.js';
20
+ import {
21
+ readProjectFiles, mapFilePath, shortPath, removeProjectKey, setIdKey, writeMapFile,
22
+ isNewMap, forgetNewMap, draftOf, siteOfBaseUrl,
23
+ } from '../util/project.js';
24
+ import { projectPassword, ownMapPassword, listNames } from '../util/passwords.js';
25
+ import { guard, StopError } from '../util/prompt.js';
26
+ import { storyCounts, bar, pct } from './status.js';
27
+
28
+ const normalise = (yaml) => exportToYaml(yamlObjToJson(jsyaml.load(yaml)));
29
+ export const projectLink = (baseUrl, projectId, mapId) => `${baseUrl}/p/${projectId}${mapId ? `/${mapId}` : ''}`;
30
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
31
+ const pad = (text, width) => text + ' '.repeat(Math.max(0, width - text.length));
32
+ const indent = (lines) => lines.map(l => ` ${l}`);
33
+
34
+ // Lines describing what changed between two versions of a map
35
+ function summaryLines(oldYaml, newYaml) {
36
+ const lines = changeSummary(oldYaml, newYaml);
37
+ if (lines.length) return indent(lines);
38
+ return [chalk.yellow(` ~ changes not shown in summary, run ${chalk.cyan('storymaps diff --full')} to see them`)];
39
+ }
40
+
41
+ // ---------------------------------------------------------------------------------------------
42
+ // pull
43
+
44
+ // Pulls every map in a project, or one map of it, into a project folder
45
+ export async function pullProject({ baseUrl, projectId, dir, force, mapId }) {
46
+ const project = await getProject(baseUrl, projectId);
47
+ const all = await readProjectFiles(dir);
48
+ for (const f of all.filter(f => f.broken)) console.log(chalk.yellow(`${f.name} can't be read as YAML, so it was left alone: ${f.broken}`));
49
+ const local = all.filter(f => !f.broken);
50
+ const other = local.find(f => f.project !== projectId);
51
+ if (other) {
52
+ throw new StopError(`${shortPath(dir)} already holds another project, ${other.project}. Use --dir to pull this one into another folder.`);
53
+ }
54
+ const byId = new Map(local.filter(f => f.id).map(f => [f.id, f]));
55
+ const maps = mapId ? project.maps.filter(m => m.id === mapId) : project.maps;
56
+ if (mapId && !maps.length) throw new StopError(`That map isn't in ${project.name}.`);
57
+ const firstTime = !local.length;
58
+
59
+ const plan = [];
60
+ for (const map of maps) {
61
+ const { data, etag } = await getMap(baseUrl, map.id, 'yaml', { project: projectId });
62
+ const existing = byId.get(map.id);
63
+ const path = existing?.path || mapFilePath(dir, map.id);
64
+ if (!existing && all.some(f => f.broken && f.path === path)) continue;
65
+ if (!existing && !existsSync(path)) {
66
+ plan.push({ map, path, data, etag, kind: 'new' });
67
+ continue;
68
+ }
69
+ const old = existing ? existing.content : await readFile(path, 'utf-8');
70
+ // Only the lines at the top differ, such as locked after a lock: updated without asking
71
+ let kind = old === data ? 'same' : 'changed';
72
+ if (kind === 'changed') {
73
+ try {
74
+ if (normalise(old) === normalise(data)) kind = 'refresh';
75
+ } catch { /* not readable as a map, so it's a change */ }
76
+ }
77
+ plan.push({ map, path, data, etag, old, kind });
78
+ }
79
+ // Maps taken out of the project in the app keep their files, without the project key, so a
80
+ // later push can't put them back by accident
81
+ const removed = mapId ? [] : local.filter(f => f.id && !project.maps.some(m => m.id === f.id));
82
+
83
+ // One map of the project: a short message, with the same question before overwriting
84
+ if (mapId) {
85
+ const [p] = plan;
86
+ if (p.kind === 'same' || p.kind === 'refresh') {
87
+ if (p.kind === 'refresh') await writeMapFile(p.path, p.data);
88
+ await writeEtag(p.map.id, p.path, p.etag);
89
+ console.log(chalk.dim('Already up to date.'));
90
+ return project;
91
+ }
92
+ if (p.kind === 'changed') {
93
+ try {
94
+ indent(changeSummary(normalise(p.old), normalise(p.data))).forEach(l => console.log(l.slice(2)));
95
+ } catch { /* the summary is a courtesy */ }
96
+ const ok = await guard({ message: 'Apply changes?', force, stopMessage: `${shortPath(p.path)} was left as it is.` });
97
+ if (!ok) {
98
+ console.log('Aborted.');
99
+ return project;
100
+ }
101
+ }
102
+ await writeMapFile(p.path, p.data);
103
+ await writeEtag(p.map.id, p.path, p.etag);
104
+ console.log(chalk.green(`Pulled ${p.map.name} into ${shortPath(p.path)}`));
105
+ return project;
106
+ }
107
+
108
+ const fileWidth = Math.max(0, ...plan.map(p => basename(p.path).length), ...removed.map(f => f.name.length));
109
+ const nameWidth = Math.max(0, ...plan.map(p => p.map.name.length));
110
+ const fileName = (path) => pad(basename(path), fileWidth);
111
+ const changed = plan.filter(p => p.kind === 'changed');
112
+ const added = plan.filter(p => p.kind === 'new');
113
+
114
+ if (firstTime) {
115
+ console.log(`${project.name}: ${plural(maps.length, 'map')} into ${shortPath(dir) === '.' ? 'this folder' : `${shortPath(dir)}/`}`);
116
+ } else {
117
+ const parts = [plural(project.maps.length, 'map')];
118
+ if (changed.length) parts.push(`${changed.length} changed`);
119
+ if (!firstTime && added.length) parts.push(`${added.length} new`);
120
+ if (removed.length) parts.push(`${removed.length} removed`);
121
+ console.log(`${project.name}: ${parts.join(', ')}`);
122
+ }
123
+ for (const p of plan) {
124
+ if (p.kind === 'same' || p.kind === 'refresh') continue;
125
+ console.log(` ${fileName(p.path)} ${pad(p.map.name, nameWidth)} ${p.kind === 'new' ? chalk.green(firstTime ? 'new' : 'new, added in the app') : ''}`.trimEnd());
126
+ if (p.kind === 'changed') {
127
+ try {
128
+ summaryLines(normalise(p.old), normalise(p.data)).forEach(l => console.log(l));
129
+ } catch { /* the summary is a courtesy */ }
130
+ }
131
+ }
132
+
133
+ for (const f of removed) {
134
+ const exists = await mapExists(baseUrl, f.id).catch(() => null);
135
+ console.log(` ${pad(f.name, fileWidth)} ${f.obj.name || f.id} ${chalk.yellow('removed from the project in the app')}`);
136
+ console.log(chalk.dim(' The file stays, without its project key.'));
137
+ if (exists === true) console.log(chalk.dim(` The map is still at ${baseUrl}/${f.id}`));
138
+ else if (exists === 'deleted') console.log(chalk.dim(' The map was deleted on the server.'));
139
+ }
140
+
141
+ if (changed.length) {
142
+ const ok = await guard({ message: 'Apply changes?', force, stopMessage: 'Nothing was written.' });
143
+ if (!ok) {
144
+ console.log('Aborted.');
145
+ return;
146
+ }
147
+ }
148
+
149
+ for (const p of [...added, ...changed, ...plan.filter(p => p.kind === 'refresh')]) {
150
+ await writeMapFile(p.path, p.data);
151
+ await writeEtag(p.map.id, p.path, p.etag);
152
+ }
153
+ for (const p of plan.filter(p => p.kind === 'same')) await writeEtag(p.map.id, p.path, p.etag);
154
+ for (const f of removed) await writeFile(f.path, removeProjectKey(f.content), 'utf-8');
155
+
156
+ const written = added.length + changed.length;
157
+ const same = plan.length - written;
158
+ if (written) console.log(chalk.green(`Pulled ${plural(written, 'map')} from ${projectLink(baseUrl, projectId)}`) + (same ? chalk.dim(`. ${same} already up to date.`) : ''));
159
+ else console.log(chalk.dim(`Already up to date: ${plural(same, 'map')}.`));
160
+ return project;
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------------------------
164
+ // push
165
+
166
+ // Checks every file, conflict and password before sending anything, then pushes only the maps
167
+ // that changed. Push adds a map to the project only when it creates that map: a map started with
168
+ // storymaps project add that isn't on the server yet.
169
+ export async function pushProject(ctx, { force }) {
170
+ const { baseUrl, projectId, dir } = ctx;
171
+ const project = await getProject(baseUrl, projectId);
172
+ const inProject = new Map(project.maps.map(m => [m.id, m]));
173
+ console.log(`Checking ${plural(ctx.files.length, 'map')}...`);
174
+
175
+ const toPush = [], toCreate = [], skipped = [], drafts = [], problems = [];
176
+ let unchanged = 0;
177
+ for (const file of ctx.files) {
178
+ if (file.broken) {
179
+ problems.push(`${file.name} can't be read as YAML: ${file.broken}\nNothing was pushed.`);
180
+ continue;
181
+ }
182
+ const label = file.obj.name || file.name;
183
+ if (!file.id) {
184
+ const became = await draftOf(file.path);
185
+ if (became) {
186
+ drafts.push({ file, became });
187
+ continue;
188
+ }
189
+ problems.push(`${file.name} isn't on ${siteOfBaseUrl(baseUrl)} yet.\nNothing was pushed. To add it to ${project.name} as a new map, run\n${chalk.cyan(`storymaps project add ${shortPath(file.path)}`)}`);
190
+ continue;
191
+ }
192
+ try {
193
+ importFromYaml(file.content);
194
+ } catch (err) {
195
+ problems.push(`${file.name} isn't a valid map:\n${err.message}`);
196
+ continue;
197
+ }
198
+ // A map started with project add is only ever created, never pushed over a map that
199
+ // already has its ID, whichever map that is
200
+ const isNew = await isNewMap(file.path, file.id);
201
+ if (inProject.has(file.id) && !isNew) {
202
+ const { data, etag } = await getMap(baseUrl, file.id, 'yaml', { project: projectId });
203
+ if (normalise(data) === normalise(file.content)) {
204
+ unchanged++;
205
+ await writeEtag(file.id, file.path, etag);
206
+ continue;
207
+ }
208
+ const name = inProject.get(file.id).name;
209
+ const known = await readEtag(file.id, file.path);
210
+ // A file this computer never pulled, such as one from a fresh clone of a repo, can't
211
+ // show whether the map changed on the server since it was written
212
+ if (!force && !known) {
213
+ throw new StopError(`${name} hasn't been pulled on this computer, so it can't tell whether the map changed on the server since ${file.name} was written.\nNothing was pushed. Run ${chalk.cyan('storymaps pull')} first, or use ${chalk.cyan('--force')} to overwrite.`);
214
+ }
215
+ if (!force && known !== etag) {
216
+ throw new StopError(`Conflict: ${name} has changed on the server since you last pulled.\nNothing was pushed. Run ${chalk.cyan('storymaps pull')} to update, or use ${chalk.cyan('--force')} to overwrite.`);
217
+ }
218
+ toPush.push({ file, map: inProject.get(file.id), remote: data, etag: force ? undefined : known });
219
+ continue;
220
+ }
221
+ const exists = await mapExists(baseUrl, file.id);
222
+ if (exists === true) {
223
+ if (isNew) toCreate.push({ file, label, taken: true });
224
+ else skipped.push({ file, label, why: `not in ${project.name}, skipped`, hint: `To add it to the project, run ${chalk.cyan(`storymaps project add ${shortPath(file.path)}`)}` });
225
+ } else if (isNew) {
226
+ toCreate.push({ file, label });
227
+ } else {
228
+ skipped.push({ file, label, why: 'no longer on the server, skipped', hint: `It may have been deleted. To start a new map from this file, run\n ${chalk.cyan(`storymaps project add ${shortPath(file.path)}`)}` });
229
+ }
230
+ }
231
+ if (problems.length) throw new StopError(problems.join('\n\n'));
232
+
233
+ if (force && toPush.length) {
234
+ const ok = await guard({ message: `Overwrite ${listNames(toPush.map(p => p.map.name))} on the server, without the conflict check?`, force: false, stopMessage: 'Nothing was pushed.' }).catch(err => {
235
+ // With no terminal, --force is the answer already given
236
+ if (err instanceof StopError) return true;
237
+ throw err;
238
+ });
239
+ if (!ok) {
240
+ console.log('Aborted.');
241
+ return;
242
+ }
243
+ }
244
+
245
+ for (const p of toPush) {
246
+ console.log(` ${p.file.name} ${p.map.name}`);
247
+ try {
248
+ summaryLines(normalise(p.remote), normalise(p.file.content)).forEach(l => console.log(l));
249
+ } catch { /* the summary is a courtesy */ }
250
+ }
251
+ for (const c of toCreate) console.log(` ${c.file.name} ${c.label} ${chalk.green('new map')}`);
252
+ for (const s of skipped) {
253
+ console.log(` ${s.file.name} ${s.label} ${chalk.yellow(s.why)}`);
254
+ console.log(chalk.dim(` ${s.hint}`));
255
+ }
256
+
257
+ // Passwords, all checked before anything is sent
258
+ const byProjectPassword = toPush.filter(p => p.map.lock === 'project');
259
+ let projectHash = null;
260
+ if (byProjectPassword.length || (toCreate.length && project.locked)) {
261
+ const reason = byProjectPassword.length
262
+ ? `${listNames(byProjectPassword.map(p => p.map.name))} ${byProjectPassword.length === 1 ? 'is' : 'are'} locked with the ${project.name} password`
263
+ : `${project.name} is locked. Enter its password to add ${listNames(toCreate.map(c => c.label))}`;
264
+ projectHash = await projectPassword(baseUrl, project, reason);
265
+ }
266
+ for (const p of toPush) {
267
+ if (p.map.lock === 'project') p.hash = projectHash;
268
+ else if (p.map.lock === 'map') p.hash = await ownMapPassword(baseUrl, p.map, shortPath(p.file.path));
269
+ }
270
+
271
+ let pushed = 0;
272
+ const links = [];
273
+ for (const p of toPush) {
274
+ await putMap(baseUrl, p.file.id, p.file.content, { etag: p.etag, lockPasswordHash: p.hash });
275
+ const { etag } = await getMap(baseUrl, p.file.id, 'yaml', { project: projectId });
276
+ await writeEtag(p.file.id, p.file.path, etag);
277
+ links.push(projectLink(baseUrl, projectId, p.file.id));
278
+ pushed++;
279
+ }
280
+ for (const c of toCreate) {
281
+ const created = await createInProject({ baseUrl, project, projectHash, file: c.file, dir, taken: c.taken });
282
+ links.push(projectLink(baseUrl, projectId, created));
283
+ pushed++;
284
+ }
285
+ for (const d of drafts) console.log(chalk.dim(`${d.file.name} was added as ${shortPath(d.became).split('/').pop()}. You can delete it.`));
286
+
287
+ if (pushed) {
288
+ console.log(chalk.green(`Pushed ${plural(pushed, 'map')}.`) + (unchanged ? ` ${unchanged} unchanged.` : ''));
289
+ if (toPush.length) links.slice(0, toPush.length).forEach(l => console.log(` ${l}`));
290
+ } else {
291
+ console.log(chalk.dim(`Nothing to push. ${unchanged} unchanged.`));
292
+ }
293
+ }
294
+
295
+ // Creates a map started with project add and adds it to the project. If another map already has
296
+ // its ID, which can only happen for a map started offline, it gets a fresh ID from the server and
297
+ // its file is renamed to match: the one rename the CLI ever does.
298
+ async function createInProject({ baseUrl, project, projectHash, file, dir, taken }) {
299
+ let id = file.id;
300
+ let content = file.content;
301
+ let path = file.path;
302
+ const name = file.obj.name || 'My Story Map';
303
+ try {
304
+ if (taken) throw new ApiError('A map already has this ID', 412);
305
+ await putMap(baseUrl, id, content, { createOnly: true });
306
+ } catch (err) {
307
+ if (!(err instanceof ApiError && err.status === 412)) throw err;
308
+ const fresh = (await getNewId(baseUrl)).id;
309
+ console.log(chalk.yellow(`Another map already has the ID ${id}, so ${name} gets a new one: ${fresh}`));
310
+ content = setIdKey(content, fresh);
311
+ await putMap(baseUrl, fresh, content, { createOnly: true });
312
+ const newPath = mapFilePath(dir, fresh);
313
+ await writeMapFile(newPath, content);
314
+ await rm(path, { force: true });
315
+ await forgetNewMap(path);
316
+ console.log(`Renamed ${shortPath(path)} to ${shortPath(newPath)}`);
317
+ id = fresh;
318
+ path = newPath;
319
+ }
320
+ console.log(chalk.green(`Created ${name}: ${projectLink(baseUrl, project.id, id)}`));
321
+ const after = await addProjectMap(baseUrl, project.id, id, projectHash || undefined);
322
+ console.log(chalk.green(`Added ${name} to ${project.name}.`));
323
+ reportLock(name, after.maps.find(m => m.id === id));
324
+ await forgetNewMap(path);
325
+ const { etag } = await getMap(baseUrl, id, 'yaml', { project: project.id });
326
+ await writeEtag(id, path, etag);
327
+ return id;
328
+ }
329
+
330
+ // Says the lock a map actually has, as the server labels it. Never suggests what it should have.
331
+ export function reportLock(name, map) {
332
+ if (!map?.lock) console.log(chalk.dim(`${name} isn't locked, so anyone with its link can edit it.`));
333
+ else if (map.lock === 'project') console.log(chalk.dim(`${name} is locked with the project password.`));
334
+ else console.log(chalk.dim(`${name} keeps its own lock.`));
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------------------------
338
+ // status
339
+
340
+ // One line per map in tab order, with its file, and a total. From the folder, or from the server
341
+ // when given a project link.
342
+ export async function statusProject({ baseUrl, projectId, files }) {
343
+ const project = await getProject(baseUrl, projectId);
344
+ const byId = new Map((files || []).filter(f => f.id && !f.broken).map(f => [f.id, f]));
345
+ const rows = [];
346
+ for (const map of project.maps) {
347
+ const file = byId.get(map.id);
348
+ const obj = file ? file.obj : jsyaml.load((await getMap(baseUrl, map.id, 'yaml')).data);
349
+ rows.push({ name: map.name, file: file ? file.name : '', counts: storyCounts(obj), lock: map.lock });
350
+ }
351
+ console.log();
352
+ console.log(chalk.bold(project.name));
353
+ console.log(chalk.dim(projectLink(baseUrl, projectId)));
354
+ if (project.locked) console.log(chalk.yellow('project locked'));
355
+ console.log();
356
+ const nameWidth = Math.max(5, ...rows.map(r => r.name.length));
357
+ const fileWidth = Math.max(0, ...rows.map(r => r.file.length));
358
+ const countWidth = Math.max(...rows.map(r => `${r.counts.done}/${r.counts.total}`.length));
359
+ let done = 0, total = 0;
360
+ for (const r of rows) {
361
+ done += r.counts.done;
362
+ total += r.counts.total;
363
+ const file = fileWidth ? `${chalk.dim(pad(r.file, fileWidth))} ` : '';
364
+ // Each map's own lock, as the server labels it: the project password, or its own
365
+ const percent = `(${pct(r.counts.done, r.counts.total)})`;
366
+ const lock = r.lock === 'project' ? 'locked' : r.lock === 'map' ? 'own lock' : '';
367
+ // Padding sits outside the colour codes, so an unlocked row ends without stray spaces
368
+ const after = lock ? `${' '.repeat(Math.max(0, 6 - percent.length))} ${chalk.yellow(lock)}` : '';
369
+ console.log(` ${pad(r.name, nameWidth)} ${file}${bar(r.counts.done, r.counts.total)} ${pad(`${r.counts.done}/${r.counts.total}`, countWidth)} ${chalk.dim(percent)}${after}`);
370
+ }
371
+ console.log();
372
+ console.log(` ${pad('Total', nameWidth)} ${fileWidth ? ' '.repeat(fileWidth + 2) : ''}${bar(done, total)} ${done}/${total} stories ${chalk.dim(`(${pct(done, total)})`)}`);
373
+ console.log();
374
+ }
375
+
376
+ // ---------------------------------------------------------------------------------------------
377
+ // diff
378
+
379
+ export async function diffProject(ctx, { full } = {}) {
380
+ const project = await getProject(ctx.baseUrl, ctx.projectId);
381
+ const inProject = new Map(project.maps.map(m => [m.id, m]));
382
+ let same = 0;
383
+ for (const file of ctx.files) {
384
+ if (file.broken) {
385
+ console.log(chalk.yellow(` ${file.name} can't be read as YAML: ${file.broken}`));
386
+ continue;
387
+ }
388
+ if (!file.id || !inProject.has(file.id)) continue;
389
+ const { data } = await getMap(ctx.baseUrl, file.id, 'yaml', { project: ctx.projectId });
390
+ const [remote, local] = [normalise(data), normalise(file.content)];
391
+ if (remote === local) {
392
+ same++;
393
+ continue;
394
+ }
395
+ if (full) {
396
+ console.log(chalk.bold(`--- remote: ${projectLink(ctx.baseUrl, ctx.projectId, file.id)}`));
397
+ console.log(chalk.bold(`+++ local: ${shortPath(file.path)}`));
398
+ printFullDiff(diffLines(remote, local));
399
+ console.log();
400
+ continue;
401
+ }
402
+ console.log(` ${file.name} ${inProject.get(file.id).name}`);
403
+ summaryLines(remote, local).forEach(l => console.log(l));
404
+ }
405
+ console.log(chalk.dim(same === ctx.files.length ? 'No differences.' : `${plural(same, 'map')} the same as the server.`));
406
+ if (!full && same < ctx.files.length) console.log(chalk.dim(`Run ${chalk.cyan('storymaps diff --full')} for the full file diff.`));
407
+ }
408
+
409
+ // ---------------------------------------------------------------------------------------------
410
+ // backup, backups, restore
411
+
412
+ const cards = (n) => plural(n, 'card');
413
+ const BACKUP_LIMIT_WAIT = 61_000;
414
+
415
+ // Backs up every map with one note. Unchanged maps reuse their last backup. A failure doesn't
416
+ // stop the rest, as backups change nothing; failures are reported at the end.
417
+ export async function backupProject(ctx, { message }) {
418
+ const project = await getProject(ctx.baseUrl, ctx.projectId);
419
+ const byId = new Map(ctx.files.filter(f => f.id).map(f => [f.id, f]));
420
+ const maps = project.maps.filter(m => byId.has(m.id));
421
+ const projectHash = maps.some(m => m.lock === 'project')
422
+ ? await projectPassword(ctx.baseUrl, project, `${project.name} is locked. Enter its password to back up its maps`)
423
+ : null;
424
+ const nameWidth = Math.max(...maps.map(m => m.name.length));
425
+ let made = 0, reused = 0;
426
+ const failed = [];
427
+ for (const map of maps) {
428
+ const file = byId.get(map.id);
429
+ let hash;
430
+ try {
431
+ hash = map.lock === 'project' ? projectHash : map.lock === 'map' ? await ownMapPassword(ctx.baseUrl, map, shortPath(file.path)) : undefined;
432
+ } catch (err) {
433
+ failed.push(`${map.name}: ${err.message}`);
434
+ continue;
435
+ }
436
+ let backup;
437
+ for (let attempt = 0; attempt < 2; attempt++) {
438
+ try {
439
+ backup = await createBackup(ctx.baseUrl, map.id, message || '', { lockPasswordHash: hash });
440
+ break;
441
+ } catch (err) {
442
+ if (err instanceof ApiError && err.status === 429 && attempt === 0) {
443
+ console.log(chalk.dim('The server takes 10 backups a minute, so this waits a minute...'));
444
+ await new Promise(r => setTimeout(r, BACKUP_LIMIT_WAIT));
445
+ continue;
446
+ }
447
+ failed.push(`${map.name}: ${err.message}`);
448
+ break;
449
+ }
450
+ }
451
+ if (!backup) continue;
452
+ const label = ` ${file.name} ${pad(map.name, nameWidth)} `;
453
+ if (backup.unchanged) {
454
+ reused++;
455
+ console.log(label + chalk.dim(`no changes since backup ${backup.id} from ${formatTime(backup.timestamp)}`));
456
+ } else {
457
+ made++;
458
+ console.log(label + chalk.green(`backed up as ${backup.id} (${cards(backup.cardCount)})`));
459
+ }
460
+ }
461
+ const parts = [];
462
+ if (made) parts.push(chalk.green(`Backed up ${plural(made, 'map')}.`));
463
+ if (reused) parts.push(`${reused} had no changes since their last backup.`);
464
+ if (parts.length) console.log(parts.join(' '));
465
+ if (failed.length) {
466
+ console.error(chalk.red(`Not backed up:\n${failed.map(f => ` ${f}`).join('\n')}`));
467
+ process.exitCode = 1;
468
+ }
469
+ }
470
+
471
+ // The newest backup of each map, in tab order
472
+ export async function backupsProject(ctx) {
473
+ const project = await getProject(ctx.baseUrl, ctx.projectId);
474
+ const byId = new Map(ctx.files.filter(f => f.id).map(f => [f.id, f]));
475
+ const maps = project.maps.filter(m => byId.has(m.id));
476
+ const nameWidth = Math.max(...maps.map(m => m.name.length));
477
+ console.log(`${project.name}, newest backup of each map`);
478
+ for (const map of maps) {
479
+ const backups = await listBackups(ctx.baseUrl, map.id);
480
+ const newest = backups[backups.length - 1];
481
+ if (!newest) {
482
+ console.log(` ${pad(map.name, nameWidth)} ${chalk.dim('no backups yet')}`);
483
+ continue;
484
+ }
485
+ console.log(` ${pad(map.name, nameWidth)} ${chalk.cyan(newest.id)} ${chalk.dim(formatTime(newest.timestamp))} ${newest.note || chalk.dim('(no note)')}`);
486
+ }
487
+ console.log(chalk.dim(`Every backup of one map: ${chalk.cyan('storymaps backups -f storymaps/<id>.yml')}`));
488
+ }
489
+
490
+ // Which map in the folder a backup ID belongs to
491
+ export async function findBackupMap(ctx, backupId) {
492
+ for (const file of ctx.files.filter(f => f.id)) {
493
+ const backups = await listBackups(ctx.baseUrl, file.id).catch(() => []);
494
+ if (backups.some(b => b.id === backupId)) return file;
495
+ }
496
+ throw new StopError(`No map in ${shortPath(ctx.dir)} has a backup ${backupId}. Run storymaps backups to see them.`);
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------------------------
500
+ // open
501
+
502
+ export async function openProject(ctx) {
503
+ const project = await getProject(ctx.baseUrl, ctx.projectId);
504
+ const first = project.maps[0]?.id;
505
+ const url = projectLink(ctx.baseUrl, ctx.projectId, first);
506
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
507
+ exec(`${cmd} ${url}`);
508
+ console.log(chalk.green(url));
509
+ }