├── .gitignore
├── .github
└── FUNDING.yml
├── tests
├── test_deps.ts
├── mocks
│ ├── export_plaintext_partial.txt
│ ├── export_string_partial.txt
│ ├── export_opml_partial.xml
│ ├── export_json_partial.json
│ ├── get_initialization_data.json
│ ├── get_tree_data_shared_third.json
│ ├── get_tree_data_shared_main.json
│ ├── get_initialization_data_shared.json
│ ├── get_tree_data_shared_first.json
│ ├── get_tree_data_shared_second.json
│ ├── export_plaintext_all.txt
│ ├── export_string_all.txt
│ ├── export_opml_all.xml
│ ├── export_json_all.json
│ ├── get_tree_data.json
│ └── get_tree_data_extended.json
├── export_test.ts
├── shared_test.ts
└── document_test.ts
├── deps.ts
├── deno.json
├── .vscode
└── settings.json
├── mod.ts
├── src
├── share.ts
├── workflowy.ts
├── export.ts
├── schema.ts
├── client.ts
└── document.ts
├── LICENSE
├── _npm.ts
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | npm
2 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: karelklima
2 |
--------------------------------------------------------------------------------
/tests/test_deps.ts:
--------------------------------------------------------------------------------
1 | export { assertEquals, assertObjectMatch } from "jsr:@std/assert@^1.0.13";
2 |
--------------------------------------------------------------------------------
/deps.ts:
--------------------------------------------------------------------------------
1 | export { getSetCookies, setCookie } from "jsr:@std/http@^1.0.20/cookie";
2 |
3 | export { z } from "npm:zod@^4";
4 |
--------------------------------------------------------------------------------
/deno.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@workflowy/workflowy",
3 | "version": "2.8.2",
4 | "exports": "./mod.ts",
5 | "lock": false
6 | }
7 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "deno.enable": true,
3 | "deno.unstable": true,
4 | "editor.defaultFormatter": "denoland.vscode-deno",
5 | "editor.formatOnSave": true
6 | }
--------------------------------------------------------------------------------
/tests/mocks/export_plaintext_partial.txt:
--------------------------------------------------------------------------------
1 | List with sublists
2 |
3 | "Nested in four levels"
4 |
5 | - One
6 | - One
7 | - Two
8 | - Three
9 | - Four
10 | - Two
11 |
--------------------------------------------------------------------------------
/tests/mocks/export_string_partial.txt:
--------------------------------------------------------------------------------
1 | - List with sublists
2 | Nested in four levels
3 | - One
4 | - One
5 | - Two
6 | - Three
7 | - Four
8 | - Two
9 |
--------------------------------------------------------------------------------
/mod.ts:
--------------------------------------------------------------------------------
1 | export { WorkFlowy, WorkFlowy as default } from "./src/workflowy.ts";
2 | export type * from "./src/client.ts";
3 | export type * from "./src/document.ts";
4 | export type * from "./src/schema.ts";
5 | export { PermissionLevel } from "./src/share.ts";
6 |
--------------------------------------------------------------------------------
/tests/mocks/export_opml_partial.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/tests/mocks/export_json_partial.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "List with sublists",
3 | "items": [
4 | { "name": "One" },
5 | {
6 | "name": "One",
7 | "items": [
8 | {
9 | "name": "Two",
10 | "items": [
11 | {
12 | "name": "Three",
13 | "items": [
14 | { "name": "Four" }
15 | ]
16 | }
17 | ]
18 | },
19 | { "name": "Two" }
20 | ]
21 | }
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/tests/mocks/get_initialization_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "projectTreeData": {
3 | "mainProjectTreeInfo": {
4 | "rootProject": null,
5 | "rootProjectChildren": [],
6 | "sharedData": {},
7 | "initialMostRecentOperationTransactionId": "1160259809",
8 | "initialPollingIntervalInMs": 10000,
9 | "serverExpandedProjectsList": [],
10 | "isReadOnly": null,
11 | "ownerId": 0,
12 | "ownerName": "owner",
13 | "dateJoinedTimestampInSeconds": 1682623040,
14 | "itemsCreatedInCurrentMonth": 5,
15 | "monthlyItemQuota": 10000000
16 | },
17 | "auxiliaryProjectTreeInfos": []
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data_shared_third.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "daba6df4-cde1-3322-96cd-957fc76123e8",
5 | "nm": "List shared via URL without access token",
6 | "ct": 45772460,
7 | "pr": 538,
8 | "prnt": null,
9 | "metadata": {},
10 | "lm": 45772507
11 | }
12 | ],
13 | "shared_projects": {
14 | "daba6df4-cde1-3322-96cd-957fc76123e8": {
15 | "share_id": "NnjJ.lybhWrZBRA",
16 | "url_shared_info": {
17 | "write_permission": false,
18 | "permission_level": 1
19 | }
20 | }
21 | },
22 | "most_recent_operation_transaction_id": "1160300075",
23 | "server_expanded_projects_list": []
24 | }
25 |
--------------------------------------------------------------------------------
/src/share.ts:
--------------------------------------------------------------------------------
1 | export enum PermissionLevel {
2 | None = 0,
3 | View = 1,
4 | EditAndComment = 2,
5 | FullAccess = 3,
6 | }
7 |
8 | export function fromNativePermissionLevel(level: number): PermissionLevel {
9 | return level as PermissionLevel;
10 | }
11 |
12 | export function toNativePermissionLevel(level: PermissionLevel): number {
13 | return level;
14 | }
15 |
16 | export function createAccessToken(): string {
17 | const length = 16;
18 | const chars =
19 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
20 | let token = "";
21 | for (let l = 0; l < length; l++) {
22 | token += chars.charAt(Math.floor(Math.random() * chars.length));
23 | }
24 | return token;
25 | }
26 |
27 | export function createSharedUrl(accessToken: string): string {
28 | return `https://workflowy.com/s/${accessToken}`;
29 | }
30 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data_shared_main.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "de843e0e-2d25-a812-1415-6ed31ae618f4",
5 | "nm": "AAA",
6 | "ct": 146,
7 | "pr": 100,
8 | "prnt": null,
9 | "metadata": {},
10 | "lm": 223
11 | },
12 | {
13 | "id": "12f62eec-754c-b677-b683-7dd448dc49ac",
14 | "nm": "ZZZ",
15 | "ct": 148,
16 | "pr": 200,
17 | "prnt": null,
18 | "metadata": {},
19 | "no": "Two Description",
20 | "lm": 155459
21 | },
22 | {
23 | "id": "3e6b63c5-7e40-1e1e-9987-cd7af6e64893",
24 | "nm": "",
25 | "ct": 150,
26 | "pr": 150,
27 | "prnt": null,
28 | "metadata": {},
29 | "lm": 157519,
30 | "as": "NnjJ.tUQQvYjgkX"
31 | }
32 | ],
33 | "shared_projects": {},
34 | "most_recent_operation_transaction_id": "1160300075",
35 | "server_expanded_projects_list": []
36 | }
37 |
--------------------------------------------------------------------------------
/tests/mocks/get_initialization_data_shared.json:
--------------------------------------------------------------------------------
1 | {
2 | "projectTreeData": {
3 | "mainProjectTreeInfo": {
4 | "rootProject": null,
5 | "rootProjectChildren": [],
6 | "sharedData": {},
7 | "initialMostRecentOperationTransactionId": "1160259809",
8 | "initialPollingIntervalInMs": 10000,
9 | "serverExpandedProjectsList": [],
10 | "isReadOnly": null,
11 | "ownerId": 0,
12 | "ownerName": "owner",
13 | "dateJoinedTimestampInSeconds": 1682623040,
14 | "itemsCreatedInCurrentMonth": 5,
15 | "monthlyItemQuota": 10000000
16 | },
17 | "auxiliaryProjectTreeInfos": [{
18 | "dateJoinedTimestampInSeconds": 1682623040,
19 | "ownerId": 0,
20 | "initialMostRecentOperationTransactionId": "1160259809",
21 | "shareId": "NnjJ.tUQQvYjgkX"
22 | }, {
23 | "dateJoinedTimestampInSeconds": 1682623040,
24 | "ownerId": 0,
25 | "initialMostRecentOperationTransactionId": "1160259809",
26 | "shareId": "NnjJ.lybhWrZBRX"
27 | }]
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Karel Klíma
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data_shared_first.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "aaba6df4-cde1-3322-96cd-957fc76123e8",
5 | "nm": "List shared via URL",
6 | "ct": 45772460,
7 | "pr": 538,
8 | "prnt": null,
9 | "metadata": {},
10 | "lm": 45772507
11 | },
12 | {
13 | "id": "12f62eec-754c-b677-b683-7dd448dc49ac",
14 | "nm": "Normal list",
15 | "ct": 148,
16 | "pr": 200,
17 | "prnt": "aaba6df4-cde1-3322-96cd-957fc76123e8",
18 | "metadata": {},
19 | "lm": 155459
20 | },
21 | {
22 | "id": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
23 | "nm": "",
24 | "ct": 150,
25 | "pr": 300,
26 | "prnt": "aaba6df4-cde1-3322-96cd-957fc76123e8",
27 | "metadata": {},
28 | "lm": 157519,
29 | "as": "NnjJ.lybhWrZBRX"
30 | }
31 | ],
32 | "shared_projects": {
33 | "aaba6df4-cde1-3322-96cd-957fc76123e8": {
34 | "share_id": "NnjJ.tUQQvYjgkX",
35 | "url_shared_info": {
36 | "write_permission": false,
37 | "permission_level": 1,
38 | "access_token": "plUzlWcMHcwbR3wZ"
39 | }
40 | }
41 | },
42 | "most_recent_operation_transaction_id": "1160300075",
43 | "server_expanded_projects_list": []
44 | }
45 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data_shared_second.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "8960ce1b-e5b5-4aff-3303-50577f20e76b",
5 | "nm": "List shared via email",
6 | "ct": 45707709,
7 | "pr": 525,
8 | "prnt": null,
9 | "metadata": {},
10 | "lm": 45708060
11 | },
12 | {
13 | "id": "68e27b97-4444-77c6-f6e8-22c1e94ab431",
14 | "nm": "Normal second list",
15 | "ct": 158394,
16 | "pr": 400,
17 | "prnt": "8960ce1b-e5b5-4aff-3303-50577f20e76b",
18 | "metadata": {},
19 | "lm": 158394
20 | },
21 | {
22 | "id": "faba6df4-cde1-3322-96cd-957fc76123e8",
23 | "nm": "",
24 | "ct": 150,
25 | "pr": 1000,
26 | "prnt": "8960ce1b-e5b5-4aff-3303-50577f20e76b",
27 | "metadata": {},
28 | "lm": 157519,
29 | "as": "NnjJ.lybhWrZBRA"
30 | }
31 | ],
32 | "shared_projects": {
33 | "8960ce1b-e5b5-4aff-3303-50577f20e76b": {
34 | "share_id": "NnjJ.lybhWrZBRX",
35 | "email_shared_info": {
36 | "emails": [
37 | {
38 | "email": "example@example.com",
39 | "access_token": "L2RdOGpOND",
40 | "write_permission": true,
41 | "permission_level": 3
42 | }
43 | ]
44 | }
45 | }
46 | },
47 | "most_recent_operation_transaction_id": "1160300075",
48 | "server_expanded_projects_list": []
49 | }
50 |
--------------------------------------------------------------------------------
/tests/mocks/export_plaintext_all.txt:
--------------------------------------------------------------------------------
1 | - List with sublists
2 | "Nested in four levels"
3 | - One
4 | - One
5 | - Two
6 | - Three
7 | - Four
8 | - Two
9 | - List with description
10 | "Two Description"
11 | - [COMPLETE] List completed
12 | - List mirrored
13 | - Sublist in mirror
14 | - List mirrored
15 | - Sublist in mirror
16 | - List with bold, italics, underline and link
17 | - List with date Mon, Oct 7, 2024 and tag #test
18 | - List with HTML special chars < > & " '
19 | - List with long text: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.
20 | "Note: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis."
21 |
--------------------------------------------------------------------------------
/tests/mocks/export_string_all.txt:
--------------------------------------------------------------------------------
1 | - List with sublists
2 | Nested in four levels
3 | - One
4 | - One
5 | - Two
6 | - Three
7 | - Four
8 | - Two
9 | - List with description
10 | Two Description
11 | - List completed
12 | - List mirrored
13 | - Sublist in mirror
14 | - List mirrored
15 | - Sublist in mirror
16 | - List with bold, italics, underline and link
17 | - List with date and tag #test
18 | - List with HTML special chars < > & " '
19 | - List with long text: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.
20 | Note: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.
21 |
--------------------------------------------------------------------------------
/_npm.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This script builds the NPM package from Deno source
3 | */
4 | import { build, emptyDir } from "jsr:@deno/dnt@^0.42.3";
5 |
6 | await emptyDir("./npm");
7 |
8 | await build({
9 | entryPoints: ["./mod.ts"],
10 | outDir: "./npm",
11 | shims: {
12 | deno: "dev",
13 | //undici: true,
14 | crypto: true,
15 | },
16 | compilerOptions: {
17 | lib: ["ESNext"],
18 | target: "ES2022",
19 | },
20 | package: {
21 | // package.json properties
22 | name: "workflowy",
23 | version: Deno.args[0],
24 | description: "WorkFlowy client for reading and updating of lists",
25 | author: "Karel Klima (https://karelklima.com)",
26 | license: "MIT",
27 | repository: {
28 | type: "git",
29 | url: "git+https://github.com/karelklima/workflowy.git",
30 | },
31 | bugs: {
32 | url: "https://github.com/karelklima/workflowy/issues",
33 | },
34 | },
35 | postBuild() {
36 | // steps to run after building and before running the tests
37 | Deno.copyFileSync("LICENSE", "npm/LICENSE");
38 | Deno.copyFileSync("README.md", "npm/README.md");
39 |
40 | const testMocks = [
41 | "export_string_all.txt",
42 | "export_string_partial.txt",
43 | "export_plaintext_all.txt",
44 | "export_plaintext_partial.txt",
45 | "export_json_all.json",
46 | "export_json_partial.json",
47 | "export_opml_all.xml",
48 | "export_opml_partial.xml",
49 | ];
50 |
51 | for (const mock of testMocks) {
52 | Deno.copyFileSync(
53 | `tests/mocks/${mock}`,
54 | `npm/script/tests/mocks/${mock}`,
55 | );
56 | Deno.copyFileSync(
57 | `tests/mocks/${mock}`,
58 | `npm/esm/tests/mocks/${mock}`,
59 | );
60 | }
61 | },
62 | });
63 |
--------------------------------------------------------------------------------
/src/workflowy.ts:
--------------------------------------------------------------------------------
1 | import { Client } from "./client.ts";
2 | import { Document } from "./document.ts";
3 | import type { InitializationData, TreeData } from "./schema.ts";
4 |
5 | /**
6 | * The entry point of the library
7 | *
8 | * ### Basic example
9 | *
10 | * ```ts
11 | * import { WorkFlowy } from "workflowy"
12 | *
13 | * const workflowy = new WorkFlowy("username", "password");
14 | * const document = await workflowy.getDocument();
15 | *
16 | * console.log(String(document.root))
17 | * ```
18 | */
19 | export class WorkFlowy {
20 | #client: Client;
21 |
22 | constructor(username: string, password: string) {
23 | this.#client = new Client(username, password);
24 | }
25 |
26 | /**
27 | * Returns the WorkFlowy client instance that is used to fetch and update
28 | * data in WorkFlowy.
29 | *
30 | * @returns {Client}
31 | */
32 | public getClient(): Client {
33 | return this.#client;
34 | }
35 |
36 | /**
37 | * Loads data from WorkFlowy and creates an interactive document out of it
38 | *
39 | * @param includeSharedLists whether to download dependent shared lists
40 | * @returns {Promise} WorkFlowy outline
41 | */
42 | public async getDocument(includeSharedLists = true): Promise {
43 | const initializationData = await this.#client.getInitializationData();
44 | const treeData = await this.#client.getTreeData();
45 | const sharedTrees: Record = includeSharedLists
46 | ? await this.#getSharedTrees(initializationData)
47 | : {};
48 |
49 | return new Document(
50 | this.#client,
51 | treeData,
52 | initializationData,
53 | sharedTrees,
54 | );
55 | }
56 |
57 | async #getSharedTrees(
58 | initializationData: InitializationData,
59 | ): Promise> {
60 | const sharedTrees: Record = {};
61 |
62 | for (const sharedTree of initializationData.auxiliaryProjectTreeInfos) {
63 | const treeData = await this.#client.getSharedTreeData(sharedTree.shareId);
64 | sharedTrees[sharedTree.shareId] = treeData;
65 | }
66 |
67 | return sharedTrees;
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/tests/mocks/export_opml_all.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
--------------------------------------------------------------------------------
/tests/mocks/export_json_all.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "Root",
3 | "name": "Home",
4 | "items": [
5 | {
6 | "name": "List with sublists",
7 | "items": [
8 | { "name": "One" },
9 | {
10 | "name": "One",
11 | "items": [
12 | {
13 | "name": "Two",
14 | "items": [
15 | {
16 | "name": "Three",
17 | "items": [
18 | { "name": "Four" }
19 | ]
20 | }
21 | ]
22 | },
23 | { "name": "Two" }
24 | ]
25 | }
26 | ]
27 | },
28 | {
29 | "name": "List with description",
30 | "note": "Two Description"
31 | },
32 | {
33 | "name": "List completed",
34 | "isCompleted": true
35 | },
36 | {
37 | "name": "List mirrored",
38 | "items": [{
39 | "name": "Sublist in mirror"
40 | }]
41 | },
42 | {
43 | "name": "List mirrored",
44 | "items": [{
45 | "name": "Sublist in mirror"
46 | }]
47 | },
48 | {
49 | "name": "List with bold, italics, underline and link"
50 | },
51 | {
52 | "name": "List with date and tag #test"
53 | },
54 | {
55 | "name": "List with HTML special chars < > & \" '"
56 | },
57 | {
58 | "name": "List with long text: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.",
59 | "note": "Note: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis."
60 | }
61 | ]
62 | }
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # WorkFlowy API
2 |
3 | This is a [WorkFlowy](https://workflowy.com) client for Deno and Node. The goal
4 | of this library is to enable WorkFlowy power users to access WorkFlowy lists
5 | programatically and perhaps create some new automations and integrations.
6 |
7 | ## Features
8 |
9 | - Reading and updating WorkFlowy lists
10 | - Export of lists to JSON or formatted plain text
11 | - Basic search of items in lists
12 | - Support for live copies (mirrors)
13 |
14 | ## Basic usage
15 |
16 | ### Authentication
17 |
18 | In order to access WorkFlowy content, you need to provide your WorkFlowy
19 | username and password. Authentication via one-time code or two-factor
20 | authentication are not supported because of technical limitations of WorkFlowy
21 | API.
22 |
23 | ### Fetching a WorkFlowy document
24 |
25 | ```typescript
26 | import { WorkFlowy } from "workflowy";
27 |
28 | // Log in with your username and password
29 | const workflowy = new WorkFlowy("your@email.com", "your-password");
30 | // Load WorkFlowy outline into an interactive document structure
31 | const document = await workflowy.getDocument();
32 | ```
33 |
34 | ### Finding lists in a document
35 |
36 | ```typescript
37 | const rootList = document.root;
38 | const topLevelLists = document.items; // array of lists in the root
39 | const myList = topLevelLists[0];
40 |
41 | myList.findOne(/^Needle/); // Finds a sublist using a RegExp
42 | myList.findAll(/^Needle/); // Finds all sublists using a RegExp
43 | ```
44 |
45 | ### Accessing basic list properties
46 |
47 | ```typescript
48 | const rootList = document.root;
49 | const myList = document.items[0];
50 |
51 | myList.name; // name of the list
52 | myList.note; // note of the list
53 | myList.isCompleted; // whether or not the list or item is completed
54 | myList.items; // items and sublists
55 | ```
56 |
57 | ### Editing lists
58 |
59 | ```typescript
60 | myList.setName("New name").setNote("New note"); // sets a name and a note
61 | const sublist = myList.createList(); // Creates a sublist
62 | const subitem = myList.createItem(); // Alias for createList
63 |
64 | myList.move(targetList); // moves a list or item to a different list
65 | myList.delete(); // deletes the list
66 | ```
67 |
68 | ### Saving the changes to WorkFlowy
69 |
70 | ```typescript
71 | if (document.isDirty()) {
72 | // Saves the changes if there are any
73 | await document.save();
74 | }
75 | ```
76 |
77 | ## Installation
78 |
79 | ### From `npm` (Node/Bun)
80 |
81 | ```
82 | npm install workflowy # npm
83 | yarn add workflowy # yarn
84 | bun add workflowy # bun
85 | pnpm add workflowy # pnpm
86 | ```
87 |
88 | ### From `deno.land/x` (Deno)
89 |
90 | Unlike Node, Deno relies on direct URL imports instead of a package manager like
91 | NPM. The latest Deno version can be imported like so:
92 |
93 | ```typescript
94 | import { WorkFlowy } from "https://deno.land/x/workflowy/mod.ts";
95 | ```
96 |
97 | ## Acknowledgements
98 |
99 | Big thanks to [Mike Robertson](https://github.com/mikerobe) for providing the
100 | `workflowy` NPM package name!
101 |
102 | ## License
103 |
104 | MIT
105 |
--------------------------------------------------------------------------------
/src/export.ts:
--------------------------------------------------------------------------------
1 | import type { List } from "./document.ts";
2 | import { ROOT } from "./schema.ts";
3 |
4 | export function toString(list: List, omitHeader = false, indent = ""): string {
5 | const text: string[] = [];
6 | const printHeader = !omitHeader && list.id !== ROOT;
7 | if (printHeader) {
8 | text.push(indent + "- " + list.name);
9 | if (list.note) {
10 | text.push(indent + " " + list.note);
11 | }
12 | }
13 | const nextIndent = `${indent}${printHeader ? " " : ""}`;
14 | const childrenTextChunks = list.items.map((sublist) =>
15 | toString(sublist, false, nextIndent)
16 | );
17 |
18 | if (childrenTextChunks.length > 0) {
19 | text.push(childrenTextChunks.join("\n"));
20 | }
21 | return text.join("\n");
22 | }
23 |
24 | export function toPlainText(
25 | list: List,
26 | top = true,
27 | indent = "",
28 | ): string {
29 | const nextIndent = `${indent}${top ? "" : " "}`;
30 | const childrenTextChunks = list.items.map((sublist) =>
31 | toPlainText(sublist, false, nextIndent)
32 | );
33 |
34 | if (list.id === ROOT) {
35 | return childrenTextChunks.join("\n");
36 | }
37 |
38 | const decode = (text: string) =>
39 | text.replace(/<[^>]+>/g, "")
40 | .replace(/&/g, "&")
41 | .replace(/</g, "<")
42 | .replace(/>/g, ">");
43 |
44 | const text: string[] = [];
45 | const complete = list.isCompleted ? "[COMPLETE] " : "";
46 | if (top) {
47 | text.push(`${complete}${decode(list.name)}`);
48 | text.push("");
49 | if (list.note) {
50 | text.push(`"${decode(list.note)}"`);
51 | text.push("");
52 | }
53 | } else {
54 | text.push(`${indent}- ${complete}${decode(list.name)}`);
55 | if (list.note) {
56 | text.push(`${indent} "${decode(list.note)}"`);
57 | }
58 | }
59 |
60 | if (childrenTextChunks.length > 0) {
61 | text.push(childrenTextChunks.join("\n"));
62 | }
63 | return text.join("\n");
64 | }
65 |
66 | // deno-lint-ignore no-explicit-any
67 | export function toJson(list: List): any {
68 | return {
69 | id: list.id,
70 | name: list.name,
71 | note: list.note,
72 | isCompleted: list.isCompleted,
73 | items: list.items.map((sublist) => toJson(sublist)),
74 | };
75 | }
76 |
77 | export function toOpml(list: List, top = true): string {
78 | const escape = (text: string) =>
79 | text.replace(/&/g, "&")
80 | .replace(/&/g, "&")
81 | .replace(/"/g, """)
82 | .replace(//g, ">");
84 |
85 | const children = list.items.map((sublist) => toOpml(sublist, false)).join("");
86 | const attributes = [
87 | list.isCompleted ? ' _complete="true"' : "",
88 | ` text="${escape(list.name)}"`,
89 | list.note ? ` _note="${escape(list.note)}"` : "",
90 | ].join("");
91 |
92 | const content = list.id === ROOT
93 | ? children
94 | : children === ""
95 | ? ``
96 | : `${children}`;
97 |
98 | return top
99 | ? `${content}`
100 | : content;
101 | }
102 |
--------------------------------------------------------------------------------
/tests/export_test.ts:
--------------------------------------------------------------------------------
1 | import mockInitializationData from "./mocks/get_initialization_data.json" with {
2 | type: "json",
3 | };
4 | import mockTreeData from "./mocks/get_tree_data_extended.json" with {
5 | type: "json",
6 | };
7 |
8 | import { assertEquals, assertObjectMatch } from "./test_deps.ts";
9 |
10 | import { Document } from "../src/document.ts";
11 | import type { Client } from "../src/client.ts";
12 | import { InitializationDataSchema, TreeDataSchema } from "../src/schema.ts";
13 |
14 | const mockClient = () => ({} as unknown as Client);
15 | const mockTree = () => TreeDataSchema.parse(mockTreeData);
16 | const mockInitialization = () =>
17 | InitializationDataSchema.parse(mockInitializationData);
18 |
19 | const mockDocument = () =>
20 | new Document(mockClient(), mockTree(), mockInitialization());
21 |
22 | const readFile = (fileName: string) =>
23 | Deno.readTextFileSync(new URL(import.meta.resolve(fileName)));
24 |
25 | Deno.test("WorkFlowy Export / To string all", () => {
26 | const document = mockDocument();
27 |
28 | const text = document.root.toString();
29 | const expected = readFile("./mocks/export_string_all.txt").replaceAll(
30 | "\r\n",
31 | "\n",
32 | ).trimEnd();
33 |
34 | assertEquals(text, expected);
35 | });
36 |
37 | Deno.test("WorkFlowy Export / To string partial", () => {
38 | const document = mockDocument();
39 |
40 | const text = document.root.items[0].toString();
41 | const expected = readFile("./mocks/export_string_partial.txt").replaceAll(
42 | "\r\n",
43 | "\n",
44 | ).trimEnd();
45 |
46 | assertEquals(text, expected);
47 | });
48 |
49 | Deno.test("WorkFlowy Export / To Plain Text all", () => {
50 | const document = mockDocument();
51 |
52 | const text = document.root.toPlainText();
53 | const expected = readFile("./mocks/export_plaintext_all.txt").replaceAll(
54 | "\r\n",
55 | "\n",
56 | ).trimEnd();
57 |
58 | assertEquals(text, expected);
59 | });
60 |
61 | Deno.test("WorkFlowy Export / To Plain Text partial", () => {
62 | const document = mockDocument();
63 |
64 | const text = document.root.items[0].toPlainText();
65 | const expected = readFile("./mocks/export_plaintext_partial.txt").replaceAll(
66 | "\r\n",
67 | "\n",
68 | ).trimEnd();
69 |
70 | assertEquals(text, expected);
71 | });
72 |
73 | Deno.test("WorkFlowy Export / To JSON all", () => {
74 | const document = mockDocument();
75 |
76 | const json = document.root.toJson();
77 | const expected = JSON.parse(readFile("./mocks/export_json_all.json"));
78 |
79 | assertObjectMatch(json, expected);
80 | });
81 |
82 | Deno.test("WorkFlowy Export / To JSON partial", () => {
83 | const document = mockDocument();
84 |
85 | const json = document.root.items[0].toJson();
86 | const expected = JSON.parse(readFile("./mocks/export_json_partial.json"));
87 |
88 | assertObjectMatch(json, expected);
89 | });
90 |
91 | Deno.test("WorkFlowy Export / To OPML all", () => {
92 | const document = mockDocument();
93 |
94 | const opml = document.root.toOpml();
95 | const expected = readFile("./mocks/export_opml_all.xml")
96 | .replace(/>\s+<")
97 | .trimEnd();
98 |
99 | assertEquals(opml, expected);
100 | });
101 |
102 | Deno.test("WorkFlowy Export / To OPML partial", () => {
103 | const document = mockDocument();
104 |
105 | const opml = document.root.items[0].toOpml();
106 | const expected = readFile("./mocks/export_opml_partial.xml")
107 | .replace(/>\s+<")
108 | .trimEnd();
109 |
110 | assertEquals(opml, expected);
111 | });
112 |
--------------------------------------------------------------------------------
/tests/shared_test.ts:
--------------------------------------------------------------------------------
1 | import mockInitializationData from "./mocks/get_initialization_data_shared.json" with {
2 | type: "json",
3 | };
4 | import mockTreeDataMain from "./mocks/get_tree_data_shared_main.json" with {
5 | type: "json",
6 | };
7 | import mockTreeDataFirst from "./mocks/get_tree_data_shared_first.json" with {
8 | type: "json",
9 | };
10 | import mockTreeDataSecond from "./mocks/get_tree_data_shared_second.json" with {
11 | type: "json",
12 | };
13 |
14 | import mockTreeDataThird from "./mocks/get_tree_data_shared_third.json" with {
15 | type: "json",
16 | };
17 |
18 | import { assertEquals } from "./test_deps.ts";
19 |
20 | import { Document } from "../src/document.ts";
21 | import type { Client } from "../src/client.ts";
22 | import {
23 | InitializationDataSchema,
24 | ROOT,
25 | TreeDataSchema,
26 | } from "../src/schema.ts";
27 |
28 | const mockClient = () => ({} as unknown as Client);
29 | const mockTree = () => TreeDataSchema.parse(mockTreeDataMain);
30 | const mockInitialization = () =>
31 | InitializationDataSchema.parse(mockInitializationData);
32 | const mockSharedTrees = () => {
33 | const first =
34 | mockTreeDataFirst.shared_projects["aaba6df4-cde1-3322-96cd-957fc76123e8"]
35 | .share_id;
36 | const second =
37 | mockTreeDataSecond.shared_projects["8960ce1b-e5b5-4aff-3303-50577f20e76b"]
38 | .share_id;
39 | const third =
40 | mockTreeDataThird.shared_projects["daba6df4-cde1-3322-96cd-957fc76123e8"]
41 | .share_id;
42 | return {
43 | [first]: TreeDataSchema.parse(mockTreeDataFirst),
44 | [second]: TreeDataSchema.parse(mockTreeDataSecond),
45 | [third]: TreeDataSchema.parse(mockTreeDataThird),
46 | };
47 | };
48 |
49 | const mockDocument = () =>
50 | new Document(
51 | mockClient(),
52 | mockTree(),
53 | mockInitialization(),
54 | mockSharedTrees(),
55 | );
56 |
57 | Deno.test("WorkFlowy Shared / Read", () => {
58 | const document = mockDocument();
59 |
60 | const firstShared = document.items[1];
61 |
62 | assertEquals(firstShared.name, "List shared via URL");
63 | assertEquals(firstShared.isSharedViaUrl, true);
64 | assertEquals(
65 | firstShared.sharedUrl,
66 | "https://workflowy.com/s/plUzlWcMHcwbR3wZ",
67 | );
68 |
69 | assertEquals(firstShared.items.length, 2);
70 | assertEquals(firstShared.items[0].name, "Normal list");
71 |
72 | const secondShared = firstShared.items[1];
73 |
74 | assertEquals(secondShared.name, "List shared via email");
75 | assertEquals(secondShared.items.length, 2);
76 | assertEquals(secondShared.items[0].name, "Normal second list");
77 |
78 | const thirdShared = secondShared.items[1];
79 |
80 | assertEquals(thirdShared.name, "List shared via URL without access token");
81 | assertEquals(thirdShared.isSharedViaUrl, true);
82 | assertEquals(
83 | thirdShared.sharedUrl,
84 | "https://workflowy.com/s/NnjJ.lybhWrZBRA",
85 | );
86 | });
87 |
88 | Deno.test("WorkFlowy Shared / Write", () => {
89 | const document = mockDocument();
90 |
91 | const firstShared = document.items[1];
92 | const secondShared = firstShared.items[1];
93 |
94 | const firstNewSharedList = firstShared.createList(0);
95 | firstNewSharedList.setName("New first shared list");
96 |
97 | const secondNewSharedList = secondShared.createList(0);
98 | secondNewSharedList.setName("New second shared list");
99 |
100 | const ops = document.getPendingOperations();
101 | assertEquals(Object.keys(ops).length, 2);
102 | assertEquals(ops[ROOT], undefined);
103 | });
104 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "1c336730-7fc0-74f7-1d21-53b1cbd064a1",
5 | "nm": "Sublist in mirror",
6 | "ct": 158406,
7 | "pr": 100,
8 | "prnt": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
9 | "metadata": {},
10 | "lm": 158406
11 | },
12 | {
13 | "id": "0f6f3186-bfc5-2874-992d-6b490b8efe3c",
14 | "nm": "One",
15 | "ct": 164,
16 | "pr": 100,
17 | "prnt": "de843e0e-2d25-a812-1415-6ed31ae618f4",
18 | "metadata": {},
19 | "lm": 227
20 | },
21 | {
22 | "id": "de843e0e-2d25-a812-1415-6ed31ae618f4",
23 | "nm": "List with sublist",
24 | "ct": 146,
25 | "pr": 100,
26 | "prnt": null,
27 | "metadata": {},
28 | "lm": 223
29 | },
30 | {
31 | "id": "22b98fae-39f4-2774-9d4e-de3869f043a6",
32 | "nm": "One",
33 | "ct": 168,
34 | "pr": 200,
35 | "prnt": "de843e0e-2d25-a812-1415-6ed31ae618f4",
36 | "metadata": {},
37 | "lm": 231
38 | },
39 | {
40 | "id": "12f62eec-754c-b677-b683-7dd448dc49ac",
41 | "nm": "List with description",
42 | "ct": 148,
43 | "pr": 200,
44 | "prnt": null,
45 | "metadata": {},
46 | "no": "Two Description",
47 | "lm": 155459
48 | },
49 | {
50 | "id": "3e6b63c5-7e40-1e1e-9987-cd7af6e64893",
51 | "nm": "List completed",
52 | "ct": 150,
53 | "pr": 300,
54 | "prnt": null,
55 | "metadata": {},
56 | "cp": 199,
57 | "lm": 157519
58 | },
59 | {
60 | "id": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
61 | "nm": "List mirrored",
62 | "ct": 158394,
63 | "pr": 400,
64 | "prnt": null,
65 | "metadata": {
66 | "mirror": {
67 | "mirrorRootIds": { "460e5b75-1807-9e3a-5c19-8f835768c782": true }
68 | },
69 | "virtualRootIds": { "460e5b75-1807-9e3a-5c19-8f835768c782": true }
70 | },
71 | "lm": 158394
72 | },
73 | {
74 | "id": "460e5b75-1807-9e3a-5c19-8f835768c782",
75 | "nm": "",
76 | "ct": 158432,
77 | "pr": 500,
78 | "prnt": null,
79 | "metadata": {
80 | "mirror": {
81 | "originalId": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
82 | "isMirrorRoot": true
83 | },
84 | "originalId": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
85 | "isVirtualRoot": true
86 | },
87 | "lm": 158432
88 | },
89 | {
90 | "id": "aaba6df4-cde1-3322-96cd-957fc76123e8",
91 | "nm": "List shared via URL",
92 | "ct": 45772460,
93 | "pr": 538,
94 | "prnt": null,
95 | "metadata": {},
96 | "lm": 45772507
97 | },
98 | {
99 | "id": "8960ce1b-e5b5-4aff-3303-50577f20e76b",
100 | "nm": "List shared via email",
101 | "ct": 45707709,
102 | "pr": 525,
103 | "prnt": null,
104 | "metadata": {},
105 | "lm": 45708060
106 | }
107 | ],
108 | "shared_projects": {
109 | "aaba6df4-cde1-3322-96cd-957fc76123e8": {
110 | "share_id": "NnjJ.tUQQvYjgkV",
111 | "url_shared_info": {
112 | "write_permission": false,
113 | "permission_level": 1,
114 | "access_token": "plUzlWcMHcwbR3wZ"
115 | }
116 | },
117 | "8960ce1b-e5b5-4aff-3303-50577f20e76b": {
118 | "share_id": "NnjJ.lybhWrZBRs",
119 | "email_shared_info": {
120 | "emails": [
121 | {
122 | "email": "example@example.com",
123 | "access_token": "L2RdOGpOND",
124 | "write_permission": true,
125 | "permission_level": 3
126 | }
127 | ]
128 | }
129 | }
130 | },
131 | "most_recent_operation_transaction_id": "1160300075",
132 | "server_expanded_projects_list": [
133 | "de843e0e"
134 | ]
135 | }
136 |
--------------------------------------------------------------------------------
/src/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from "../deps.ts";
2 |
3 | export const ROOT = "Root";
4 |
5 | export const LoginResultSchema = z.object({
6 | success: z.boolean().optional(),
7 | errors: z.object().optional(),
8 | }).transform((i) => ({
9 | success: i.success === true,
10 | errors: JSON.stringify(i.errors),
11 | }));
12 |
13 | export type LoginResult = z.infer;
14 |
15 | const TreeInfoSchema = z.object({
16 | dateJoinedTimestampInSeconds: z.number(),
17 | initialMostRecentOperationTransactionId: z.string(),
18 | ownerId: z.number(),
19 | shareId: z.string().default(ROOT),
20 | });
21 |
22 | export const InitializationDataSchema = z.object({
23 | projectTreeData: z.object({
24 | auxiliaryProjectTreeInfos: z.array(TreeInfoSchema),
25 | mainProjectTreeInfo: TreeInfoSchema,
26 | }),
27 | }).transform((i) => i.projectTreeData);
28 |
29 | export type InitializationData = z.infer;
30 |
31 | const TreeItemShareInfoSchema = z.object({
32 | share_id: z.string(),
33 | url_shared_info: z.object({
34 | access_token: z.string().optional(),
35 | permission_level: z.number(),
36 | }).optional(),
37 | email_shared_info: z.object({
38 | emails: z.array(z.object({
39 | email: z.string(),
40 | access_token: z.string(),
41 | permission_level: z.number(),
42 | })),
43 | }).optional(),
44 | }).transform((i) => ({
45 | shareId: i.share_id,
46 | isSharedViaUrl: i.url_shared_info !== undefined,
47 | urlAccessToken: i.url_shared_info?.access_token,
48 | urlPermissionLevel: i.url_shared_info?.permission_level,
49 | isSharedViaEmail: i.email_shared_info !== undefined,
50 | }));
51 |
52 | export type TreeItemShareInfo = z.infer;
53 |
54 | export const TreeDataSchema = z.object({
55 | most_recent_operation_transaction_id: z.string(),
56 | items: z.array(
57 | z.object({
58 | id: z.string(),
59 | nm: z.string(),
60 | no: z.string().optional(),
61 | prnt: z.string().or(z.null()),
62 | pr: z.number(),
63 | cp: z.number().optional(),
64 | lm: z.number(),
65 | metadata: z.object({
66 | mirror: z.object({
67 | originalId: z.string().optional(),
68 | isMirrorRoot: z.boolean().optional(),
69 | }).optional(),
70 | }),
71 | as: z.string().optional(),
72 | }).transform((i) => ({
73 | id: i.id,
74 | name: i.nm,
75 | note: i.no,
76 | parentId: i.prnt !== null ? i.prnt : ROOT,
77 | priority: i.pr,
78 | completed: i.cp,
79 | lastModified: i.lm,
80 | originalId: i.metadata?.mirror?.originalId,
81 | isMirrorRoot: i.metadata?.mirror?.isMirrorRoot === true,
82 | shareId: i.as,
83 | })),
84 | ),
85 | shared_projects: z.record(z.string(), TreeItemShareInfoSchema),
86 | server_expanded_projects_list: z.array(z.string()).default([]),
87 | });
88 |
89 | export type TreeData = z.infer;
90 |
91 | export type TreeItem = TreeData["items"][number];
92 |
93 | export type TreeItemWithChildren = TreeItem & {
94 | children: string[];
95 | treeId: string;
96 | };
97 |
98 | export const OperationSchema = z.object({
99 | type: z.string(),
100 | data: z.object({
101 | projectid: z.string(),
102 | parentid: z.string().optional(),
103 | name: z.string().optional(),
104 | description: z.string().optional(),
105 | priority: z.number().optional(),
106 | starting_priority: z.number().optional(),
107 | permission_level: z.number().optional(),
108 | access_token: z.string().optional(),
109 | }),
110 | client_timestamp: z.number().optional(),
111 | undo_data: z.object({
112 | previous_last_modified: z.number().optional(),
113 | previous_last_modified_by: z.any().optional(),
114 | previous_name: z.string().optional(),
115 | previous_description: z.string().optional(),
116 | previous_completed: z.number().or(z.boolean()).optional(),
117 | previous_parentid: z.string().optional(),
118 | previous_priority: z.number().optional(),
119 | parentid: z.string().optional(),
120 | priority: z.number().optional(),
121 | permission_level: z.number().optional(),
122 | previous_permission_level: z.number().optional(),
123 | }),
124 | });
125 |
126 | export type Operation = z.infer;
127 |
128 | export const OperationResultSchema = z.object({
129 | results: z.array(z.object({
130 | concurrent_remote_operation_transactions: z.array(z.string()),
131 | error_encountered_in_remote_operations: z.boolean(),
132 | new_most_recent_operation_transaction_id: z.string(),
133 | new_polling_interval_in_ms: z.number(),
134 | share_id: z.string().default(ROOT),
135 | })),
136 | }).transform((data) => data.results);
137 |
138 | export type OperationResult = z.infer;
139 |
--------------------------------------------------------------------------------
/tests/mocks/get_tree_data_extended.json:
--------------------------------------------------------------------------------
1 | {
2 | "items": [
3 | {
4 | "id": "0f6f3186-bfc5-2874-992d-6b490b8efe3c",
5 | "nm": "One",
6 | "ct": 164,
7 | "pr": 100,
8 | "prnt": "de843e0e-2d25-a812-1415-6ed31ae618f4",
9 | "metadata": {},
10 | "lm": 227
11 | },
12 | {
13 | "id": "22b98fae-39f4-2774-9d4e-de3869f043a6",
14 | "nm": "One",
15 | "ct": 168,
16 | "pr": 200,
17 | "prnt": "de843e0e-2d25-a812-1415-6ed31ae618f4",
18 | "metadata": {},
19 | "lm": 45673905
20 | },
21 | {
22 | "id": "3e6b63c5-7e40-1e1e-9987-cd7af6e64893",
23 | "nm": "List completed",
24 | "ct": 150,
25 | "pr": 300,
26 | "prnt": null,
27 | "metadata": {},
28 | "cp": 199,
29 | "lm": 157519
30 | },
31 | {
32 | "id": "de843e0e-2d25-a812-1415-6ed31ae618f4",
33 | "nm": "List with sublists",
34 | "ct": 146,
35 | "pr": 100,
36 | "prnt": null,
37 | "metadata": {},
38 | "no": "Nested in four levels",
39 | "lm": 45673898
40 | },
41 | {
42 | "id": "12f62eec-754c-b677-b683-7dd448dc49ac",
43 | "nm": "List with description",
44 | "ct": 148,
45 | "pr": 200,
46 | "prnt": null,
47 | "metadata": {},
48 | "no": "Two Description",
49 | "lm": 164621
50 | },
51 | {
52 | "id": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
53 | "nm": "List mirrored",
54 | "ct": 158394,
55 | "pr": 400,
56 | "prnt": null,
57 | "metadata": {
58 | "mirror": {
59 | "mirrorRootIds": {
60 | "460e5b75-1807-9e3a-5c19-8f835768c782": true
61 | }
62 | },
63 | "virtualRootIds": {
64 | "460e5b75-1807-9e3a-5c19-8f835768c782": true
65 | }
66 | },
67 | "lm": 413259
68 | },
69 | {
70 | "id": "460e5b75-1807-9e3a-5c19-8f835768c782",
71 | "nm": "",
72 | "ct": 158432,
73 | "pr": 500,
74 | "prnt": null,
75 | "metadata": {
76 | "mirror": {
77 | "originalId": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
78 | "isMirrorRoot": true
79 | },
80 | "originalId": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
81 | "isVirtualRoot": true
82 | },
83 | "lm": 158432
84 | },
85 | {
86 | "id": "9fcb2182-10a5-5f5a-a896-915965d8e755",
87 | "nm": "List with long text: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.",
88 | "ct": 45673768,
89 | "pr": 588,
90 | "prnt": null,
91 | "metadata": {},
92 | "no": "Note: Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed luctus facilisis neque quis scelerisque. Etiam pharetra feugiat lorem, in fringilla nunc laoreet a. Nunc laoreet mauris non consequat gravida. Nunc placerat orci a sapien aliquam, volutpat posuere metus pellentesque. Nulla sit amet sapien ut urna sagittis eleifend ut at dolor. Sed erat metus, placerat id convallis sit amet, mollis vel lacus. Proin quis ullamcorper mauris. Cras commodo turpis vitae felis luctus, ut elementum odio pretium. Phasellus eleifend ut ex at faucibus. Mauris mollis augue at lectus pharetra mattis.",
93 | "lm": 45673857
94 | },
95 | {
96 | "id": "93abe0c9-613c-920a-c302-24a555ab1e1f",
97 | "nm": "Two",
98 | "ct": 45673899,
99 | "pr": 100,
100 | "prnt": "22b98fae-39f4-2774-9d4e-de3869f043a6",
101 | "metadata": {},
102 | "lm": 45673902
103 | },
104 | {
105 | "id": "2fd5d3d4-03c3-3856-f724-6725c1c23f38",
106 | "nm": "Two",
107 | "ct": 45673903,
108 | "pr": 200,
109 | "prnt": "22b98fae-39f4-2774-9d4e-de3869f043a6",
110 | "metadata": {},
111 | "lm": 45673906
112 | },
113 | {
114 | "id": "2d2088f1-97b8-01f7-a397-a1d701fd965c",
115 | "nm": "Three",
116 | "ct": 45673902,
117 | "pr": 100,
118 | "prnt": "93abe0c9-613c-920a-c302-24a555ab1e1f",
119 | "metadata": {},
120 | "lm": 45673920
121 | },
122 | {
123 | "id": "1c336730-7fc0-74f7-1d21-53b1cbd064a1",
124 | "nm": "Sublist in mirror",
125 | "ct": 158406,
126 | "pr": 100,
127 | "prnt": "68e27b97-4444-77c6-f6e8-22c1e94ab437",
128 | "metadata": {},
129 | "lm": 45673693
130 | },
131 | {
132 | "id": "52fa4a01-4b37-256f-cb3a-7ccd91f88916",
133 | "nm": "Four",
134 | "ct": 45673920,
135 | "pr": 100,
136 | "prnt": "2d2088f1-97b8-01f7-a397-a1d701fd965c",
137 | "metadata": {},
138 | "lm": 45673922
139 | },
140 | {
141 | "id": "730f31e9-beb5-b4b9-ad2e-7f0fa2393875",
142 | "nm": "List with bold, italics, underline and link",
143 | "ct": 45673673,
144 | "pr": 550,
145 | "prnt": null,
146 | "metadata": {},
147 | "lm": 45673740
148 | },
149 | {
150 | "id": "db58a522-a865-641a-f907-7a0a2e0881ee",
151 | "nm": "List with date and tag #test",
152 | "ct": 45673742,
153 | "pr": 575,
154 | "prnt": null,
155 | "metadata": {},
156 | "lm": 45673765
157 | },
158 | {
159 | "id": "c957fc58-b8a8-3f0f-20b6-ddb2b13dcf4e",
160 | "nm": "List with HTML special chars < > & \" '",
161 | "ct": 45674231,
162 | "pr": 582,
163 | "prnt": null,
164 | "metadata": {},
165 | "lm": 45674382
166 | }
167 | ],
168 | "shared_projects": {},
169 | "most_recent_operation_transaction_id": "1170199658",
170 | "server_expanded_projects_list": [
171 | "de843e0e",
172 | "68e27b97",
173 | "460e5b75",
174 | "12f62eec",
175 | "22b98fae",
176 | "93abe0c9",
177 | "2d2088f1"
178 | ]
179 | }
180 |
--------------------------------------------------------------------------------
/src/client.ts:
--------------------------------------------------------------------------------
1 | import { getSetCookies, setCookie } from "../deps.ts";
2 |
3 | import {
4 | type InitializationData,
5 | InitializationDataSchema,
6 | type LoginResult,
7 | LoginResultSchema,
8 | type Operation,
9 | type OperationResult,
10 | OperationResultSchema,
11 | ROOT,
12 | type TreeData,
13 | TreeDataSchema,
14 | } from "./schema.ts";
15 |
16 | const WORKFLOWY_URL = "https://workflowy.com";
17 | const LOGIN_URL = `${WORKFLOWY_URL}/ajax_login`;
18 | const INITIALIZATION_DATA_URL =
19 | `${WORKFLOWY_URL}/get_initialization_data?client_version=21&client_version_v2=28&no_root_children=1`;
20 | const TREE_DATA_URL = `${WORKFLOWY_URL}/get_tree_data/`;
21 | const SHARED_TREE_DATA_URL = `${WORKFLOWY_URL}/get_tree_data/?share_id=`;
22 | const PUSH_AND_POLL_URL = `${WORKFLOWY_URL}/push_and_poll`;
23 | const CLIENT_VERSION = "21";
24 | const SESSION_COOKIE_NAME = `sessionid`;
25 |
26 | /**
27 | * A class that facilitates authentication, fetching and updating data from
28 | * WorkFlowy internal API, using the same methods as WorkFlowy application.
29 | *
30 | * ### Basic example
31 | *
32 | * ```ts
33 | * import { Client, type TreeItem, type Operation } from "workflowy"
34 | *
35 | * const client = new Client("username", "password");
36 | * const items: TreeItem[] = await client.getTreeData();
37 | *
38 | * const updateOperations: Operation[] = [];
39 | * const updateResult = await client.pushAndPull(updateOperations);
40 | * ```
41 | */
42 | export class Client {
43 | #sessionHeaders = new Headers();
44 | #clientId: string;
45 | #lastTransactionIds: Record = {};
46 |
47 | #username: string;
48 | #password: string;
49 |
50 | /**
51 | * Cretes a new Client instance using WorkFlowy username and password
52 | * @param username WorkFlowy username
53 | * @param password WorkFlowy password
54 | */
55 | constructor(username: string, password: string) {
56 | this.#username = username;
57 | this.#password = password;
58 | this.#clientId = this.createClientId();
59 | }
60 |
61 | private createClientId() {
62 | const date = new Date();
63 |
64 | const pad = (number: number, digits = 2) =>
65 | String(number).padStart(digits, "0");
66 |
67 | const year = date.getUTCFullYear();
68 | const month = pad(date.getUTCMonth() + 1);
69 | const day = pad(date.getUTCDate());
70 | const hours = pad(date.getUTCHours());
71 | const minutes = pad(date.getUTCMinutes());
72 | const seconds = pad(date.getUTCSeconds());
73 | const milliseconds = pad(date.getUTCMilliseconds(), 3);
74 |
75 | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`;
76 | }
77 |
78 | async #timeoutFetch(
79 | url: string | URL,
80 | init: RequestInit = {},
81 | timeout = 20000,
82 | ) {
83 | const controller = new AbortController();
84 | const id = setTimeout(() => controller.abort(), timeout);
85 | const result = await fetch(url, init);
86 | clearTimeout(id);
87 | return result;
88 | }
89 |
90 | async #authenticatedFetch(
91 | url: string | URL,
92 | init: RequestInit = {},
93 | // deno-lint-ignore no-explicit-any
94 | ): Promise {
95 | let response = await this.#timeoutFetch(url, {
96 | ...init,
97 | headers: this.#sessionHeaders,
98 | });
99 |
100 | if (!response.ok) {
101 | await response.body?.cancel();
102 | await this.login();
103 | response = await this.#timeoutFetch(url, {
104 | ...init,
105 | headers: this.#sessionHeaders,
106 | });
107 | }
108 |
109 | if (!response.ok) {
110 | throw Error(
111 | `WorkFlowy request error: ${response.status} ${response.statusText}`,
112 | );
113 | }
114 |
115 | return response.json();
116 | }
117 |
118 | async #getLastTransactionId(treeId: string) {
119 | if (this.#lastTransactionIds[ROOT] === undefined) {
120 | const initializationData = await this.getInitializationData();
121 | this.#lastTransactionIds[ROOT] = initializationData.mainProjectTreeInfo
122 | .initialMostRecentOperationTransactionId;
123 | for (const treeInfo of initializationData.auxiliaryProjectTreeInfos) {
124 | this.#lastTransactionIds[treeInfo.shareId] =
125 | treeInfo.initialMostRecentOperationTransactionId;
126 | }
127 | }
128 | if (this.#lastTransactionIds[treeId] === undefined) {
129 | throw Error(`Last transaction ID of tree ${treeId} not found.`);
130 | }
131 | return this.#lastTransactionIds[treeId];
132 | }
133 |
134 | #setLastTransactionId(treeId: string, transactionId: string) {
135 | if (this.#lastTransactionIds === undefined) {
136 | throw Error(
137 | "WorkFlowy client not initialized properly, transaction IDs missing.",
138 | );
139 | }
140 | this.#lastTransactionIds![treeId] = transactionId;
141 | }
142 |
143 | /**
144 | * Authenticates the user. Methods like `.getTreeData()` and `.getInicializationData()`
145 | * call this method if the user is not yet authenticated.
146 | *
147 | * Queries `workflowy.com/ajax_login` endpoint
148 | * @returns `{ success: true }` if the autentication was successful
149 | * @throws Error if the authentication failed
150 | */
151 | public async login(): Promise {
152 | const formData = new FormData();
153 | formData.append("username", this.#username);
154 | formData.append("password", this.#password);
155 |
156 | const response = await this.#timeoutFetch(LOGIN_URL, {
157 | method: "POST",
158 | body: formData,
159 | });
160 |
161 | if (!response.ok) {
162 | throw Error(
163 | `WorkFlowy login error: ${response.status} ${response.statusText}`,
164 | );
165 | }
166 |
167 | const loginResponse = await response.json();
168 | const loginResult = LoginResultSchema.parse(loginResponse);
169 |
170 | if (!loginResult.success) {
171 | throw Error(`WorkFlowy login error: ${loginResult.errors}`);
172 | }
173 |
174 | const cookies = getSetCookies(response.headers);
175 |
176 | for (const cookie of cookies) {
177 | if (cookie.name === SESSION_COOKIE_NAME) {
178 | const dummyHeaders = new Headers();
179 | setCookie(dummyHeaders, cookie);
180 | this.#sessionHeaders.set("Cookie", dummyHeaders.get("Set-Cookie")!);
181 | }
182 | }
183 |
184 | return loginResult;
185 | }
186 |
187 | #initializationDataPromise: Promise | undefined;
188 |
189 | /**
190 | * Fetches initialization data from WorkFlowy, needed to further query
191 | * WorkFlowy endpoints in order to fetch and update WorkFlowy document
192 | *
193 | * Queries `workflowy.com/get_initialization_data` endpoint
194 | * @returns Some initialization information about the user
195 | */
196 | public async getInitializationData(): Promise {
197 | if (this.#initializationDataPromise === undefined) {
198 | this.#initializationDataPromise = (async () => {
199 | const json = await this.#authenticatedFetch(INITIALIZATION_DATA_URL);
200 | return InitializationDataSchema.parse(json);
201 | })();
202 | }
203 | return await this.#initializationDataPromise;
204 | }
205 |
206 | /**
207 | * Fetches the whole WorkFlowy document
208 | *
209 | * Queries `workflowy.com/get_tree_data` endpoint
210 | * @returns List of all items in WorkFlowy document
211 | */
212 | public async getTreeData(): Promise {
213 | const json = await this.#authenticatedFetch(TREE_DATA_URL);
214 | const data = TreeDataSchema.parse(json);
215 | this.#setLastTransactionId(
216 | ROOT,
217 | data.most_recent_operation_transaction_id,
218 | );
219 | return data;
220 | }
221 |
222 | /**
223 | * Fetches the shared WorkFlowy subdocument
224 | *
225 | * Queries `workflowy.com/get_tree_data/?share_id=` endpoint
226 | * @returns List of all items in WorkFlowy shared document
227 | */
228 | public async getSharedTreeData(shareId: string): Promise {
229 | const json = await this.#authenticatedFetch(SHARED_TREE_DATA_URL + shareId);
230 | const data = TreeDataSchema.parse(json);
231 | this.#setLastTransactionId(
232 | shareId,
233 | data.most_recent_operation_transaction_id,
234 | );
235 | return data;
236 | }
237 |
238 | /**
239 | * Applies a list of operations to WorkFlowy document
240 | *
241 | * Queries `workflowy.com/push_and_pull` endpoint
242 | * @param operations List of operations to perform in WorkFlowy
243 | * @param expansionsDelta Record of list expansions to perform in WorkFlowy
244 | * @returns List of operations ran on WorkFlowy since last push and pull
245 | */
246 | public async pushAndPull(
247 | operations: Operation[] | Record,
248 | expansionsDelta: Record = {},
249 | ): Promise {
250 | const operationMap = Array.isArray(operations)
251 | ? { Root: operations }
252 | : { Root: [], ...operations } as Record;
253 | const initializationData = await this.getInitializationData();
254 | const time = Math.floor(Date.now() / 1000);
255 | const timestamp = time -
256 | initializationData.mainProjectTreeInfo.dateJoinedTimestampInSeconds;
257 | const push_poll_id = crypto.randomUUID().substring(0, 8);
258 |
259 | const payload = [];
260 |
261 | for (const treeId of Object.keys(operationMap)) {
262 | const lastTransactionId = await this.#getLastTransactionId(treeId);
263 | const ops = operationMap[treeId].map((operation) => ({
264 | ...operation,
265 | client_timestamp: timestamp,
266 | }));
267 | payload.push({
268 | most_recent_operation_transaction_id: lastTransactionId,
269 | operations: ops,
270 | share_id: treeId === ROOT ? undefined : treeId,
271 | project_expansions_delta: expansionsDelta[treeId],
272 | });
273 | }
274 |
275 | const push_poll_data = JSON.stringify(payload);
276 |
277 | const formData = new FormData();
278 | formData.append("client_id", this.#clientId);
279 | formData.append("client_version", CLIENT_VERSION);
280 | formData.append("push_poll_id", push_poll_id);
281 | formData.append("push_poll_data", push_poll_data);
282 | formData.append(
283 | "crosscheck_user_id",
284 | initializationData.mainProjectTreeInfo.ownerId.toString(),
285 | );
286 |
287 | const json = await this.#authenticatedFetch(PUSH_AND_POLL_URL, {
288 | method: "POST",
289 | body: formData,
290 | });
291 |
292 | const operationResult = OperationResultSchema.parse(json);
293 |
294 | for (const treeResult of operationResult) {
295 | this.#setLastTransactionId(
296 | treeResult.share_id,
297 | treeResult.new_most_recent_operation_transaction_id,
298 | );
299 | }
300 | return operationResult;
301 | }
302 |
303 | /** Returns the client ID that this instance uses to query WorkFlowy */
304 | public get clientId(): string {
305 | return this.#clientId;
306 | }
307 |
308 | /** Returns the session headers with authentication cookie to query WorkFlowy */
309 | public get sessionHeaders(): Headers {
310 | return this.#sessionHeaders;
311 | }
312 | }
313 |
--------------------------------------------------------------------------------
/tests/document_test.ts:
--------------------------------------------------------------------------------
1 | import mockInitializationData from "./mocks/get_initialization_data.json" with {
2 | type: "json",
3 | };
4 | import mockTreeData from "./mocks/get_tree_data.json" with { type: "json" };
5 |
6 | import { assertEquals, assertObjectMatch } from "./test_deps.ts";
7 |
8 | import { Document } from "../src/document.ts";
9 | import type { Client } from "../src/client.ts";
10 | import {
11 | InitializationDataSchema,
12 | ROOT,
13 | TreeDataSchema,
14 | } from "../src/schema.ts";
15 | import { PermissionLevel } from "../src/share.ts";
16 |
17 | const mockClient = () => ({} as unknown as Client);
18 | const mockTree = () => TreeDataSchema.parse(mockTreeData);
19 | const mockInitialization = () =>
20 | InitializationDataSchema.parse(mockInitializationData);
21 |
22 | const mockDocument = () =>
23 | new Document(mockClient(), mockTree(), mockInitialization());
24 |
25 | Deno.test("WorkFlowy Document / Load tree", () => {
26 | const document = mockDocument();
27 |
28 | const home = document.root;
29 | assertEquals(home.id, ROOT);
30 | assertEquals(home.items.length, 7);
31 |
32 | assertEquals(home.items[0].name, "List with sublist");
33 | assertEquals(home.items[0].items[0].name, "One");
34 | assertEquals(home.items[0].items[0].name, "One");
35 |
36 | assertEquals(home.items[1].name, "List with description");
37 | assertEquals(home.items[1].note, "Two Description");
38 |
39 | assertEquals(home.items[2].name, "List completed");
40 | assertEquals(home.items[2].isCompleted, true);
41 | assertEquals(home.items[2].isSharedViaUrl, false);
42 | assertEquals(home.items[2].isSharedViaEmail, false);
43 | assertEquals(home.items[2].sharedUrl, undefined);
44 | assertEquals(home.items[2].sharedUrlPermissionLevel, PermissionLevel.None);
45 |
46 | assertEquals(home.items[3].name, "List mirrored");
47 | assertEquals(home.items[3].isMirror, false);
48 | assertEquals(home.items[3].items[0].name, "Sublist in mirror");
49 |
50 | assertEquals(home.items[4].name, "List mirrored");
51 | assertEquals(home.items[4].isMirror, true);
52 | assertEquals(home.items[4].items[0].name, "Sublist in mirror");
53 |
54 | assertEquals(home.items[5].name, "List shared via email");
55 | assertEquals(home.items[5].isSharedViaEmail, true);
56 | assertEquals(home.items[5].isSharedViaUrl, false);
57 | assertEquals(home.items[5].sharedUrl, undefined);
58 | assertEquals(home.items[5].sharedUrlPermissionLevel, PermissionLevel.None);
59 | assertEquals(home.items[5].isCompleted, false);
60 |
61 | assertEquals(home.items[6].name, "List shared via URL");
62 | assertEquals(home.items[6].isSharedViaEmail, false);
63 | assertEquals(home.items[6].isSharedViaUrl, true);
64 | assertEquals(
65 | home.items[6].sharedUrl,
66 | "https://workflowy.com/s/plUzlWcMHcwbR3wZ",
67 | );
68 | assertEquals(home.items[6].sharedUrlPermissionLevel, PermissionLevel.View);
69 | assertEquals(home.items[6].isCompleted, false);
70 | });
71 |
72 | Deno.test("WorkFlowy Document / Get list by short ID", () => {
73 | const document = mockDocument();
74 |
75 | const list = document.getList("7dd448dc49ac");
76 |
77 | assertEquals(list.id, "12f62eec-754c-b677-b683-7dd448dc49ac");
78 | });
79 |
80 | Deno.test("WorkFlowy Document / Create list", () => {
81 | const document = mockDocument();
82 |
83 | const list = document.root.createList(1);
84 |
85 | assertEquals(list.parent.id, ROOT);
86 | assertEquals(list.priority, 1);
87 | assertEquals(document.root.itemIds.length, 8);
88 |
89 | const ops = document.getPendingOperations()[ROOT];
90 | assertEquals(ops.length, 1);
91 | assertObjectMatch(ops[0], {
92 | type: "create",
93 | data: {
94 | projectid: list.id,
95 | parentid: ROOT,
96 | priority: 1,
97 | },
98 | });
99 | });
100 |
101 | Deno.test("WorkFlowy Document / Edit list", () => {
102 | const document = mockDocument();
103 |
104 | const list = document.root.items[1];
105 |
106 | list.setName("New name").setNote("New description");
107 |
108 | assertEquals(document.root.items[1].name, "New name");
109 | assertEquals(document.root.items[1].note, "New description");
110 |
111 | const ops = document.getPendingOperations()[ROOT];
112 | assertEquals(ops.length, 2);
113 | assertObjectMatch(ops[0], {
114 | type: "edit",
115 | data: {
116 | projectid: list.id,
117 | name: "New name",
118 | },
119 | undo_data: {
120 | previous_name: "List with description",
121 | },
122 | });
123 | assertObjectMatch(ops[1], {
124 | type: "edit",
125 | data: {
126 | projectid: list.id,
127 | description: "New description",
128 | },
129 | undo_data: {
130 | previous_description: "Two Description",
131 | },
132 | });
133 | });
134 |
135 | Deno.test("WorkFlowy Document / Edit mirror", () => {
136 | const document = mockDocument();
137 |
138 | const list = document.root.items[4];
139 |
140 | list.setName("New name");
141 |
142 | assertEquals(document.root.items[4].name, "New name");
143 | assertEquals(document.root.items[3].name, "New name");
144 |
145 | const ops = document.getPendingOperations()[ROOT];
146 | assertEquals(ops.length, 1);
147 | assertObjectMatch(ops[0], {
148 | type: "edit",
149 | data: {
150 | projectid: list.originalId,
151 | name: "New name",
152 | },
153 | undo_data: {
154 | previous_name: "List mirrored",
155 | },
156 | });
157 | });
158 |
159 | Deno.test("WorkFlowy Document / Complete list", () => {
160 | const document = mockDocument();
161 |
162 | const list = document.root.items[1];
163 |
164 | list.setCompleted().setCompleted();
165 |
166 | assertEquals(document.root.items[1].isCompleted, true);
167 |
168 | const ops = document.getPendingOperations()[ROOT];
169 | assertEquals(ops.length, 1);
170 | assertObjectMatch(ops[0], {
171 | type: "complete",
172 | data: {
173 | projectid: list.id,
174 | },
175 | undo_data: {
176 | previous_completed: false,
177 | },
178 | });
179 | });
180 |
181 | Deno.test("WorkFlowy Document / Uncomplete list", () => {
182 | const document = mockDocument();
183 |
184 | const list = document.root.items[2];
185 |
186 | list.setCompleted(false).setCompleted(false);
187 |
188 | assertEquals(document.root.items[2].isCompleted, false);
189 |
190 | const ops = document.getPendingOperations()[ROOT];
191 | assertEquals(ops.length, 1);
192 | assertObjectMatch(ops[0], {
193 | type: "uncomplete",
194 | data: {
195 | projectid: list.id,
196 | },
197 | undo_data: {
198 | previous_completed: 199,
199 | },
200 | });
201 | });
202 |
203 | Deno.test("WorkFlowy Document / Move list", () => {
204 | const document = mockDocument();
205 |
206 | const originalParent = document.root.items[0];
207 | const list = originalParent.items[1];
208 | const target = document.root.items[3];
209 |
210 | assertEquals(originalParent.items.length, 2);
211 | assertEquals(target.items.length, 1);
212 |
213 | list.move(target, 0);
214 |
215 | assertEquals(originalParent.items.length, 1);
216 | assertEquals(list.parent.id, target.id);
217 | assertEquals(target.itemIds[0], list.id);
218 | assertEquals(target.items.length, 2);
219 |
220 | const ops = document.getPendingOperations()[ROOT];
221 | assertEquals(ops.length, 1);
222 | assertObjectMatch(ops[0], {
223 | type: "move",
224 | data: {
225 | projectid: list.id,
226 | parentid: target.id,
227 | priority: 0,
228 | },
229 | undo_data: {
230 | previous_parentid: originalParent.id,
231 | previous_priority: 1,
232 | },
233 | });
234 | });
235 |
236 | Deno.test("WorkFlowy Document / Delete list", () => {
237 | const document = mockDocument();
238 |
239 | const list = document.root.items[1];
240 |
241 | assertEquals(document.root.items.length, 7);
242 | assertEquals(document.root.items[1].name, "List with description");
243 |
244 | list.delete();
245 |
246 | assertEquals(document.root.items.length, 6);
247 | assertEquals(document.root.items[1].name, "List completed");
248 |
249 | const ops = document.getPendingOperations()[ROOT];
250 | assertEquals(ops.length, 1);
251 | assertObjectMatch(ops[0], {
252 | type: "delete",
253 | data: {
254 | projectid: list.id,
255 | },
256 | undo_data: {},
257 | });
258 | });
259 |
260 | Deno.test("WorkFlowy Document / Sharing / Share unshared list via URL", () => {
261 | const document = mockDocument();
262 |
263 | const list = document.root.items[0];
264 |
265 | assertEquals(list.isSharedViaEmail, false);
266 | assertEquals(list.isSharedViaUrl, false);
267 | assertEquals(list.sharedUrl, undefined);
268 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
269 |
270 | const url = list.shareViaUrl(PermissionLevel.EditAndComment);
271 |
272 | assertEquals(list.isSharedViaEmail, false);
273 | assertEquals(list.isSharedViaUrl, true);
274 | assertEquals(list.sharedUrl, url);
275 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.EditAndComment);
276 |
277 | const ops = document.getPendingOperations()[ROOT];
278 | assertEquals(ops.length, 2);
279 | assertObjectMatch(ops[0], {
280 | type: "share",
281 | data: {
282 | projectid: list.id,
283 | },
284 | undo_data: {},
285 | });
286 | assertObjectMatch(ops[1], {
287 | type: "add_shared_url",
288 | data: {
289 | projectid: list.id,
290 | access_token: list.sharedUrl?.split("/").pop(),
291 | permission_level: PermissionLevel.EditAndComment,
292 | },
293 | undo_data: {},
294 | });
295 | });
296 |
297 | Deno.test("WorkFlowy Document / Sharing / Share shared list via URL", () => {
298 | const document = mockDocument();
299 |
300 | const list = document.root.items[6];
301 |
302 | assertEquals(list.isSharedViaEmail, false);
303 | assertEquals(list.isSharedViaUrl, true);
304 | assertEquals(list.sharedUrl, "https://workflowy.com/s/plUzlWcMHcwbR3wZ");
305 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.View);
306 |
307 | list.shareViaUrl(PermissionLevel.EditAndComment);
308 |
309 | assertEquals(list.isSharedViaEmail, false);
310 | assertEquals(list.isSharedViaUrl, true);
311 | assertEquals(list.sharedUrl, "https://workflowy.com/s/plUzlWcMHcwbR3wZ");
312 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.EditAndComment);
313 |
314 | const ops = document.getPendingOperations()[ROOT];
315 | assertEquals(ops.length, 1);
316 | assertObjectMatch(ops[0], {
317 | type: "add_shared_url",
318 | data: {
319 | projectid: list.id,
320 | access_token: list.sharedUrl?.split("/").pop(),
321 | permission_level: PermissionLevel.EditAndComment,
322 | },
323 | undo_data: {},
324 | });
325 | });
326 |
327 | Deno.test("WorkFlowy Document / Sharing / Unshare URL of unshared list", () => {
328 | const document = mockDocument();
329 |
330 | const list = document.root.items[0];
331 |
332 | assertEquals(list.isSharedViaEmail, false);
333 | assertEquals(list.isSharedViaUrl, false);
334 | assertEquals(list.sharedUrl, undefined);
335 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
336 |
337 | list.unshareViaUrl();
338 |
339 | assertEquals(list.isSharedViaEmail, false);
340 | assertEquals(list.isSharedViaUrl, false);
341 | assertEquals(list.sharedUrl, undefined);
342 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
343 |
344 | const ops = document.getPendingOperations()[ROOT];
345 | assertEquals(ops, undefined);
346 | });
347 |
348 | Deno.test("WorkFlowy Document / Sharing / Unshare URL of list shared via URL", () => {
349 | const document = mockDocument();
350 |
351 | const list = document.root.items[6];
352 |
353 | assertEquals(list.isSharedViaEmail, false);
354 | assertEquals(list.isSharedViaUrl, true);
355 | assertEquals(list.sharedUrl, "https://workflowy.com/s/plUzlWcMHcwbR3wZ");
356 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.View);
357 |
358 | list.unshareViaUrl();
359 |
360 | assertEquals(list.isSharedViaEmail, false);
361 | assertEquals(list.isSharedViaUrl, false);
362 | assertEquals(list.sharedUrl, undefined);
363 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
364 |
365 | const ops = document.getPendingOperations()[ROOT];
366 | assertEquals(ops.length, 2);
367 | assertObjectMatch(ops[0], {
368 | type: "remove_shared_url",
369 | data: {
370 | projectid: list.id,
371 | },
372 | undo_data: {},
373 | });
374 | assertObjectMatch(ops[1], {
375 | type: "unshare",
376 | data: {
377 | projectid: list.id,
378 | },
379 | undo_data: {},
380 | });
381 | });
382 |
383 | Deno.test("WorkFlowy Document / Sharing / Unshare URL of list shared via email and URL", () => {
384 | const document = mockDocument();
385 |
386 | const list = document.root.items[5];
387 |
388 | assertEquals(list.isSharedViaEmail, true);
389 | assertEquals(list.isSharedViaUrl, false);
390 | assertEquals(list.sharedUrl, undefined);
391 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
392 |
393 | const url = list.shareViaUrl(PermissionLevel.FullAccess);
394 | list.unshareViaUrl();
395 |
396 | assertEquals(list.isSharedViaEmail, true);
397 | assertEquals(list.isSharedViaUrl, false);
398 | assertEquals(list.sharedUrl, undefined);
399 | assertEquals(list.sharedUrlPermissionLevel, PermissionLevel.None);
400 |
401 | const ops = document.getPendingOperations()[ROOT];
402 | assertEquals(ops.length, 2);
403 | assertObjectMatch(ops[0], {
404 | type: "add_shared_url",
405 | data: {
406 | projectid: list.id,
407 | access_token: url.split("/").pop(),
408 | permission_level: PermissionLevel.FullAccess,
409 | },
410 | undo_data: {},
411 | });
412 | assertObjectMatch(ops[1], {
413 | type: "remove_shared_url",
414 | data: {
415 | projectid: list.id,
416 | },
417 | undo_data: {},
418 | });
419 | });
420 |
421 | Deno.test("WorkFlowy Document / Expand list", () => {
422 | const document = mockDocument();
423 |
424 | const list = document.getList("12f62eec-754c-b677-b683-7dd448dc49ac");
425 |
426 | assertEquals(list.isExpanded, false);
427 |
428 | list.expand();
429 |
430 | assertEquals(list.isExpanded, true);
431 |
432 | const expandedDeltas = document.getPendingExpansionsDelta();
433 | assertEquals(expandedDeltas.size, 1);
434 | assertEquals(expandedDeltas.has("12f62eec"), true);
435 | assertEquals(expandedDeltas.get("12f62eec"), true);
436 | });
437 |
438 | Deno.test("WorkFlowy Document / Collapse list", () => {
439 | const document = mockDocument();
440 |
441 | const list = document.getList("de843e0e-2d25-a812-1415-6ed31ae618f4");
442 |
443 | assertEquals(list.isExpanded, true);
444 |
445 | list.collapse();
446 |
447 | assertEquals(list.isExpanded, false);
448 |
449 | const expandedDeltas = document.getPendingExpansionsDelta();
450 | assertEquals(expandedDeltas.size, 1);
451 | assertEquals(expandedDeltas.has("de843e0e"), true);
452 | assertEquals(expandedDeltas.get("de843e0e"), false);
453 | });
454 |
--------------------------------------------------------------------------------
/src/document.ts:
--------------------------------------------------------------------------------
1 | import type { Client } from "./client.ts";
2 | import {
3 | type InitializationData,
4 | type Operation,
5 | ROOT,
6 | type TreeData,
7 | type TreeItemShareInfo,
8 | type TreeItemWithChildren,
9 | } from "./schema.ts";
10 | import { toJson, toOpml, toPlainText, toString } from "./export.ts";
11 | import {
12 | createAccessToken,
13 | createSharedUrl,
14 | fromNativePermissionLevel,
15 | PermissionLevel,
16 | toNativePermissionLevel,
17 | } from "./share.ts";
18 |
19 | class Companion {
20 | private operations: Record = {};
21 | private expansionsDelta: Map = new Map();
22 | constructor(
23 | public readonly client: Client,
24 | public readonly itemMap: Map,
25 | public readonly shortIdMap = new Map(),
26 | public readonly shareMap: Map,
27 | public readonly shareIdMap: Map,
28 | public readonly expandedProjects: Set,
29 | public readonly initializationData: InitializationData,
30 | ) {}
31 |
32 | public resolveId(id: string): string {
33 | return this.shortIdMap.has(id) ? this.shortIdMap.get(id)! : id;
34 | }
35 |
36 | public getPendingOperations() {
37 | return this.operations;
38 | }
39 |
40 | public getPendingExpansionsDelta() {
41 | return this.expansionsDelta;
42 | }
43 |
44 | public addOperation(treeId: string, operation: Operation): void {
45 | if (!this.operations[treeId]) {
46 | this.operations[treeId] = [];
47 | }
48 | this.operations[treeId].push(operation);
49 | }
50 |
51 | public addExpansionDelta(id: string, expanded: boolean) {
52 | this.expansionsDelta.set(id, expanded);
53 | }
54 |
55 | public isDirty() {
56 | return Object.keys(this.operations).length > 0 ||
57 | this.expansionsDelta.size > 0;
58 | }
59 |
60 | public async save(): Promise {
61 | const ops = this.operations;
62 | this.operations = {}; // purge
63 | const expansionsDelta = Object.fromEntries(this.expansionsDelta);
64 | this.expansionsDelta = new Map(); // purge
65 | const operationResult = await this.client.pushAndPull(ops, expansionsDelta);
66 |
67 | const errorEncountered = operationResult.some((r) =>
68 | r.error_encountered_in_remote_operations
69 | );
70 |
71 | if (errorEncountered) {
72 | throw new Error("Error encountered in remote WorkFlowy operations");
73 | }
74 | }
75 |
76 | public getRealTimestamp(timestamp: number): Date {
77 | const u = timestamp +
78 | this.initializationData.mainProjectTreeInfo.dateJoinedTimestampInSeconds;
79 | return new Date(u * 1000);
80 | }
81 |
82 | public getNow(): number {
83 | const timeInSeconds = Math.floor(Date.now() / 1000);
84 | return timeInSeconds -
85 | this.initializationData.mainProjectTreeInfo.dateJoinedTimestampInSeconds;
86 | }
87 | }
88 |
89 | export class Document {
90 | #companion: Companion;
91 | /** Pointer to the root of the WorkFlowy */
92 | public readonly root: List;
93 |
94 | constructor(
95 | client: Client,
96 | data: TreeData,
97 | initializationData: InitializationData,
98 | sharedTrees: Record = {},
99 | ) {
100 | const itemMap = new Map();
101 | const shortIdMap = new Map();
102 |
103 | const getItem = (id: string, treeId: string) => {
104 | if (itemMap.has(id)) {
105 | return itemMap.get(id)!;
106 | }
107 | const shortId = id.split("-").pop()!;
108 | shortIdMap.set(shortId, id);
109 | const newItem = {
110 | id,
111 | children: [],
112 | treeId,
113 | } as unknown as TreeItemWithChildren;
114 | itemMap.set(id, newItem);
115 | return newItem;
116 | };
117 |
118 | data.items.sort((a, b) => Math.sign(a.priority - b.priority));
119 |
120 | for (const item of data.items) {
121 | const p = getItem(item.parentId, ROOT);
122 | p.children.push(item.id);
123 | const t = getItem(item.id, ROOT);
124 | itemMap.set(item.id, { ...t, ...item });
125 | }
126 |
127 | itemMap.get(ROOT)!.name = "Home";
128 |
129 | const shareMap = new Map();
130 |
131 | for (const [id, shareInfo] of Object.entries(data.shared_projects)) {
132 | shareMap.set(id, shareInfo);
133 | }
134 |
135 | const expandedProjects = new Set(data.server_expanded_projects_list);
136 |
137 | const shareIdMap = new Map();
138 |
139 | for (const [shareId, sharedTree] of Object.entries(sharedTrees)) {
140 | sharedTree.items.sort((a, b) => Math.sign(a.priority - b.priority));
141 |
142 | for (const item of sharedTree.items) {
143 | if (item.parentId !== ROOT) {
144 | const p = getItem(item.parentId, shareId);
145 | p.children.push(item.id);
146 | } else {
147 | shareIdMap.set(shareId, item.id);
148 | }
149 | const t = getItem(item.id, shareId);
150 | itemMap.set(item.id, { ...t, ...item });
151 | }
152 |
153 | for (
154 | const [id, shareInfo] of Object.entries(sharedTree.shared_projects)
155 | ) {
156 | shareMap.set(id, shareInfo);
157 | }
158 |
159 | for (const id of sharedTree.server_expanded_projects_list) {
160 | expandedProjects.add(id);
161 | }
162 | }
163 |
164 | for (const [id, shareInfo] of shareMap) {
165 | shareIdMap.set(shareInfo.shareId, id);
166 | }
167 |
168 | this.#companion = new Companion(
169 | client,
170 | itemMap,
171 | shortIdMap,
172 | shareMap,
173 | shareIdMap,
174 | expandedProjects,
175 | initializationData,
176 | );
177 | this.root = this.getList(ROOT);
178 | }
179 |
180 | /** Lists in the root of WorkFlowy */
181 | public get items(): List[] {
182 | return this.root.items;
183 | }
184 |
185 | /**
186 | * Returns a List specified by ID or short ID. Make sure that the ID exists.
187 | * The short ID is the part of ID that is visible in Workflowy URLs.
188 | * @param id ID of the list to get
189 | * @returns A list instance with the particular ID
190 | */
191 | public getList(id: string) {
192 | const resolvedId = this.#companion.resolveId(id);
193 | return new List(resolvedId, this.#companion);
194 | }
195 |
196 | /** Returns a list of changes to be made to WorkFlowy when saving */
197 | public getPendingOperations(): Record {
198 | return this.#companion.getPendingOperations();
199 | }
200 |
201 | /** Returns a map of list expansions changes to be made when saving*/
202 | public getPendingExpansionsDelta(): Map {
203 | return this.#companion.getPendingExpansionsDelta();
204 | }
205 |
206 | /** Returns true if there are unsaved changes */
207 | public isDirty(): boolean {
208 | return this.#companion.isDirty();
209 | }
210 |
211 | /** Saves the document */
212 | public save(): Promise {
213 | return this.#companion.save();
214 | }
215 | }
216 |
217 | /**
218 | * A list corresponds to a node in a WorkFlowy document
219 | */
220 | export class List {
221 | #companion: Companion;
222 |
223 | constructor(
224 | public readonly id: string,
225 | companion: Companion,
226 | ) {
227 | this.#companion = companion;
228 | }
229 |
230 | private get source(): TreeItemWithChildren {
231 | return this.#companion.itemMap.get(this.id)!;
232 | }
233 |
234 | private get data(): TreeItemWithChildren {
235 | const source = this.source;
236 | if (source.isMirrorRoot) {
237 | return this.#companion.itemMap.get(source.originalId!)!;
238 | }
239 | if (source.shareId !== undefined) {
240 | const proxyId = this.#companion.shareIdMap.get(source.shareId);
241 | if (proxyId === undefined) {
242 | throw new Error(`Shared list not found: ${source.shareId}`);
243 | }
244 | return this.#companion.itemMap.get(proxyId!)!;
245 | }
246 | return source;
247 | }
248 |
249 | private get shareData(): TreeItemShareInfo {
250 | const id = this.data.id;
251 | if (!this.#companion.shareMap.has(id)) {
252 | this.#companion.shareMap.set(id, {
253 | shareId: "none",
254 | isSharedViaUrl: false,
255 | urlAccessToken: undefined,
256 | urlPermissionLevel: undefined,
257 | isSharedViaEmail: false,
258 | });
259 | }
260 | return this.#companion.shareMap.get(id)!;
261 | }
262 |
263 | private get idPrefix(): string {
264 | const id = this.data.id;
265 | const hyphenIndex = id.indexOf("-");
266 | return hyphenIndex === -1 ? id : id.slice(0, hyphenIndex);
267 | }
268 |
269 | /** List name */
270 | public get name(): string {
271 | return this.data.name;
272 | }
273 |
274 | /** List note */
275 | public get note(): string {
276 | return this.data.note || "";
277 | }
278 |
279 | /** Date of last change */
280 | public get lastModifiedAt(): Date {
281 | return this.#companion.getRealTimestamp(this.data.lastModified);
282 | }
283 |
284 | /** Date of completion, or undefined if not completed */
285 | public get completedAt(): Date | undefined {
286 | if (this.data.completed !== undefined) {
287 | return this.#companion.getRealTimestamp(this.data.completed);
288 | }
289 | return undefined;
290 | }
291 |
292 | /** True if completed, false otherwise */
293 | public get isCompleted(): boolean {
294 | return this.completedAt !== undefined;
295 | }
296 |
297 | /** True if the list is a mirror of another list */
298 | public get isMirror(): boolean {
299 | return this.source.isMirrorRoot;
300 | }
301 |
302 | /** True if the list is expanded */
303 | public get isExpanded(): boolean {
304 | return this.#companion.expandedProjects.has(this.idPrefix);
305 | }
306 |
307 | /** ID of mirrored list, if this list is a mirror; otherwise undefined */
308 | public get originalId(): string | undefined {
309 | return this.source.originalId;
310 | }
311 |
312 | /** Returns a parent list */
313 | public get parent(): List {
314 | return new List(this.source.parentId, this.#companion);
315 | }
316 |
317 | /** Returns a position of the list relative to its siblings within the parent */
318 | public get priority(): number {
319 | return this.parent.itemIds.indexOf(this.id);
320 | }
321 |
322 | /** Returns all items in this list */
323 | public get items(): List[] {
324 | return this.data.children.map((cId) => new List(cId, this.#companion));
325 | }
326 |
327 | /** Returns all item IDs in this list */
328 | public get itemIds(): string[] {
329 | return this.data.children;
330 | }
331 |
332 | /** True if the list is shared via URL */
333 | public get isSharedViaUrl(): boolean {
334 | return this.shareData.isSharedViaUrl;
335 | }
336 |
337 | /** True if the list is shared via email */
338 | public get isSharedViaEmail(): boolean {
339 | return this.shareData.isSharedViaEmail;
340 | }
341 |
342 | /** Returns shared URL or undefined if the list is not shared */
343 | public get sharedUrl(): string | undefined {
344 | if (this.isSharedViaUrl) {
345 | const key = this.shareData.urlAccessToken ?? this.shareData.shareId;
346 | return createSharedUrl(key);
347 | }
348 | return undefined;
349 | }
350 |
351 | /** Returns shared permission level of the list */
352 | public get sharedUrlPermissionLevel(): PermissionLevel {
353 | return fromNativePermissionLevel(this.shareData.urlPermissionLevel || 0);
354 | }
355 |
356 | /** Finds an item in this list and returns it, undefined if it does not find any */
357 | public findOne(
358 | namePattern: RegExp,
359 | descriptionPattern = /.*/,
360 | ): List | undefined {
361 | const results = this.findAll(namePattern, descriptionPattern);
362 | return results.length > 0 ? results[0] : undefined;
363 | }
364 |
365 | /** Finds all items in this list */
366 | public findAll(
367 | namePattern: RegExp,
368 | notePattern = /.*/,
369 | ): List[] {
370 | const results: List[] = [];
371 | for (const candidate of this.items) {
372 | const nameMatch = candidate.name.match(namePattern);
373 | const descriptionMatch = candidate.note.match(
374 | notePattern,
375 | );
376 | if (nameMatch && descriptionMatch) {
377 | results.push(candidate);
378 | }
379 | }
380 | return results;
381 | }
382 |
383 | /**
384 | * Creates a new sublist
385 | * @param priority position of the sublist within other sublists;
386 | * -1 appends the list to the end
387 | * @returns the new list
388 | */
389 | public createList(priority = -1): List {
390 | if (priority === -1) {
391 | priority = this.itemIds.length;
392 | }
393 | priority = Math.max(0, Math.min(priority, this.itemIds.length));
394 |
395 | const newId = crypto.randomUUID();
396 |
397 | this.#companion.itemMap.set(newId, {
398 | id: newId,
399 | name: "",
400 | note: undefined,
401 | parentId: this.id,
402 | priority: 0, // there is some special algo in WF
403 | completed: undefined,
404 | lastModified: this.#companion.getNow(),
405 | originalId: undefined,
406 | isMirrorRoot: false,
407 | shareId: undefined,
408 | children: [],
409 | treeId: this.data.treeId,
410 | });
411 |
412 | this.#companion.shortIdMap.set(newId.split("-").pop()!, newId);
413 |
414 | this.itemIds.splice(priority, 0, newId);
415 |
416 | const parentid = this.id === "home" ? ROOT : this.id;
417 |
418 | this.#companion.addOperation(this.data.treeId, {
419 | type: "create",
420 | data: {
421 | projectid: newId,
422 | parentid,
423 | priority,
424 | },
425 | undo_data: {},
426 | });
427 |
428 | return new List(newId, this.#companion);
429 | }
430 |
431 | /**
432 | * Alias for `.createList` in case if it feels weird to call an item a list :-)
433 | * @param priority position of the item within other items in the same parent;
434 | * -1 appends the item to the end
435 | * @returns the new item
436 | */
437 | public createItem(priority = -1): List {
438 | return this.createList(priority);
439 | }
440 |
441 | /**
442 | * Sets a new name
443 | * @returns {List} this
444 | */
445 | public setName(name: string): List {
446 | this.#companion.addOperation(this.data.treeId, {
447 | type: "edit",
448 | data: {
449 | projectid: this.data.id,
450 | name,
451 | },
452 | undo_data: {
453 | previous_last_modified: this.data.lastModified,
454 | previous_last_modified_by: null,
455 | previous_name: this.name,
456 | },
457 | });
458 | this.data.name = name;
459 | return this;
460 | }
461 |
462 | /**
463 | * Sets a new note
464 | * @returns {List} this
465 | */
466 | public setNote(note: string): List {
467 | this.#companion.addOperation(this.data.treeId, {
468 | type: "edit",
469 | data: {
470 | projectid: this.data.id,
471 | description: note,
472 | },
473 | undo_data: {
474 | previous_last_modified: this.data.lastModified,
475 | previous_last_modified_by: null,
476 | previous_description: this.data.note,
477 | },
478 | });
479 | this.data.note = note;
480 | return this;
481 | }
482 |
483 | /**
484 | * Completes the list
485 | * @returns {List} this
486 | */
487 | public setCompleted(complete = true): List {
488 | if (complete === this.isCompleted) {
489 | return this;
490 | }
491 |
492 | this.#companion.addOperation(this.data.treeId, {
493 | type: complete ? "complete" : "uncomplete",
494 | data: {
495 | projectid: this.data.id,
496 | },
497 | undo_data: {
498 | previous_last_modified: this.data.lastModified,
499 | previous_last_modified_by: null,
500 | previous_completed: this.data.completed ?? false,
501 | },
502 | });
503 |
504 | this.data.completed = complete ? this.#companion.getNow() : undefined;
505 | return this;
506 | }
507 |
508 | /**
509 | * Moves this list to a different list
510 | *
511 | * @param {List} target New parent of the current list
512 | * @param {number} priority Position of this list in the new parent;
513 | * -1 appends the list to the end of the target list's children
514 | */
515 | public move(target: List, priority = -1) {
516 | if (priority === -1) {
517 | priority = target.itemIds.length;
518 | }
519 | priority = Math.max(0, Math.min(priority, target.itemIds.length));
520 |
521 | this.#companion.addOperation(this.data.treeId, {
522 | type: "move",
523 | data: {
524 | projectid: this.id,
525 | parentid: target.id,
526 | priority,
527 | },
528 | undo_data: {
529 | previous_parentid: this.parent.id,
530 | previous_priority: this.priority,
531 | previous_last_modified: this.data.lastModified,
532 | previous_last_modified_by: null,
533 | },
534 | });
535 |
536 | this.parent.itemIds.splice(this.priority, 1);
537 | this.data.parentId = target.id;
538 | target.itemIds.splice(priority, 0, this.id);
539 | }
540 |
541 | /**
542 | * Deletes this list from WorkFlowy. Use with caution!
543 | */
544 | public delete() {
545 | this.#companion.addOperation(this.data.treeId, {
546 | type: "delete",
547 | data: {
548 | projectid: this.id,
549 | },
550 | undo_data: {
551 | previous_last_modified: this.data.lastModified,
552 | previous_last_modified_by: null,
553 | parentid: this.parent.id,
554 | priority: this.priority,
555 | },
556 | });
557 | this.parent.itemIds.splice(this.priority, 1);
558 | this.#companion.itemMap.delete(this.id);
559 | this.#companion.shortIdMap.delete(this.id.split("-").pop()!);
560 | }
561 |
562 | /**
563 | * Enables sharing for the list and returns a shared URL
564 | *
565 | * @param permissionLevel Permission level - View, EditAndComment, or FullAccess
566 | * @returns Shared URL
567 | */
568 | public shareViaUrl(permissionLevel: PermissionLevel): string {
569 | if (
570 | permissionLevel !== PermissionLevel.View &&
571 | permissionLevel !== PermissionLevel.EditAndComment &&
572 | permissionLevel !== PermissionLevel.FullAccess
573 | ) {
574 | throw new Error(
575 | "Invalid permission level. Use PermissionLevel.View, PermissionLevel.EditAndComment, or PermissionLevel.FullAccess. To disable sharing use the unshare method.",
576 | );
577 | }
578 |
579 | if (
580 | this.isSharedViaUrl && this.sharedUrlPermissionLevel === permissionLevel
581 | ) {
582 | return this.sharedUrl!;
583 | }
584 |
585 | if (!this.isSharedViaUrl && !this.isSharedViaEmail) {
586 | this.#companion.addOperation(this.data.treeId, {
587 | type: "share",
588 | data: {
589 | projectid: this.id,
590 | },
591 | undo_data: {
592 | previous_last_modified: this.data.lastModified,
593 | previous_last_modified_by: null,
594 | },
595 | });
596 | }
597 |
598 | this.shareData.isSharedViaUrl = true;
599 |
600 | if (!this.shareData.urlAccessToken) {
601 | this.shareData.urlAccessToken = createAccessToken();
602 | }
603 |
604 | this.shareData.urlPermissionLevel = toNativePermissionLevel(
605 | permissionLevel,
606 | );
607 |
608 | this.#companion.addOperation(this.data.treeId, {
609 | type: "add_shared_url",
610 | data: {
611 | projectid: this.id,
612 | permission_level: this.shareData.urlPermissionLevel,
613 | access_token: this.shareData.urlAccessToken,
614 | },
615 | undo_data: {
616 | previous_last_modified: this.data.lastModified,
617 | previous_last_modified_by: null,
618 | previous_permission_level: this.shareData.urlPermissionLevel, // This is weird, but WF does it so
619 | },
620 | });
621 |
622 | return this.sharedUrl!;
623 | }
624 |
625 | /**
626 | * Disables sharing via URL for the list
627 | */
628 | public unshareViaUrl() {
629 | if (!this.isSharedViaUrl) {
630 | return;
631 | }
632 |
633 | this.#companion.addOperation(this.data.treeId, {
634 | type: "remove_shared_url",
635 | data: {
636 | projectid: this.id,
637 | },
638 | undo_data: {
639 | previous_last_modified: this.data.lastModified,
640 | previous_last_modified_by: null,
641 | permission_level: this.shareData.urlPermissionLevel!, // This is weird, but WF does it so
642 | },
643 | });
644 |
645 | this.shareData.isSharedViaUrl = false;
646 | this.shareData.urlAccessToken = undefined;
647 | this.shareData.urlPermissionLevel = undefined;
648 |
649 | if (this.isSharedViaEmail) {
650 | return;
651 | }
652 |
653 | this.#companion.addOperation(this.data.treeId, {
654 | type: "unshare",
655 | data: {
656 | projectid: this.id,
657 | },
658 | undo_data: {
659 | previous_last_modified: this.data.lastModified,
660 | previous_last_modified_by: null,
661 | },
662 | });
663 | }
664 |
665 | /**
666 | * Expands the list
667 | */
668 | public expand(): void {
669 | if (!this.isExpanded) {
670 | this.#companion.expandedProjects.add(this.idPrefix);
671 | this.#companion.addExpansionDelta(this.idPrefix, true);
672 | }
673 | }
674 |
675 | /**
676 | * Collapses the list
677 | */
678 | public collapse(): void {
679 | if (this.isExpanded) {
680 | this.#companion.expandedProjects.delete(this.idPrefix);
681 | this.#companion.addExpansionDelta(this.idPrefix, false);
682 | }
683 | }
684 |
685 | /**
686 | * Prints the list and its content as a nice string
687 | *
688 | * @param omitHeader Whether to print only the content the current list
689 | * @returns {string} stringified list
690 | */
691 | public toString(omitHeader = false): string {
692 | return toString(this, omitHeader);
693 | }
694 |
695 | /**
696 | * Prints the list and its content in Plain Text format
697 | *
698 | * @returns {string} list in Plain Text format
699 | */
700 | public toPlainText(): string {
701 | return toPlainText(this);
702 | }
703 |
704 | /**
705 | * Prints the list and its content in JSON format
706 | *
707 | * @returns list in JSON format
708 | */
709 | // deno-lint-ignore no-explicit-any
710 | public toJson(): any {
711 | return toJson(this);
712 | }
713 |
714 | /**
715 | * Prints the list and its content in OPML format
716 | *
717 | * @returns list in OPML format
718 | */
719 | public toOpml(): string {
720 | return toOpml(this);
721 | }
722 | }
723 |
--------------------------------------------------------------------------------