├── .gitignore ├── .npmignore ├── tslint.json ├── __mocks__ └── axios.ts ├── README.md ├── jestconfig.json ├── tsconfig.json ├── src ├── events.ts ├── window-client.ts ├── date.ts ├── rest-client.ts ├── writes.ts ├── types.ts ├── client.ts ├── index.ts ├── queries.ts └── dom.ts ├── tests ├── util.ts ├── date.test.ts ├── dom.test.ts ├── window-client.test.ts └── rest-client.test.ts ├── .github └── workflows │ └── main.yaml ├── scripts └── demo.js ├── LICENSE └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /lib 3 | .env.local 4 | .env 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | tslint.json 4 | .prettierrc 5 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:recommended", "tslint-config-prettier"] 3 | } 4 | -------------------------------------------------------------------------------- /__mocks__/axios.ts: -------------------------------------------------------------------------------- 1 | const axios = { 2 | post: jest.fn(() => Promise.resolve({ data: {} })), 3 | }; 4 | module.exports = axios; 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # DEPRECATED - roam-client 2 | 3 | This package has been deprecated and fully folded into the [roamjs-components](https://github.com/dvargas92495/roamjs-components) package. 4 | -------------------------------------------------------------------------------- /jestconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "transform": { 3 | "^.+\\.(t|j)sx?$": "ts-jest" 4 | }, 5 | "setupFilesAfterEnv": ["@testing-library/jest-dom/extend-expect"], 6 | "testRegex": "/tests/.*\\.test\\.(jsx?|tsx?)$", 7 | "moduleFileExtensions": ["ts", "tsx", "js", "jsx", "json", "node"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["es2019", "DOM"], 5 | "module": "commonjs", 6 | "declaration": true, 7 | "outDir": "./lib", 8 | "strict": true, 9 | "esModuleInterop": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules", "**/__tests__/*"] 13 | } 14 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { PullBlock } from "./types"; 2 | 3 | export const watchOnce = ( 4 | pullPattern: string, 5 | entityId: string, 6 | callback: (before: PullBlock, after: PullBlock) => boolean 7 | ) => { 8 | const watcher = (before: PullBlock, after: PullBlock) => { 9 | if (callback(before, after)) { 10 | window.roamAlphaAPI.data.removePullWatch(pullPattern, entityId, watcher); 11 | } 12 | }; 13 | window.roamAlphaAPI.data.addPullWatch(pullPattern, entityId, watcher); 14 | }; 15 | -------------------------------------------------------------------------------- /tests/util.ts: -------------------------------------------------------------------------------- 1 | export const getFocusedTextArea = () => { 2 | const textarea = document.createElement("textarea"); 3 | document.body.appendChild(textarea); 4 | textarea.focus(); 5 | return textarea; 6 | }; 7 | 8 | const pull = () => ({ ":block/children": [], ":block/string": "", ":block/order": 0 }); 9 | const defaultWrite = () => true; 10 | export const alphaRest = { 11 | pull, 12 | createBlock: defaultWrite, 13 | updateBlock: defaultWrite, 14 | moveBlock: defaultWrite, 15 | deleteBlock: defaultWrite, 16 | createPage: defaultWrite, 17 | updatePage: defaultWrite, 18 | deletePage: defaultWrite, 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish package 2 | on: 3 | push: 4 | branches: main 5 | paths: 6 | - "src/**" 7 | - "package.json" 8 | - ".github/workflows/main.yaml" 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-20.04 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Use Node.js 14.17.6 16 | uses: actions/setup-node@v1 17 | with: 18 | node-version: 14.17.6 19 | - name: install 20 | run: npm install 21 | - uses: JS-DevTools/npm-publish@v1 22 | with: 23 | token: ${{ secrets.NPM_TOKEN }} 24 | access: "public" 25 | -------------------------------------------------------------------------------- /scripts/demo.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const RestClient = require("../lib").RestClient; 3 | 4 | const client = new RestClient({ 5 | graphName: "roam-js-extensions", 6 | }); 7 | 8 | client 9 | .findOrCreatePage("roam/js") 10 | .then((parentUid) => 11 | client.findOrCreateBlock({ 12 | text: "{{[[roam/js]]}}", 13 | parentUid, 14 | }) 15 | ) 16 | .then((parentUid) => 17 | client.upsertBlock({ 18 | text: "some code", 19 | uid: "roamjs-uid", 20 | parentUid, 21 | }) 22 | ) 23 | .then((b) => console.log("Done", b)) 24 | .catch((e) => console.error(e.response ? e.response.data.error : e.message)); 25 | -------------------------------------------------------------------------------- /src/window-client.ts: -------------------------------------------------------------------------------- 1 | import { ClientParams } from "./types"; 2 | import { RoamClient } from "./client"; 3 | 4 | class WindowClient extends RoamClient { 5 | constructor() { 6 | super(); 7 | if (!window) { 8 | throw new Error("Client could only be used in a Window"); 9 | } 10 | } 11 | 12 | protected post(body: ClientParams) { 13 | if (!window.roamDatomicAlphaAPI) { 14 | throw new Error("Client could only be used with new Roam backend"); 15 | } 16 | return window 17 | .roamDatomicAlphaAPI(body) 18 | .then((r) => (typeof r.success !== "undefined" ? r.success : r)); 19 | } 20 | } 21 | 22 | export default WindowClient; 23 | -------------------------------------------------------------------------------- /tests/date.test.ts: -------------------------------------------------------------------------------- 1 | import { parseRoamDate, toRoamDate, toRoamDateUid } from "../src"; 2 | 3 | test("Roam Date should be parsed correctly", () => { 4 | const parsedDate = parseRoamDate("October 7th, 2020"); 5 | expect(parsedDate.getFullYear()).toBe(2020); 6 | expect(parsedDate.getMonth()).toBe(9); 7 | expect(parsedDate.getDate()).toBe(7); 8 | }); 9 | 10 | test("Date should be formatted to title correctly", () => { 11 | const date = new Date("10/16/2020"); 12 | const dateString = toRoamDate(date); 13 | expect(dateString).toBe("October 16th, 2020"); 14 | }); 15 | 16 | test("Date should be formatted to uid correctly", () => { 17 | const date = new Date("10/16/2020"); 18 | const dateString = toRoamDateUid(date); 19 | expect(dateString).toBe("10-16-2020"); 20 | }); 21 | -------------------------------------------------------------------------------- /src/date.ts: -------------------------------------------------------------------------------- 1 | import format from "date-fns/format"; 2 | import parse from "date-fns/parse"; 3 | 4 | export const parseRoamDate = (s: string) => 5 | parse(s, "MMMM do, yyyy", new Date()); 6 | 7 | export const parseRoamDateUid = (s: string) => 8 | parse(s, "MM-dd-yyyy", new Date()); 9 | 10 | export const toRoamDate = (d = new Date()) => 11 | isNaN(d.valueOf()) ? "" : format(d, "MMMM do, yyyy"); 12 | 13 | export const toRoamDateUid = (d = new Date()) => 14 | isNaN(d.valueOf()) ? "" : format(d, "MM-dd-yyyy"); 15 | 16 | export const DAILY_NOTE_PAGE_REGEX = /(January|February|March|April|May|June|July|August|September|October|November|December) [0-3]?[0-9](st|nd|rd|th), [0-9][0-9][0-9][0-9]/; 17 | export const DAILY_NOTE_PAGE_TITLE_REGEX = new RegExp( 18 | `^${DAILY_NOTE_PAGE_REGEX.source}$` 19 | ); 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 David Vargas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/dom.test.ts: -------------------------------------------------------------------------------- 1 | import { createIconButton, getUids, getUidsFromButton } from "../src"; 2 | 3 | test("Icon button is rendered", () => { 4 | const iconButton = createIconButton("sort"); 5 | expect(iconButton).toMatchInlineSnapshot( 6 | ` 7 | 11 | 14 | 15 | ` 16 | ); 17 | }); 18 | 19 | test("getUids get parent and block for regular page", () => { 20 | const block = document.createElement("div"); 21 | block.id = 22 | "block-input-abcd1234abcd1234abcd1234abcd-body-outline-abcdefghi-jklmnopqr"; 23 | const { blockUid, parentUid } = getUids(block); 24 | expect(blockUid).toBe("jklmnopqr"); 25 | expect(parentUid).toBe("abcdefghi"); 26 | }); 27 | 28 | test("getUids get parent and block for daily page", () => { 29 | const block = document.createElement("div"); 30 | block.id = 31 | "block-input-abcd1234abcd1234abcd1234abcd-body-outline-10-20-2020-jklmnopqr"; 32 | const { blockUid, parentUid } = getUids(block); 33 | expect(blockUid).toBe("jklmnopqr"); 34 | expect(parentUid).toBe("10-20-2020"); 35 | }); 36 | 37 | test("getUidsFromButton get parent and block for normal page", () => { 38 | const block = document.createElement("div"); 39 | block.id = 40 | "block-input-abcd1234abcd1234abcd1234abcd-body-outline-abcdefghi-jklmnopqr"; 41 | const button = document.createElement("button"); 42 | block.appendChild(button); 43 | block.className = "roam-block"; 44 | const { blockUid, parentUid } = getUidsFromButton(button); 45 | expect(blockUid).toBe("jklmnopqr"); 46 | expect(parentUid).toBe("abcdefghi"); 47 | }); 48 | -------------------------------------------------------------------------------- /src/rest-client.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { ClientParams } from "./types"; 3 | import { RoamClient } from "./client"; 4 | 5 | type RestClientProps = { 6 | apiKey?: string; 7 | apiToken?: string; 8 | graphName: string; 9 | contentType?: 10 | | "application/json" 11 | | "application/edn" 12 | | "application/transit+json"; 13 | }; 14 | 15 | class RestClient extends RoamClient { 16 | apiKey: string; 17 | apiToken: string; 18 | graphName: string; 19 | contentType: 20 | | "application/json" 21 | | "application/edn" 22 | | "application/transit+json"; 23 | 24 | constructor(props: RestClientProps) { 25 | super(); 26 | this.apiKey = props.apiKey || process.env.ROAM_CLIENT_API_KEY || ""; 27 | this.apiToken = props.apiToken || process.env.ROAM_CLIENT_API_TOKEN || ""; 28 | this.graphName = props.graphName; 29 | this.contentType = props.contentType || "application/json"; 30 | 31 | if (!this.apiKey) { 32 | throw new Error("Rest Client is missing an API Key"); 33 | } 34 | 35 | if (!this.apiToken) { 36 | throw new Error("Rest Client is missing an API Token"); 37 | } 38 | } 39 | 40 | protected post(body: ClientParams) { 41 | return axios 42 | .post( 43 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 44 | { 45 | ...body, 46 | "graph-name": this.graphName, 47 | }, 48 | { 49 | headers: { 50 | "x-api-key": this.apiKey, 51 | "x-api-token": this.apiToken, 52 | "Content-Type": this.contentType, 53 | }, 54 | } 55 | ) 56 | .then((r) => r.data.success); 57 | } 58 | } 59 | 60 | export default RestClient; 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roam-client", 3 | "version": "2.0.0", 4 | "description": "Utilities and UI components to help developers write their own Roam extensions.", 5 | "main": "lib/index.js", 6 | "types": "lib/index.d.ts", 7 | "scripts": { 8 | "build": "tsc", 9 | "format": "prettier --write \"src/**/*.ts\"", 10 | "lint": "tslint -p tsconfig.json", 11 | "prepare": "npm run build", 12 | "prepublishOnly": "npm t", 13 | "preversion": "npm run lint", 14 | "version": "npm run format && git add -A src", 15 | "postversion": "git push origin main && git push --tags origin main", 16 | "pretest": "npm run lint", 17 | "test": "jest --config jestconfig.json" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/dvargas92495/roam-client.git" 22 | }, 23 | "keywords": [ 24 | "Roam" 25 | ], 26 | "author": "dvargas92495 ", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/dvargas92495/roam-client/issues" 30 | }, 31 | "homepage": "https://github.com/dvargas92495/roam-client#readme", 32 | "devDependencies": { 33 | "@testing-library/dom": "^7.29.4", 34 | "@testing-library/jest-dom": "^5.11.4", 35 | "@testing-library/user-event": "^12.6.2", 36 | "@types/jest": "^26.0.14", 37 | "@types/marked": "^2.0.2", 38 | "@types/randomstring": "^1.1.6", 39 | "dotenv": "^8.2.0", 40 | "jest": "^26.4.2", 41 | "jest-when": "^2.7.2", 42 | "prettier": "^2.2.1", 43 | "ts-jest": "^26.4.1", 44 | "tslint": "^6.1.3", 45 | "tslint-config-prettier": "^1.18.0", 46 | "typescript": "^4.3.5" 47 | }, 48 | "files": [ 49 | "lib/**/*" 50 | ], 51 | "dependencies": { 52 | "axios": "^0.21.4", 53 | "date-fns": "^2.16.1", 54 | "randomstring": "^1.1.5" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/writes.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DAILY_NOTE_PAGE_TITLE_REGEX, 3 | parseRoamDate, 4 | toRoamDateUid, 5 | } from "./date"; 6 | import { getActiveUids, getUidsFromId } from "./dom"; 7 | import { InputTextNode } from "./types"; 8 | 9 | export const updateActiveBlock = (text: string) => 10 | window.roamAlphaAPI.updateBlock({ 11 | block: { 12 | uid: getActiveUids().blockUid, 13 | string: text, 14 | }, 15 | }); 16 | 17 | export const clearBlockById = (id: string) => 18 | window.roamAlphaAPI.updateBlock({ 19 | block: { 20 | uid: getUidsFromId(id).blockUid, 21 | string: "", 22 | }, 23 | }); 24 | 25 | export const clearBlockByUid = (uid: string) => 26 | window.roamAlphaAPI.updateBlock({ 27 | block: { 28 | uid, 29 | string: "", 30 | }, 31 | }); 32 | 33 | export const createBlock = ({ 34 | node: { 35 | text, 36 | children = [], 37 | uid = window.roamAlphaAPI.util.generateUID(), 38 | heading, 39 | viewType, 40 | textAlign, 41 | open = true, 42 | }, 43 | parentUid, 44 | order = 0, 45 | }: { 46 | node: InputTextNode; 47 | parentUid: string; 48 | order?: number; 49 | }) => { 50 | window.roamAlphaAPI.createBlock({ 51 | location: { "parent-uid": parentUid, order }, 52 | block: { 53 | uid, 54 | string: text, 55 | heading, 56 | "text-align": textAlign, 57 | "children-view-type": viewType, 58 | open, 59 | }, 60 | }); 61 | children.forEach((n, o) => 62 | createBlock({ node: n, parentUid: uid, order: o }) 63 | ); 64 | if (!open) window.roamAlphaAPI.updateBlock({ block: { uid, open: false } }); // Roam doesn't do this for us yet 65 | return uid; 66 | }; 67 | 68 | export const createPage = ({ 69 | title, 70 | tree = [], 71 | }: { 72 | title: string; 73 | tree?: InputTextNode[]; 74 | }): string => { 75 | const uid = DAILY_NOTE_PAGE_TITLE_REGEX.test(title) 76 | ? toRoamDateUid(parseRoamDate(title)) 77 | : window.roamAlphaAPI.util.generateUID(); 78 | window.roamAlphaAPI.createPage({ page: { title, uid } }); 79 | tree.forEach((node, order) => createBlock({ node, parentUid: uid, order })); 80 | return uid; 81 | }; 82 | 83 | export const updateBlock = ({ 84 | text, 85 | uid, 86 | heading, 87 | textAlign, 88 | viewType, 89 | open, 90 | }: { uid: string } & Omit) => { 91 | window.roamAlphaAPI.updateBlock({ 92 | block: { 93 | string: text, 94 | uid, 95 | heading, 96 | "text-align": textAlign, 97 | "children-view-type": viewType, 98 | open, 99 | }, 100 | }); 101 | return uid; 102 | }; 103 | 104 | export const deleteBlock = (uid: string) => { 105 | window.roamAlphaAPI.deleteBlock({ block: { uid } }); 106 | return uid; 107 | }; 108 | 109 | export const openBlockInSidebar = (blockUid: string): boolean | void => 110 | window.roamAlphaAPI.ui.rightSidebar 111 | .getWindows() 112 | .some((w) => w.type === "block" && w["block-uid"] === blockUid) 113 | ? window.roamAlphaAPI.ui.rightSidebar.open() 114 | : window.roamAlphaAPI.ui.rightSidebar.addWindow({ 115 | window: { 116 | type: "block", 117 | "block-uid": blockUid, 118 | }, 119 | }); 120 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type RoamBasicBlock = { 2 | string: string; 3 | uid: string; 4 | }; 5 | 6 | export type RoamBasicPage = { title: string; uid: string }; 7 | 8 | export type RoamBasicNode = { 9 | text: string; 10 | uid: string; 11 | children: RoamBasicNode[]; 12 | }; 13 | 14 | export type RoamUnorderedBasicNode = { 15 | text: string; 16 | uid: string; 17 | order: number; 18 | children?: RoamUnorderedBasicNode[]; 19 | }; 20 | 21 | export type RoamPull = { 22 | "create/time"?: number; 23 | "node/title"?: string; 24 | "log/id"?: number; 25 | "block/uid"?: string; 26 | "edit/time"?: number; 27 | "block/children"?: RoamNode[]; 28 | "block/open"?: boolean; 29 | "block/order"?: number; 30 | "block/string"?: string; 31 | } & RoamNode; 32 | 33 | export type PullBlock = { 34 | ":block/children"?: { ":db/id": number }[]; 35 | ":block/string"?: string; 36 | ":block/order"?: number; 37 | ":block/uid"?: string; 38 | ":block/heading"?: number; 39 | ":block/open"?: boolean; 40 | ":block/text-align"?: TextAlignment; 41 | ":children/view-type"?: `:${ViewType}`; 42 | ":edit/time"?: number; 43 | ":block/props"?: any; 44 | }; 45 | 46 | export type RoamPullResult = RoamPull | null; 47 | 48 | export type ViewType = "document" | "bullet" | "numbered"; 49 | 50 | export type TextAlignment = "left" | "center" | "right"; 51 | 52 | export type RoamBlock = { 53 | attrs?: { source: string[] }[][]; 54 | children?: { id: number }[]; 55 | id?: number; 56 | string?: string; 57 | title?: string; 58 | time?: number; 59 | uid?: string; 60 | order?: number; 61 | "view-type"?: ViewType; 62 | }; 63 | 64 | export type RoamError = { 65 | raw: string; 66 | "status-code": number; 67 | }; 68 | 69 | export type TreeNode = { 70 | text: string; 71 | order: number; 72 | children: TreeNode[]; 73 | uid: string; 74 | heading: number; 75 | open: boolean; 76 | viewType: ViewType; 77 | editTime: Date; 78 | textAlign: TextAlignment; 79 | props: { 80 | imageResize: { 81 | [link: string]: { 82 | height: number; 83 | width: number; 84 | }; 85 | }; 86 | iframe: { 87 | [link: string]: { 88 | height: number; 89 | width: number; 90 | }; 91 | }; 92 | }; 93 | }; 94 | 95 | export type TextNode = { 96 | text: string; 97 | children: TextNode[]; 98 | }; 99 | 100 | export type InputTextNode = { 101 | text: string; 102 | children?: InputTextNode[]; 103 | uid?: string; 104 | heading?: number; 105 | textAlign?: TextAlignment; 106 | viewType?: ViewType; 107 | open?: boolean; 108 | }; 109 | 110 | type PlusType = [number, string]; 111 | 112 | export type RoamNode = { "db/id": number }; 113 | 114 | export type RoamQuery = RoamPull & { 115 | "block/graph"?: RoamNode; 116 | "node/graph+title"?: PlusType; 117 | "block/graph+uid"?: PlusType; 118 | "node/graph"?: RoamNode; 119 | "edit/email"?: string; 120 | "entity/graph"?: RoamNode; 121 | }; 122 | 123 | export type RoamQueryResult = number & RoamQuery; 124 | 125 | export type ClientParams = { 126 | action: 127 | | "pull" 128 | | "q" 129 | | "create-block" 130 | | "update-block" 131 | | "create-page" 132 | | "move-block" 133 | | "delete-block" 134 | | "delete-page" 135 | | "update-page"; 136 | selector?: string; 137 | uid?: string; 138 | query?: string; 139 | inputs?: string[]; 140 | } & ActionParams; 141 | 142 | type ActionParams = { 143 | location?: { 144 | "parent-uid": string; 145 | order: number; 146 | }; 147 | block?: { 148 | string?: string; 149 | uid?: string; 150 | open?: boolean; 151 | heading?: number; 152 | "text-align"?: TextAlignment; 153 | "children-view-type"?: ViewType; 154 | }; 155 | page?: { 156 | title?: string; 157 | uid?: string; 158 | }; 159 | }; 160 | 161 | export type WriteAction = (a: ActionParams) => boolean; 162 | 163 | export type UserSettings = { 164 | "global-filters": { 165 | includes: string[]; 166 | removes: string[]; 167 | }; 168 | }; 169 | 170 | type SidebarWindowType = 171 | | SidebarBlockWindow 172 | | SidebarMentionsWindow 173 | | SidebarGraphWindow 174 | | SidebarOutlineWindow; 175 | 176 | export type SidebarWindowInput = { 177 | "block-uid": string; 178 | type: SidebarWindowType["type"]; 179 | }; 180 | 181 | type SidebarBlockWindow = { 182 | type: "block"; 183 | "block-uid": string; 184 | }; 185 | 186 | type SidebarOutlineWindow = { 187 | type: "outline"; 188 | "page-uid": string; 189 | }; 190 | 191 | type SidebarMentionsWindow = { 192 | type: "mentions"; 193 | "mentions-uid": string; 194 | }; 195 | 196 | type SidebarGraphWindow = { 197 | type: "graph"; 198 | // "page-uid": string; Currently not working despite documentation 199 | "block-uid": string; 200 | }; 201 | 202 | export type SidebarAction = (action: { window: SidebarWindowInput }) => boolean; 203 | 204 | export type SidebarWindow = { 205 | "collapsed?": boolean; 206 | order: number; 207 | "pinned?": boolean; 208 | "window-id": string; 209 | } & SidebarWindowType; 210 | -------------------------------------------------------------------------------- /tests/window-client.test.ts: -------------------------------------------------------------------------------- 1 | import { WindowClient } from "../src"; 2 | const mockWindow = jest.fn(); 3 | window.roamDatomicAlphaAPI = mockWindow; 4 | 5 | test("Could successfully instantiate window client", () => { 6 | const client = new WindowClient(); 7 | expect(client).toBeDefined(); 8 | }); 9 | 10 | test("Create Block with Window Client", async () => { 11 | const client = new WindowClient(); 12 | mockWindow.mockResolvedValue([{ string: "text", uid: "childUid" }]); 13 | const response = await client.createBlock({ 14 | parentUid: "parentUid", 15 | order: 0, 16 | text: "text", 17 | uid: "childUid", 18 | }); 19 | expect(mockWindow).toBeCalledWith({ 20 | action: "create-block", 21 | location: { 22 | "parent-uid": "parentUid", 23 | order: 0, 24 | }, 25 | block: { 26 | string: "text", 27 | uid: "childUid", 28 | }, 29 | }); 30 | expect(response.string).toBe("text"); 31 | expect(response.uid).toBe("childUid"); 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | test("Move Block with Window Client", async () => { 36 | const client = new WindowClient(); 37 | mockWindow.mockResolvedValue({ success: true }); 38 | const response = await client.moveBlock({ 39 | parentUid: "parentUid", 40 | order: 0, 41 | uid: "childUid", 42 | }); 43 | expect(mockWindow).toBeCalledWith({ 44 | action: "move-block", 45 | location: { 46 | "parent-uid": "parentUid", 47 | order: 0, 48 | }, 49 | block: { 50 | uid: "childUid", 51 | }, 52 | }); 53 | expect(response).toBe(true); 54 | jest.clearAllMocks(); 55 | }); 56 | 57 | test("Update Block with Window Client", async () => { 58 | const client = new WindowClient(); 59 | mockWindow.mockResolvedValue({ 60 | success: true, 61 | }); 62 | const response = await client.updateBlock({ 63 | open: true, 64 | text: "text", 65 | uid: "childUid", 66 | }); 67 | expect(mockWindow).toBeCalledWith({ 68 | action: "update-block", 69 | block: { 70 | uid: "childUid", 71 | open: true, 72 | string: "text", 73 | }, 74 | }); 75 | expect(response).toBe(true); 76 | jest.clearAllMocks(); 77 | }); 78 | 79 | test("Delete Block with Window Client", async () => { 80 | const client = new WindowClient(); 81 | mockWindow.mockResolvedValue({ 82 | success: true, 83 | }); 84 | const response = await client.deleteBlock({ 85 | uid: "childUid", 86 | }); 87 | expect(mockWindow).toBeCalledWith({ 88 | action: "delete-block", 89 | block: { 90 | uid: "childUid", 91 | }, 92 | }); 93 | expect(response).toBe(true); 94 | jest.clearAllMocks(); 95 | }); 96 | 97 | test("Pull with Window Client", async () => { 98 | const client = new WindowClient(); 99 | await client.pull({ 100 | selector: "[:block/string]", 101 | uid: "yS-It9SFL", 102 | }); 103 | expect(mockWindow).toBeCalledWith({ 104 | action: "pull", 105 | selector: "[:block/string]", 106 | uid: "yS-It9SFL", 107 | }); 108 | jest.clearAllMocks(); 109 | }); 110 | 111 | test("Q with Window Client", async () => { 112 | const client = new WindowClient(); 113 | mockWindow.mockResolvedValue([[1]]); 114 | const response = await client.q({ 115 | query: "[:find ?e in $ ?title :where [?e :node/title ?title]]", 116 | inputs: ["title"], 117 | }); 118 | expect(mockWindow).toBeCalledWith({ 119 | action: "q", 120 | query: "[:find ?e in $ ?title :where [?e :node/title ?title]]", 121 | inputs: ["title"], 122 | }); 123 | expect(response).toHaveLength(1); 124 | expect(response[0]).toBe(1); 125 | jest.clearAllMocks(); 126 | }); 127 | 128 | test("Create Page with Window Client", async () => { 129 | const client = new WindowClient(); 130 | mockWindow.mockResolvedValue([{ title: "My Page", uid: "mpgy3y0p2" }]); 131 | const response = await client.createPage({ 132 | title: "My Page", 133 | uid: "mpgy3y0p2", 134 | }); 135 | expect(mockWindow).toBeCalledWith({ 136 | action: "create-page", 137 | page: { 138 | title: "My Page", 139 | uid: "mpgy3y0p2", 140 | }, 141 | }); 142 | expect(response.title).toBe("My Page"); 143 | expect(response.uid).toBe("mpgy3y0p2"); 144 | jest.clearAllMocks(); 145 | }); 146 | 147 | test("Update Page with Window Client", async () => { 148 | const client = new WindowClient(); 149 | mockWindow.mockResolvedValue(true); 150 | const response = await client.updatePage({ 151 | title: "My New Page", 152 | uid: "mpgy3y0p2", 153 | }); 154 | expect(mockWindow).toBeCalledWith({ 155 | action: "update-page", 156 | page: { 157 | title: "My New Page", 158 | uid: "mpgy3y0p2", 159 | }, 160 | }); 161 | expect(response).toBe(true); 162 | jest.clearAllMocks(); 163 | }); 164 | 165 | test("Delete Page with Window Client", async () => { 166 | const client = new WindowClient(); 167 | mockWindow.mockResolvedValue(true); 168 | const response = await client.deletePage({ 169 | uid: "mpgy3y0p2", 170 | }); 171 | expect(mockWindow).toBeCalledWith({ 172 | action: "delete-page", 173 | page: { 174 | uid: "mpgy3y0p2", 175 | }, 176 | }); 177 | expect(response).toBe(true); 178 | jest.clearAllMocks(); 179 | }); 180 | -------------------------------------------------------------------------------- /src/client.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RoamBasicBlock, 3 | RoamBasicPage, 4 | RoamPullResult, 5 | RoamQueryResult, 6 | ClientParams, 7 | } from "./types"; 8 | 9 | export class RoamClient { 10 | protected post(body: ClientParams): Promise { 11 | throw new Error("Not Implemented"); 12 | } 13 | 14 | public createBlock({ 15 | parentUid, 16 | order, 17 | text, 18 | uid, 19 | }: { 20 | parentUid: string; 21 | order: number; 22 | text: string; 23 | uid?: string; 24 | }) { 25 | return this.post({ 26 | location: { 27 | "parent-uid": parentUid, 28 | order, 29 | }, 30 | block: { 31 | string: text, 32 | uid, 33 | }, 34 | action: "create-block", 35 | }).then((r) => r[0] as RoamBasicBlock); 36 | } 37 | 38 | public createAttribute({ 39 | key, 40 | value, 41 | parentUid, 42 | }: { 43 | key: string; 44 | value: string; 45 | parentUid: string; 46 | }) { 47 | return this.appendBlock({ 48 | text: `${key}:: ${value}`, 49 | parentUid, 50 | }); 51 | } 52 | 53 | public createPage(page: { title: string; uid?: string }) { 54 | return this.post({ 55 | page, 56 | action: "create-page", 57 | }).then((r) => r[0] as RoamBasicPage); 58 | } 59 | 60 | public deleteBlock(block: { uid: string }) { 61 | return this.post({ 62 | block, 63 | action: "delete-block", 64 | }).then((r) => r as boolean); 65 | } 66 | 67 | public deletePage(page: { uid: string }) { 68 | return this.post({ 69 | page, 70 | action: "delete-page", 71 | }).then((r) => r as boolean); 72 | } 73 | 74 | public moveBlock({ 75 | parentUid, 76 | order, 77 | uid, 78 | }: { 79 | parentUid: string; 80 | order: number; 81 | uid?: string; 82 | }) { 83 | return this.post({ 84 | location: { 85 | "parent-uid": parentUid, 86 | order, 87 | }, 88 | block: { 89 | uid, 90 | }, 91 | action: "move-block", 92 | }).then((r) => r as boolean); 93 | } 94 | 95 | public pull(params: { selector?: string; uid: string }) { 96 | return this.post({ 97 | selector: params.selector || "[*]", 98 | uid: params.uid, 99 | action: "pull", 100 | }).then((r) => r as RoamPullResult); 101 | } 102 | 103 | public q({ query, inputs }: { query: string; inputs?: string[] }) { 104 | return this.post({ 105 | action: "q", 106 | query, 107 | inputs, 108 | }).then( 109 | (r) => r.map((res: RoamQueryResult[]) => res[0]) as RoamQueryResult[] 110 | ); 111 | } 112 | 113 | public updateBlock({ 114 | uid, 115 | text, 116 | open, 117 | }: { 118 | uid: string; 119 | text?: string; 120 | open?: boolean; 121 | }) { 122 | return this.post({ 123 | block: { 124 | string: text || "", 125 | uid, 126 | open, 127 | }, 128 | action: "update-block", 129 | }).then((r) => r as boolean); 130 | } 131 | 132 | public updatePage(page: { title: string; uid: string }) { 133 | return this.post({ 134 | page, 135 | action: "update-page", 136 | }).then((r) => r as boolean); 137 | } 138 | 139 | public async findOrCreatePage(pageName: string, uid?: string) { 140 | const queryResults = await this.q({ 141 | query: `[:find (pull ?e [:block/uid]) :where [?e :node/title "${pageName}"]]`, 142 | }); 143 | if (queryResults.length === 0) { 144 | const basicPage = await this.createPage({ 145 | title: pageName, 146 | uid, 147 | }); 148 | return basicPage.uid; 149 | } 150 | return queryResults[0]["block/uid"]; 151 | } 152 | 153 | public async findOrCreateBlock({ 154 | text, 155 | parentUid, 156 | }: { 157 | text: string; 158 | parentUid: string; 159 | }) { 160 | const queryResults = await this.q({ 161 | query: `[:find (pull ?c [:block/uid]) :where [?c :block/string "${text}"] [?p :block/children ?c] [?p :block/uid "${parentUid}"]]`, 162 | }); 163 | if (queryResults.length === 0) { 164 | return await this.appendBlock({ text, parentUid }); 165 | } 166 | return queryResults[0]["block/uid"]; 167 | } 168 | 169 | public async upsertBlock({ 170 | uid, 171 | text, 172 | parentUid, 173 | }: { 174 | uid: string; 175 | text: string; 176 | parentUid: string; 177 | }) { 178 | const queryResults = await this.q({ 179 | query: `[:find (pull ?b [:block/uid]) :where [?b :block/uid "${uid}"]]`, 180 | }); 181 | if (queryResults.length === 0) { 182 | return this.appendBlock({ text, parentUid, uid }).then(() => true); 183 | } 184 | return this.updateBlock({ uid, text }); 185 | } 186 | 187 | public async appendBlock({ 188 | text, 189 | parentUid, 190 | uid, 191 | }: { 192 | text: string; 193 | parentUid: string; 194 | uid?: string; 195 | }) { 196 | const parents = await this.q({ 197 | query: `[:find (pull ?p [:block/children, :block/uid]) :where [?p :block/uid "${parentUid}"]]`, 198 | }); 199 | if (parents.length === 0 || !parents[0]) { 200 | throw new Error(`No existing parent of uid ${parentUid}`); 201 | } 202 | const children = parents[0]["block/children"]; 203 | const basicPage = await this.createBlock({ 204 | text, 205 | parentUid, 206 | order: children?.length || 0, 207 | uid, 208 | }); 209 | return basicPage.uid; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /tests/rest-client.test.ts: -------------------------------------------------------------------------------- 1 | import { RestClient } from "../src"; 2 | import axios from "axios"; 3 | const mockAxios = axios.post as jest.Mock; 4 | 5 | test("Could successfully instantiate rest client", () => { 6 | const client = new RestClient({ 7 | apiKey: "API_KEY", 8 | apiToken: "API_TOKEN", 9 | graphName: "MY_GRAPH", 10 | contentType: "application/json", 11 | }); 12 | expect(client).toBeDefined(); 13 | }); 14 | 15 | test("Throws error when missing key", () => { 16 | expect( 17 | () => 18 | new RestClient({ 19 | apiToken: "API_TOKEN", 20 | graphName: "MY_GRAPH", 21 | contentType: "application/json", 22 | }) 23 | ).toThrow(); 24 | }); 25 | 26 | test("Throws error when missing token", () => { 27 | expect( 28 | () => 29 | new RestClient({ 30 | apiKey: "API_KEY", 31 | graphName: "MY_GRAPH", 32 | contentType: "application/json", 33 | }) 34 | ).toThrow(); 35 | }); 36 | 37 | test("Instantiate with just graph name", () => { 38 | const env = { ...process.env }; 39 | process.env.ROAM_CLIENT_API_KEY = "API_KEY"; 40 | process.env.ROAM_CLIENT_API_TOKEN = "API_TOKEN"; 41 | const client = new RestClient({ 42 | graphName: "MY_GRAPH", 43 | }); 44 | expect(client).toBeDefined(); 45 | process.env = { ...env }; 46 | }); 47 | 48 | test("Create Block with Rest Client", async () => { 49 | const client = new RestClient({ 50 | apiKey: "API_KEY", 51 | apiToken: "API_TOKEN", 52 | graphName: "MY_GRAPH", 53 | contentType: "application/json", 54 | }); 55 | mockAxios.mockResolvedValue({ 56 | data: { success: [{ string: "text", uid: "childUid" }] }, 57 | }); 58 | const response = await client.createBlock({ 59 | parentUid: "parentUid", 60 | order: 0, 61 | text: "text", 62 | uid: "childUid", 63 | }); 64 | expect(mockAxios).toBeCalledWith( 65 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 66 | { 67 | action: "create-block", 68 | "graph-name": "MY_GRAPH", 69 | location: { 70 | "parent-uid": "parentUid", 71 | order: 0, 72 | }, 73 | block: { 74 | string: "text", 75 | uid: "childUid", 76 | }, 77 | }, 78 | { 79 | headers: { 80 | "x-api-key": "API_KEY", 81 | "x-api-token": "API_TOKEN", 82 | "Content-Type": "application/json", 83 | }, 84 | } 85 | ); 86 | expect(response.string).toBe("text"); 87 | expect(response.uid).toBe("childUid"); 88 | jest.clearAllMocks(); 89 | }); 90 | 91 | test("Move Block with Rest Client", async () => { 92 | const client = new RestClient({ 93 | apiKey: "API_KEY", 94 | apiToken: "API_TOKEN", 95 | graphName: "MY_GRAPH", 96 | contentType: "application/json", 97 | }); 98 | mockAxios.mockResolvedValue({ 99 | data: { success: true }, 100 | }); 101 | const response = await client.moveBlock({ 102 | parentUid: "parentUid", 103 | order: 0, 104 | uid: "childUid", 105 | }); 106 | expect(mockAxios).toBeCalledWith( 107 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 108 | { 109 | action: "move-block", 110 | "graph-name": "MY_GRAPH", 111 | location: { 112 | "parent-uid": "parentUid", 113 | order: 0, 114 | }, 115 | block: { 116 | uid: "childUid", 117 | }, 118 | }, 119 | { 120 | headers: { 121 | "x-api-key": "API_KEY", 122 | "x-api-token": "API_TOKEN", 123 | "Content-Type": "application/json", 124 | }, 125 | } 126 | ); 127 | expect(response).toBe(true); 128 | jest.clearAllMocks(); 129 | }); 130 | 131 | test("Update Block with Rest Client", async () => { 132 | const client = new RestClient({ 133 | apiKey: "API_KEY", 134 | apiToken: "API_TOKEN", 135 | graphName: "MY_GRAPH", 136 | contentType: "application/json", 137 | }); 138 | mockAxios.mockResolvedValue({ 139 | data: { success: true }, 140 | }); 141 | const response = await client.updateBlock({ 142 | open: true, 143 | text: "text", 144 | uid: "childUid", 145 | }); 146 | expect(mockAxios).toBeCalledWith( 147 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 148 | { 149 | action: "update-block", 150 | "graph-name": "MY_GRAPH", 151 | block: { 152 | uid: "childUid", 153 | open: true, 154 | string: "text", 155 | }, 156 | }, 157 | { 158 | headers: { 159 | "x-api-key": "API_KEY", 160 | "x-api-token": "API_TOKEN", 161 | "Content-Type": "application/json", 162 | }, 163 | } 164 | ); 165 | expect(response).toBe(true); 166 | jest.clearAllMocks(); 167 | }); 168 | 169 | test("Delete Block with Rest Client", async () => { 170 | const client = new RestClient({ 171 | apiKey: "API_KEY", 172 | apiToken: "API_TOKEN", 173 | graphName: "MY_GRAPH", 174 | contentType: "application/json", 175 | }); 176 | mockAxios.mockResolvedValue({ 177 | data: { success: true }, 178 | }); 179 | const response = await client.deleteBlock({ 180 | uid: "childUid", 181 | }); 182 | expect(mockAxios).toBeCalledWith( 183 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 184 | { 185 | action: "delete-block", 186 | "graph-name": "MY_GRAPH", 187 | block: { 188 | uid: "childUid", 189 | }, 190 | }, 191 | { 192 | headers: { 193 | "x-api-key": "API_KEY", 194 | "x-api-token": "API_TOKEN", 195 | "Content-Type": "application/json", 196 | }, 197 | } 198 | ); 199 | expect(response).toBe(true); 200 | jest.clearAllMocks(); 201 | }); 202 | 203 | test("Pull with Rest Client", async () => { 204 | const client = new RestClient({ 205 | apiKey: "API_KEY", 206 | apiToken: "API_TOKEN", 207 | graphName: "MY_GRAPH", 208 | contentType: "application/json", 209 | }); 210 | mockAxios.mockResolvedValue({ data: { success: [[1]] } }); 211 | await client.pull({ 212 | selector: "[:block/string]", 213 | uid: "yS-It9SFL", 214 | }); 215 | expect(mockAxios).toBeCalledWith( 216 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 217 | { 218 | action: "pull", 219 | "graph-name": "MY_GRAPH", 220 | selector: "[:block/string]", 221 | uid: "yS-It9SFL", 222 | }, 223 | { 224 | headers: { 225 | "x-api-key": "API_KEY", 226 | "x-api-token": "API_TOKEN", 227 | "Content-Type": "application/json", 228 | }, 229 | } 230 | ); 231 | jest.clearAllMocks(); 232 | }); 233 | 234 | test("Q with Rest Client", async () => { 235 | const client = new RestClient({ 236 | apiKey: "API_KEY", 237 | apiToken: "API_TOKEN", 238 | graphName: "MY_GRAPH", 239 | contentType: "application/json", 240 | }); 241 | mockAxios.mockResolvedValue({ data: { success: [[1]] } }); 242 | const response = await client.q({ 243 | query: "[:find ?e in $ ?title :where [?e :node/title ?title]]", 244 | inputs: ["title"], 245 | }); 246 | expect(mockAxios).toBeCalledWith( 247 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 248 | { 249 | action: "q", 250 | "graph-name": "MY_GRAPH", 251 | query: "[:find ?e in $ ?title :where [?e :node/title ?title]]", 252 | inputs: ["title"], 253 | }, 254 | { 255 | headers: { 256 | "x-api-key": "API_KEY", 257 | "x-api-token": "API_TOKEN", 258 | "Content-Type": "application/json", 259 | }, 260 | } 261 | ); 262 | expect(response).toHaveLength(1); 263 | expect(response[0]).toBe(1); 264 | jest.clearAllMocks(); 265 | }); 266 | 267 | test("Create Page with Rest Client", async () => { 268 | const client = new RestClient({ 269 | apiKey: "API_KEY", 270 | apiToken: "API_TOKEN", 271 | graphName: "MY_GRAPH", 272 | contentType: "application/json", 273 | }); 274 | mockAxios.mockResolvedValue({ 275 | data: { success: [{ title: "My Page", uid: "mpgy3y0p2" }] }, 276 | }); 277 | const response = await client.createPage({ 278 | title: "My Page", 279 | uid: "mpgy3y0p2", 280 | }); 281 | expect(mockAxios).toBeCalledWith( 282 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 283 | { 284 | action: "create-page", 285 | "graph-name": "MY_GRAPH", 286 | page: { 287 | title: "My Page", 288 | uid: "mpgy3y0p2", 289 | }, 290 | }, 291 | { 292 | headers: { 293 | "x-api-key": "API_KEY", 294 | "x-api-token": "API_TOKEN", 295 | "Content-Type": "application/json", 296 | }, 297 | } 298 | ); 299 | expect(response.title).toBe("My Page"); 300 | expect(response.uid).toBe("mpgy3y0p2"); 301 | jest.clearAllMocks(); 302 | }); 303 | 304 | test("Update Page with Rest Client", async () => { 305 | const client = new RestClient({ 306 | apiKey: "API_KEY", 307 | apiToken: "API_TOKEN", 308 | graphName: "MY_GRAPH", 309 | contentType: "application/json", 310 | }); 311 | mockAxios.mockResolvedValue({ 312 | data: { success: true }, 313 | }); 314 | const response = await client.updatePage({ 315 | title: "My New Page", 316 | uid: "mpgy3y0p2", 317 | }); 318 | expect(mockAxios).toBeCalledWith( 319 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 320 | { 321 | action: "update-page", 322 | "graph-name": "MY_GRAPH", 323 | page: { 324 | title: "My New Page", 325 | uid: "mpgy3y0p2", 326 | }, 327 | }, 328 | { 329 | headers: { 330 | "x-api-key": "API_KEY", 331 | "x-api-token": "API_TOKEN", 332 | "Content-Type": "application/json", 333 | }, 334 | } 335 | ); 336 | expect(response).toBe(true); 337 | jest.clearAllMocks(); 338 | }); 339 | 340 | test("Delete Page with Rest Client", async () => { 341 | const client = new RestClient({ 342 | apiKey: "API_KEY", 343 | apiToken: "API_TOKEN", 344 | graphName: "MY_GRAPH", 345 | contentType: "application/json", 346 | }); 347 | mockAxios.mockResolvedValue({ 348 | data: { success: true }, 349 | }); 350 | const response = await client.deletePage({ 351 | uid: "mpgy3y0p2", 352 | }); 353 | expect(mockAxios).toBeCalledWith( 354 | "https://4c67k7zc26.execute-api.us-west-2.amazonaws.com/v1/alphaAPI", 355 | { 356 | action: "delete-page", 357 | "graph-name": "MY_GRAPH", 358 | page: { 359 | uid: "mpgy3y0p2", 360 | }, 361 | }, 362 | { 363 | headers: { 364 | "x-api-key": "API_KEY", 365 | "x-api-token": "API_TOKEN", 366 | "Content-Type": "application/json", 367 | }, 368 | } 369 | ); 370 | expect(response).toBe(true); 371 | jest.clearAllMocks(); 372 | }); 373 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { addStyle, BLOCK_REF_REGEX } from "./dom"; 3 | import { getOrderByBlockUid, getPageUidByPageTitle } from "./queries"; 4 | import { 5 | RoamBlock, 6 | ClientParams, 7 | TextNode, 8 | WriteAction, 9 | ViewType, 10 | SidebarWindowInput, 11 | SidebarWindow, 12 | SidebarAction, 13 | PullBlock, 14 | InputTextNode, 15 | } from "./types"; 16 | export { 17 | updateActiveBlock, 18 | clearBlockById, 19 | clearBlockByUid, 20 | createPage, 21 | createBlock, 22 | updateBlock, 23 | deleteBlock, 24 | openBlockInSidebar, 25 | } from "./writes"; 26 | export { default as RestClient } from "./rest-client"; 27 | export { default as WindowClient } from "./window-client"; 28 | export { 29 | getAllBlockUids, 30 | getAllBlockUidsAndTexts, 31 | getAllPageNames, 32 | getBasicTreeByParentUid, 33 | getBlockUidsByPageTitle, 34 | getBlockUidsWithParentUid, 35 | getBlockUidAndTextIncludingText, 36 | getBlockUidsAndTextsReferencingPage, 37 | getBlockUidByTextOnPage, 38 | getBlockUidsReferencingBlock, 39 | getBlockUidsReferencingPage, 40 | getChildrenLengthByPageUid, 41 | getCreateTimeByBlockUid, 42 | getDisplayNameByEmail, 43 | getDisplayNameByUid, 44 | getEditTimeByBlockUid, 45 | getEditedUserEmailByBlockUid, 46 | getFirstChildTextByBlockUid, 47 | getFirstChildUidByBlockUid, 48 | getFullTreeByParentUid, 49 | getLinkedPageReferences, 50 | getLinkedPageTitlesUnderUid, 51 | getNthChildUidByBlockUid, 52 | getPageTitleByBlockUid, 53 | getPageTitleByPageUid, 54 | getPageTitleReferencesByPageTitle, 55 | getPageTitlesAndBlockUidsReferencingPage, 56 | getPageTitlesAndUidsDirectlyReferencingPage, 57 | getPageTitlesReferencingBlockUid, 58 | getPageTitlesStartingWithPrefix, 59 | getPageUidByPageTitle, 60 | getPageViewType, 61 | getParentTextByBlockUid, 62 | getParentTextByBlockUidAndTag, 63 | getParentUidByBlockUid, 64 | getParentUidsOfBlockUid, 65 | getSettingsByEmail, 66 | getShallowTreeByParentUid, 67 | getTextByBlockUid, 68 | getTreeByBlockUid, 69 | getTreeByPageName, 70 | fixViewType, 71 | isTagOnPage, 72 | normalizePageTitle, 73 | } from "./queries"; 74 | export { 75 | DAILY_NOTE_PAGE_REGEX, 76 | DAILY_NOTE_PAGE_TITLE_REGEX, 77 | parseRoamDate, 78 | parseRoamDateUid, 79 | toRoamDate, 80 | toRoamDateUid, 81 | } from "./date"; 82 | export { 83 | addBlockCommand, 84 | addButtonListener, 85 | addOldRoamJSDependency, 86 | addRoamJSDependency, 87 | addStyle, 88 | BLOCK_REF_REGEX, 89 | createBlockObserver, 90 | createButtonObserver, 91 | createHashtagObserver, 92 | createHTMLObserver, 93 | createIconButton, 94 | createObserver, 95 | createOverlayObserver, 96 | createPageObserver, 97 | createPageTitleObserver, 98 | genericError, 99 | getActiveUids, 100 | getBlockUidFromTarget, 101 | getCurrentPageUid, 102 | getDropUidOffset, 103 | getPageTitleByHtmlElement, 104 | getPageTitleValueByHtmlElement, 105 | getReferenceBlockUid, 106 | getRoamUrl, 107 | getRoamUrlByPage, 108 | getUids, 109 | getUidsFromButton, 110 | getUidsFromId, 111 | openBlock, 112 | resolveRefs, 113 | } from "./dom"; 114 | export { watchOnce } from "./events"; 115 | export { RoamBlock, ViewType, getOrderByBlockUid, TextNode, PullBlock }; 116 | export { 117 | RoamNode, 118 | InputTextNode, 119 | TreeNode, 120 | UserSettings, 121 | RoamBasicNode, 122 | } from "./types"; 123 | 124 | declare global { 125 | interface Window { 126 | roamAlphaAPI: { 127 | q: (query: string, ...params: any[]) => any[][]; 128 | pull: (selector: string, id: number | string) => PullBlock; 129 | createBlock: WriteAction; 130 | updateBlock: WriteAction; 131 | createPage: WriteAction; 132 | moveBlock: WriteAction; 133 | deleteBlock: WriteAction; 134 | updatePage: WriteAction; 135 | deletePage: WriteAction; 136 | util: { 137 | generateUID: () => string; 138 | }; 139 | data: { 140 | addPullWatch: ( 141 | pullPattern: string, 142 | entityId: string, 143 | callback: (before: PullBlock, after: PullBlock) => void 144 | ) => boolean; 145 | block: { 146 | create: WriteAction; 147 | update: WriteAction; 148 | move: WriteAction; 149 | delete: WriteAction; 150 | }; 151 | page: { 152 | create: WriteAction; 153 | update: WriteAction; 154 | delete: WriteAction; 155 | }; 156 | pull: (selector: string, id: number) => PullBlock; 157 | q: (query: string, ...params: any[]) => any[][]; 158 | removePullWatch: ( 159 | pullPattern: string, 160 | entityId: string, 161 | callback: (before: PullBlock, after: PullBlock) => void 162 | ) => boolean; 163 | redo: () => void; 164 | undo: () => void; 165 | user: { 166 | upsert: () => void; 167 | }; 168 | }; 169 | ui: { 170 | rightSidebar: { 171 | open: () => void; 172 | close: () => void; 173 | getWindows: () => SidebarWindow[]; 174 | addWindow: SidebarAction; 175 | setWindowOrder: (action: { 176 | window: SidebarWindowInput & { order: number }; 177 | }) => boolean; 178 | collapseWindow: SidebarAction; 179 | pinWindow: SidebarAction; 180 | expandWindow: SidebarAction; 181 | removeWindow: SidebarAction; 182 | unpinWindow: SidebarAction; 183 | }; 184 | commandPalette: { 185 | addCommand: (action: { label: string; callback: () => void }) => void; 186 | removeCommand: (action: { label: string }) => void; 187 | }; 188 | blockContextMenu: { 189 | addCommand: (action: { 190 | label: string; 191 | callback: (props: { 192 | "block-string": string; 193 | "block-uid": string; 194 | heading: 0 | 1 | 2 | 3 | null; 195 | "page-uid": string; 196 | "read-only?": boolean; 197 | "window-id": string; 198 | }) => void; 199 | }) => void; 200 | removeCommand: (action: { label: string }) => void; 201 | }; 202 | getFocusedBlock: () => null | { 203 | "window-id": string; 204 | "block-uid": string; 205 | }; 206 | components: { 207 | renderBlock: (args: { uid: string; el: HTMLElement }) => null; 208 | }; 209 | mainWindow: { 210 | openBlock: (p: { block: { uid: string } }) => true; 211 | openPage: (p: { page: { uid: string } | { title: string } }) => true; 212 | }; 213 | }; 214 | }; 215 | roamDatomicAlphaAPI?: ( 216 | params: ClientParams 217 | ) => Promise; 218 | // roamjs namespace should only be used for methods that must be accessed across extension scripts 219 | roamjs?: { 220 | loaded: Set; 221 | extension: { 222 | [id: string]: { 223 | [method: string]: (args?: unknown) => void; 224 | }; 225 | }; 226 | version: { [id: string]: string }; 227 | // DEPRECATED remove in 2.0 228 | dynamicElements: Set; 229 | }; 230 | roam42?: { 231 | smartBlocks?: { 232 | customCommands: { 233 | key: string; // `<% ${string} %> (SmartBlock function)`, sad - https://github.com/microsoft/TypeScript/issues/13969 234 | icon: "gear"; 235 | value: string; 236 | processor: (match: string) => Promise; 237 | }[]; 238 | activeWorkflow: { 239 | outputAdditionalBlock: (text: string) => void; 240 | }; 241 | }; 242 | }; 243 | } 244 | } 245 | 246 | const toAttributeValue = (s: string) => 247 | (s.trim().startsWith("{{or: ") 248 | ? s.substring("{{or: ".length, s.indexOf("|")) 249 | : s 250 | ).trim(); 251 | 252 | export const getAttrConfigFromQuery = (query: string) => { 253 | const pageResults = window.roamAlphaAPI.q(query); 254 | if (pageResults.length === 0) { 255 | return {}; 256 | } 257 | const resultAsBlock = pageResults[0][0] as RoamBlock; 258 | if (!resultAsBlock.attrs) { 259 | return {}; 260 | } 261 | 262 | const configurationAttrRefs = resultAsBlock.attrs.map( 263 | (a: any) => a[2].source[1] 264 | ); 265 | const entries = configurationAttrRefs.map( 266 | (r: string) => 267 | (window.roamAlphaAPI.q( 268 | `[:find (pull ?e [:block/string]) :where [?e :block/uid "${r}"] ]` 269 | )[0]?.[0]?.string as string) 270 | ?.split("::") 271 | .map(toAttributeValue) || [r, "undefined"] 272 | ); 273 | return Object.fromEntries(entries); 274 | }; 275 | 276 | const ATTR_REGEX = /^(.*?)::(.*?)$/; 277 | export const getAttrConfigFromUid = (uid: string) => { 278 | const allAttrs = window.roamAlphaAPI 279 | .q( 280 | `[:find (pull ?c [:block/string]) :where [?b :block/uid "${uid}"] [?c :block/parents ?b] [?c :block/refs]]` 281 | ) 282 | .map((a) => a?.[0]?.string as string) 283 | .filter((a) => ATTR_REGEX.test(a)) 284 | .map((r) => 285 | (ATTR_REGEX.exec(r) || ["", "", ""]).slice(1, 3).map(toAttributeValue) 286 | ) 287 | .filter(([k]) => !!k); 288 | return Object.fromEntries(allAttrs); 289 | }; 290 | 291 | export const getConfigFromPage = (inputPage?: string) => { 292 | const page = 293 | inputPage || 294 | document.getElementsByClassName("rm-title-display")[0]?.textContent; 295 | if (!page) { 296 | return {}; 297 | } 298 | return getAttrConfigFromUid(getPageUidByPageTitle(page)); 299 | }; 300 | 301 | export const pushBullets = ( 302 | bullets: string[], 303 | blockUid: string, 304 | parentUid: string 305 | ) => { 306 | const blockIndex = getOrderByBlockUid(blockUid); 307 | for (let index = 0; index < bullets.length; index++) { 308 | const bullet = bullets[index]; 309 | if (index === 0) { 310 | window.roamAlphaAPI.updateBlock({ 311 | block: { 312 | uid: blockUid, 313 | string: bullet, 314 | }, 315 | }); 316 | } else { 317 | window.roamAlphaAPI.createBlock({ 318 | block: { 319 | string: bullet, 320 | }, 321 | location: { 322 | "parent-uid": parentUid, 323 | order: blockIndex + index, 324 | }, 325 | }); 326 | } 327 | } 328 | }; 329 | 330 | const getCurrentUser = (): string[] => { 331 | const globalAppState = JSON.parse( 332 | localStorage.getItem("globalAppState") || '["","",[]]' 333 | ) as (string | string[])[]; 334 | const userIndex = globalAppState.findIndex((s) => s === "~:user"); 335 | if (userIndex > 0) { 336 | return globalAppState[userIndex + 1] as string[]; 337 | } 338 | return []; 339 | }; 340 | 341 | export const getCurrentUserEmail = () => { 342 | const userArray = getCurrentUser(); 343 | const emailIndex = userArray.findIndex((s) => s === "~:email"); 344 | if (emailIndex > 0) { 345 | return userArray[emailIndex + 1]; 346 | } 347 | return ""; 348 | }; 349 | 350 | export const getCurrentUserUid = () => { 351 | const userArray = getCurrentUser(); 352 | const uidIndex = userArray.findIndex((s) => s === "~:uid"); 353 | if (uidIndex > 0) { 354 | return userArray[uidIndex + 1]; 355 | } 356 | return ""; 357 | }; 358 | 359 | export const getCurrentUserDisplayName = () => { 360 | const userArray = getCurrentUser(); 361 | const uidIndex = userArray.findIndex((s) => s === "~:display-name"); 362 | if (uidIndex > 0) { 363 | return userArray[uidIndex + 1] || ""; 364 | } 365 | return ""; 366 | }; 367 | 368 | export const extractTag = (tag: string): string => 369 | tag.startsWith("#[[") && tag.endsWith("]]") 370 | ? tag.substring(3, tag.length - 2) 371 | : tag.startsWith("[[") && tag.endsWith("]]") 372 | ? tag.substring(2, tag.length - 2) 373 | : tag.startsWith("#") 374 | ? tag.substring(1) 375 | : tag.endsWith("::") 376 | ? tag.substring(0, tag.length - 2) 377 | : tag; 378 | 379 | export const extractRef = (ref: string): string => 380 | new RegExp( 381 | `(?:\\(\\()?${BLOCK_REF_REGEX.source.slice(4, -4)}(?:\\)\\))?` 382 | ).exec(ref)?.[1] || ref; 383 | 384 | export const toConfig = (id: string) => `roam/js/${id}`; 385 | 386 | export const getGraph = (): string => 387 | /^#\/app\/([^/]*?)(?:\/page\/.{9,10})?$/.exec(window.location.hash)?.[1] || 388 | ""; 389 | 390 | export const localStorageSet = (key: string, val: string) => 391 | localStorage.setItem(`roamjs:${key}:${getGraph()}`, val); 392 | 393 | export const localStorageGet = (key: string) => 394 | localStorage.getItem(`roamjs:${key}:${getGraph()}`); 395 | 396 | export const localStorageRemove = (key: string) => 397 | localStorage.removeItem(`roamjs:${key}:${getGraph()}`); 398 | 399 | export const runExtension = async ( 400 | extensionId: string, 401 | run: () => void, 402 | options: { skipAnalytics?: boolean } = {} 403 | ): Promise => { 404 | if (window.roamjs?.loaded?.has?.(extensionId)) { 405 | return; 406 | } 407 | window.roamjs = { 408 | loaded: window.roamjs?.loaded || new Set(), 409 | extension: window.roamjs?.extension || {}, 410 | version: window.roamjs?.version || {}, 411 | dynamicElements: window.roamjs?.dynamicElements || new Set(), 412 | }; 413 | window.roamjs.loaded.add(extensionId); 414 | window.roamjs.version[extensionId] = process.env.ROAMJS_VERSION || ""; 415 | 416 | if (!options.skipAnalytics) { 417 | axios.post(`https://api.roamjs.com/mixpanel`, { 418 | eventName: "Load Extension", 419 | properties: { extensionId }, 420 | }); 421 | } 422 | addStyle( 423 | `.bp3-button:focus { 424 | outline-width: 2px; 425 | }`, 426 | "roamjs-default" 427 | ); 428 | 429 | run(); 430 | }; 431 | 432 | // @DEPRECATED - PART OF SMARTBLOCKS V1, USE BELOW 433 | export const createCustomSmartBlockCommand = ({ 434 | command, 435 | processor, 436 | }: { 437 | command: string; 438 | processor: (afterColon?: string) => Promise; 439 | }): void => { 440 | const inputListener = () => { 441 | if (window.roam42 && window.roam42.smartBlocks) { 442 | const value = `<%${command.toUpperCase()}(:.*)?%>`; 443 | window.roam42.smartBlocks.customCommands.push({ 444 | key: `<% ${command.toUpperCase()} %> (SmartBlock function)`, 445 | icon: "gear", 446 | processor: (match: string) => { 447 | const colonPrefix = `<%${command.toUpperCase()}:`; 448 | if (match.startsWith(colonPrefix)) { 449 | const afterColon = match.replace("<%${}:", "").replace("%>", ""); 450 | return processor(afterColon); 451 | } else { 452 | return processor(); 453 | } 454 | }, 455 | value, 456 | }); 457 | document.removeEventListener("input", inputListener); 458 | } 459 | }; 460 | document.addEventListener("input", inputListener); 461 | }; 462 | 463 | type CommandOutput = string | string[] | InputTextNode[]; 464 | type CommandHandler = ( 465 | ...args: string[] 466 | ) => CommandOutput | Promise; 467 | export const registerSmartBlocksCommand = ({ 468 | text: inputText, 469 | handler, 470 | }: { 471 | text: string; 472 | handler: (u: unknown) => CommandHandler; 473 | }) => { 474 | const text = inputText.toUpperCase(); 475 | const register = (retry: number): void | number | false => 476 | window.roamjs?.extension?.smartblocks?.registerCommand 477 | ? window.roamjs.extension.smartblocks.registerCommand({ 478 | text, 479 | handler, 480 | }) 481 | : retry === 120 && window.roamjs 482 | ? !(window.roamjs = { 483 | ...window.roamjs, 484 | extension: { 485 | ...window.roamjs.extension, 486 | [text]: { 487 | ...window.roamjs.extension[text], 488 | registerSmartBlocksCommand: () => { 489 | window.roamjs?.extension.smartblocks.registerCommand({ 490 | text, 491 | handler, 492 | }); 493 | }, 494 | }, 495 | }, 496 | }) 497 | : window.setTimeout(() => register(retry + 1), 1000); 498 | register(0); 499 | }; 500 | 501 | export const createTagRegex = (tag: string) => 502 | new RegExp(`#?\\[\\[${tag}\\]\\]|#${tag}`); 503 | -------------------------------------------------------------------------------- /src/queries.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RoamBasicNode, 3 | RoamBlock, 4 | RoamUnorderedBasicNode, 5 | TextAlignment, 6 | TreeNode, 7 | UserSettings, 8 | ViewType, 9 | } from "./types"; 10 | 11 | export const normalizePageTitle = (title: string) => 12 | title.replace(/\\/, "\\\\").replace(/"/g, '\\"'); 13 | 14 | export const allBlockMapper = (t: TreeNode): TreeNode[] => [ 15 | t, 16 | ...t.children.flatMap(allBlockMapper), 17 | ]; 18 | 19 | // DEPRECATED - Remove for 2.0.0 20 | export const getChildRefUidsByBlockUid = (b: string): string[] => 21 | window.roamAlphaAPI 22 | .q( 23 | `[:find ?u :where [?r :block/uid ?u] [?e :block/refs ?r] [?e :block/uid "${b}"]]` 24 | ) 25 | .map((r) => r[0] as string); 26 | 27 | // DEPRECATED - Remove for 2.0.0 28 | export const getLinkedPageReferences = (t: string): RoamBlock[] => { 29 | const findParentBlock: (b: RoamBlock) => RoamBlock = (b: RoamBlock) => 30 | b.title 31 | ? b 32 | : findParentBlock( 33 | window.roamAlphaAPI.q( 34 | `[:find (pull ?e [*]) :where [?e :block/children ${b.id}]]` 35 | )[0][0] as RoamBlock 36 | ); 37 | const parentBlocks = window.roamAlphaAPI 38 | .q( 39 | `[:find (pull ?parentPage [*]) :where [?parentPage :block/children ?referencingBlock] [?referencingBlock :block/refs ?referencedPage] [?referencedPage :node/title "${normalizePageTitle( 40 | t 41 | )}"]]` 42 | ) 43 | .filter((block) => block.length); 44 | return parentBlocks.map((b) => 45 | findParentBlock(b[0] as RoamBlock) 46 | ) as RoamBlock[]; 47 | }; 48 | 49 | export const getPageTitleReferencesByPageTitle = (title: string) => 50 | window.roamAlphaAPI 51 | .q( 52 | `[:find ?t :where [?b :node/title ?t] [?b :block/children ?c] [?c :block/refs ?r] [?r :node/title "${normalizePageTitle( 53 | title 54 | )}"]]` 55 | ) 56 | .map((p) => p[0] as string); 57 | 58 | export const getOrderByBlockUid = (blockUid: string) => 59 | window.roamAlphaAPI.q( 60 | `[:find ?o :where [?r :block/order ?o] [?r :block/uid "${blockUid}"]]` 61 | )?.[0]?.[0] as number; 62 | 63 | export const getParentUidByBlockUid = (blockUid: string): string => 64 | window.roamAlphaAPI.q( 65 | `[:find ?u :where [?p :block/uid ?u] [?p :block/children ?e] [?e :block/uid "${blockUid}"]]` 66 | )?.[0]?.[0] as string; 67 | 68 | const getTreeByBlockId = (blockId: number): TreeNode => { 69 | const block = window.roamAlphaAPI.pull("[*]", blockId); 70 | const children = block[":block/children"] || []; 71 | const props = block[":block/props"] || {}; 72 | return { 73 | text: block[":block/string"] || "", 74 | order: block[":block/order"] || 0, 75 | uid: block[":block/uid"] || "", 76 | children: children 77 | .map((c) => getTreeByBlockId(c[":db/id"])) 78 | .sort((a, b) => a.order - b.order), 79 | heading: block[":block/heading"] || 0, 80 | open: block[":block/open"] || true, 81 | viewType: block[":children/view-type"]?.substring(1) as ViewType, 82 | editTime: new Date(block[":edit/time"] || 0), 83 | textAlign: block[":block/text-align"] || "left", 84 | props: { 85 | imageResize: Object.fromEntries( 86 | Object.keys(props[":image-size"] || {}).map((p) => [ 87 | p, 88 | { 89 | height: props[":image-size"][p][":height"], 90 | width: props[":image-size"][p][":width"], 91 | }, 92 | ]) 93 | ), 94 | iframe: Object.fromEntries( 95 | Object.keys(props[":iframe"] || {}).map((p) => [ 96 | p, 97 | { 98 | height: props[":iframe"][p][":size"][":height"], 99 | width: props[":iframe"][p][":size"][":width"], 100 | }, 101 | ]) 102 | ), 103 | }, 104 | }; 105 | }; 106 | 107 | export const getTreeByBlockUid = (blockUid: string): TreeNode => { 108 | if (!blockUid) { 109 | return { 110 | text: "", 111 | order: 0, 112 | uid: "", 113 | children: [], 114 | heading: 0, 115 | open: true, 116 | viewType: "bullet", 117 | editTime: new Date(0), 118 | textAlign: "left", 119 | props: { 120 | imageResize: {}, 121 | iframe: {}, 122 | }, 123 | }; 124 | } 125 | const blockId = window.roamAlphaAPI.q( 126 | `[:find ?e :where [?e :block/uid "${blockUid}"]]` 127 | )?.[0]?.[0] as number; 128 | return getTreeByBlockId(blockId); 129 | }; 130 | 131 | export const getTreeByPageName = (name: string): TreeNode[] => { 132 | const result = window.roamAlphaAPI.q( 133 | `[:find (pull ?e [:block/children :children/view-type]) :where [?e :node/title "${normalizePageTitle( 134 | name 135 | )}"]]` 136 | ); 137 | if (!result.length) { 138 | return []; 139 | } 140 | const block = result[0][0] as RoamBlock; 141 | const children = block?.children || []; 142 | const viewType = block?.["view-type"] || "bullet"; 143 | return children 144 | .map((c) => getTreeByBlockId(c.id)) 145 | .sort((a, b) => a.order - b.order) 146 | .map((c) => fixViewType({ c, v: viewType })); 147 | }; 148 | 149 | // DEPRECATED - Remove for 2.0.0 150 | export const fixViewType = ({ 151 | c, 152 | v, 153 | }: { 154 | c: TreeNode; 155 | v: ViewType; 156 | }): TreeNode => { 157 | if (!c.viewType) { 158 | c.viewType = v; 159 | } 160 | c.children.forEach((cc) => fixViewType({ c: cc, v: c.viewType })); 161 | return c; 162 | }; 163 | 164 | export const getEditedUserEmailByBlockUid = (blockUid: string) => 165 | window.roamAlphaAPI.q( 166 | `[:find ?e :where [?u :user/email ?e] [?b :edit/user ?u] [?b :block/uid "${blockUid}"]]` 167 | )?.[0]?.[0] || ""; 168 | 169 | export const getTextByBlockUid = (uid: string): string => 170 | window.roamAlphaAPI.q( 171 | `[:find (pull ?e [:block/string]) :where [?e :block/uid "${uid}"]]` 172 | )?.[0]?.[0]?.string || ""; 173 | 174 | export const getPageTitleByBlockUid = (blockUid: string): string => 175 | window.roamAlphaAPI.q( 176 | `[:find (pull ?p [:node/title]) :where [?e :block/uid "${blockUid}"] [?e :block/page ?p]]` 177 | )?.[0]?.[0]?.title || ""; 178 | 179 | export const getPageTitleByPageUid = (blockUid: string): string => 180 | window.roamAlphaAPI.q( 181 | `[:find (pull ?p [:node/title]) :where [?p :block/uid "${blockUid}"]]` 182 | )?.[0]?.[0]?.title || ""; 183 | 184 | export const getParentTextByBlockUid = (blockUid: string): string => 185 | window.roamAlphaAPI.q( 186 | `[:find ?s :where [?p :block/string ?s] [?p :block/children ?e] [?e :block/uid "${blockUid}"]]` 187 | )?.[0]?.[0] || ""; 188 | 189 | export const getParentTextByBlockUidAndTag = ({ 190 | blockUid, 191 | tag, 192 | }: { 193 | blockUid: string; 194 | tag: string; 195 | }) => 196 | window.roamAlphaAPI.q( 197 | `[:find ?s :where [?p :block/string ?s] [?p :block/refs ?t] [?t :node/title "${tag}"] [?b :block/parents ?p] [?b :block/uid "${blockUid}"]]` 198 | )?.[0]?.[0] || ""; 199 | 200 | export const getSettingsByEmail = (email: string) => 201 | (window.roamAlphaAPI.q( 202 | `[:find ?settings :where[?e :user/settings ?settings] [?e :user/email "${email}"]]` 203 | )?.[0]?.[0] as UserSettings) || {}; 204 | 205 | export const getDisplayNameByEmail = (email: string) => 206 | (window.roamAlphaAPI.q( 207 | `[:find ?name :where[?e :user/display-name ?name] [?e :user/email "${email}"]]` 208 | )?.[0]?.[0] as string) || ""; 209 | 210 | export const getDisplayNameByUid = (uid: string): string => 211 | window.roamAlphaAPI.q( 212 | `[:find ?s :where [?p :node/title ?s] [?e :user/display-page ?p] [?e :user/uid "${uid}"]]` 213 | )?.[0]?.[0] || ""; 214 | 215 | export const getCreateTimeByBlockUid = (uid: string): number => 216 | window.roamAlphaAPI.q( 217 | `[:find ?t :where [?e :create/time ?t] [?e :block/uid "${uid}"]]` 218 | )?.[0]?.[0] as number; 219 | 220 | export const getEditTimeByBlockUid = (uid: string): number => 221 | window.roamAlphaAPI.q( 222 | `[:find ?t :where [?e :edit/time ?t] [?e :block/uid "${uid}"]]` 223 | )?.[0]?.[0] as number; 224 | 225 | export const getAllPageNames = (): string[] => 226 | window.roamAlphaAPI 227 | .q("[:find ?s :where [?e :node/title ?s]]") 228 | .map((b) => b[0] as string); 229 | 230 | export const getAllBlockUids = (): string[] => 231 | window.roamAlphaAPI 232 | .q(`[:find ?u :where [?e :block/uid ?u] [?e :block/string]]`) 233 | .map((f) => f[0]); 234 | 235 | export const getAllBlockUidsAndTexts = (): { uid: string; text: string }[] => 236 | window.roamAlphaAPI 237 | .q(`[:find ?u ?s :where [?e :block/uid ?u] [?e :block/string ?s]]`) 238 | .map((f) => ({ uid: f[0] as string, text: f[1] as string })); 239 | 240 | export const getPageViewType = (title: string): ViewType => 241 | (window.roamAlphaAPI.q( 242 | `[:find ?v :where [?e :children/view-type ?v] [?e :node/title "${normalizePageTitle( 243 | title 244 | )}"]]` 245 | )?.[0]?.[0] as ViewType) || "bullet"; 246 | 247 | export const getPageUidByPageTitle = (title: string): string => 248 | (window.roamAlphaAPI.q( 249 | `[:find (pull ?e [:block/uid]) :where [?e :node/title "${normalizePageTitle( 250 | title 251 | )}"]]` 252 | )?.[0]?.[0]?.uid as string) || ""; 253 | 254 | export const getBlockUidAndTextIncludingText = ( 255 | t: string 256 | ): { uid: string; text: string }[] => 257 | window.roamAlphaAPI 258 | .q( 259 | `[:find ?u ?contents :where [?block :block/uid ?u] [?block :block/string ?contents][(clojure.string/includes? ?contents "${t}")]]` 260 | ) 261 | .map(([uid, text]: string[]) => ({ uid, text })); 262 | 263 | export const getBlockUidsAndTextsReferencingPage = ( 264 | title: string 265 | ): { uid: string; text: string }[] => 266 | window.roamAlphaAPI 267 | .q( 268 | `[:find ?u ?s :where [?r :block/uid ?u] [?r :block/string ?s] [?r :block/refs ?p] [?p :node/title "${normalizePageTitle( 269 | title 270 | )}"]]` 271 | ) 272 | .map(([uid, text]: string[]) => ({ uid, text })); 273 | 274 | export const getBlockUidByTextOnPage = ({ 275 | text, 276 | title, 277 | }: { 278 | text: string; 279 | title: string; 280 | }): string => 281 | (window.roamAlphaAPI.q( 282 | `[:find ?u :where [?b :block/page ?p] [?b :block/uid ?u] [?b :block/string "${text}"] [?p :node/title "${title}"]]` 283 | )?.[0]?.[0] as string) || ""; 284 | 285 | export const getBlockUidsReferencingBlock = (uid: string): string[] => 286 | window.roamAlphaAPI 287 | .q( 288 | `[:find ?u :where [?r :block/uid ?u] [?r :block/refs ?b] [?b :block/uid "${uid}"]]` 289 | ) 290 | .map((s) => s[0]); 291 | 292 | export const getBlockUidsReferencingPage = (title: string): string[] => 293 | window.roamAlphaAPI 294 | .q( 295 | `[:find ?u :where [?r :block/uid ?u] [?r :block/refs ?p] [?p :node/title "${normalizePageTitle( 296 | title 297 | )}"]]` 298 | ) 299 | .map((s) => s[0]); 300 | 301 | export const getChildrenLengthByPageUid = (uid: string): number => 302 | window.roamAlphaAPI.q( 303 | `[:find ?c :where [?e :block/children ?c] [?e :block/uid "${uid}"]]` 304 | ).length; 305 | 306 | export const getPageTitlesStartingWithPrefix = (prefix: string): string[] => 307 | window.roamAlphaAPI 308 | .q( 309 | `[:find ?title :where [?b :node/title ?title][(clojure.string/starts-with? ?title "${prefix}")]]` 310 | ) 311 | .map((r) => r[0] as string); 312 | 313 | export const getPageTitlesReferencingBlockUid = (uid: string): string[] => 314 | window.roamAlphaAPI 315 | .q( 316 | `[:find ?t :where [?r :block/uid "${uid}"] [?b :block/refs ?r] [?b :block/page ?p] [?p :node/title ?t]]` 317 | ) 318 | .map((s) => s[0]); 319 | 320 | export const getPageTitlesAndBlockUidsReferencingPage = ( 321 | pageName: string 322 | ): { title: string; uid: string }[] => 323 | window.roamAlphaAPI 324 | .q( 325 | `[:find (pull ?pr [:node/title]) (pull ?r [:block/uid]) :where [?p :node/title "${normalizePageTitle( 326 | pageName 327 | )}"] [?r :block/refs ?p] [?r :block/page ?pr]]` 328 | ) 329 | .map(([{ title }, { uid }]: Record[]) => ({ title, uid })); 330 | 331 | export const getPageTitlesAndUidsDirectlyReferencingPage = ( 332 | pageName: string 333 | ): { title: string; uid: string }[] => 334 | window.roamAlphaAPI 335 | .q( 336 | `[:find ?t ?u :where [?r :block/uid ?u] [?r :node/title ?t] [?r :block/refs ?p] [?p :node/title "${normalizePageTitle( 337 | pageName 338 | )}"]]` 339 | ) 340 | .map(([title, uid]: string[]) => ({ title, uid })); 341 | 342 | export const getBlockUidsByPageTitle = (title: string) => 343 | window.roamAlphaAPI 344 | .q( 345 | `[:find ?u :where [?b :block/uid ?u] [?b :block/page ?e] [?e :node/title "${normalizePageTitle( 346 | title 347 | )}"]]` 348 | ) 349 | .map((b) => b[0] as string); 350 | 351 | export const getNthChildUidByBlockUid = ({ 352 | blockUid, 353 | order, 354 | }: { 355 | blockUid: string; 356 | order: number; 357 | }): string => 358 | window.roamAlphaAPI.q( 359 | `[:find ?u :where [?c :block/uid ?u] [?c :block/order ${order}] [?p :block/children ?c] [?p :block/uid "${blockUid}"]]` 360 | )?.[0]?.[0] as string; 361 | 362 | export const getFirstChildUidByBlockUid = (blockUid: string): string => 363 | getNthChildUidByBlockUid({ blockUid, order: 0 }); 364 | 365 | export const getFirstChildTextByBlockUid = (blockUid: string): string => 366 | window.roamAlphaAPI.q( 367 | `[:find ?s :where [?c :block/string ?s] [?c :block/order 0] [?p :block/children ?c] [?p :block/uid "${blockUid}"]]` 368 | )?.[0]?.[0] as string; 369 | 370 | export const getShallowTreeByParentUid = ( 371 | parentUid: string 372 | ): { uid: string; text: string }[] => 373 | window.roamAlphaAPI 374 | .q( 375 | `[:find (pull ?c [:block/uid :block/string :block/order]) :where [?b :block/uid "${parentUid}"] [?b :block/children ?c]]` 376 | ) 377 | .sort((a, b) => a[0].order - b[0].order) 378 | .map(([a]: { uid: string; string: string }[]) => ({ 379 | uid: a.uid, 380 | text: a.string, 381 | })); 382 | 383 | export const getLinkedPageTitlesUnderUid = (uid: string): string[] => 384 | window.roamAlphaAPI 385 | .q( 386 | `[:find ?t :where [?r :node/title ?t] [?c :block/refs ?r] [?c :block/parents ?b] [?b :block/uid "${uid}"]]` 387 | ) 388 | .map((r) => r[0] as string); 389 | 390 | export const getBlockUidsWithParentUid = (uid: string): string[] => 391 | window.roamAlphaAPI 392 | .q( 393 | `[:find ?u :where [?c :block/uid ?u] [?c :block/parents ?b] [?b :block/uid "${uid}"]]` 394 | ) 395 | .map((r) => r[0] as string); 396 | 397 | export const getParentUidsOfBlockUid = (uid: string): string[] => 398 | window.roamAlphaAPI 399 | .q( 400 | `[:find ?u :where [?p :block/uid ?u] [?b :block/parents ?p] [?b :block/uid "${uid}"]]` 401 | ) 402 | .map((r) => r[0] as string); 403 | 404 | const sortBasicNodes = (c: RoamUnorderedBasicNode[]): RoamBasicNode[] => 405 | c 406 | .sort(({ order: a }, { order: b }) => a - b) 407 | .map(({ order, children = [], ...node }) => ({ 408 | children: sortBasicNodes(children), 409 | ...node, 410 | })); 411 | 412 | export const getBasicTreeByParentUid = (uid: string): RoamBasicNode[] => 413 | sortBasicNodes( 414 | window.roamAlphaAPI 415 | .q( 416 | `[:find (pull ?c [[:block/string :as "text"] :block/uid :block/order {:block/children ...}]) :where [?b :block/uid "${uid}"] [?b :block/children ?c]]` 417 | ) 418 | .map((a) => a[0] as RoamUnorderedBasicNode) 419 | ); 420 | 421 | type RoamRawBlock = { 422 | text: string; 423 | uid: string; 424 | order: number; 425 | heading?: number; 426 | open: boolean; 427 | viewType?: ViewType; 428 | textAlign?: TextAlignment; 429 | editTime: number; 430 | props?: {}; 431 | children?: RoamRawBlock[]; 432 | }; 433 | 434 | const formatRoamNode = (n: Partial, v: ViewType): TreeNode => ({ 435 | text: n.text || "", 436 | open: typeof n.open === "undefined" ? true : n.open, 437 | order: n.order || 0, 438 | uid: n.uid || "", 439 | heading: n.heading || 0, 440 | viewType: n.viewType || v, 441 | editTime: new Date(n.editTime || 0), 442 | props: { imageResize: {}, iframe: {} }, 443 | textAlign: n.textAlign || "left", 444 | children: (n.children || []) 445 | .sort(({ order: a }, { order: b }) => a - b) 446 | .map((r) => formatRoamNode(r, n.viewType || v)), 447 | }); 448 | 449 | export const getFullTreeByParentUid = (uid: string): TreeNode => 450 | formatRoamNode( 451 | window.roamAlphaAPI.q( 452 | `[:find (pull ?b [ 453 | [:block/string :as "text"] 454 | [:node/title :as "text"] 455 | :block/uid 456 | :block/order 457 | :block/heading 458 | :block/open 459 | [:children/view-type :as "viewType"] 460 | [:block/text-align :as "textAlign"] 461 | [:edit/time :as "editTime"] 462 | :block/props 463 | {:block/children ...} 464 | ]) :where [?b :block/uid "${uid}"]]` 465 | )?.[0]?.[0] || ({} as RoamRawBlock), 466 | window.roamAlphaAPI 467 | .q( 468 | `[:find 469 | (pull ?p [:children/view-type]) :where 470 | [?c :block/uid "${uid}"] [?c :block/parents ?p]]` 471 | ) 472 | .reverse() 473 | .map((a) => a[0]) 474 | .map((a) => a && a["view-type"]) 475 | .find((a) => !!a) || "bullet" 476 | ); 477 | 478 | export const isTagOnPage = ({ 479 | tag, 480 | title, 481 | }: { 482 | tag: string; 483 | title: string; 484 | }): boolean => 485 | !!window.roamAlphaAPI.q( 486 | `[:find ?r :where [?r :node/title "${normalizePageTitle( 487 | tag 488 | )}"] [?b :block/refs ?r] [?b :block/page ?p] [?p :node/title "${normalizePageTitle( 489 | title 490 | )}"]]` 491 | )?.[0]?.[0]; 492 | -------------------------------------------------------------------------------- /src/dom.ts: -------------------------------------------------------------------------------- 1 | import { AxiosError } from "axios"; 2 | import { toRoamDateUid } from "./date"; 3 | import { 4 | getBlockUidsByPageTitle, 5 | getBlockUidsReferencingBlock, 6 | getChildrenLengthByPageUid, 7 | getNthChildUidByBlockUid, 8 | getOrderByBlockUid, 9 | getPageUidByPageTitle, 10 | getParentUidByBlockUid, 11 | getTextByBlockUid, 12 | } from "./queries"; 13 | import { RoamError, TreeNode, ViewType } from "./types"; 14 | import { createBlock, updateActiveBlock, updateBlock } from "./writes"; 15 | 16 | export const BLOCK_REF_REGEX = /\(\(([\w\d-]{9,10})\)\)/; 17 | const aliasRefRegex = new RegExp( 18 | `\\[[^\\]]*\\]\\((${BLOCK_REF_REGEX.source})\\)`, 19 | "g" 20 | ); 21 | const aliasTagRegex = new RegExp( 22 | `\\[[^\\]]*\\]\\((\\[\\[([^\\]]*)\\]\\])\\)`, 23 | "g" 24 | ); 25 | 26 | export const resolveRefs = (text: string): string => { 27 | return text 28 | .replace(aliasTagRegex, (alias, del, pageName) => { 29 | const blockUid = getPageUidByPageTitle(pageName); 30 | return alias.replace(del, `${getRoamUrl(blockUid)}`); 31 | }) 32 | .replace(aliasRefRegex, (alias, del, blockUid) => { 33 | return alias.replace(del, `${getRoamUrl(blockUid)}`); 34 | }) 35 | .replace(new RegExp(BLOCK_REF_REGEX, "g"), (_, blockUid) => { 36 | const reference = getTextByBlockUid(blockUid); 37 | return reference || blockUid; 38 | }); 39 | }; 40 | 41 | export const addStyle = (content: string, id?: string): HTMLStyleElement => { 42 | const existing = document.getElementById(id || "") as HTMLStyleElement; 43 | if (existing) return existing; 44 | const css = document.createElement("style"); 45 | css.textContent = content; 46 | if (id) css.id = id; 47 | document.getElementsByTagName("head")[0].appendChild(css); 48 | return css; 49 | }; 50 | 51 | /** 52 | * TODO: Replace this paradigm with an tree node config instead. 53 | */ 54 | const getButtonConfig = (target: HTMLButtonElement, targetCommand: string) => { 55 | const rawParts = target.innerText 56 | .substring(targetCommand.length + 1) 57 | .split(" "); 58 | let quotedWord = ""; 59 | const restOfButtonText: string[] = []; 60 | for (const part of rawParts) { 61 | if (quotedWord) { 62 | if (part.endsWith('"')) { 63 | restOfButtonText.push( 64 | `${quotedWord} ${part.substring(0, part.length - 1)}` 65 | ); 66 | quotedWord = ""; 67 | } else { 68 | quotedWord = `${quotedWord} ${part}`; 69 | } 70 | } else { 71 | if (part.startsWith('"')) { 72 | quotedWord = part.substring(1); 73 | } else { 74 | restOfButtonText.push(part); 75 | } 76 | } 77 | } 78 | const numPairs = Math.floor(restOfButtonText.length / 2); 79 | const buttonConfig = {} as { [key: string]: string }; 80 | for (let i = 0; i < numPairs; i++) { 81 | buttonConfig[restOfButtonText[i * 2]] = restOfButtonText[i * 2 + 1]; 82 | } 83 | return buttonConfig; 84 | }; 85 | 86 | const clickEventListener = ( 87 | targetCommand: string, 88 | callback: (buttonConfig: { [key: string]: string }, blockUid: string) => void 89 | ) => async (e: MouseEvent) => { 90 | const htmlTarget = e.target as HTMLElement; 91 | if ( 92 | htmlTarget && 93 | htmlTarget.tagName === "BUTTON" && 94 | htmlTarget.innerText 95 | .toUpperCase() 96 | .trim() 97 | .startsWith(targetCommand.toUpperCase()) 98 | ) { 99 | const target = htmlTarget as HTMLButtonElement; 100 | const buttonConfig = getButtonConfig(target, targetCommand); 101 | const { blockUid } = getUidsFromButton(target); 102 | window.roamAlphaAPI.updateBlock({ block: { uid: blockUid, string: "" } }); 103 | callback(buttonConfig, blockUid); 104 | } 105 | }; 106 | 107 | export const addOldRoamJSDependency = (extension: string) => { 108 | const id = `roamjs-${extension.replace(/\/main$/, "")}`; 109 | const existing = document.getElementById(id); 110 | if (!existing) { 111 | const script = document.createElement("script"); 112 | script.src = `https://roamjs.com/${extension}.js`; 113 | script.async = true; 114 | script.type = "text/javascript"; 115 | script.id = id; 116 | document.querySelector("head")?.appendChild(script); 117 | } 118 | }; 119 | 120 | export const addRoamJSDependency = (extension: string) => { 121 | addOldRoamJSDependency(`${extension}/main`); 122 | }; 123 | 124 | export const addButtonListener = ( 125 | targetCommand: string, 126 | callback: (buttonConfig: { [key: string]: string }, blockUid: string) => void 127 | ) => 128 | document.addEventListener( 129 | "click", 130 | clickEventListener(targetCommand, callback) 131 | ); 132 | 133 | /** 134 | * @param icon A blueprint icon which could be found in https://blueprintjs.com/docs/#icons 135 | */ 136 | export const createIconButton = (icon: string) => { 137 | const popoverButton = document.createElement("span"); 138 | popoverButton.className = "bp3-button bp3-minimal bp3-small"; 139 | popoverButton.tabIndex = 0; 140 | 141 | const popoverIcon = document.createElement("span"); 142 | popoverIcon.className = `bp3-icon bp3-icon-${icon}`; 143 | popoverButton.appendChild(popoverIcon); 144 | 145 | return popoverButton; 146 | }; 147 | 148 | export const getUidsFromId = (id: string) => { 149 | const blockUid = id.substring(id.length - 9, id.length); 150 | const restOfHTMLId = id.substring(0, id.length - 9); 151 | const potentialDateUid = restOfHTMLId.substring( 152 | restOfHTMLId.length - 11, 153 | restOfHTMLId.length - 1 154 | ); 155 | const parentUid = isNaN(new Date(potentialDateUid).valueOf()) 156 | ? potentialDateUid.substring(1) 157 | : potentialDateUid; 158 | return { 159 | blockUid, 160 | parentUid, 161 | }; 162 | }; 163 | 164 | export const getUids = (block: HTMLDivElement | HTMLTextAreaElement) => { 165 | return block ? getUidsFromId(block.id) : { blockUid: "", parentUid: "" }; 166 | }; 167 | 168 | export const getActiveUids = () => 169 | getUids(document.activeElement as HTMLTextAreaElement); 170 | 171 | export const getUidsFromButton = (button: HTMLButtonElement) => { 172 | const block = button.closest(".roam-block") as HTMLDivElement; 173 | return block ? getUids(block) : { blockUid: "", parentUid: "" }; 174 | }; 175 | 176 | export const genericError = (e: Partial) => { 177 | const message = 178 | (e.response 179 | ? typeof e.response.data === "string" 180 | ? e.response.data 181 | : JSON.stringify(e.response.data) 182 | : e.message) || 183 | e.raw || 184 | "Unknown Error Occurred"; 185 | const errMsg = `Error: ${ 186 | message.length > 50 ? `${message.substring(0, 50)}...` : message 187 | }`; 188 | updateActiveBlock(errMsg); 189 | return errMsg; 190 | }; 191 | 192 | const getMutatedNodes = ({ 193 | ms, 194 | tag, 195 | className, 196 | nodeList, 197 | }: { 198 | ms: MutationRecord[]; 199 | tag: string; 200 | className: string; 201 | nodeList: "addedNodes" | "removedNodes"; 202 | }) => { 203 | const blocks = ms.flatMap((m) => 204 | Array.from(m[nodeList]).filter( 205 | (d: Node) => 206 | d.nodeName === tag && 207 | Array.from((d as HTMLElement).classList).includes(className) 208 | ) 209 | ); 210 | const childBlocks = ms.flatMap((m) => 211 | Array.from(m[nodeList]) 212 | .filter((n) => n.hasChildNodes()) 213 | .flatMap((d) => 214 | Array.from((d as HTMLElement).getElementsByClassName(className)) 215 | ) 216 | ); 217 | return [...blocks, ...childBlocks]; 218 | }; 219 | 220 | export const createObserver = ( 221 | mutationCallback: ( 222 | mutationList: MutationRecord[], 223 | observer: MutationObserver 224 | ) => void 225 | ): void => 226 | createDivObserver( 227 | mutationCallback, 228 | document.getElementsByClassName("roam-body")[0] 229 | ); 230 | 231 | export const createOverlayObserver = ( 232 | mutationCallback: (mutationList: MutationRecord[]) => void 233 | ): void => createDivObserver(mutationCallback, document.body); 234 | 235 | const createDivObserver = ( 236 | mutationCallback: MutationCallback, 237 | mutationTarget: Element 238 | ) => { 239 | const observer = new MutationObserver(mutationCallback); 240 | observer.observe(mutationTarget, { childList: true, subtree: true }); 241 | }; 242 | 243 | export const createHTMLObserver = ({ 244 | callback, 245 | tag, 246 | className, 247 | removeCallback, 248 | useBody, 249 | }: { 250 | callback: (b: HTMLElement) => void; 251 | tag: string; 252 | className: string; 253 | removeCallback?: (b: HTMLElement) => void; 254 | useBody?: boolean; 255 | }): void => { 256 | const blocks = document.getElementsByClassName( 257 | className 258 | ) as HTMLCollectionOf; 259 | Array.from(blocks).forEach(callback); 260 | 261 | (useBody ? createOverlayObserver : createObserver)((ms) => { 262 | const addedNodes = getMutatedNodes({ 263 | ms, 264 | nodeList: "addedNodes", 265 | tag, 266 | className, 267 | }); 268 | addedNodes.map((n) => n as HTMLElement).forEach(callback); 269 | if (removeCallback) { 270 | const removedNodes = getMutatedNodes({ 271 | ms, 272 | nodeList: "removedNodes", 273 | tag, 274 | className, 275 | }); 276 | removedNodes.map((n) => n as HTMLElement).forEach(removeCallback); 277 | } 278 | }); 279 | }; 280 | 281 | export const createButtonObserver = ({ 282 | attribute, 283 | render, 284 | shortcut = attribute, 285 | }: { 286 | shortcut?: string; 287 | attribute: string; 288 | render: (b: HTMLButtonElement) => void; 289 | }): void => 290 | createHTMLObserver({ 291 | callback: (b) => { 292 | if ( 293 | b.innerText.toUpperCase() === 294 | attribute.toUpperCase().replace(/-/g, " ") || 295 | b.innerText.toUpperCase() === shortcut.toUpperCase() 296 | ) { 297 | const dataAttribute = `data-roamjs-${attribute}`; 298 | if (!b.getAttribute(dataAttribute)) { 299 | b.setAttribute(dataAttribute, "true"); 300 | render(b as HTMLButtonElement); 301 | } 302 | } 303 | }, 304 | tag: "BUTTON", 305 | className: "bp3-button", 306 | useBody: true, 307 | }); 308 | 309 | export const createHashtagObserver = ({ 310 | callback, 311 | attribute, 312 | }: { 313 | callback: (s: HTMLSpanElement) => void; 314 | attribute: string; 315 | }): void => 316 | createHTMLObserver({ 317 | useBody: true, 318 | tag: "SPAN", 319 | className: "rm-page-ref--tag", 320 | callback: (s: HTMLSpanElement) => { 321 | if (!s.getAttribute(attribute)) { 322 | s.setAttribute(attribute, "true"); 323 | callback(s); 324 | } 325 | }, 326 | }); 327 | 328 | export const createBlockObserver = ( 329 | blockCallback: (b: HTMLDivElement) => void, 330 | blockRefCallback?: (b: HTMLSpanElement) => void 331 | ): void => { 332 | createHTMLObserver({ 333 | callback: (e) => blockCallback(e as HTMLDivElement), 334 | tag: "DIV", 335 | className: "roam-block", 336 | }); 337 | if (blockRefCallback) { 338 | createHTMLObserver({ 339 | callback: blockRefCallback, 340 | tag: "SPAN", 341 | className: "rm-block-ref", 342 | }); 343 | } 344 | }; 345 | 346 | export const createPageObserver = ( 347 | name: string, 348 | callback: (blockUid: string, added: boolean) => void 349 | ): void => 350 | createObserver((ms) => { 351 | const addedNodes = getMutatedNodes({ 352 | ms, 353 | nodeList: "addedNodes", 354 | tag: "DIV", 355 | className: "roam-block", 356 | }).map((blockNode) => ({ 357 | blockUid: getUids(blockNode as HTMLDivElement).blockUid, 358 | added: true, 359 | })); 360 | 361 | const removedNodes = getMutatedNodes({ 362 | ms, 363 | nodeList: "removedNodes", 364 | tag: "DIV", 365 | className: "roam-block", 366 | }).map((blockNode) => ({ 367 | blockUid: getUids(blockNode as HTMLDivElement).blockUid, 368 | added: false, 369 | })); 370 | 371 | if (addedNodes.length || removedNodes.length) { 372 | const blockUids = new Set(getBlockUidsByPageTitle(name)); 373 | [...removedNodes, ...addedNodes] 374 | .filter(({ blockUid }) => blockUids.has(blockUid)) 375 | .forEach(({ blockUid, added }) => callback(blockUid, added)); 376 | } 377 | }); 378 | 379 | export const createPageTitleObserver = ({ 380 | title, 381 | callback, 382 | log = false, 383 | }: { 384 | title: string; 385 | callback: (d: HTMLDivElement) => void; 386 | log?: boolean; 387 | }): void => { 388 | const listener = (url: string) => { 389 | const d = document.getElementsByClassName( 390 | "roam-article" 391 | )[0] as HTMLDivElement; 392 | if (d) { 393 | const uid = getPageUidByPageTitle(title); 394 | const attribute = `data-roamjs-${uid}`; 395 | if ((uid && url === getRoamUrl(uid)) || (log && url === getRoamUrl())) { 396 | // React's rerender crushes the old article/heading 397 | setTimeout(() => { 398 | if (!d.hasAttribute(attribute)) { 399 | d.setAttribute(attribute, "true"); 400 | callback( 401 | document.getElementsByClassName( 402 | "roam-article" 403 | )[0] as HTMLDivElement 404 | ); 405 | } 406 | }, 1); 407 | } else { 408 | d.removeAttribute(attribute); 409 | } 410 | } 411 | }; 412 | window.addEventListener("hashchange", (e) => listener(e.newURL)); 413 | listener(window.location.href); 414 | }; 415 | 416 | const getDomRefs = (blockUid: string) => { 417 | const currentlyEditingBlock = document.querySelector( 418 | "textarea.rm-block-input" 419 | ) as HTMLTextAreaElement; 420 | if (getUids(currentlyEditingBlock).blockUid === blockUid) { 421 | return ( 422 | currentlyEditingBlock.value.match(/\(\(([\w\d-]{9})\)\)/g) || [] 423 | ).map((s) => s.slice(2, -2)); 424 | } 425 | return []; 426 | }; 427 | 428 | export const getReferenceBlockUid = ( 429 | e: HTMLElement, 430 | className: "rm-block-ref" | "rm-alias--block" 431 | ): string => { 432 | const parent = e.closest(".roam-block") as HTMLDivElement; 433 | if (!parent) { 434 | return ""; 435 | } 436 | const { blockUid } = getUids(parent); 437 | const childRefs = getBlockUidsReferencingBlock(blockUid); 438 | const refs = childRefs.length ? childRefs : getDomRefs(blockUid); 439 | const index = Array.from(parent.getElementsByClassName(className)).findIndex( 440 | (el) => el === e || el.contains(e) 441 | ); 442 | return refs[index]; 443 | }; 444 | 445 | export const getBlockUidFromTarget = (target: HTMLElement): string => { 446 | const ref = target.closest(".rm-block-ref") as HTMLSpanElement; 447 | if (ref) { 448 | return ref.getAttribute("data-uid") || ""; 449 | } 450 | 451 | const customView = target.closest(".roamjs-block-view") as HTMLDivElement; 452 | if (customView) { 453 | return getUids(customView).blockUid; 454 | } 455 | 456 | const aliasTooltip = target.closest(".rm-alias-tooltip__content"); 457 | if (aliasTooltip) { 458 | const aliasRef = document.querySelector( 459 | ".bp3-popover-open .rm-alias--block" 460 | ) as HTMLAnchorElement; 461 | return getReferenceBlockUid(aliasRef, "rm-alias--block"); 462 | } 463 | 464 | const { blockUid } = getUids(target.closest(".roam-block") as HTMLDivElement); 465 | const kanbanTitle = target.closest(".kanban-title"); 466 | if (kanbanTitle) { 467 | const container = kanbanTitle.closest(".kanban-column-container"); 468 | if (container) { 469 | const column = kanbanTitle.closest(".kanban-column"); 470 | const order = Array.from(container.children).findIndex( 471 | (d) => d === column 472 | ); 473 | return getNthChildUidByBlockUid({ blockUid, order }); 474 | } 475 | } 476 | return blockUid; 477 | }; 478 | 479 | export const openBlock = (blockId?: string, position?: number): void => 480 | openBlockElement(document.getElementById(blockId || ""), position); 481 | 482 | const openBlockElement = ( 483 | block: HTMLElement | null, 484 | position?: number 485 | ): void => { 486 | if (block) { 487 | block.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); 488 | setTimeout(() => { 489 | const textArea = document.getElementById(block.id) as HTMLTextAreaElement; 490 | if (textArea?.tagName === "TEXTAREA") { 491 | const selection = 492 | typeof position !== "number" ? textArea.value.length : position; 493 | textArea.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); 494 | textArea.setSelectionRange(selection, selection); 495 | } 496 | }, 50); 497 | } 498 | }; 499 | 500 | export const getRoamUrl = (blockUid?: string): string => 501 | `${window.location.href.replace(/\/page\/.*$/, "")}${ 502 | blockUid ? `/page/${blockUid}` : "" 503 | }`; 504 | 505 | export const getCurrentPageUid = (): string => 506 | window.location.hash.match(/\/page\/(.*)$/)?.[1] || toRoamDateUid(new Date()); 507 | 508 | export const getRoamUrlByPage = (page: string): string => { 509 | const uid = getPageUidByPageTitle(page); 510 | return uid && getRoamUrl(uid); 511 | }; 512 | 513 | export const addBlockCommand = ({ 514 | label, 515 | callback, 516 | }: { 517 | label: string; 518 | callback: (uid: string) => void; 519 | }) => { 520 | const textareaRef: { current: HTMLTextAreaElement | null } = { 521 | current: null, 522 | }; 523 | 524 | const loadBlockUid = (pageUid: string) => { 525 | if (textareaRef.current) { 526 | const uid = getUids(textareaRef.current).blockUid; 527 | const parentUid = getParentUidByBlockUid(uid); 528 | 529 | const text = getTextByBlockUid(uid); 530 | if (text.length) { 531 | return createBlock({ 532 | node: { text: "Loading..." }, 533 | parentUid, 534 | order: getOrderByBlockUid(uid) + 1, 535 | }); 536 | } 537 | return updateBlock({ 538 | uid, 539 | text: "Loading...", 540 | }); 541 | } 542 | return createBlock({ 543 | node: { text: "Loading..." }, 544 | parentUid: pageUid, 545 | order: getChildrenLengthByPageUid(pageUid), 546 | }); 547 | }; 548 | 549 | createHTMLObserver({ 550 | tag: "TEXTAREA", 551 | className: "rm-block-input", 552 | callback: (t: HTMLElement) => 553 | (textareaRef.current = t as HTMLTextAreaElement), 554 | }); 555 | 556 | window.roamAlphaAPI.ui.commandPalette.addCommand({ 557 | label, 558 | callback: () => { 559 | const parentUid = getCurrentPageUid(); 560 | const blockUid = loadBlockUid(parentUid); 561 | return callback(blockUid); 562 | }, 563 | }); 564 | }; 565 | 566 | const elToTitle = (e?: Node): string => { 567 | if (!e) { 568 | return ""; 569 | } else if (e.nodeName === "#text") { 570 | return e.nodeValue || ""; 571 | } else if ( 572 | e.nodeName === "SPAN" && 573 | (e as HTMLSpanElement).classList.contains("rm-page-ref__brackets") 574 | ) { 575 | return ""; 576 | } else if ( 577 | e.nodeName === "SPAN" && 578 | (e as HTMLSpanElement).classList.contains("rm-page-ref") 579 | ) { 580 | return `[[${Array.from(e.childNodes).map(elToTitle).join("")}]]`; 581 | } else { 582 | return Array.from(e.childNodes).map(elToTitle).join(""); 583 | } 584 | }; 585 | 586 | export const getPageTitleByHtmlElement = ( 587 | e: Element 588 | ): ChildNode | undefined => { 589 | const container = 590 | e.closest(".roam-log-page") || 591 | e.closest(".rm-sidebar-outline") || 592 | e.closest(".roam-article") || 593 | document; 594 | const heading = 595 | (container.getElementsByClassName( 596 | "rm-title-display" 597 | )[0] as HTMLHeadingElement) || 598 | (container.getElementsByClassName( 599 | "rm-zoom-item-content" 600 | )[0] as HTMLSpanElement); 601 | return Array.from(heading.childNodes).find( 602 | (n) => n.nodeName === "#text" || n.nodeName === "SPAN" 603 | ); 604 | }; 605 | 606 | export const getPageTitleValueByHtmlElement = (e: Element) => 607 | elToTitle(getPageTitleByHtmlElement(e)); 608 | 609 | export const getDropUidOffset = ( 610 | d: HTMLDivElement 611 | ): { parentUid: string; offset: number } => { 612 | const separator = d.parentElement; 613 | const childrenContainer = separator?.parentElement; 614 | const children = Array.from(childrenContainer?.children || []); 615 | const index = children.findIndex((c) => c === separator); 616 | const offset = children.reduce( 617 | (prev, cur, ind) => 618 | cur.classList.contains("roam-block-container") && ind < index 619 | ? prev + 1 620 | : prev, 621 | 0 622 | ); 623 | const parentBlock = childrenContainer?.previousElementSibling?.getElementsByClassName( 624 | "roam-block" 625 | )?.[0] as HTMLDivElement; 626 | const parentUid = parentBlock 627 | ? getUids(parentBlock).blockUid 628 | : childrenContainer 629 | ? getPageUidByPageTitle( 630 | getPageTitleByHtmlElement(childrenContainer)?.textContent || "" 631 | ) 632 | : ""; 633 | return { 634 | parentUid, 635 | offset, 636 | }; 637 | }; 638 | --------------------------------------------------------------------------------