├── .github └── FUNDING.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── _npm.ts ├── deno.json ├── deno.lock ├── deps.ts ├── mod.ts ├── src ├── client.ts ├── document.ts ├── export.ts ├── schema.ts ├── share.ts └── workflowy.ts └── tests ├── document_test.ts ├── export_test.ts ├── mocks ├── export_json_all.json ├── export_json_partial.json ├── export_opml_all.xml ├── export_opml_partial.xml ├── export_plaintext_all.txt ├── export_plaintext_partial.txt ├── export_string_all.txt ├── export_string_partial.txt ├── get_initialization_data.json ├── get_initialization_data_shared.json ├── get_tree_data.json ├── get_tree_data_extended.json ├── get_tree_data_shared_first.json ├── get_tree_data_shared_main.json └── get_tree_data_shared_second.json ├── shared_test.ts └── test_deps.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: karelklima 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true, 4 | "editor.defaultFormatter": "denoland.vscode-deno", 5 | "editor.formatOnSave": true 6 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /_npm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This script builds the NPM package from Deno source 3 | */ 4 | import { build, emptyDir } from "jsr:@deno/dnt@^0.41.3"; 5 | 6 | await emptyDir("./npm"); 7 | 8 | await build({ 9 | entryPoints: ["./mod.ts"], 10 | outDir: "./npm", 11 | shims: { 12 | deno: "dev", 13 | weakRef: "dev", 14 | undici: true, 15 | crypto: true, 16 | }, 17 | compilerOptions: { 18 | lib: ["ES2023"], 19 | target: "ES2022", 20 | }, 21 | package: { 22 | // package.json properties 23 | name: "workflowy", 24 | version: Deno.args[0], 25 | description: "WorkFlowy client for reading and updating of lists", 26 | author: "Karel Klima (https://karelklima.com)", 27 | license: "MIT", 28 | repository: { 29 | type: "git", 30 | url: "git+https://github.com/karelklima/workflowy.git", 31 | }, 32 | bugs: { 33 | url: "https://github.com/karelklima/workflowy/issues", 34 | }, 35 | }, 36 | postBuild() { 37 | // steps to run after building and before running the tests 38 | Deno.copyFileSync("LICENSE", "npm/LICENSE"); 39 | Deno.copyFileSync("README.md", "npm/README.md"); 40 | 41 | const testMocks = [ 42 | "export_string_all.txt", 43 | "export_string_partial.txt", 44 | "export_plaintext_all.txt", 45 | "export_plaintext_partial.txt", 46 | "export_json_all.json", 47 | "export_json_partial.json", 48 | "export_opml_all.xml", 49 | "export_opml_partial.xml", 50 | ]; 51 | 52 | for (const mock of testMocks) { 53 | Deno.copyFileSync( 54 | `tests/mocks/${mock}`, 55 | `npm/script/tests/mocks/${mock}`, 56 | ); 57 | Deno.copyFileSync( 58 | `tests/mocks/${mock}`, 59 | `npm/esm/tests/mocks/${mock}`, 60 | ); 61 | } 62 | }, 63 | }); 64 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@workflowy/workflowy", 3 | "version": "2.8.0", 4 | "exports": "./mod.ts" 5 | } 6 | -------------------------------------------------------------------------------- /deno.lock: -------------------------------------------------------------------------------- 1 | { 2 | "version": "3", 3 | "packages": { 4 | "specifiers": { 5 | "jsr:@david/code-block-writer@^13.0.2": "jsr:@david/code-block-writer@13.0.3", 6 | "jsr:@deno/cache-dir@^0.10.3": "jsr:@deno/cache-dir@0.10.3", 7 | "jsr:@deno/dnt@^0.41.3": "jsr:@deno/dnt@0.41.3", 8 | "jsr:@std/assert": "jsr:@std/assert@1.0.6", 9 | "jsr:@std/assert@1.0.0": "jsr:@std/assert@1.0.0", 10 | "jsr:@std/assert@1.0.2": "jsr:@std/assert@1.0.2", 11 | "jsr:@std/assert@1.0.4": "jsr:@std/assert@1.0.4", 12 | "jsr:@std/assert@^0.223.0": "jsr:@std/assert@0.223.0", 13 | "jsr:@std/assert@^0.226.0": "jsr:@std/assert@0.226.0", 14 | "jsr:@std/bytes@^0.223.0": "jsr:@std/bytes@0.223.0", 15 | "jsr:@std/fmt@1": "jsr:@std/fmt@1.0.2", 16 | "jsr:@std/fmt@^0.223": "jsr:@std/fmt@0.223.0", 17 | "jsr:@std/fs@1": "jsr:@std/fs@1.0.4", 18 | "jsr:@std/fs@^0.223": "jsr:@std/fs@0.223.0", 19 | "jsr:@std/fs@^0.229.3": "jsr:@std/fs@0.229.3", 20 | "jsr:@std/http": "jsr:@std/http@1.0.7", 21 | "jsr:@std/http@1.0.0": "jsr:@std/http@1.0.0", 22 | "jsr:@std/http@^1.0.0": "jsr:@std/http@1.0.7", 23 | "jsr:@std/internal@^1.0.1": "jsr:@std/internal@1.0.4", 24 | "jsr:@std/internal@^1.0.3": "jsr:@std/internal@1.0.4", 25 | "jsr:@std/internal@^1.0.4": "jsr:@std/internal@1.0.4", 26 | "jsr:@std/io@^0.223": "jsr:@std/io@0.223.0", 27 | "jsr:@std/path@1": "jsr:@std/path@1.0.6", 28 | "jsr:@std/path@1.0.0-rc.1": "jsr:@std/path@1.0.0-rc.1", 29 | "jsr:@std/path@^0.223": "jsr:@std/path@0.223.0", 30 | "jsr:@std/path@^0.225.2": "jsr:@std/path@0.225.2", 31 | "jsr:@std/path@^1.0.6": "jsr:@std/path@1.0.6", 32 | "jsr:@ts-morph/bootstrap@^0.24.0": "jsr:@ts-morph/bootstrap@0.24.0", 33 | "jsr:@ts-morph/common@^0.24.0": "jsr:@ts-morph/common@0.24.0", 34 | "npm:zod": "npm:zod@3.23.8", 35 | "npm:zod@^3.10.0": "npm:zod@3.23.8" 36 | }, 37 | "jsr": { 38 | "@david/code-block-writer@13.0.3": { 39 | "integrity": "f98c77d320f5957899a61bfb7a9bead7c6d83ad1515daee92dbacc861e13bb7f" 40 | }, 41 | "@deno/cache-dir@0.10.3": { 42 | "integrity": "eb022f84ecc49c91d9d98131c6e6b118ff63a29e343624d058646b9d50404776", 43 | "dependencies": [ 44 | "jsr:@std/fmt@^0.223", 45 | "jsr:@std/fs@^0.223", 46 | "jsr:@std/io@^0.223", 47 | "jsr:@std/path@^0.223" 48 | ] 49 | }, 50 | "@deno/dnt@0.41.3": { 51 | "integrity": "b2ef2c8a5111eef86cb5bfcae103d6a2938e8e649e2461634a7befb7fc59d6d2", 52 | "dependencies": [ 53 | "jsr:@david/code-block-writer@^13.0.2", 54 | "jsr:@deno/cache-dir@^0.10.3", 55 | "jsr:@std/fmt@1", 56 | "jsr:@std/fs@1", 57 | "jsr:@std/path@1", 58 | "jsr:@ts-morph/bootstrap@^0.24.0" 59 | ] 60 | }, 61 | "@std/assert@0.223.0": { 62 | "integrity": "eb8d6d879d76e1cc431205bd346ed4d88dc051c6366365b1af47034b0670be24" 63 | }, 64 | "@std/assert@0.226.0": { 65 | "integrity": "0dfb5f7c7723c18cec118e080fec76ce15b4c31154b15ad2bd74822603ef75b3" 66 | }, 67 | "@std/assert@1.0.0": { 68 | "integrity": "0e4f6d873f7f35e2a1e6194ceee39686c996b9e5d134948e644d35d4c4df2008", 69 | "dependencies": [ 70 | "jsr:@std/internal@^1.0.1" 71 | ] 72 | }, 73 | "@std/assert@1.0.2": { 74 | "integrity": "ccacec332958126deaceb5c63ff8b4eaf9f5ed0eac9feccf124110435e59e49c", 75 | "dependencies": [ 76 | "jsr:@std/internal@^1.0.1" 77 | ] 78 | }, 79 | "@std/assert@1.0.4": { 80 | "integrity": "d4c767ea578e5bc09c15b6e503376003e5b2d1f4c0cdf08524a92101ff4d7b96", 81 | "dependencies": [ 82 | "jsr:@std/internal@^1.0.3" 83 | ] 84 | }, 85 | "@std/assert@1.0.6": { 86 | "integrity": "1904c05806a25d94fe791d6d883b685c9e2dcd60e4f9fc30f4fc5cf010c72207", 87 | "dependencies": [ 88 | "jsr:@std/internal@^1.0.4" 89 | ] 90 | }, 91 | "@std/bytes@0.223.0": { 92 | "integrity": "84b75052cd8680942c397c2631318772b295019098f40aac5c36cead4cba51a8" 93 | }, 94 | "@std/fmt@0.223.0": { 95 | "integrity": "6deb37794127dfc7d7bded2586b9fc6f5d50e62a8134846608baf71ffc1a5208" 96 | }, 97 | "@std/fmt@1.0.2": { 98 | "integrity": "87e9dfcdd3ca7c066e0c3c657c1f987c82888eb8103a3a3baa62684ffeb0f7a7" 99 | }, 100 | "@std/fs@0.223.0": { 101 | "integrity": "3b4b0550b2c524cbaaa5a9170c90e96cbb7354e837ad1bdaf15fc9df1ae9c31c" 102 | }, 103 | "@std/fs@0.229.3": { 104 | "integrity": "783bca21f24da92e04c3893c9e79653227ab016c48e96b3078377ebd5222e6eb", 105 | "dependencies": [ 106 | "jsr:@std/path@1.0.0-rc.1" 107 | ] 108 | }, 109 | "@std/fs@1.0.4": { 110 | "integrity": "2907d32d8d1d9e540588fd5fe0ec21ee638134bd51df327ad4e443aaef07123c", 111 | "dependencies": [ 112 | "jsr:@std/path@^1.0.6" 113 | ] 114 | }, 115 | "@std/http@1.0.0": { 116 | "integrity": "001812606b471c2d64799f14e5677bd26bfd029c35046eba0bddac1a49109789" 117 | }, 118 | "@std/http@1.0.7": { 119 | "integrity": "9b904fc256678a5c9759f1a53a24a3fdcc59d83dc62099bb472683b6f819194c" 120 | }, 121 | "@std/internal@1.0.4": { 122 | "integrity": "62e8e4911527e5e4f307741a795c0b0a9e6958d0b3790716ae71ce085f755422" 123 | }, 124 | "@std/io@0.223.0": { 125 | "integrity": "2d8c3c2ab3a515619b90da2c6ff5ea7b75a94383259ef4d02116b228393f84f1", 126 | "dependencies": [ 127 | "jsr:@std/assert@^0.223.0", 128 | "jsr:@std/bytes@^0.223.0" 129 | ] 130 | }, 131 | "@std/path@0.223.0": { 132 | "integrity": "593963402d7e6597f5a6e620931661053572c982fc014000459edc1f93cc3989", 133 | "dependencies": [ 134 | "jsr:@std/assert@^0.223.0" 135 | ] 136 | }, 137 | "@std/path@0.225.2": { 138 | "integrity": "0f2db41d36b50ef048dcb0399aac720a5348638dd3cb5bf80685bf2a745aa506", 139 | "dependencies": [ 140 | "jsr:@std/assert@^0.226.0" 141 | ] 142 | }, 143 | "@std/path@1.0.0-rc.1": { 144 | "integrity": "b8c00ae2f19106a6bb7cbf1ab9be52aa70de1605daeb2dbdc4f87a7cbaf10ff6" 145 | }, 146 | "@std/path@1.0.6": { 147 | "integrity": "ab2c55f902b380cf28e0eec501b4906e4c1960d13f00e11cfbcd21de15f18fed" 148 | }, 149 | "@ts-morph/bootstrap@0.24.0": { 150 | "integrity": "a826a2ef7fa8a7c3f1042df2c034d20744d94da2ee32bf29275bcd4dffd3c060", 151 | "dependencies": [ 152 | "jsr:@ts-morph/common@^0.24.0" 153 | ] 154 | }, 155 | "@ts-morph/common@0.24.0": { 156 | "integrity": "12b625b8e562446ba658cdbe9ad77774b4bd96b992ae8bd34c60dbf24d06c1f3", 157 | "dependencies": [ 158 | "jsr:@std/fs@^0.229.3", 159 | "jsr:@std/path@^0.225.2" 160 | ] 161 | } 162 | }, 163 | "npm": { 164 | "zod@3.23.8": { 165 | "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", 166 | "dependencies": {} 167 | } 168 | } 169 | }, 170 | "remote": {} 171 | } 172 | -------------------------------------------------------------------------------- /deps.ts: -------------------------------------------------------------------------------- 1 | export { getSetCookies, setCookie } from "jsr:@std/http@^1.0.0/cookie"; 2 | 3 | export { z } from "npm:zod@^3.10.0"; 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.join(", ")}`); 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 | : operations; 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 | -------------------------------------------------------------------------------- /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(this.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 | return createSharedUrl(this.shareData.urlAccessToken!); 346 | } 347 | return undefined; 348 | } 349 | 350 | /** Returns shared permission level of the list */ 351 | public get sharedUrlPermissionLevel(): PermissionLevel { 352 | return fromNativePermissionLevel(this.shareData.urlPermissionLevel || 0); 353 | } 354 | 355 | /** Finds an item in this list and returns it, undefined if it does not find any */ 356 | public findOne( 357 | namePattern: RegExp, 358 | descriptionPattern = /.*/, 359 | ): List | undefined { 360 | const results = this.findAll(namePattern, descriptionPattern); 361 | return results.length > 0 ? results[0] : undefined; 362 | } 363 | 364 | /** Finds all items in this list */ 365 | public findAll( 366 | namePattern: RegExp, 367 | notePattern = /.*/, 368 | ): List[] { 369 | const results: List[] = []; 370 | for (const candidate of this.items) { 371 | const nameMatch = candidate.name.match(namePattern); 372 | const descriptionMatch = candidate.note.match( 373 | notePattern, 374 | ); 375 | if (nameMatch && descriptionMatch) { 376 | results.push(candidate); 377 | } 378 | } 379 | return results; 380 | } 381 | 382 | /** 383 | * Creates a new sublist 384 | * @param priority position of the sublist within other sublists; 385 | * -1 appends the list to the end 386 | * @returns the new list 387 | */ 388 | public createList(priority = -1): List { 389 | if (priority === -1) { 390 | priority = this.itemIds.length; 391 | } 392 | priority = Math.max(0, Math.min(priority, this.itemIds.length)); 393 | 394 | const newId = crypto.randomUUID(); 395 | 396 | this.#companion.itemMap.set(newId, { 397 | id: newId, 398 | name: "", 399 | note: undefined, 400 | parentId: this.id, 401 | priority: 0, // there is some special algo in WF 402 | completed: undefined, 403 | lastModified: this.#companion.getNow(), 404 | originalId: undefined, 405 | isMirrorRoot: false, 406 | shareId: undefined, 407 | children: [], 408 | treeId: this.data.treeId, 409 | }); 410 | 411 | this.#companion.shortIdMap.set(newId.split("-").pop()!, newId); 412 | 413 | this.itemIds.splice(priority, 0, newId); 414 | 415 | const parentid = this.id === "home" ? ROOT : this.id; 416 | 417 | this.#companion.addOperation(this.data.treeId, { 418 | type: "create", 419 | data: { 420 | projectid: newId, 421 | parentid, 422 | priority, 423 | }, 424 | undo_data: {}, 425 | }); 426 | 427 | return new List(newId, this.#companion); 428 | } 429 | 430 | /** 431 | * Alias for `.createList` in case if it feels weird to call an item a list :-) 432 | * @param priority position of the item within other items in the same parent; 433 | * -1 appends the item to the end 434 | * @returns the new item 435 | */ 436 | public createItem(priority = -1): List { 437 | return this.createList(priority); 438 | } 439 | 440 | /** 441 | * Sets a new name 442 | * @returns {List} this 443 | */ 444 | public setName(name: string): List { 445 | this.#companion.addOperation(this.data.treeId, { 446 | type: "edit", 447 | data: { 448 | projectid: this.data.id, 449 | name, 450 | }, 451 | undo_data: { 452 | previous_last_modified: this.data.lastModified, 453 | previous_last_modified_by: null, 454 | previous_name: this.name, 455 | }, 456 | }); 457 | this.data.name = name; 458 | return this; 459 | } 460 | 461 | /** 462 | * Sets a new note 463 | * @returns {List} this 464 | */ 465 | public setNote(note: string): List { 466 | this.#companion.addOperation(this.data.treeId, { 467 | type: "edit", 468 | data: { 469 | projectid: this.data.id, 470 | description: note, 471 | }, 472 | undo_data: { 473 | previous_last_modified: this.data.lastModified, 474 | previous_last_modified_by: null, 475 | previous_description: this.data.note, 476 | }, 477 | }); 478 | this.data.note = note; 479 | return this; 480 | } 481 | 482 | /** 483 | * Completes the list 484 | * @returns {List} this 485 | */ 486 | public setCompleted(complete = true): List { 487 | if (complete === this.isCompleted) { 488 | return this; 489 | } 490 | 491 | this.#companion.addOperation(this.data.treeId, { 492 | type: complete ? "complete" : "uncomplete", 493 | data: { 494 | projectid: this.data.id, 495 | }, 496 | undo_data: { 497 | previous_last_modified: this.data.lastModified, 498 | previous_last_modified_by: null, 499 | previous_completed: this.data.completed ?? false, 500 | }, 501 | }); 502 | 503 | this.data.completed = complete ? this.#companion.getNow() : undefined; 504 | return this; 505 | } 506 | 507 | /** 508 | * Moves this list to a different list 509 | * 510 | * @param {List} target New parent of the current list 511 | * @param {number} priority Position of this list in the new parent; 512 | * -1 appends the list to the end of the target list's children 513 | */ 514 | public move(target: List, priority = -1) { 515 | if (priority === -1) { 516 | priority = target.itemIds.length; 517 | } 518 | priority = Math.max(0, Math.min(priority, target.itemIds.length)); 519 | 520 | this.#companion.addOperation(this.data.treeId, { 521 | type: "move", 522 | data: { 523 | projectid: this.id, 524 | parentid: target.id, 525 | priority, 526 | }, 527 | undo_data: { 528 | previous_parentid: this.parent.id, 529 | previous_priority: this.priority, 530 | previous_last_modified: this.data.lastModified, 531 | previous_last_modified_by: null, 532 | }, 533 | }); 534 | 535 | this.parent.itemIds.splice(this.priority, 1); 536 | this.data.parentId = target.id; 537 | target.itemIds.splice(priority, 0, this.id); 538 | } 539 | 540 | /** 541 | * Deletes this list from WorkFlowy. Use with caution! 542 | */ 543 | public delete() { 544 | this.#companion.addOperation(this.data.treeId, { 545 | type: "delete", 546 | data: { 547 | projectid: this.id, 548 | }, 549 | undo_data: { 550 | previous_last_modified: this.data.lastModified, 551 | previous_last_modified_by: null, 552 | parentid: this.parent.id, 553 | priority: this.priority, 554 | }, 555 | }); 556 | this.parent.itemIds.splice(this.priority, 1); 557 | this.#companion.itemMap.delete(this.id); 558 | this.#companion.shortIdMap.delete(this.id.split("-").pop()!); 559 | } 560 | 561 | /** 562 | * Enables sharing for the list and returns a shared URL 563 | * 564 | * @param permissionLevel Permission level - View, EditAndComment, or FullAccess 565 | * @returns Shared URL 566 | */ 567 | public shareViaUrl(permissionLevel: PermissionLevel): string { 568 | if ( 569 | permissionLevel !== PermissionLevel.View && 570 | permissionLevel !== PermissionLevel.EditAndComment && 571 | permissionLevel !== PermissionLevel.FullAccess 572 | ) { 573 | throw new Error( 574 | "Invalid permission level. Use PermissionLevel.View, PermissionLevel.EditAndComment, or PermissionLevel.FullAccess. To disable sharing use the unshare method.", 575 | ); 576 | } 577 | 578 | if ( 579 | this.isSharedViaUrl && this.sharedUrlPermissionLevel === permissionLevel 580 | ) { 581 | return this.sharedUrl!; 582 | } 583 | 584 | if (!this.isSharedViaUrl && !this.isSharedViaEmail) { 585 | this.#companion.addOperation(this.data.treeId, { 586 | type: "share", 587 | data: { 588 | projectid: this.id, 589 | }, 590 | undo_data: { 591 | previous_last_modified: this.data.lastModified, 592 | previous_last_modified_by: null, 593 | }, 594 | }); 595 | } 596 | 597 | this.shareData.isSharedViaUrl = true; 598 | 599 | if (!this.shareData.urlAccessToken) { 600 | this.shareData.urlAccessToken = createAccessToken(); 601 | } 602 | 603 | this.shareData.urlPermissionLevel = toNativePermissionLevel( 604 | permissionLevel, 605 | ); 606 | 607 | this.#companion.addOperation(this.data.treeId, { 608 | type: "add_shared_url", 609 | data: { 610 | projectid: this.id, 611 | permission_level: this.shareData.urlPermissionLevel, 612 | access_token: this.shareData.urlAccessToken, 613 | }, 614 | undo_data: { 615 | previous_last_modified: this.data.lastModified, 616 | previous_last_modified_by: null, 617 | previous_permission_level: this.shareData.urlPermissionLevel, // This is weird, but WF does it so 618 | }, 619 | }); 620 | 621 | return this.sharedUrl!; 622 | } 623 | 624 | /** 625 | * Disables sharing via URL for the list 626 | */ 627 | public unshareViaUrl() { 628 | if (!this.isSharedViaUrl) { 629 | return; 630 | } 631 | 632 | this.#companion.addOperation(this.data.treeId, { 633 | type: "remove_shared_url", 634 | data: { 635 | projectid: this.id, 636 | }, 637 | undo_data: { 638 | previous_last_modified: this.data.lastModified, 639 | previous_last_modified_by: null, 640 | permission_level: this.shareData.urlPermissionLevel!, // This is weird, but WF does it so 641 | }, 642 | }); 643 | 644 | this.shareData.isSharedViaUrl = false; 645 | this.shareData.urlAccessToken = undefined; 646 | this.shareData.urlPermissionLevel = undefined; 647 | 648 | if (this.isSharedViaEmail) { 649 | return; 650 | } 651 | 652 | this.#companion.addOperation(this.data.treeId, { 653 | type: "unshare", 654 | data: { 655 | projectid: this.id, 656 | }, 657 | undo_data: { 658 | previous_last_modified: this.data.lastModified, 659 | previous_last_modified_by: null, 660 | }, 661 | }); 662 | } 663 | 664 | /** 665 | * Expands the list 666 | */ 667 | public expand(): void { 668 | if (!this.isExpanded) { 669 | this.#companion.expandedProjects.add(this.idPrefix); 670 | this.#companion.addExpansionDelta(this.idPrefix, true); 671 | } 672 | } 673 | 674 | /** 675 | * Collapses the list 676 | */ 677 | public collapse(): void { 678 | if (this.isExpanded) { 679 | this.#companion.expandedProjects.delete(this.idPrefix); 680 | this.#companion.addExpansionDelta(this.idPrefix, false); 681 | } 682 | } 683 | 684 | /** 685 | * Prints the list and its content as a nice string 686 | * 687 | * @param omitHeader Whether to print only the content the current list 688 | * @returns {string} stringified list 689 | */ 690 | public toString(omitHeader = false): string { 691 | return toString(this, omitHeader); 692 | } 693 | 694 | /** 695 | * Prints the list and its content in Plain Text format 696 | * 697 | * @returns {string} list in Plain Text format 698 | */ 699 | public toPlainText(): string { 700 | return toPlainText(this); 701 | } 702 | 703 | /** 704 | * Prints the list and its content in JSON format 705 | * 706 | * @returns list in JSON format 707 | */ 708 | // deno-lint-ignore no-explicit-any 709 | public toJson(): any { 710 | return toJson(this); 711 | } 712 | 713 | /** 714 | * Prints the list and its content in OPML format 715 | * 716 | * @returns list in OPML format 717 | */ 718 | public toOpml(): string { 719 | return toOpml(this); 720 | } 721 | } 722 | -------------------------------------------------------------------------------- /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(/&amp;/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 | -------------------------------------------------------------------------------- /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({ 8 | __all__: z.array(z.string()), 9 | }).optional(), 10 | }).transform((i) => ({ 11 | success: i.success === true, 12 | errors: i.errors?.__all__ || [], 13 | })); 14 | 15 | export type LoginResult = z.infer; 16 | 17 | const TreeInfoSchema = z.object({ 18 | dateJoinedTimestampInSeconds: z.number(), 19 | initialMostRecentOperationTransactionId: z.string(), 20 | ownerId: z.number(), 21 | shareId: z.string().default(ROOT), 22 | }); 23 | 24 | export const InitializationDataSchema = z.object({ 25 | projectTreeData: z.object({ 26 | auxiliaryProjectTreeInfos: z.array(TreeInfoSchema), 27 | mainProjectTreeInfo: TreeInfoSchema, 28 | }), 29 | }).transform((i) => i.projectTreeData); 30 | 31 | export type InitializationData = z.infer; 32 | 33 | const TreeItemShareInfoSchema = z.object({ 34 | share_id: z.string(), 35 | url_shared_info: z.object({ 36 | access_token: z.string(), 37 | permission_level: z.number(), 38 | }).optional(), 39 | email_shared_info: z.object({ 40 | emails: z.array(z.object({ 41 | email: z.string(), 42 | access_token: z.string(), 43 | permission_level: z.number(), 44 | })), 45 | }).optional(), 46 | }).transform((i) => ({ 47 | shareId: i.share_id, 48 | isSharedViaUrl: i.url_shared_info !== undefined, 49 | urlAccessToken: i.url_shared_info?.access_token, 50 | urlPermissionLevel: i.url_shared_info?.permission_level, 51 | isSharedViaEmail: i.email_shared_info !== undefined, 52 | })); 53 | 54 | export type TreeItemShareInfo = z.infer; 55 | 56 | export const TreeDataSchema = z.object({ 57 | most_recent_operation_transaction_id: z.string(), 58 | items: z.array( 59 | z.object({ 60 | id: z.string(), 61 | nm: z.string(), 62 | no: z.string().optional(), 63 | prnt: z.string().or(z.null()), 64 | pr: z.number(), 65 | cp: z.number().optional(), 66 | lm: z.number(), 67 | metadata: z.object({ 68 | mirror: z.object({ 69 | originalId: z.string().optional(), 70 | isMirrorRoot: z.boolean().optional(), 71 | }).optional(), 72 | }), 73 | as: z.string().optional(), 74 | }).transform((i) => ({ 75 | id: i.id, 76 | name: i.nm, 77 | note: i.no, 78 | parentId: i.prnt !== null ? i.prnt : ROOT, 79 | priority: i.pr, 80 | completed: i.cp, 81 | lastModified: i.lm, 82 | originalId: i.metadata?.mirror?.originalId, 83 | isMirrorRoot: i.metadata?.mirror?.isMirrorRoot === true, 84 | shareId: i.as, 85 | })), 86 | ), 87 | shared_projects: z.record(TreeItemShareInfoSchema), 88 | server_expanded_projects_list: z.array(z.string()).default([]), 89 | }); 90 | 91 | export type TreeData = z.infer; 92 | 93 | export type TreeItem = TreeData["items"][number]; 94 | 95 | export type TreeItemWithChildren = TreeItem & { 96 | children: string[]; 97 | treeId: string; 98 | }; 99 | 100 | export const OperationSchema = z.object({ 101 | type: z.string(), 102 | data: z.object({ 103 | projectid: z.string(), 104 | parentid: z.string().optional(), 105 | name: z.string().optional(), 106 | description: z.string().optional(), 107 | priority: z.number().optional(), 108 | starting_priority: z.number().optional(), 109 | permission_level: z.number().optional(), 110 | access_token: z.string().optional(), 111 | }), 112 | client_timestamp: z.number().optional(), 113 | undo_data: z.object({ 114 | previous_last_modified: z.number().optional(), 115 | previous_last_modified_by: z.any().optional(), 116 | previous_name: z.string().optional(), 117 | previous_description: z.string().optional(), 118 | previous_completed: z.number().or(z.boolean()).optional(), 119 | previous_parentid: z.string().optional(), 120 | previous_priority: z.number().optional(), 121 | parentid: z.string().optional(), 122 | priority: z.number().optional(), 123 | permission_level: z.number().optional(), 124 | previous_permission_level: z.number().optional(), 125 | }), 126 | }); 127 | 128 | export type Operation = z.infer; 129 | 130 | export const OperationResultSchema = z.object({ 131 | results: z.array(z.object({ 132 | concurrent_remote_operation_transactions: z.array(z.string()), 133 | error_encountered_in_remote_operations: z.boolean(), 134 | new_most_recent_operation_transaction_id: z.string(), 135 | new_polling_interval_in_ms: z.number(), 136 | share_id: z.string().default(ROOT), 137 | })), 138 | }).transform((data) => data.results); 139 | 140 | export type OperationResult = z.infer; 141 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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_opml_partial.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /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_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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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_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 | "shared_projects": { 23 | "8960ce1b-e5b5-4aff-3303-50577f20e76b": { 24 | "share_id": "NnjJ.lybhWrZBRX", 25 | "email_shared_info": { 26 | "emails": [ 27 | { 28 | "email": "example@example.com", 29 | "access_token": "L2RdOGpOND", 30 | "write_permission": true, 31 | "permission_level": 3 32 | } 33 | ] 34 | } 35 | } 36 | }, 37 | "most_recent_operation_transaction_id": "1160300075", 38 | "server_expanded_projects_list": [] 39 | } 40 | -------------------------------------------------------------------------------- /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 { assertEquals } from "./test_deps.ts"; 15 | 16 | import { Document } from "../src/document.ts"; 17 | import type { Client } from "../src/client.ts"; 18 | import { 19 | InitializationDataSchema, 20 | ROOT, 21 | TreeDataSchema, 22 | } from "../src/schema.ts"; 23 | 24 | const mockClient = () => ({} as unknown as Client); 25 | const mockTree = () => TreeDataSchema.parse(mockTreeDataMain); 26 | const mockInitialization = () => 27 | InitializationDataSchema.parse(mockInitializationData); 28 | const mockSharedTrees = () => { 29 | const first = 30 | mockTreeDataFirst.shared_projects["aaba6df4-cde1-3322-96cd-957fc76123e8"] 31 | .share_id; 32 | const second = 33 | mockTreeDataSecond.shared_projects["8960ce1b-e5b5-4aff-3303-50577f20e76b"] 34 | .share_id; 35 | return { 36 | [first]: TreeDataSchema.parse(mockTreeDataFirst), 37 | [second]: TreeDataSchema.parse(mockTreeDataSecond), 38 | }; 39 | }; 40 | 41 | const mockDocument = () => 42 | new Document( 43 | mockClient(), 44 | mockTree(), 45 | mockInitialization(), 46 | mockSharedTrees(), 47 | ); 48 | 49 | Deno.test("WorkFlowy Shared / Read", () => { 50 | const document = mockDocument(); 51 | 52 | const firstShared = document.items[1]; 53 | 54 | assertEquals(firstShared.name, "List shared via URL"); 55 | 56 | assertEquals(firstShared.items.length, 2); 57 | assertEquals(firstShared.items[0].name, "Normal list"); 58 | 59 | const secondShared = firstShared.items[1]; 60 | 61 | assertEquals(secondShared.name, "List shared via email"); 62 | assertEquals(secondShared.items.length, 1); 63 | assertEquals(secondShared.items[0].name, "Normal second list"); 64 | }); 65 | 66 | Deno.test("WorkFlowy Shared / Write", () => { 67 | const document = mockDocument(); 68 | 69 | const firstShared = document.items[1]; 70 | const secondShared = firstShared.items[1]; 71 | 72 | const firstNewSharedList = firstShared.createList(0); 73 | firstNewSharedList.setName("New first shared list"); 74 | 75 | const secondNewSharedList = secondShared.createList(0); 76 | secondNewSharedList.setName("New second shared list"); 77 | 78 | const ops = document.getPendingOperations(); 79 | assertEquals(Object.keys(ops).length, 2); 80 | assertEquals(ops[ROOT], undefined); 81 | }); 82 | -------------------------------------------------------------------------------- /tests/test_deps.ts: -------------------------------------------------------------------------------- 1 | export { assertEquals, assertObjectMatch } from "jsr:@std/assert@1.0.0"; 2 | --------------------------------------------------------------------------------