Skip to content

Commit 9384306

Browse files
authoredApr 16, 2024
Merge pull request #15 from Mermaid-Chart/feat/support-multiple-files-at-once-in-cli
feat(cli): support `pull`/`push`/`link` on multiple mermaid files at once
2 parents 354b144 + b188d0d commit 9384306

File tree

5 files changed

+204
-81
lines changed

5 files changed

+204
-81
lines changed
 

‎packages/cli/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"dependencies": {
5454
"@commander-js/extra-typings": "^11.1.0",
5555
"@iarna/toml": "^2.2.5",
56+
"@inquirer/confirm": "^2.0.15",
5657
"@inquirer/input": "^1.2.14",
5758
"@inquirer/select": "^1.3.1",
5859
"@mermaidchart/sdk": "workspace:^",

‎packages/cli/src/commander.test.ts

+82-13
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { copyFile, mkdir, readFile, rm } from 'node:fs/promises';
44
import type { Command, CommanderError, OutputConfiguration } from '@commander-js/extra-typings';
55
import { MermaidChart } from '@mermaidchart/sdk';
66

7+
import confirm from '@inquirer/confirm';
78
import input from '@inquirer/input';
89
import select from '@inquirer/select';
910
import type { MCDocument, MCProject, MCUser } from '@mermaidchart/sdk/dist/types.js';
@@ -186,9 +187,15 @@ describe('logout', () => {
186187

187188
describe('link', () => {
188189
const diagram = 'test/output/unsynced.mmd';
190+
const diagram2 = 'test/output/unsynced2.mmd';
191+
const diagram3 = 'test/output/unsynced3.mmd';
189192

190193
beforeEach(async () => {
191-
await copyFile('test/fixtures/unsynced.mmd', diagram);
194+
await Promise.all([
195+
copyFile('test/fixtures/unsynced.mmd', diagram),
196+
copyFile('test/fixtures/unsynced.mmd', diagram2),
197+
copyFile('test/fixtures/unsynced.mmd', diagram3),
198+
]);
192199
});
193200

194201
it('should create a new diagram on MermaidChart and add id to frontmatter', async () => {
@@ -214,13 +221,65 @@ describe('link', () => {
214221
`id: ${mockedEmptyDiagram.documentID}`,
215222
);
216223
});
224+
225+
for (const rememberProjectId of [true, false]) {
226+
it(`should link multiple diagrams ${
227+
rememberProjectId ? 'and remember project id' : ''
228+
}`, async () => {
229+
const { program } = mockedProgram();
230+
231+
vi.mock('@inquirer/confirm');
232+
vi.mock('@inquirer/select');
233+
vi.mocked(confirm).mockResolvedValue(rememberProjectId);
234+
vi.mocked(select).mockResolvedValue(mockedProjects[0].id);
235+
236+
vi.mocked(MermaidChart.prototype.createDocument).mockResolvedValue(mockedEmptyDiagram);
237+
238+
await expect(readFile(diagram, { encoding: 'utf8' })).resolves.not.toContain(/^id:/);
239+
240+
await program.parseAsync(['--config', CONFIG_AUTHED, 'link', diagram, diagram2, diagram3], {
241+
from: 'user',
242+
});
243+
244+
if (rememberProjectId) {
245+
expect(vi.mocked(confirm)).toHaveBeenCalledOnce();
246+
expect(vi.mocked(select)).toHaveBeenCalledOnce();
247+
} else {
248+
// if the user didn't allow using the same project id for all diagrams,
249+
// ask every time
250+
expect(vi.mocked(confirm)).toHaveBeenCalledOnce();
251+
expect(vi.mocked(select)).toHaveBeenCalledTimes(3);
252+
}
253+
254+
// should have uploaded and created three files
255+
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledTimes(3);
256+
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledWith(
257+
expect.objectContaining({
258+
code: expect.not.stringContaining('id:'), // id: field should not be uploaded
259+
title: diagram, // title should default to file name
260+
}),
261+
);
262+
263+
await Promise.all(
264+
[diagram, diagram2, diagram3].map(async (file) => {
265+
await expect(readFile(file, { encoding: 'utf8' })).resolves.toContain(
266+
`id: ${mockedEmptyDiagram.documentID}`,
267+
);
268+
}),
269+
);
270+
});
271+
}
217272
});
218273

219274
describe('pull', () => {
220275
const diagram = 'test/output/connected-diagram.mmd';
276+
const diagram2 = 'test/output/connected-diagram-2.mmd';
221277

222278
beforeEach(async () => {
223-
await copyFile('test/fixtures/connected-diagram.mmd', diagram);
279+
await Promise.all([
280+
copyFile('test/fixtures/connected-diagram.mmd', diagram),
281+
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
282+
]);
224283
});
225284

226285
it('should fail if MermaidChart document has not yet been linked', async () => {
@@ -243,7 +302,7 @@ describe('pull', () => {
243302
).rejects.toThrowError(`Diagram at ${diagram} has no code`);
244303
});
245304

246-
it('should pull document and add a `id:` field to frontmatter', async () => {
305+
it('should pull documents and add a `id:` field to frontmatter', async () => {
247306
const { program } = mockedProgram();
248307

249308
const mockedDiagram = {
@@ -255,22 +314,30 @@ title: My cool flowchart
255314
A[I've been updated!]`,
256315
};
257316

258-
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValueOnce(mockedDiagram);
317+
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValue(mockedDiagram);
259318

260-
await program.parseAsync(['--config', CONFIG_AUTHED, 'pull', diagram], { from: 'user' });
319+
await program.parseAsync(['--config', CONFIG_AUTHED, 'pull', diagram, diagram2], {
320+
from: 'user',
321+
});
261322

262-
const diagramContents = await readFile(diagram, { encoding: 'utf8' });
323+
for (const file of [diagram, diagram2]) {
324+
const diagramContents = await readFile(file, { encoding: 'utf8' });
263325

264-
expect(diagramContents).toContain(`id: ${mockedDiagram.documentID}`);
265-
expect(diagramContents).toContain("flowchart TD\n A[I've been updated!]");
326+
expect(diagramContents).toContain(`id: ${mockedDiagram.documentID}`);
327+
expect(diagramContents).toContain("flowchart TD\n A[I've been updated!]");
328+
}
266329
});
267330
});
268331

269332
describe('push', () => {
270333
const diagram = 'test/output/connected-diagram.mmd';
334+
const diagram2 = 'test/output/connected-diagram-2.mmd';
271335

272336
beforeEach(async () => {
273-
await copyFile('test/fixtures/connected-diagram.mmd', diagram);
337+
await Promise.all([
338+
copyFile('test/fixtures/connected-diagram.mmd', diagram),
339+
copyFile('test/fixtures/connected-diagram.mmd', diagram2),
340+
]);
274341
});
275342

276343
it('should fail if MermaidChart document has not yet been linked', async () => {
@@ -283,16 +350,18 @@ describe('push', () => {
283350
).rejects.toThrowError('Diagram at test/fixtures/unsynced.mmd has no id');
284351
});
285352

286-
it('should push document and remove the `id:` field front frontmatter', async () => {
353+
it('should push documents and remove the `id:` field front frontmatter', async () => {
287354
const { program } = mockedProgram();
288355

289-
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValueOnce(mockedEmptyDiagram);
356+
vi.mocked(MermaidChart.prototype.getDocument).mockResolvedValue(mockedEmptyDiagram);
290357

291358
await expect(readFile(diagram, { encoding: 'utf8' })).resolves.not.toContain(/^id:/);
292359

293-
await program.parseAsync(['--config', CONFIG_AUTHED, 'push', diagram], { from: 'user' });
360+
await program.parseAsync(['--config', CONFIG_AUTHED, 'push', diagram, diagram2], {
361+
from: 'user',
362+
});
294363

295-
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledOnce();
364+
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledTimes(2);
296365
expect(vi.mocked(MermaidChart.prototype.setDocument)).toHaveBeenCalledWith(
297366
expect.objectContaining({
298367
code: expect.not.stringContaining('id:'),

‎packages/cli/src/commander.ts

+83-53
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {
77
import { readFile, writeFile } from 'fs/promises';
88
import { MermaidChart } from '@mermaidchart/sdk';
99
import { createRequire } from 'node:module';
10+
import confirm from '@inquirer/confirm';
1011
import input from '@inquirer/input';
1112
import select, { Separator } from '@inquirer/select';
1213
import { type Config, defaultConfigPath, readConfig, writeConfig } from './config.js';
13-
import { link, pull, push } from './methods.js';
14+
import { link, type LinkOptions, pull, push } from './methods.js';
1415

1516
/**
1617
* Global configuration option for the root Commander Command.
@@ -158,78 +159,107 @@ function logout() {
158159

159160
function linkCmd() {
160161
return createCommand('link')
161-
.description('Link the given Mermaid diagram to Mermaid Chart')
162-
.addArgument(new Argument('<path>', 'The path of the file to link.'))
163-
.action(async (path, _options, command) => {
162+
.description('Link the given Mermaid diagrams to Mermaid Chart')
163+
.addArgument(new Argument('<path...>', 'The paths of the files to link.'))
164+
.action(async (paths, _options, command) => {
164165
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
165166
const client = await createClient(optsWithGlobals);
167+
168+
// Ask the user which project they want to upload each diagram to
169+
const getProjectId: LinkOptions['getProjectId'] = async (cache, documentTitle) => {
170+
if (cache.previousSelectedProjectId !== undefined) {
171+
if (cache.usePreviousSelectedProjectId === undefined) {
172+
cache.usePreviousSelectedProjectId = confirm({
173+
message: `Would you like to upload all diagrams to this project?`,
174+
default: true,
175+
});
176+
}
177+
if (await cache.usePreviousSelectedProjectId) {
178+
return cache.previousSelectedProjectId;
179+
}
180+
}
181+
182+
cache.projects = cache.projects ?? client.getProjects();
183+
const projectId = await select({
184+
message: `Select a project to upload ${documentTitle} to`,
185+
choices: [
186+
...(await cache.projects).map((project) => {
187+
return {
188+
name: project.title,
189+
value: project.id,
190+
};
191+
}),
192+
new Separator(
193+
`Or go to ${new URL('/app/projects', client.baseURL)} to create a new project`,
194+
),
195+
],
196+
});
197+
198+
cache.previousSelectedProjectId = projectId;
199+
200+
return projectId;
201+
};
202+
166203
const linkCache = {};
167204

168-
const existingFile = await readFile(path, { encoding: 'utf8' });
169-
170-
const linkedDiagram = await link(existingFile, client, {
171-
cache: linkCache,
172-
title: path,
173-
async getProjectId(cache) {
174-
cache.projects = cache.projects ?? client.getProjects();
175-
const projectId = await select({
176-
message: `Select a project to upload ${path} to`,
177-
choices: [
178-
...(await cache.projects).map((project) => {
179-
return {
180-
name: project.title,
181-
value: project.id,
182-
};
183-
}),
184-
new Separator(
185-
`Or go to ${new URL('/app/projects', client.baseURL)} to create a new project`,
186-
),
187-
],
188-
});
189-
return projectId;
190-
},
191-
});
205+
for (const path of paths) {
206+
const existingFile = await readFile(path, { encoding: 'utf8' });
207+
208+
const linkedDiagram = await link(existingFile, client, {
209+
cache: linkCache,
210+
title: path,
211+
getProjectId,
212+
});
192213

193-
await writeFile(path, linkedDiagram, { encoding: 'utf8' });
214+
await writeFile(path, linkedDiagram, { encoding: 'utf8' });
215+
}
194216
});
195217
}
196218

197219
function pullCmd() {
198220
return createCommand('pull')
199-
.description('Pulls a document from from Mermaid Chart')
200-
.addArgument(new Argument('<path>', 'The path of the file to pull.'))
201-
.option('--check', 'Check whether the local file would be overwrited')
202-
.action(async (path, options, command) => {
221+
.description('Pulls documents from Mermaid Chart')
222+
.addArgument(new Argument('<path...>', 'The paths of the files to pull.'))
223+
.option('--check', 'Check whether the local files would be overwrited')
224+
.action(async (paths, options, command) => {
203225
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
204226
const client = await createClient(optsWithGlobals);
205-
const text = await readFile(path, { encoding: 'utf8' });
206-
207-
const newFile = await pull(text, client, { title: path });
208-
209-
if (text === newFile) {
210-
console.log(`✅ - ${path} is up to date`);
211-
} else {
212-
if (options['check']) {
213-
console.log(`❌ - ${path} would be updated`);
214-
process.exitCode = 1;
215-
} else {
216-
await writeFile(path, newFile, { encoding: 'utf8' });
217-
console.log(`✅ - ${path} was updated`);
218-
}
219-
}
227+
await Promise.all(
228+
paths.map(async (path) => {
229+
const text = await readFile(path, { encoding: 'utf8' });
230+
231+
const newFile = await pull(text, client, { title: path });
232+
233+
if (text === newFile) {
234+
console.log(`✅ - ${path} is up to date`);
235+
} else {
236+
if (options['check']) {
237+
console.log(`❌ - ${path} would be updated`);
238+
process.exitCode = 1;
239+
} else {
240+
await writeFile(path, newFile, { encoding: 'utf8' });
241+
console.log(`✅ - ${path} was updated`);
242+
}
243+
}
244+
}),
245+
);
220246
});
221247
}
222248

223249
function pushCmd() {
224250
return createCommand('push')
225-
.description('Push a local diagram to Mermaid Chart')
226-
.addArgument(new Argument('<path>', 'The path of the file to push.'))
227-
.action(async (path, _options, command) => {
251+
.description('Push local diagrams to Mermaid Chart')
252+
.addArgument(new Argument('<path...>', 'The paths of the files to push.'))
253+
.action(async (paths, _options, command) => {
228254
const optsWithGlobals = command.optsWithGlobals<CommonOptions>();
229255
const client = await createClient(optsWithGlobals);
230-
const text = await readFile(path, { encoding: 'utf8' });
256+
await Promise.all(
257+
paths.map(async (path) => {
258+
const text = await readFile(path, { encoding: 'utf8' });
231259

232-
await push(text, client, { title: path });
260+
await push(text, client, { title: path });
261+
}),
262+
);
233263
});
234264
}
235265

‎packages/cli/src/methods.ts

+20-7
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,22 @@ import { extractFrontMatter, injectFrontMatter, removeFrontMatterKeys } from './
66
/**
77
* Cached data to use when pulling/pushing/linking multiple files at once.
88
*/
9-
interface Cache {
9+
export interface Cache {
1010
/**
11-
* If set, the user has said to use the projectId to create all documents
11+
* If `true`, the user has said to use the projectId to create all documents
1212
* in.
13+
*
14+
* If `undefined`, ask the user if they want to use their first chosen project
15+
* id for every other document.
16+
*
17+
* If `false`, don't ask the user.
1318
*/
14-
selectedProjectId?: string;
19+
usePreviousSelectedProjectId?: Promise<boolean>;
20+
/**
21+
* Previously selected project ID.
22+
* Will be reused if {@link usePreviousSelectedProjectId} is `true`.
23+
*/
24+
previousSelectedProjectId?: string;
1525
/**
1626
* Cached response from {@link MermaidChart.getProjects}.
1727
*/
@@ -23,10 +33,13 @@ interface CommonOptions {
2333
title: string;
2434
}
2535

26-
interface LinkOptions extends CommonOptions {
36+
/**
37+
* Options to pass to {@link link}.
38+
*/
39+
export interface LinkOptions extends CommonOptions {
2740
/** Function that asks the user which project id they want to upload a diagram to */
28-
getProjectId: (cache: LinkOptions['cache']) => Promise<string>;
29-
// cache to be shared between link calls
41+
getProjectId: (cache: LinkOptions['cache'], documentTitle: string) => Promise<string>;
42+
// cache to be shared between link calls. This object may be modified between calls.
3043
cache: Cache;
3144
}
3245

@@ -48,7 +61,7 @@ export async function link(diagram: string, client: MermaidChart, options: LinkO
4861

4962
const { title, getProjectId, cache } = options;
5063

51-
const projectId = cache.selectedProjectId ?? (await getProjectId(cache));
64+
const projectId = await getProjectId(cache, title);
5265

5366
const createdDocument = await client.createDocument(projectId);
5467

0 commit comments

Comments
 (0)
Failed to load comments.