miro-export 1.1.0 → 1.2.0
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/.github/workflows/build.yml +3 -3
- package/.github/workflows/publish.yml +2 -2
- package/.github/workflows/test.yml +4 -3
- package/README.md +19 -4
- package/build/cli.js +66 -49
- package/build/index.d.ts +20 -0
- package/build/index.js +41 -15
- package/package.json +17 -17
- package/src/cli.ts +86 -63
- package/src/index.ts +77 -19
- package/src/miro-types.ts +19 -17
- package/tests/api.test.ts +20 -0
|
@@ -8,11 +8,11 @@ jobs:
|
|
|
8
8
|
build:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
steps:
|
|
11
|
-
- uses: actions/checkout@
|
|
11
|
+
- uses: actions/checkout@v6
|
|
12
12
|
- uses: pnpm/action-setup@v3
|
|
13
13
|
with:
|
|
14
14
|
version: 8
|
|
15
|
-
- uses: actions/setup-node@
|
|
15
|
+
- uses: actions/setup-node@v6
|
|
16
16
|
with:
|
|
17
17
|
node-version: "22.x"
|
|
18
18
|
- name: Install dependencies
|
|
@@ -22,7 +22,7 @@ jobs:
|
|
|
22
22
|
- name: Build
|
|
23
23
|
run: pnpm run build
|
|
24
24
|
- name: Upload Artifact
|
|
25
|
-
uses: actions/upload-artifact@
|
|
25
|
+
uses: actions/upload-artifact@v6
|
|
26
26
|
with:
|
|
27
27
|
name: build
|
|
28
28
|
path: build/
|
|
@@ -8,11 +8,11 @@ jobs:
|
|
|
8
8
|
publish:
|
|
9
9
|
runs-on: ubuntu-latest
|
|
10
10
|
steps:
|
|
11
|
-
- uses: actions/checkout@
|
|
11
|
+
- uses: actions/checkout@v6
|
|
12
12
|
- uses: pnpm/action-setup@v3
|
|
13
13
|
with:
|
|
14
14
|
version: 8
|
|
15
|
-
- uses: actions/setup-node@
|
|
15
|
+
- uses: actions/setup-node@v6
|
|
16
16
|
with:
|
|
17
17
|
node-version: "22.x"
|
|
18
18
|
registry-url: "https://registry.npmjs.org"
|
|
@@ -6,13 +6,13 @@ on:
|
|
|
6
6
|
|
|
7
7
|
jobs:
|
|
8
8
|
test:
|
|
9
|
-
runs-on: ubuntu-
|
|
9
|
+
runs-on: ubuntu-22.04
|
|
10
10
|
steps:
|
|
11
|
-
- uses: actions/checkout@
|
|
11
|
+
- uses: actions/checkout@v6
|
|
12
12
|
- uses: pnpm/action-setup@v3
|
|
13
13
|
with:
|
|
14
14
|
version: 8
|
|
15
|
-
- uses: actions/setup-node@
|
|
15
|
+
- uses: actions/setup-node@v6
|
|
16
16
|
with:
|
|
17
17
|
node-version: "22.x"
|
|
18
18
|
- name: Install dependencies
|
|
@@ -21,3 +21,4 @@ jobs:
|
|
|
21
21
|
run: pnpm run test
|
|
22
22
|
env:
|
|
23
23
|
TEST_BOARD_ID: ${{secrets.TEST_BOARD_ID}}
|
|
24
|
+
PRIVATE_TEST_BOARD_ID: ${{secrets.PRIVATE_TEST_BOARD_ID}}
|
package/README.md
CHANGED
|
@@ -12,6 +12,17 @@ If accessing a private board, a personal token is required. To get a token, log
|
|
|
12
12
|
|
|
13
13
|
## CLI
|
|
14
14
|
|
|
15
|
+
You can use this tool as a command-line tool.
|
|
16
|
+
|
|
17
|
+
### Prerequisites
|
|
18
|
+
|
|
19
|
+
- [Node.js >=22](https://nodejs.org/en/download)
|
|
20
|
+
- npm (built-in to Node.js), yarn, or pnpm
|
|
21
|
+
|
|
22
|
+
### Installation
|
|
23
|
+
|
|
24
|
+
The CLI can be ran using [npx](https://docs.npmjs.com/cli/v8/commands/npx) with `npx miro-export [options]` (see options below). Alternatively, it's possible to install the package to the global scope with, for example, `npm i -g miro-export`.
|
|
25
|
+
|
|
15
26
|
### Usage
|
|
16
27
|
|
|
17
28
|
```
|
|
@@ -19,8 +30,9 @@ Options:
|
|
|
19
30
|
-t, --token <token> Miro token
|
|
20
31
|
-b, --board-id <boardId> The board ID
|
|
21
32
|
-f, --frame-names <frameNames...> The frame name(s), leave empty to export entire board
|
|
22
|
-
-o, --output-file <filename> A file to output the SVG to (stdout if not
|
|
23
|
-
-e, --export-format <format> 'svg' or 'json' (default:
|
|
33
|
+
-o, --output-file <filename> A file to output the SVG to (stdout if not specified)
|
|
34
|
+
-e, --export-format <format> 'svg' or 'json' (default: "svg")
|
|
35
|
+
-l, --load-timeout <milliseconds> Timeout for loading the board in milliseconds (default: 15000)
|
|
24
36
|
-h, --help display help for command
|
|
25
37
|
```
|
|
26
38
|
|
|
@@ -30,6 +42,9 @@ Options:
|
|
|
30
42
|
# export "Frame 2" to the file "My Frame 2.svg"
|
|
31
43
|
miro-export -t XYZ -b uMoVLkx8gIc= -f "Frame 2" -o "My Frame 2.svg"
|
|
32
44
|
|
|
45
|
+
# using npx
|
|
46
|
+
npx miro-export -t XYZ -b uMoVLkx8gIc= -f "Frame 2" -o "My Frame 2.svg"
|
|
47
|
+
|
|
33
48
|
# export entire board to stdout
|
|
34
49
|
miro-export -t XYZ -b uMoVLkx8gIc=
|
|
35
50
|
|
|
@@ -86,13 +101,13 @@ await using miroBoard = new MiroBoard({
|
|
|
86
101
|
});
|
|
87
102
|
|
|
88
103
|
// get all board objects of type frame and with title "Frame 1"
|
|
89
|
-
const framesWithTitleFrame1 = miroBoard.getBoardObjects(
|
|
104
|
+
const framesWithTitleFrame1 = await miroBoard.getBoardObjects(
|
|
90
105
|
{ type: "frame" }, // required (but empty object is OK too), limited field support
|
|
91
106
|
{ title: "Frame 1" } // optional additional filters
|
|
92
107
|
);
|
|
93
108
|
|
|
94
109
|
// get SVG of the first frame found above
|
|
95
|
-
const svgOfFrame1 = miroBoard.getSvg([framesWithTitleFrame1[0].id]);
|
|
110
|
+
const svgOfFrame1 = await miroBoard.getSvg([framesWithTitleFrame1[0].id]);
|
|
96
111
|
|
|
97
112
|
// if you can't use "await using" for disposal, you can also dispose manually:
|
|
98
113
|
// await miroBoard.dispose()
|
package/build/cli.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
1
2
|
var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
|
|
2
3
|
if (value !== null && value !== void 0) {
|
|
3
4
|
if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
|
|
@@ -53,69 +54,85 @@ var __disposeResources = (this && this.__disposeResources) || (function (Suppres
|
|
|
53
54
|
import { writeFile } from "fs/promises";
|
|
54
55
|
import { program } from "@commander-js/extra-typings";
|
|
55
56
|
import { MiroBoard } from "./index.js";
|
|
56
|
-
const { token, boardId, frameNames, outputFile, exportFormat } = program
|
|
57
|
+
const { token, boardId, frameNames, outputFile, exportFormat, loadTimeout } = program
|
|
57
58
|
.option("-t, --token <token>", "Miro token")
|
|
58
59
|
.requiredOption("-b, --board-id <boardId>", "The board ID")
|
|
59
60
|
.option("-f, --frame-names <frameNames...>", "The frame name(s), leave empty to export entire board")
|
|
60
|
-
.option("-o, --output-file <filename>", "A file to output the SVG to (stdout if not
|
|
61
|
-
.option("-e, --export-format <format>", "'svg' or 'json'
|
|
61
|
+
.option("-o, --output-file <filename>", "A file to output the SVG to (stdout if not specified)")
|
|
62
|
+
.option("-e, --export-format <format>", "'svg' or 'json'", "svg")
|
|
63
|
+
.option("-l, --load-timeout <milliseconds>", "Timeout for loading the board in milliseconds", (arg) => parseInt(arg, 10), 15000)
|
|
62
64
|
.parse()
|
|
63
65
|
.opts();
|
|
64
66
|
(async () => {
|
|
65
|
-
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
66
67
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const env_1 = { stack: [], error: void 0, hasError: false };
|
|
69
|
+
try {
|
|
70
|
+
const miroBoard = __addDisposableResource(env_1, new MiroBoard({
|
|
71
|
+
token,
|
|
72
|
+
boardId,
|
|
73
|
+
boardLoadTimeoutMs: loadTimeout
|
|
74
|
+
}), true);
|
|
75
|
+
async function getFrames(frameNames) {
|
|
76
|
+
const frames = await miroBoard.getBoardObjects({ type: "frame" }, { title: frameNames });
|
|
77
|
+
if (frames && frames.length !== frameNames.length) {
|
|
78
|
+
throw Error(`${frameNames.length - frames.length} frame(s) could not be found on the board.`);
|
|
79
|
+
}
|
|
80
|
+
return frames;
|
|
72
81
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
async function getSvg(frames) {
|
|
76
|
-
return await miroBoard.getSvg(frames?.map(({ id }) => id).filter((id) => !!id));
|
|
77
|
-
}
|
|
78
|
-
async function getJson(frames) {
|
|
79
|
-
if (frames) {
|
|
80
|
-
const frameChildren = await miroBoard.getBoardObjects({
|
|
81
|
-
id: frames.flatMap((frame) => frame.childrenIds)
|
|
82
|
-
});
|
|
83
|
-
const groupChildren = await miroBoard.getBoardObjects({
|
|
84
|
-
id: frameChildren
|
|
85
|
-
.filter((child) => child.type === "group")
|
|
86
|
-
.flatMap((child) => child.itemsIds)
|
|
87
|
-
});
|
|
88
|
-
return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
|
|
82
|
+
async function getSvg(frames) {
|
|
83
|
+
return await miroBoard.getSvg(frames?.map(({ id }) => id).filter((id) => !!id));
|
|
89
84
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
85
|
+
async function getJson(frames) {
|
|
86
|
+
if (frames) {
|
|
87
|
+
const frameChildren = await miroBoard.getBoardObjects({
|
|
88
|
+
id: frames.flatMap((frame) => frame.childrenIds)
|
|
89
|
+
});
|
|
90
|
+
const groupChildren = await miroBoard.getBoardObjects({
|
|
91
|
+
id: frameChildren
|
|
92
|
+
.filter((child) => child.type === "group")
|
|
93
|
+
.flatMap((child) => child.itemsIds)
|
|
94
|
+
});
|
|
95
|
+
return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
|
|
96
|
+
}
|
|
97
|
+
return JSON.stringify(await miroBoard.getBoardObjects({}));
|
|
100
98
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
99
|
+
const getFn = exportFormat === "json" ? getJson : getSvg;
|
|
100
|
+
if (outputFile?.includes("{frameName}")) {
|
|
101
|
+
if (!frameNames) {
|
|
102
|
+
throw Error("Expected frame names to be given when the output file name format expects a frame name.");
|
|
103
|
+
}
|
|
104
|
+
for (const frameName of frameNames) {
|
|
105
|
+
const output = await getFn(await getFrames([frameName]));
|
|
106
|
+
await writeFile(outputFile.replace("{frameName}", frameName), output);
|
|
107
|
+
}
|
|
106
108
|
}
|
|
107
109
|
else {
|
|
108
|
-
|
|
110
|
+
const svg = await getFn(frameNames && (await getFrames(frameNames)));
|
|
111
|
+
if (outputFile) {
|
|
112
|
+
await writeFile(outputFile, svg);
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
process.stdout.write(svg);
|
|
116
|
+
}
|
|
109
117
|
}
|
|
110
118
|
}
|
|
119
|
+
catch (e_1) {
|
|
120
|
+
env_1.error = e_1;
|
|
121
|
+
env_1.hasError = true;
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
const result_1 = __disposeResources(env_1);
|
|
125
|
+
if (result_1)
|
|
126
|
+
await result_1;
|
|
127
|
+
}
|
|
111
128
|
}
|
|
112
|
-
catch (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
129
|
+
catch (err) {
|
|
130
|
+
const RED = "\x1b[31m";
|
|
131
|
+
const RESET = "\x1b[0m";
|
|
132
|
+
const GRAY = "\x1b[38;5;248m";
|
|
133
|
+
console.error(`❌ ${RED}Error:${RESET} ${err instanceof Error ? err.message : err}`);
|
|
134
|
+
console.error(GRAY);
|
|
135
|
+
console.error(err);
|
|
136
|
+
console.error(RESET);
|
|
120
137
|
}
|
|
121
138
|
})();
|
package/build/index.d.ts
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
|
+
import { type LaunchOptions } from "puppeteer";
|
|
1
2
|
import type { BoardObject } from "./miro-types.ts";
|
|
2
3
|
import type { GetBoardsFilter } from "./miro-runtime.ts";
|
|
3
4
|
interface InitialMiroBoardOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The Miro authentication token with access to load the board.
|
|
7
|
+
* Optional if anonymous users may access the board without
|
|
8
|
+
* logging in.
|
|
9
|
+
*/
|
|
4
10
|
token?: string;
|
|
11
|
+
/**
|
|
12
|
+
* The Miro board ID.
|
|
13
|
+
*/
|
|
5
14
|
boardId: string;
|
|
15
|
+
/**
|
|
16
|
+
* Optional custom Puppeteer launch options.
|
|
17
|
+
*/
|
|
18
|
+
puppeteerOptions?: LaunchOptions;
|
|
19
|
+
/**
|
|
20
|
+
* Timeout until it is determined that the Miro board could
|
|
21
|
+
* not be loaded for some reason, in milliseconds. Default
|
|
22
|
+
* is 15 seconds (15,000 milliseconds).
|
|
23
|
+
*/
|
|
24
|
+
boardLoadTimeoutMs?: number;
|
|
6
25
|
}
|
|
7
26
|
type AdditionalFilter<T> = Partial<T> | Partial<{
|
|
8
27
|
[K in keyof T]: T[K][];
|
|
@@ -12,6 +31,7 @@ type FilteredResultsByType<F extends string | string[] | undefined, T> = F exten
|
|
|
12
31
|
}>[] : T[];
|
|
13
32
|
export declare class MiroBoard {
|
|
14
33
|
private context;
|
|
34
|
+
private isDisposed;
|
|
15
35
|
constructor(options: InitialMiroBoardOptions);
|
|
16
36
|
private initialize;
|
|
17
37
|
dispose(): Promise<void>;
|
package/build/index.js
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
1
|
import puppeteer, { Browser, Page } from "puppeteer";
|
|
2
|
+
const DEFAULT_BOARD_LOAD_TIMEOUT_MS = 15_000;
|
|
3
3
|
export class MiroBoard {
|
|
4
4
|
context = Promise.withResolvers();
|
|
5
|
+
isDisposed = false;
|
|
5
6
|
constructor(options) {
|
|
6
7
|
this.initialize(options);
|
|
7
8
|
}
|
|
8
9
|
async initialize(options) {
|
|
9
|
-
const browser = await puppeteer.launch({
|
|
10
|
+
const browser = await puppeteer.launch({
|
|
11
|
+
headless: true,
|
|
12
|
+
...(options.puppeteerOptions ?? {})
|
|
13
|
+
});
|
|
10
14
|
const page = await browser.newPage();
|
|
11
15
|
if (options.token) {
|
|
12
16
|
await browser.browserContexts()[0].setCookie({
|
|
@@ -24,24 +28,46 @@ export class MiroBoard {
|
|
|
24
28
|
await page.goto(`https://miro.com/app/board/${options.boardId}/`, {
|
|
25
29
|
waitUntil: "domcontentloaded"
|
|
26
30
|
});
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
let miroValue;
|
|
32
|
-
Object.defineProperty(window, "miro", {
|
|
33
|
-
get() {
|
|
34
|
-
return miroValue;
|
|
35
|
-
},
|
|
36
|
-
set(value) {
|
|
37
|
-
miroValue = value;
|
|
31
|
+
try {
|
|
32
|
+
await page.evaluate((timeoutDuration) => new Promise((resolve, reject) => {
|
|
33
|
+
if (window.miro) {
|
|
38
34
|
resolve();
|
|
39
35
|
}
|
|
40
|
-
|
|
41
|
-
|
|
36
|
+
const timeout = setTimeout(() => {
|
|
37
|
+
reject(new Error(`Miro board could not be loaded: application instance not available after ${timeoutDuration} ms. Check your network connection, access token and board access.`));
|
|
38
|
+
}, timeoutDuration);
|
|
39
|
+
const authModalChecker = setInterval(() => {
|
|
40
|
+
if (document.querySelector('[data-testid="signup-popup-container"]')) {
|
|
41
|
+
reject(new Error(`Miro board requires authentication. Check board access settings to allow anonymous access or supply a token.`));
|
|
42
|
+
}
|
|
43
|
+
}, 250);
|
|
44
|
+
let miroValue;
|
|
45
|
+
Object.defineProperty(window, "miro", {
|
|
46
|
+
get() {
|
|
47
|
+
return miroValue;
|
|
48
|
+
},
|
|
49
|
+
set(value) {
|
|
50
|
+
clearTimeout(timeout);
|
|
51
|
+
clearInterval(authModalChecker);
|
|
52
|
+
miroValue = value;
|
|
53
|
+
resolve();
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}), options.boardLoadTimeoutMs ?? DEFAULT_BOARD_LOAD_TIMEOUT_MS);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
await browser.close();
|
|
60
|
+
this.context.reject(err);
|
|
61
|
+
this.isDisposed = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
42
64
|
this.context.resolve({ browser, page });
|
|
43
65
|
}
|
|
44
66
|
async dispose() {
|
|
67
|
+
if (this.isDisposed) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
this.isDisposed = true;
|
|
45
71
|
await (await this.browser).close();
|
|
46
72
|
}
|
|
47
73
|
async [Symbol.asyncDispose]() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "miro-export",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"author": "jolle <npm-contact@jolle.io>",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,32 +23,32 @@
|
|
|
23
23
|
"miro-export": "./build/cli.js"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@eslint/eslintrc": "^3.
|
|
27
|
-
"@eslint/js": "^9.
|
|
28
|
-
"@stylistic/eslint-plugin-ts": "^
|
|
29
|
-
"@types/node": "^
|
|
26
|
+
"@eslint/eslintrc": "^3.3.3",
|
|
27
|
+
"@eslint/js": "^9.39.2",
|
|
28
|
+
"@stylistic/eslint-plugin-ts": "^4.4.1",
|
|
29
|
+
"@types/node": "^25.0.3",
|
|
30
30
|
"ajv": "^8.17.1",
|
|
31
|
-
"eslint": "^9.
|
|
32
|
-
"eslint-config-prettier": "^
|
|
33
|
-
"eslint-plugin-prettier": "^5.
|
|
31
|
+
"eslint": "^9.39.2",
|
|
32
|
+
"eslint-config-prettier": "^10.1.8",
|
|
33
|
+
"eslint-plugin-prettier": "^5.5.4",
|
|
34
34
|
"eslint-plugin-promise": "^7.2.1",
|
|
35
|
-
"prettier": "^3.4
|
|
36
|
-
"ts-json-schema-generator": "^2.
|
|
37
|
-
"tsx": "^4.
|
|
38
|
-
"typescript": "^5.
|
|
39
|
-
"typescript-eslint": "^8.
|
|
35
|
+
"prettier": "^3.7.4",
|
|
36
|
+
"ts-json-schema-generator": "^2.4.0",
|
|
37
|
+
"tsx": "^4.21.0",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"typescript-eslint": "^8.50.1"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@commander-js/extra-typings": "^
|
|
43
|
-
"commander": "^
|
|
44
|
-
"puppeteer": "^
|
|
42
|
+
"@commander-js/extra-typings": "^14.0.0",
|
|
43
|
+
"commander": "^14.0.2",
|
|
44
|
+
"puppeteer": "^24.34.0"
|
|
45
45
|
},
|
|
46
46
|
"engines": {
|
|
47
47
|
"node": ">=22.0.0"
|
|
48
48
|
},
|
|
49
49
|
"scripts": {
|
|
50
50
|
"lint": "eslint src/*.ts && prettier -c src/*.ts",
|
|
51
|
-
"build": "tsc && echo \"#!/usr/bin/env node\n$(cat ./build/
|
|
51
|
+
"build": "tsc && echo \"#!/usr/bin/env node\n$(cat ./build/cli.js)\" > ./build/cli.js",
|
|
52
52
|
"test": "npm run test:board-object-types && npm run test:api",
|
|
53
53
|
"test:board-object-types": "tsx tests/board-object-types.ts",
|
|
54
54
|
"test:api": "tsx tests/api.test.ts"
|
package/src/cli.ts
CHANGED
|
@@ -3,84 +3,107 @@ import { program } from "@commander-js/extra-typings";
|
|
|
3
3
|
import { MiroBoard } from "./index.js";
|
|
4
4
|
import type { FrameBoardObject } from "./miro-types.ts";
|
|
5
5
|
|
|
6
|
-
const { token, boardId, frameNames, outputFile, exportFormat } =
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
6
|
+
const { token, boardId, frameNames, outputFile, exportFormat, loadTimeout } =
|
|
7
|
+
program
|
|
8
|
+
.option("-t, --token <token>", "Miro token")
|
|
9
|
+
.requiredOption("-b, --board-id <boardId>", "The board ID")
|
|
10
|
+
.option(
|
|
11
|
+
"-f, --frame-names <frameNames...>",
|
|
12
|
+
"The frame name(s), leave empty to export entire board"
|
|
13
|
+
)
|
|
14
|
+
.option(
|
|
15
|
+
"-o, --output-file <filename>",
|
|
16
|
+
"A file to output the SVG to (stdout if not specified)"
|
|
17
|
+
)
|
|
18
|
+
.option("-e, --export-format <format>", "'svg' or 'json'", "svg")
|
|
19
|
+
.option(
|
|
20
|
+
"-l, --load-timeout <milliseconds>",
|
|
21
|
+
"Timeout for loading the board in milliseconds",
|
|
22
|
+
(arg) => parseInt(arg, 10),
|
|
23
|
+
15000
|
|
24
|
+
)
|
|
25
|
+
.parse()
|
|
26
|
+
.opts();
|
|
20
27
|
|
|
21
28
|
(async () => {
|
|
22
|
-
|
|
29
|
+
try {
|
|
30
|
+
await using miroBoard = new MiroBoard({
|
|
31
|
+
token,
|
|
32
|
+
boardId,
|
|
33
|
+
boardLoadTimeoutMs: loadTimeout
|
|
34
|
+
});
|
|
23
35
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
36
|
+
async function getFrames(frameNames: string[]) {
|
|
37
|
+
const frames = await miroBoard.getBoardObjects(
|
|
38
|
+
{ type: "frame" as const },
|
|
39
|
+
{ title: frameNames }
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
if (frames && frames.length !== frameNames.length) {
|
|
43
|
+
throw Error(
|
|
44
|
+
`${
|
|
45
|
+
frameNames.length - frames.length
|
|
46
|
+
} frame(s) could not be found on the board.`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return frames;
|
|
51
|
+
}
|
|
29
52
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
frameNames.length - frames.length
|
|
34
|
-
} frame(s) could not be found on the board.`
|
|
53
|
+
async function getSvg(frames?: FrameBoardObject[]) {
|
|
54
|
+
return await miroBoard.getSvg(
|
|
55
|
+
frames?.map(({ id }) => id).filter((id): id is string => !!id)
|
|
35
56
|
);
|
|
36
57
|
}
|
|
37
58
|
|
|
38
|
-
|
|
39
|
-
|
|
59
|
+
async function getJson(frames?: FrameBoardObject[]) {
|
|
60
|
+
if (frames) {
|
|
61
|
+
const frameChildren = await miroBoard.getBoardObjects({
|
|
62
|
+
id: frames.flatMap((frame) => frame.childrenIds)
|
|
63
|
+
});
|
|
40
64
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
65
|
+
const groupChildren = await miroBoard.getBoardObjects({
|
|
66
|
+
id: frameChildren
|
|
67
|
+
.filter((child) => child.type === "group")
|
|
68
|
+
.flatMap((child) => child.itemsIds)
|
|
69
|
+
});
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const frameChildren = await miroBoard.getBoardObjects({
|
|
50
|
-
id: frames.flatMap((frame) => frame.childrenIds)
|
|
51
|
-
});
|
|
71
|
+
return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
|
|
72
|
+
}
|
|
52
73
|
|
|
53
|
-
|
|
54
|
-
id: frameChildren
|
|
55
|
-
.filter((child) => child.type === "group")
|
|
56
|
-
.flatMap((child) => child.itemsIds)
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
return JSON.stringify([...frames, ...frameChildren, ...groupChildren]);
|
|
74
|
+
return JSON.stringify(await miroBoard.getBoardObjects({}));
|
|
60
75
|
}
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
}
|
|
77
|
+
const getFn = exportFormat === "json" ? getJson : getSvg;
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
if (outputFile?.includes("{frameName}")) {
|
|
80
|
+
if (!frameNames) {
|
|
81
|
+
throw Error(
|
|
82
|
+
"Expected frame names to be given when the output file name format expects a frame name."
|
|
83
|
+
);
|
|
84
|
+
}
|
|
66
85
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
for (const frameName of frameNames) {
|
|
75
|
-
const output = await getFn(await getFrames([frameName]));
|
|
76
|
-
await writeFile(outputFile.replace("{frameName}", frameName), output);
|
|
77
|
-
}
|
|
78
|
-
} else {
|
|
79
|
-
const svg = await getFn(frameNames && (await getFrames(frameNames)));
|
|
80
|
-
if (outputFile) {
|
|
81
|
-
await writeFile(outputFile, svg);
|
|
86
|
+
for (const frameName of frameNames) {
|
|
87
|
+
const output = await getFn(await getFrames([frameName]));
|
|
88
|
+
await writeFile(outputFile.replace("{frameName}", frameName), output);
|
|
89
|
+
}
|
|
82
90
|
} else {
|
|
83
|
-
|
|
91
|
+
const svg = await getFn(frameNames && (await getFrames(frameNames)));
|
|
92
|
+
if (outputFile) {
|
|
93
|
+
await writeFile(outputFile, svg);
|
|
94
|
+
} else {
|
|
95
|
+
process.stdout.write(svg);
|
|
96
|
+
}
|
|
84
97
|
}
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const RED = "\x1b[31m";
|
|
100
|
+
const RESET = "\x1b[0m";
|
|
101
|
+
const GRAY = "\x1b[38;5;248m";
|
|
102
|
+
console.error(
|
|
103
|
+
`❌ ${RED}Error:${RESET} ${err instanceof Error ? err.message : err}`
|
|
104
|
+
);
|
|
105
|
+
console.error(GRAY);
|
|
106
|
+
console.error(err);
|
|
107
|
+
console.error(RESET);
|
|
85
108
|
}
|
|
86
109
|
})();
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
|
-
import puppeteer, { Browser, Page } from "puppeteer";
|
|
1
|
+
import puppeteer, { Browser, Page, type LaunchOptions } from "puppeteer";
|
|
2
2
|
import type { BoardObject } from "./miro-types.ts";
|
|
3
3
|
import type { GetBoardsFilter } from "./miro-runtime.ts";
|
|
4
4
|
|
|
5
5
|
interface InitialMiroBoardOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The Miro authentication token with access to load the board.
|
|
8
|
+
* Optional if anonymous users may access the board without
|
|
9
|
+
* logging in.
|
|
10
|
+
*/
|
|
6
11
|
token?: string;
|
|
12
|
+
/**
|
|
13
|
+
* The Miro board ID.
|
|
14
|
+
*/
|
|
7
15
|
boardId: string;
|
|
16
|
+
/**
|
|
17
|
+
* Optional custom Puppeteer launch options.
|
|
18
|
+
*/
|
|
19
|
+
puppeteerOptions?: LaunchOptions;
|
|
20
|
+
/**
|
|
21
|
+
* Timeout until it is determined that the Miro board could
|
|
22
|
+
* not be loaded for some reason, in milliseconds. Default
|
|
23
|
+
* is 15 seconds (15,000 milliseconds).
|
|
24
|
+
*/
|
|
25
|
+
boardLoadTimeoutMs?: number;
|
|
8
26
|
}
|
|
9
27
|
|
|
28
|
+
const DEFAULT_BOARD_LOAD_TIMEOUT_MS = 15_000;
|
|
29
|
+
|
|
10
30
|
type AdditionalFilter<T> = Partial<T> | Partial<{ [K in keyof T]: T[K][] }>;
|
|
11
31
|
|
|
12
32
|
type FilteredResultsByType<
|
|
@@ -16,13 +36,17 @@ type FilteredResultsByType<
|
|
|
16
36
|
|
|
17
37
|
export class MiroBoard {
|
|
18
38
|
private context = Promise.withResolvers<{ browser: Browser; page: Page }>();
|
|
39
|
+
private isDisposed = false;
|
|
19
40
|
|
|
20
41
|
constructor(options: InitialMiroBoardOptions) {
|
|
21
42
|
this.initialize(options);
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
private async initialize(options: InitialMiroBoardOptions) {
|
|
25
|
-
const browser = await puppeteer.launch({
|
|
46
|
+
const browser = await puppeteer.launch({
|
|
47
|
+
headless: true,
|
|
48
|
+
...(options.puppeteerOptions ?? {})
|
|
49
|
+
});
|
|
26
50
|
const page = await browser.newPage();
|
|
27
51
|
|
|
28
52
|
if (options.token) {
|
|
@@ -44,30 +68,64 @@ export class MiroBoard {
|
|
|
44
68
|
waitUntil: "domcontentloaded"
|
|
45
69
|
});
|
|
46
70
|
|
|
47
|
-
|
|
48
|
-
(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
let miroValue: (typeof window)["miro"];
|
|
55
|
-
Object.defineProperty(window, "miro", {
|
|
56
|
-
get() {
|
|
57
|
-
return miroValue;
|
|
58
|
-
},
|
|
59
|
-
set(value) {
|
|
60
|
-
miroValue = value;
|
|
71
|
+
try {
|
|
72
|
+
await page.evaluate(
|
|
73
|
+
(timeoutDuration) =>
|
|
74
|
+
new Promise<void>((resolve, reject) => {
|
|
75
|
+
if (window.miro) {
|
|
61
76
|
resolve();
|
|
62
77
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
78
|
+
|
|
79
|
+
const timeout = setTimeout(() => {
|
|
80
|
+
reject(
|
|
81
|
+
new Error(
|
|
82
|
+
`Miro board could not be loaded: application instance not available after ${timeoutDuration} ms. Check your network connection, access token and board access.`
|
|
83
|
+
)
|
|
84
|
+
);
|
|
85
|
+
}, timeoutDuration);
|
|
86
|
+
|
|
87
|
+
const authModalChecker = setInterval(() => {
|
|
88
|
+
if (
|
|
89
|
+
document.querySelector('[data-testid="signup-popup-container"]')
|
|
90
|
+
) {
|
|
91
|
+
reject(
|
|
92
|
+
new Error(
|
|
93
|
+
`Miro board requires authentication. Check board access settings to allow anonymous access or supply a token.`
|
|
94
|
+
)
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}, 250);
|
|
98
|
+
|
|
99
|
+
let miroValue: (typeof window)["miro"];
|
|
100
|
+
Object.defineProperty(window, "miro", {
|
|
101
|
+
get() {
|
|
102
|
+
return miroValue;
|
|
103
|
+
},
|
|
104
|
+
set(value) {
|
|
105
|
+
clearTimeout(timeout);
|
|
106
|
+
clearInterval(authModalChecker);
|
|
107
|
+
miroValue = value;
|
|
108
|
+
resolve();
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}),
|
|
112
|
+
options.boardLoadTimeoutMs ?? DEFAULT_BOARD_LOAD_TIMEOUT_MS
|
|
113
|
+
);
|
|
114
|
+
} catch (err) {
|
|
115
|
+
await browser.close();
|
|
116
|
+
this.context.reject(err);
|
|
117
|
+
this.isDisposed = true;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
66
120
|
|
|
67
121
|
this.context.resolve({ browser, page });
|
|
68
122
|
}
|
|
69
123
|
|
|
70
124
|
async dispose() {
|
|
125
|
+
if (this.isDisposed) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
this.isDisposed = true;
|
|
71
129
|
await (await this.browser).close();
|
|
72
130
|
}
|
|
73
131
|
|
package/src/miro-types.ts
CHANGED
|
@@ -69,15 +69,14 @@ interface BoardObjectWithDimensions {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
export interface PreviewBoardObject
|
|
72
|
-
extends BoardItemBase,
|
|
73
|
-
BoardObjectBase,
|
|
74
|
-
BoardObjectWithDimensions {
|
|
72
|
+
extends BoardItemBase, BoardObjectBase, BoardObjectWithDimensions {
|
|
75
73
|
type: "preview";
|
|
76
74
|
url: string;
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
export interface FrameBoardObject
|
|
80
|
-
extends
|
|
78
|
+
extends
|
|
79
|
+
Omit<BoardItemBase, "connectorIds">,
|
|
81
80
|
BoardObjectBase,
|
|
82
81
|
BoardObjectWithDimensions,
|
|
83
82
|
BoardObjectWithCoordinates {
|
|
@@ -96,7 +95,8 @@ export interface GroupBoardItem extends BoardItemBase {
|
|
|
96
95
|
}
|
|
97
96
|
|
|
98
97
|
export interface StickyNoteBoardObject
|
|
99
|
-
extends
|
|
98
|
+
extends
|
|
99
|
+
BoardItemBase,
|
|
100
100
|
BoardObjectBase,
|
|
101
101
|
BoardObjectWithCoordinates,
|
|
102
102
|
BoardObjectWithDimensions {
|
|
@@ -112,7 +112,8 @@ export interface StickyNoteBoardObject
|
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
export interface TextBoardObject
|
|
115
|
-
extends
|
|
115
|
+
extends
|
|
116
|
+
BoardItemBase,
|
|
116
117
|
BoardObjectBase,
|
|
117
118
|
BoardObjectWithCoordinates,
|
|
118
119
|
BoardObjectWithDimensions {
|
|
@@ -130,7 +131,8 @@ export interface TextBoardObject
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
export interface ImageBoardObject
|
|
133
|
-
extends
|
|
134
|
+
extends
|
|
135
|
+
BoardItemBase,
|
|
134
136
|
BoardObjectBase,
|
|
135
137
|
BoardObjectWithDimensions,
|
|
136
138
|
BoardObjectWithCoordinates {
|
|
@@ -159,9 +161,7 @@ interface TableStyle {
|
|
|
159
161
|
}
|
|
160
162
|
|
|
161
163
|
export interface TableBoardObject
|
|
162
|
-
extends BoardItemBase,
|
|
163
|
-
BoardObjectWithDimensions,
|
|
164
|
-
BoardObjectWithCoordinates {
|
|
164
|
+
extends BoardItemBase, BoardObjectWithDimensions, BoardObjectWithCoordinates {
|
|
165
165
|
type: "image";
|
|
166
166
|
rotation: number;
|
|
167
167
|
cols?: number | { width: number }[];
|
|
@@ -176,7 +176,8 @@ export interface TableBoardObject
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
export interface StructDocBoardObject
|
|
179
|
-
extends
|
|
179
|
+
extends
|
|
180
|
+
BoardItemBase,
|
|
180
181
|
BoardObjectBase,
|
|
181
182
|
BoardObjectWithDimensions,
|
|
182
183
|
BoardObjectWithCoordinates {
|
|
@@ -186,9 +187,7 @@ export interface StructDocBoardObject
|
|
|
186
187
|
}
|
|
187
188
|
|
|
188
189
|
export interface EmbedBoardObject
|
|
189
|
-
extends BoardItemBase,
|
|
190
|
-
BoardObjectBase,
|
|
191
|
-
BoardObjectWithCoordinates {
|
|
190
|
+
extends BoardItemBase, BoardObjectBase, BoardObjectWithCoordinates {
|
|
192
191
|
type: "struct_doc";
|
|
193
192
|
url: string;
|
|
194
193
|
previewUrl: string;
|
|
@@ -198,7 +197,8 @@ export interface EmbedBoardObject
|
|
|
198
197
|
}
|
|
199
198
|
|
|
200
199
|
export interface ShapeBoardObject
|
|
201
|
-
extends
|
|
200
|
+
extends
|
|
201
|
+
BoardItemBase,
|
|
202
202
|
BoardObjectBase,
|
|
203
203
|
BoardObjectWithCoordinates,
|
|
204
204
|
BoardObjectWithDimensions {
|
|
@@ -265,7 +265,8 @@ export interface TagBoardItem extends BoardItemBase {
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
export interface KanbanBoardObject
|
|
268
|
-
extends
|
|
268
|
+
extends
|
|
269
|
+
BoardItemBase,
|
|
269
270
|
BoardObjectBase,
|
|
270
271
|
BoardObjectWithCoordinates,
|
|
271
272
|
BoardObjectWithDimensions {
|
|
@@ -273,7 +274,8 @@ export interface KanbanBoardObject
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
export interface UnsupportedBoardObject
|
|
276
|
-
extends
|
|
277
|
+
extends
|
|
278
|
+
BoardItemBase,
|
|
277
279
|
BoardObjectBase,
|
|
278
280
|
BoardObjectWithCoordinates,
|
|
279
281
|
BoardObjectWithDimensions {
|
package/tests/api.test.ts
CHANGED
|
@@ -3,12 +3,18 @@ import assert from "node:assert";
|
|
|
3
3
|
import { MiroBoard } from "../src";
|
|
4
4
|
|
|
5
5
|
const boardId = process.env.TEST_BOARD_ID;
|
|
6
|
+
const inaccessibleBoardId = process.env.PRIVATE_TEST_BOARD_ID;
|
|
6
7
|
|
|
7
8
|
if (!boardId) {
|
|
8
9
|
console.error("TEST_BOARD_ID environment variable is required.");
|
|
9
10
|
process.exit(1);
|
|
10
11
|
}
|
|
11
12
|
|
|
13
|
+
if (!inaccessibleBoardId) {
|
|
14
|
+
console.error("PRIVATE_TEST_BOARD_ID environment variable is required.");
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
12
18
|
describe("Miro integration", async () => {
|
|
13
19
|
const miroBoard = new MiroBoard({ boardId });
|
|
14
20
|
|
|
@@ -66,3 +72,17 @@ describe("Miro integration", async () => {
|
|
|
66
72
|
assert.ok(svg.length > 10_000);
|
|
67
73
|
});
|
|
68
74
|
});
|
|
75
|
+
|
|
76
|
+
await it("should throw error for a non-public board", async () => {
|
|
77
|
+
try {
|
|
78
|
+
const miroBoard = new MiroBoard({ boardId: inaccessibleBoardId });
|
|
79
|
+
await miroBoard.getBoardObjects({});
|
|
80
|
+
assert.fail();
|
|
81
|
+
} catch (err) {
|
|
82
|
+
assert.ok(err instanceof Error);
|
|
83
|
+
assert.equal(
|
|
84
|
+
err.message,
|
|
85
|
+
"Miro board requires authentication. Check board access settings to allow anonymous access or supply a token."
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
});
|