├── .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 |
--------------------------------------------------------------------------------