├── docs ├── media │ ├── discourse-graph-stop.png │ ├── settings-roam-depot.png │ ├── discourse-graph-stopped.png │ ├── discourse-graph-linked-ref.jpg │ ├── settings-browse-extensions.png │ ├── settings-enable-discourse-graph.png │ └── settings-install-query-builder.png ├── discourse-graphs.md └── roam-queries.md ├── tsconfig.json ├── src ├── utils │ ├── isPageUid.ts │ ├── discourseConfigRef.ts │ ├── getDiscourseRelationLabels.ts │ ├── getDiscourseNodeFormatExpression.ts │ ├── isDiscourseNode.ts │ ├── getQBClauses.ts │ ├── loadImage.ts │ ├── findDiscourseNode.ts │ ├── getDiscourseRelationTriples.ts │ ├── isFlagEnabled.ts │ ├── sendErrorEmail.ts │ ├── getBlockProps.ts │ ├── discourseNodeFormatToDatalog.ts │ ├── gatherDatalogVariablesFromClause.ts │ ├── resolveQueryBuilderRef.ts │ ├── runQuery.ts │ ├── getPageMetadata.ts │ ├── types.ts │ ├── importDiscourseGraph.ts │ ├── refreshConfigTree.ts │ ├── measureCanvasNodeText.ts │ ├── getDiscourseRelations.ts │ ├── createInitialTldrawProps.ts │ ├── matchDiscourseNode.ts │ ├── replaceDatalogVariables.ts │ ├── toCellValue.ts │ ├── compileDatalog.ts │ ├── calcCanvasNodeSizeAndImg.ts │ ├── getDiscourseNodes.ts │ ├── parseQuery.ts │ ├── deriveDiscourseNodeAttribute.ts │ ├── postProcessResults.ts │ ├── triplesToBlocks.ts │ ├── getDiscourseContextResults.ts │ ├── formatUtils.ts │ ├── parseResultSettings.ts │ └── createDiscourseNode.ts ├── types.d.ts ├── data │ ├── defaultDiscourseNodes.ts │ └── defaultDiscourseRelations.ts └── components │ ├── DiscourseNodeIndex.tsx │ ├── Charts.tsx │ ├── QueryPagesPanel.tsx │ ├── ReferenceContext.tsx │ ├── ResizableDrawer.tsx │ ├── ImportDialog.tsx │ ├── tldraw │ ├── CanvasReferences.tsx │ ├── DiscourseRelationsUtil.tsx │ ├── CanvasDrawer.tsx │ └── useRoamStore.ts │ ├── DiscourseNodeConfigPanel.tsx │ ├── DiscourseNodeAttributes.tsx │ ├── DiscourseNodeSpecification.tsx │ ├── Timeline.tsx │ ├── LivePreview.tsx │ ├── DiscourseNodeCanvasSettings.tsx │ ├── DefaultFilters.tsx │ ├── QueryPage.tsx │ ├── MessageBlock.tsx │ ├── DiscourseContextOverlay.tsx │ └── ExportGithub.tsx ├── .gitignore ├── .github └── workflows │ ├── pr.yaml │ └── main.yaml ├── patches ├── @tldraw+tlschema+2.0.0-canary.ffda4cfb.patch ├── @tldraw+primitives+2.0.0-canary.ffda4cfb.patch ├── @playwright+test+1.29.0.patch └── @tldraw+tldraw++@tldraw+ui+2.0.0-canary.ffda4cfb.patch ├── LICENSE ├── tests └── extension.test.ts ├── package.json └── README.md /docs/media/discourse-graph-stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/discourse-graph-stop.png -------------------------------------------------------------------------------- /docs/media/settings-roam-depot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/settings-roam-depot.png -------------------------------------------------------------------------------- /docs/media/discourse-graph-stopped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/discourse-graph-stopped.png -------------------------------------------------------------------------------- /docs/media/discourse-graph-linked-ref.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/discourse-graph-linked-ref.jpg -------------------------------------------------------------------------------- /docs/media/settings-browse-extensions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/settings-browse-extensions.png -------------------------------------------------------------------------------- /docs/media/settings-enable-discourse-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/settings-enable-discourse-graph.png -------------------------------------------------------------------------------- /docs/media/settings-install-query-builder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RoamJS/query-builder/HEAD/docs/media/settings-install-query-builder.png -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/@samepage/scripts/tsconfig", 3 | "include": ["src", "src/types.d.ts"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /src/utils/isPageUid.ts: -------------------------------------------------------------------------------- 1 | export const isPageUid = (uid: string) => 2 | !!window.roamAlphaAPI.pull("[:node/title]", [":block/uid", uid])?.[ 3 | ":node/title" 4 | ]; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | out 5 | .env 6 | main.js 7 | extension.js 8 | extension.js.LICENSE.txt 9 | extension.css 10 | report.html 11 | stats.json 12 | analyze.txt 13 | *.woff2 14 | *.woff 15 | coverage 16 | extension.js.map 17 | extension.css.map 18 | -------------------------------------------------------------------------------- /src/utils/discourseConfigRef.ts: -------------------------------------------------------------------------------- 1 | import type { RoamBasicNode } from "roamjs-components/types"; 2 | 3 | const configTreeRef: { 4 | tree: RoamBasicNode[]; 5 | nodes: { [uid: string]: { text: string; children: RoamBasicNode[] } }; 6 | } = { tree: [], nodes: {} }; 7 | 8 | export default configTreeRef; 9 | -------------------------------------------------------------------------------- /src/utils/getDiscourseRelationLabels.ts: -------------------------------------------------------------------------------- 1 | import getDiscourseRelations from "./getDiscourseRelations"; 2 | 3 | const getDiscourseRelationLabels = (relations = getDiscourseRelations()) => 4 | Array.from(new Set(relations.flatMap((r) => [r.label, r.complement]))).filter( 5 | (s) => !!s 6 | ); 7 | 8 | export default getDiscourseRelationLabels; 9 | -------------------------------------------------------------------------------- /src/utils/getDiscourseNodeFormatExpression.ts: -------------------------------------------------------------------------------- 1 | const getDiscourseNodeFormatExpression = (format: string) => 2 | format 3 | ? new RegExp( 4 | `^${format 5 | .replace(/(\[|\]|\?|\.|\+)/g, "\\$1") 6 | .replace(/{[a-zA-Z]+}/g, "(.*?)")}$`, 7 | "s" 8 | ) 9 | : /$^/; 10 | 11 | export default getDiscourseNodeFormatExpression; 12 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.png" { 2 | const value: string; 3 | export default value; 4 | } 5 | 6 | declare module "cytoscape-navigator" { 7 | const value: (cy: cytoscape) => void; 8 | export default value; 9 | } 10 | 11 | declare module "react-in-viewport/dist/es/lib/useInViewport" { 12 | import { useInViewport } from "react-in-viewport"; 13 | export default useInViewport; 14 | } 15 | -------------------------------------------------------------------------------- /src/utils/isDiscourseNode.ts: -------------------------------------------------------------------------------- 1 | import getDiscourseNodes from "./getDiscourseNodes"; 2 | import findDiscourseNode from "./findDiscourseNode"; 3 | 4 | const isDiscourseNode = (uid: string) => { 5 | const nodes = getDiscourseNodes(); 6 | const node = findDiscourseNode(uid, nodes); 7 | if (!node) return false; 8 | return node.backedBy !== "default"; 9 | }; 10 | 11 | export default isDiscourseNode; 12 | -------------------------------------------------------------------------------- /src/utils/getQBClauses.ts: -------------------------------------------------------------------------------- 1 | import { Condition, QBClauseData } from "./types"; 2 | 3 | const getQBClauses = (cs: Condition[]): QBClauseData[] => 4 | cs.flatMap((c) => { 5 | switch (c.type) { 6 | case "not or": 7 | case "or": 8 | return getQBClauses(c.conditions.flat()); 9 | case "clause": 10 | case "not": 11 | default: 12 | return c; 13 | } 14 | }); 15 | 16 | export default getQBClauses; 17 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | 3 | on: 4 | pull_request: 5 | workflow_dispatch: 6 | 7 | env: 8 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 9 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 10 | AWS_REGION: ${{ vars.AWS_REGION }} 11 | 12 | jobs: 13 | deploy: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: install 18 | run: npm install 19 | - name: build 20 | run: npx samepage build 21 | -------------------------------------------------------------------------------- /src/utils/loadImage.ts: -------------------------------------------------------------------------------- 1 | export const loadImage = ( 2 | url: string 3 | ): Promise<{ width: number; height: number }> => { 4 | return new Promise((resolve, reject) => { 5 | const img = new Image(); 6 | 7 | img.onload = () => { 8 | resolve({ width: img.width, height: img.height }); 9 | }; 10 | 11 | img.onerror = () => { 12 | reject(new Error("Failed to load image")); 13 | }; 14 | 15 | setTimeout(() => { 16 | reject(new Error("Failed to load image: timeout")); 17 | }, 10000); 18 | 19 | img.src = url; 20 | }); 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/findDiscourseNode.ts: -------------------------------------------------------------------------------- 1 | import getDiscourseNodes, { DiscourseNode } from "./getDiscourseNodes"; 2 | import matchDiscourseNode from "./matchDiscourseNode"; 3 | 4 | const discourseNodeTypeCache: Record = {}; 5 | 6 | const findDiscourseNode = (uid = "", nodes = getDiscourseNodes()) => 7 | typeof discourseNodeTypeCache[uid] !== "undefined" 8 | ? discourseNodeTypeCache[uid] 9 | : (discourseNodeTypeCache[uid] = 10 | nodes.find((n) => matchDiscourseNode({ ...n, uid })) || false); 11 | 12 | export default findDiscourseNode; 13 | -------------------------------------------------------------------------------- /src/utils/getDiscourseRelationTriples.ts: -------------------------------------------------------------------------------- 1 | import getDiscourseRelations from "./getDiscourseRelations"; 2 | 3 | const getDiscourseRelationTriples = (relations = getDiscourseRelations()) => 4 | Array.from( 5 | new Set( 6 | relations.flatMap((r) => [ 7 | JSON.stringify([r.label, r.source, r.destination]), 8 | JSON.stringify([r.complement, r.destination, r.source]), 9 | ]) 10 | ) 11 | ) 12 | .map((s) => JSON.parse(s)) 13 | .map(([relation, source, target]: string[]) => ({ 14 | relation, 15 | source, 16 | target, 17 | })); 18 | 19 | export default getDiscourseRelationTriples; 20 | -------------------------------------------------------------------------------- /src/data/defaultDiscourseNodes.ts: -------------------------------------------------------------------------------- 1 | const INITIAL_NODE_VALUES = [ 2 | { 3 | type: "_CLM-node", 4 | format: "[[CLM]] - {content}", 5 | text: "Claim", 6 | shortcut: "C", 7 | }, 8 | { 9 | type: "_QUE-node", 10 | format: "[[QUE]] - {content}", 11 | text: "Question", 12 | shortcut: "Q", 13 | }, 14 | { 15 | type: "_EVD-node", 16 | format: "[[EVD]] - {content} - {Source}", 17 | text: "Evidence", 18 | shortcut: "E", 19 | }, 20 | { 21 | type: "_SRC-node", 22 | format: "@{content}", 23 | text: "Source", 24 | shortcut: "S", 25 | }, 26 | ]; 27 | 28 | export default INITIAL_NODE_VALUES; 29 | -------------------------------------------------------------------------------- /patches/@tldraw+tlschema+2.0.0-canary.ffda4cfb.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@tldraw/tlschema/dist/esm/shapes/TLEmbedShape.mjs b/node_modules/@tldraw/tlschema/dist/esm/shapes/TLEmbedShape.mjs 2 | index 0c0783a..1136d46 100644 3 | --- a/node_modules/@tldraw/tlschema/dist/esm/shapes/TLEmbedShape.mjs 4 | +++ b/node_modules/@tldraw/tlschema/dist/esm/shapes/TLEmbedShape.mjs 5 | @@ -78,7 +78,7 @@ const EMBED_DEFINITIONS = [ 6 | { 7 | type: "tldraw", 8 | title: "tldraw", 9 | - hostnames: ["beta.tldraw.com", "lite.tldraw.com"], 10 | + hostnames: ["tldraw.com", "beta.tldraw.com", "lite.tldraw.com"], 11 | minWidth: 300, 12 | minHeight: 300, 13 | width: 720, 14 | -------------------------------------------------------------------------------- /src/utils/isFlagEnabled.ts: -------------------------------------------------------------------------------- 1 | import type { RoamBasicNode } from "roamjs-components/types/native"; 2 | import getSubTree from "roamjs-components/util/getSubTree"; 3 | import toFlexRegex from "roamjs-components/util/toFlexRegex"; 4 | import discourseConfigRef from "./discourseConfigRef"; 5 | 6 | const isFlagEnabled = (flag: string, inputTree?: RoamBasicNode[]): boolean => { 7 | const flagParts = flag.split("."); 8 | const tree = inputTree || discourseConfigRef.tree; 9 | if (flagParts.length === 1) 10 | return tree.some((t) => toFlexRegex(flag).test(t.text)); 11 | else 12 | return isFlagEnabled( 13 | flagParts.slice(1).join("."), 14 | getSubTree({ tree, key: flagParts[0] }).children 15 | ); 16 | }; 17 | 18 | export default isFlagEnabled; 19 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: main 6 | paths: 7 | - "src/**" 8 | - "README.md" 9 | - "package.json" 10 | - ".github/workflows/main.yaml" 11 | 12 | env: 13 | GITHUB_TOKEN: ${{ secrets.ROAMJS_RELEASE_TOKEN }} 14 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 15 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 16 | AWS_REGION: ${{ vars.AWS_REGION }} 17 | ROAMJS_PROXY: ${{ vars.ROAMJS_PROXY }} 18 | GITHUB_APP_ID: 312167 19 | 20 | jobs: 21 | deploy: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: install 26 | run: npm install 27 | - name: build 28 | run: npx samepage build 29 | -------------------------------------------------------------------------------- /patches/@tldraw+primitives+2.0.0-canary.ffda4cfb.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@tldraw/primitives/dist/esm/lib/freehand/setStrokePointRadii.mjs b/node_modules/@tldraw/primitives/dist/esm/lib/freehand/setStrokePointRadii.mjs 2 | index 0bbfddb..ea8a571 100644 3 | --- a/node_modules/@tldraw/primitives/dist/esm/lib/freehand/setStrokePointRadii.mjs 4 | +++ b/node_modules/@tldraw/primitives/dist/esm/lib/freehand/setStrokePointRadii.mjs 5 | @@ -12,7 +12,8 @@ function setStrokePointRadii(strokePoints, options) { 6 | } = options; 7 | const { easing: taperStartEase = EASINGS.easeOutQuad } = start; 8 | const { easing: taperEndEase = EASINGS.easeOutCubic } = end; 9 | - const totalLength = strokePoints[strokePoints.length - 1].runningLength; 10 | + const totalLength = strokePoints[strokePoints.length - 1]?.runningLength || 0; 11 | + if (!totalLength) return strokePoints; 12 | let firstRadius; 13 | let prevPressure = strokePoints[0].pressure; 14 | let strokePoint; 15 | -------------------------------------------------------------------------------- /src/utils/sendErrorEmail.ts: -------------------------------------------------------------------------------- 1 | import apiPost from "roamjs-components/util/apiPost"; 2 | 3 | const sendErrorEmail = ({ 4 | error, 5 | data, 6 | type, 7 | }: { 8 | error: Error; 9 | data?: Record; 10 | type: string; 11 | }) => { 12 | const isEncrypted = window.roamAlphaAPI.graph.isEncrypted; 13 | const isOffline = window.roamAlphaAPI.graph.type === "offline"; 14 | if (isEncrypted || isOffline) return; 15 | 16 | apiPost({ 17 | domain: "https://api.samepage.network", 18 | path: "errors", 19 | data: { 20 | method: "extension-error", 21 | type, 22 | message: error.message, 23 | stack: error.stack, 24 | version: process.env.VERSION, 25 | notebookUuid: JSON.stringify({ 26 | owner: "RoamJS", 27 | app: "query-builder", 28 | workspace: window.roamAlphaAPI.graph.name, 29 | }), 30 | data, 31 | }, 32 | }).catch(() => {}); 33 | }; 34 | 35 | export default sendErrorEmail; 36 | -------------------------------------------------------------------------------- /src/utils/getBlockProps.ts: -------------------------------------------------------------------------------- 1 | export type json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | json[] 7 | | { [key: string]: json }; 8 | 9 | export const normalizeProps = (props: json): json => 10 | typeof props === "object" 11 | ? props === null 12 | ? null 13 | : Array.isArray(props) 14 | ? props.map(normalizeProps) 15 | : Object.fromEntries( 16 | Object.entries(props).map(([k, v]) => [ 17 | k.replace(/^:+/, ""), 18 | typeof v === "object" && v !== null && !Array.isArray(v) 19 | ? normalizeProps(v) 20 | : Array.isArray(v) 21 | ? v.map(normalizeProps) 22 | : v, 23 | ]) 24 | ) 25 | : props; 26 | 27 | const getBlockProps = (uid: string) => 28 | normalizeProps( 29 | (window.roamAlphaAPI.pull("[:block/props]", [":block/uid", uid])?.[ 30 | ":block/props" 31 | ] || {}) as Record 32 | ) as Record; 33 | 34 | export default getBlockProps; 35 | -------------------------------------------------------------------------------- /src/utils/discourseNodeFormatToDatalog.ts: -------------------------------------------------------------------------------- 1 | import { DatalogClause } from "roamjs-components/types/native"; 2 | import conditionToDatalog from "./conditionToDatalog"; 3 | import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; 4 | import type { DiscourseNode } from "./getDiscourseNodes"; 5 | import replaceDatalogVariables from "./replaceDatalogVariables"; 6 | 7 | const discourseNodeFormatToDatalog = ({ 8 | freeVar, 9 | ...node 10 | }: DiscourseNode & { 11 | freeVar: string; 12 | }): DatalogClause[] => { 13 | if (node.specification.length) { 14 | const clauses = node.specification.flatMap(conditionToDatalog); 15 | return replaceDatalogVariables([{ from: node.text, to: freeVar }], clauses); 16 | } 17 | return conditionToDatalog({ 18 | source: freeVar, 19 | relation: "has title", 20 | target: `/${getDiscourseNodeFormatExpression(node.format).source}/`, 21 | type: "clause", 22 | uid: window.roamAlphaAPI.util.generateUID(), 23 | }); 24 | }; 25 | 26 | export default discourseNodeFormatToDatalog; 27 | -------------------------------------------------------------------------------- /src/utils/gatherDatalogVariablesFromClause.ts: -------------------------------------------------------------------------------- 1 | import { DatalogClause } from "roamjs-components/types/native"; 2 | 3 | const gatherDatalogVariablesFromClause = ( 4 | clause: DatalogClause 5 | ): Set => { 6 | if ( 7 | clause.type === "data-pattern" || 8 | clause.type === "fn-expr" || 9 | clause.type === "pred-expr" || 10 | clause.type === "rule-expr" 11 | ) { 12 | return new Set( 13 | [...clause.arguments] 14 | .filter((v) => v.type === "variable") 15 | .map((v) => v.value) 16 | ); 17 | } else if ( 18 | clause.type === "not-clause" || 19 | clause.type === "or-clause" || 20 | clause.type === "and-clause" 21 | ) { 22 | return new Set( 23 | clause.clauses.flatMap((c) => 24 | Array.from(gatherDatalogVariablesFromClause(c)) 25 | ) 26 | ); 27 | } else if ( 28 | clause.type === "not-join-clause" || 29 | clause.type === "or-join-clause" 30 | ) { 31 | return new Set(clause.variables.map((c) => c.value)); 32 | } 33 | return new Set(); 34 | }; 35 | 36 | export default gatherDatalogVariablesFromClause; 37 | -------------------------------------------------------------------------------- /src/utils/resolveQueryBuilderRef.ts: -------------------------------------------------------------------------------- 1 | import isLiveBlock from "roamjs-components/queries/isLiveBlock"; 2 | import extractRef from "roamjs-components/util/extractRef"; 3 | import { getQueryPages } from "../components/QueryPagesPanel"; 4 | import { OnloadArgs } from "roamjs-components/types"; 5 | 6 | const resolveQueryBuilderRef = ({ 7 | queryRef, 8 | extensionAPI, 9 | }: { 10 | queryRef: string; 11 | extensionAPI: OnloadArgs["extensionAPI"]; 12 | }) => { 13 | const parentUid = isLiveBlock(extractRef(queryRef)) 14 | ? extractRef(queryRef) 15 | : window.roamAlphaAPI.data.fast 16 | .q( 17 | `[:find ?uid :where [?b :block/uid ?uid] [or-join [?b] 18 | [and [?b :block/string ?s] [[clojure.string/includes? ?s "{{query block:${queryRef}}}"]] ] 19 | ${getQueryPages(extensionAPI).map( 20 | (p) => `[and [?b :node/title "${p.replace(/\*/, queryRef)}"]]` 21 | )} 22 | [and [?b :node/title "${queryRef}"]] 23 | ]]` 24 | )[0] 25 | ?.toString() || ""; 26 | return parentUid; 27 | }; 28 | 29 | export default resolveQueryBuilderRef; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 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 | -------------------------------------------------------------------------------- /src/utils/runQuery.ts: -------------------------------------------------------------------------------- 1 | import type { OnloadArgs } from "roamjs-components/types/native"; 2 | import fireQuery, { QueryArgs } from "./fireQuery"; 3 | import parseQuery from "./parseQuery"; 4 | import parseResultSettings from "./parseResultSettings"; 5 | import postProcessResults from "./postProcessResults"; 6 | import { Column } from "./types"; 7 | 8 | const runQuery = ({ 9 | parentUid, 10 | extensionAPI, 11 | inputs, 12 | }: { 13 | parentUid: string; 14 | extensionAPI: OnloadArgs["extensionAPI"]; 15 | inputs?: QueryArgs["inputs"]; 16 | }) => { 17 | const queryArgs = Object.assign(parseQuery(parentUid), { inputs }); 18 | return fireQuery(queryArgs).then((results) => { 19 | const settings = parseResultSettings( 20 | parentUid, 21 | [ 22 | { 23 | key: "text", 24 | uid: window.roamAlphaAPI.util.generateUID(), 25 | selection: "node", 26 | } as Column, 27 | ].concat( 28 | queryArgs.selections.map((s) => ({ 29 | key: s.label, 30 | uid: s.uid, 31 | selection: s.text, 32 | })) 33 | ), 34 | extensionAPI 35 | ); 36 | return postProcessResults(results, settings); 37 | }); 38 | }; 39 | 40 | export default runQuery; 41 | -------------------------------------------------------------------------------- /src/utils/getPageMetadata.ts: -------------------------------------------------------------------------------- 1 | import normalizePageTitle from "roamjs-components/queries/normalizePageTitle"; 2 | import getDisplayNameByUid from "roamjs-components/queries/getDisplayNameByUid"; 3 | 4 | const displayNameCache: Record = {}; 5 | const getDisplayName = (s: string) => { 6 | if (displayNameCache[s]) { 7 | return displayNameCache[s]; 8 | } 9 | const value = getDisplayNameByUid(s); 10 | displayNameCache[s] = value; 11 | setTimeout(() => delete displayNameCache[s], 120000); 12 | return value; 13 | }; 14 | 15 | const getPageMetadata = (title: string, cacheKey?: string) => { 16 | const results = window.roamAlphaAPI.q( 17 | `[:find (pull ?p [:create/time :block/uid]) (pull ?cu [:user/uid]) :where [?p :node/title "${normalizePageTitle( 18 | title 19 | )}"] [?p :create/user ?cu]]` 20 | ) as [[{ time: number; uid: string }, { uid: string }]]; 21 | if (results.length) { 22 | const [[{ time: createdTime, uid: id }, { uid }]] = results; 23 | 24 | const displayName = getDisplayName(uid); 25 | const date = new Date(createdTime); 26 | return { displayName, date, id }; 27 | } 28 | return { 29 | displayName: "Unknown", 30 | date: new Date(), 31 | id: "", 32 | }; 33 | }; 34 | 35 | export default getPageMetadata; 36 | -------------------------------------------------------------------------------- /tests/extension.test.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | import root from "../src"; 3 | 4 | const extensionSettings: Record = {}; 5 | const commandPalette: Record unknown> = {}; 6 | 7 | // TODO - solve playwright's horrendous file resolution 8 | test.skip("End to end flow of Query Builder & Discourse Graphs", () => { 9 | if (!root) throw new Error("Root not found"); 10 | const { onload } = root; 11 | const unload = onload({ 12 | extension: { 13 | version: "test", 14 | }, 15 | extensionAPI: { 16 | settings: { 17 | get: (key: string) => extensionSettings[key], 18 | getAll: () => extensionSettings, 19 | set: async (key: string, value: unknown) => { 20 | extensionSettings[key] = value; 21 | }, 22 | panel: { 23 | create: (config) => {}, 24 | }, 25 | }, 26 | ui: { 27 | commandPalette: { 28 | addCommand: async (config) => { 29 | commandPalette[config.label] = config.callback; 30 | }, 31 | removeCommand: async (config) => { 32 | delete commandPalette[config.label]; 33 | }, 34 | }, 35 | }, 36 | }, 37 | }); 38 | expect(unload).toBeTruthy(); 39 | }); 40 | -------------------------------------------------------------------------------- /src/utils/types.ts: -------------------------------------------------------------------------------- 1 | type QBBase = { 2 | uid: string; 3 | }; 4 | export type QBClauseData = { 5 | relation: string; 6 | source: string; 7 | target: string; 8 | not?: boolean; 9 | } & QBBase; 10 | export type QBNestedData = { 11 | conditions: Condition[][]; 12 | } & QBBase; 13 | export type QBClause = QBClauseData & { 14 | type: "clause"; 15 | }; 16 | export type QBNot = QBClauseData & { 17 | type: "not"; 18 | }; 19 | export type QBOr = QBNestedData & { 20 | type: "or"; 21 | }; 22 | export type QBNor = QBNestedData & { 23 | type: "not or"; 24 | }; 25 | export type Condition = QBClause | QBNot | QBOr | QBNor; 26 | 27 | export type Selection = { 28 | text: string; 29 | label: string; 30 | uid: string; 31 | }; 32 | 33 | export type ExportTypes = { 34 | name: string; 35 | callback: (args: { 36 | filename: string; 37 | isSamePageEnabled: boolean; 38 | includeDiscourseContext: boolean; 39 | isExportDiscourseGraph: boolean; 40 | }) => Promise<{ title: string; content: string }[]>; 41 | }[]; 42 | 43 | export type Result = { 44 | text: string; 45 | uid: string; 46 | } & Record<`${string}-uid`, string> & 47 | Record; 48 | 49 | export type Column = { key: string; uid: string; selection: string }; 50 | 51 | export type QBGlobalRefs = { 52 | [key: string]: (args: Record) => void; 53 | }; 54 | -------------------------------------------------------------------------------- /src/utils/importDiscourseGraph.ts: -------------------------------------------------------------------------------- 1 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 2 | import type { InputTextNode } from "roamjs-components/types"; 3 | import createPage from "roamjs-components/writes/createPage"; 4 | 5 | const pruneNodes = (nodes: InputTextNode[]): InputTextNode[] => 6 | nodes 7 | .filter((n) => !getPageTitleByPageUid(n.uid || "")) 8 | .map((n) => ({ ...n, children: pruneNodes(n.children || []) })); 9 | 10 | const importDiscourseGraph = ({ 11 | title, 12 | grammar: _grammar, 13 | nodes, 14 | relations, 15 | }: { 16 | title: string; 17 | grammar: { source: string; label: string; destination: string }[]; 18 | nodes: InputTextNode[]; 19 | relations: { source: string; label: string; target: string }[]; 20 | }) => { 21 | const pagesByUids = Object.fromEntries( 22 | nodes.map(({ uid, text }) => [uid, text]) 23 | ); 24 | return createPage({ 25 | title, 26 | tree: relations.map(({ source, target, label }) => ({ 27 | text: `[[${pagesByUids[source]}]]`, 28 | children: [ 29 | { 30 | text: label, 31 | children: [ 32 | { 33 | text: `[[${pagesByUids[target]}]]`, 34 | }, 35 | ], 36 | }, 37 | ], 38 | })), 39 | }).then(() => 40 | Promise.all( 41 | pruneNodes(nodes).map((node) => 42 | createPage({ title: node.text, tree: node.children, uid: node.uid }) 43 | ) 44 | ) 45 | ); 46 | }; 47 | 48 | export default importDiscourseGraph; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-builder", 3 | "version": "1.36.1", 4 | "description": "Introduces new user interfaces for building queries in Roam", 5 | "main": "./build/main.js", 6 | "author": { 7 | "name": "David Vargas", 8 | "email": "support@roamjs.com" 9 | }, 10 | "scripts": { 11 | "postinstall": "patch-package", 12 | "start": "samepage dev", 13 | "prebuild:roam": "npm install", 14 | "build:roam": "samepage build --dry", 15 | "test": "samepage test" 16 | }, 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@types/contrast-color": "^1.0.0", 20 | "@types/react-vertical-timeline-component": "^3.3.3", 21 | "axios": "^0.27.2" 22 | }, 23 | "//": "axios dep temporary - need to fix the dep in underlying libraries", 24 | "tags": [ 25 | "queries", 26 | "widgets" 27 | ], 28 | "dependencies": { 29 | "@samepage/external": "^0.71.10", 30 | "@tldraw/tldraw": "^2.0.0-alpha.12", 31 | "contrast-color": "^1.0.1", 32 | "cytoscape-navigator": "^2.0.1", 33 | "nanoid": "2.0.4", 34 | "react-charts": "^3.0.0-beta.48", 35 | "react-draggable": "^4.4.5", 36 | "react-in-viewport": "^1.0.0-alpha.20", 37 | "react-vertical-timeline-component": "^3.5.2", 38 | "roamjs-components": "^0.83.4", 39 | "signia-react": "^0.1.1" 40 | }, 41 | "overrides": { 42 | "@tldraw/tldraw": { 43 | "react": "^17.0.2", 44 | "react-dom": "^17.0.2" 45 | } 46 | }, 47 | "samepage": { 48 | "extends": "node_modules/roamjs-components/package.json" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /patches/@playwright+test+1.29.0.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@playwright/test/lib/transform.js b/node_modules/@playwright/test/lib/transform.js 2 | index 9910124..948f529 100644 3 | --- a/node_modules/@playwright/test/lib/transform.js 4 | +++ b/node_modules/@playwright/test/lib/transform.js 5 | @@ -149,6 +149,17 @@ function js2ts(resolved) { 6 | if (!_fs.default.existsSync(resolved) && _fs.default.existsSync(tsResolved)) return tsResolved; 7 | } 8 | } 9 | + 10 | +function mebuild(filename, outfile) { 11 | + require("esbuild").buildSync({ 12 | + entryPoints: [filename], 13 | + format: "cjs", 14 | + outfile, 15 | + sourcemap: "inline", 16 | + }); 17 | + return {code: _fs.default.readFileSync(outfile).toString()}; 18 | +} 19 | + 20 | function transformHook(code, filename, moduleUrl) { 21 | // If we are not TypeScript and there is no applicable preprocessor - bail out. 22 | const isModule = !!moduleUrl; 23 | @@ -166,7 +177,9 @@ function transformHook(code, filename, moduleUrl) { 24 | const { 25 | babelTransform 26 | } = require('./babelBundle'); 27 | - const result = babelTransform(filename, isTypeScript, isModule, hasPreprocessor ? scriptPreprocessor : undefined, [require.resolve('./tsxTransform')]); 28 | + const result =filename.endsWith('.tsx') ? 29 | + mebuild(filename,codePath) 30 | + : babelTransform(filename, isTypeScript, isModule, hasPreprocessor ? scriptPreprocessor : undefined, [require.resolve('./tsxTransform')]); 31 | if (result.code) { 32 | _fs.default.mkdirSync(_path.default.dirname(cachePath), { 33 | recursive: true 34 | -------------------------------------------------------------------------------- /src/utils/refreshConfigTree.ts: -------------------------------------------------------------------------------- 1 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 2 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 3 | import getDiscourseRelationLabels from "./getDiscourseRelationLabels"; 4 | import discourseConfigRef from "./discourseConfigRef"; 5 | import registerDiscourseDatalogTranslators from "./registerDiscourseDatalogTranslators"; 6 | import { unregisterDatalogTranslator } from "./conditionToDatalog"; 7 | import type { PullBlock } from "roamjs-components/types/native"; 8 | 9 | const getPagesStartingWithPrefix = (prefix: string) => 10 | ( 11 | window.roamAlphaAPI.data.fast.q( 12 | `[:find (pull ?b [:block/uid :node/title]) :where [?b :node/title ?title] [(clojure.string/starts-with? ?title "${prefix}")]]` 13 | ) as [PullBlock][] 14 | ).map((r) => ({ 15 | title: r[0][":node/title"] || '', 16 | uid: r[0][":block/uid"] || '', 17 | })); 18 | 19 | const refreshConfigTree = () => { 20 | getDiscourseRelationLabels().forEach((key) => 21 | unregisterDatalogTranslator({ key }) 22 | ); 23 | discourseConfigRef.tree = getBasicTreeByParentUid( 24 | getPageUidByPageTitle("roam/js/discourse-graph") 25 | ); 26 | const pages = getPagesStartingWithPrefix("discourse-graph/nodes"); 27 | discourseConfigRef.nodes = Object.fromEntries( 28 | pages.map(({ title, uid }) => { 29 | return [ 30 | uid, 31 | { 32 | text: title.substring("discourse-graph/nodes/".length), 33 | children: getBasicTreeByParentUid(uid), 34 | }, 35 | ]; 36 | }) 37 | ); 38 | return registerDiscourseDatalogTranslators(); 39 | }; 40 | 41 | export default refreshConfigTree; 42 | -------------------------------------------------------------------------------- /src/utils/measureCanvasNodeText.ts: -------------------------------------------------------------------------------- 1 | type DefaultStyles = { 2 | fontFamily: string; 3 | fontStyle: string; 4 | fontWeight: string; 5 | fontSize: number; 6 | lineHeight: number; 7 | width: string; 8 | minWidth?: string; 9 | maxWidth?: string; 10 | padding: string; 11 | text: string; 12 | }; 13 | 14 | // node_modules\@tldraw\tldraw\node_modules\@tldraw\editor\dist\cjs\lib\app\managers\TextManager.js 15 | export const measureCanvasNodeText = (opts: DefaultStyles) => { 16 | const fixNewLines = /\r?\n|\r/g; 17 | 18 | const normalizeTextForDom = (text: string) => { 19 | return text 20 | .replace(fixNewLines, "\n") 21 | .split("\n") 22 | .map((x) => x || " ") 23 | .join("\n"); 24 | }; 25 | 26 | const measureText = (opts: DefaultStyles) => { 27 | const elm = document.createElement("div"); 28 | document.body.appendChild(elm); 29 | elm.setAttribute("dir", "ltr"); 30 | elm.style.setProperty("font-family", opts.fontFamily); 31 | elm.style.setProperty("font-style", opts.fontStyle); 32 | elm.style.setProperty("font-weight", opts.fontWeight); 33 | elm.style.setProperty("font-size", opts.fontSize + "px"); 34 | elm.style.setProperty( 35 | "line-height", 36 | opts.lineHeight * opts.fontSize + "px" 37 | ); 38 | elm.style.setProperty("width", opts.width); 39 | elm.style.setProperty("min-width", opts.minWidth ?? null); 40 | elm.style.setProperty("max-width", opts.maxWidth ?? null); 41 | elm.style.setProperty("padding", opts.padding); 42 | 43 | elm.textContent = normalizeTextForDom(opts.text); 44 | const rect = elm.getBoundingClientRect(); 45 | elm.remove(); 46 | return { 47 | x: 0, 48 | y: 0, 49 | w: rect.width, 50 | h: rect.height, 51 | }; 52 | }; 53 | 54 | const { w, h } = measureText(opts); 55 | 56 | return { w, h }; 57 | }; 58 | -------------------------------------------------------------------------------- /src/components/DiscourseNodeIndex.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Spinner } from "@blueprintjs/core"; 3 | import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext"; 4 | import type { OnloadArgs } from "roamjs-components/types/native"; 5 | import type { DiscourseNode } from "../utils/getDiscourseNodes"; 6 | import QueryPage from "./QueryPage"; 7 | import parseQuery, { DEFAULT_RETURN_NODE } from "../utils/parseQuery"; 8 | import createBlock from "roamjs-components/writes/createBlock"; 9 | 10 | const NodeIndex = ({ 11 | parentUid, 12 | node, 13 | onloadArgs, 14 | }: { 15 | parentUid: string; 16 | node: DiscourseNode; 17 | onloadArgs: OnloadArgs; 18 | }) => { 19 | const initialQueryArgs = React.useMemo( 20 | () => parseQuery(parentUid), 21 | [parentUid] 22 | ); 23 | const [showQuery, setShowQuery] = React.useState( 24 | !!initialQueryArgs.conditions.length 25 | ); 26 | useEffect(() => { 27 | if (!showQuery) { 28 | createBlock({ 29 | parentUid: initialQueryArgs.conditionsNodesUid, 30 | node: { 31 | text: "clause", 32 | children: [ 33 | { 34 | text: "source", 35 | children: [{ text: DEFAULT_RETURN_NODE }], 36 | }, 37 | { 38 | text: "relation", 39 | children: [{ text: "is a" }], 40 | }, 41 | { 42 | text: "target", 43 | children: [ 44 | { 45 | text: node.text, 46 | }, 47 | ], 48 | }, 49 | ], 50 | }, 51 | }).then(() => setShowQuery(true)); 52 | } 53 | }, [parentUid, initialQueryArgs, showQuery]); 54 | return ( 55 | 56 | {showQuery ? : } 57 | 58 | ); 59 | }; 60 | 61 | export default NodeIndex; 62 | -------------------------------------------------------------------------------- /src/utils/getDiscourseRelations.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | InputTextNode, 3 | RoamBasicNode, 4 | TextNode, 5 | } from "roamjs-components/types/native"; 6 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 7 | import toFlexRegex from "roamjs-components/util/toFlexRegex"; 8 | import DEFAULT_RELATION_VALUES from "../data/defaultDiscourseRelations"; 9 | import discourseConfigRef from "./discourseConfigRef"; 10 | 11 | export type DiscourseRelation = ReturnType< 12 | typeof getDiscourseRelations 13 | >[number]; 14 | 15 | const matchNodeText = (keyword: string) => { 16 | return (node: RoamBasicNode | TextNode) => 17 | toFlexRegex(keyword).test(node.text); 18 | }; 19 | 20 | const getDiscourseRelations = () => { 21 | const grammarNode = discourseConfigRef.tree.find(matchNodeText("grammar")); 22 | const relationsNode = grammarNode?.children.find(matchNodeText("relations")); 23 | const relationNodes = relationsNode?.children || DEFAULT_RELATION_VALUES; 24 | const discourseRelations = relationNodes.flatMap( 25 | (r: InputTextNode, i: number) => { 26 | const tree = (r?.children || []) as TextNode[]; 27 | const data = { 28 | id: r.uid || `${r.text}-${i}`, 29 | label: r.text, 30 | source: getSettingValueFromTree({ tree, key: "Source" }), 31 | destination: getSettingValueFromTree({ tree, key: "Destination" }), 32 | complement: getSettingValueFromTree({ tree, key: "Complement" }), 33 | }; 34 | const ifNode = tree.find(matchNodeText("if"))?.children || []; 35 | return ifNode.map((node) => ({ 36 | ...data, 37 | triples: node.children 38 | .filter((t) => !/node positions/i.test(t.text)) 39 | .map((t) => { 40 | const target = t.children[0]?.children?.[0]?.text || ""; 41 | return [t.text, t.children[0]?.text, target] as const; 42 | }), 43 | })); 44 | } 45 | ); 46 | 47 | return discourseRelations; 48 | }; 49 | 50 | export default getDiscourseRelations; 51 | -------------------------------------------------------------------------------- /src/utils/createInitialTldrawProps.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TLInstance, 3 | TLUser, 4 | TLDocument, 5 | TLPage, 6 | TLUserDocument, 7 | TLUserPresence, 8 | } from "@tldraw/tldraw"; 9 | import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid"; 10 | 11 | export const createInitialTldrawProps = () => { 12 | const instanceId = TLInstance.createId(); 13 | const userId = TLUser.createCustomId(getCurrentUserUid()); 14 | const documentId = TLDocument.createCustomId("document"); 15 | const pageId = TLPage.createId(); 16 | const userDocument = TLUserDocument.createId(); 17 | const userPresence = TLUserPresence.createId(); 18 | 19 | const userRecord: TLUser = { 20 | ...TLUser.createDefaultProperties(), 21 | typeName: "user", 22 | id: userId, 23 | }; 24 | const instanceRecord: TLInstance = { 25 | ...TLInstance.createDefaultProperties(), 26 | currentPageId: pageId, 27 | userId: userId, 28 | typeName: "instance", 29 | id: instanceId, 30 | }; 31 | const documentRecord: TLDocument = { 32 | ...TLDocument.createDefaultProperties(), 33 | typeName: "document", 34 | id: documentId, 35 | }; 36 | const pageRecord: TLPage = { 37 | // ...TLPage.createDefaultProperties(), doesn't add anything? 38 | index: "a1", 39 | name: "Page 1", 40 | typeName: "page", 41 | id: pageId, 42 | }; 43 | const userDocumentRecord: TLUserDocument = { 44 | ...TLUserDocument.createDefaultProperties(), 45 | userId: userId, 46 | typeName: "user_document", 47 | id: userDocument, 48 | }; 49 | const userPresenceRecord: TLUserPresence = { 50 | ...TLUserPresence.createDefaultProperties(), 51 | typeName: "user_presence", 52 | id: userPresence, 53 | userId: userId, 54 | }; 55 | 56 | const props = { 57 | [userId]: userRecord, 58 | [instanceId]: instanceRecord, 59 | ["document:document"]: documentRecord, 60 | [pageId]: pageRecord, 61 | [userDocument]: userDocumentRecord, 62 | [userPresence]: userPresenceRecord, 63 | }; 64 | return props; 65 | }; 66 | -------------------------------------------------------------------------------- /src/components/Charts.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Chart, AxisOptions, AxisOptionsBase } from "react-charts"; 3 | import { Result } from "roamjs-components/types/query-builder"; 4 | import { Column } from "../utils/types"; 5 | 6 | type ChartData = [Result[string], Result[string]]; 7 | 8 | const Charts = ({ 9 | data, 10 | type, 11 | columns, 12 | }: { 13 | type: AxisOptionsBase["elementType"]; 14 | data: Result[]; 15 | columns: Column[]; 16 | }): JSX.Element => { 17 | const chartData = React.useMemo( 18 | () => 19 | columns.slice(1).map((col) => { 20 | return { 21 | label: col.key, 22 | data: data.map((d) => [d[columns[0].key], d[col.key]] as ChartData), 23 | }; 24 | }), 25 | [data, columns] 26 | ); 27 | const primaryAxis = React.useMemo>( 28 | () => ({ 29 | primary: true, 30 | type: "timeLocal", 31 | position: "bottom" as const, 32 | getValue: ([d]) => 33 | d instanceof Date 34 | ? d 35 | : typeof d === "string" 36 | ? window.roamAlphaAPI.util.pageTitleToDate(d) 37 | : new Date(d), 38 | }), 39 | [] 40 | ); 41 | const secondaryAxes = React.useMemo[]>( 42 | () => 43 | columns.slice(1).map(() => ({ 44 | type: "linear", 45 | position: "left" as const, 46 | getValue: (d) => Number(d[1]) || 0, 47 | elementType: type, 48 | })), 49 | [type] 50 | ); 51 | 52 | return Object.keys(primaryAxis).length !== 0 && !secondaryAxes.length ? ( 53 |

54 | You need to have at least two selections for this layout 55 | to work, where the first is a selection that returns{" "} 56 | date values and all subsequent selections return{" "} 57 | numeric values. 58 |

59 | ) : ( 60 |
61 | 62 |
63 | ); 64 | }; 65 | 66 | export default Charts; 67 | -------------------------------------------------------------------------------- /docs/discourse-graphs.md: -------------------------------------------------------------------------------- 1 | # Discourse Graphs 2 | 3 | This extension implements the Discourse Graph protocol, developed by Joel Chan. 4 | 5 | To enable the features associated with this protocol, toggle the `Discourse Graphs Enabled` switch. 6 | 7 | For more about the suite of tools this mode brings, check out our documentation for how to use this extension at https://oasis-lab.gitbook.io/roamresearch-discourse-graph-extension/. 8 | 9 | Contact Joel Chan (joelchan@umd.edu or [@JoelChan86](https://twitter.com/joelchan86) on Twitter or in the `#discourse-graph` channel on the [Academia Roamana Discord](https://discord.gg/FHrtGe25AJt)) for more details! 10 | 11 | ## Migrate to the Roam Depot Version 12 | 13 | The Discourse Graph plugin is bundled with the Query Builder plugin. 14 | 15 | To migrate to the new version, you must disable the old version in `roam/js`, then enable it in Query Builder. 16 | 17 | ### **Step 1**: Uninstall the `roam/js` Discourse Graph 18 | 19 | - Open the `[[roam/js]]` page in your graph 20 | - Click on the "Stop this" button. It will look something like: 21 | 22 | ![](media/discourse-graph-stop.png) 23 | 24 | - The end result should look like this: 25 | 26 | ![](media/discourse-graph-stopped.png) 27 | 28 | **Important:** Once you see that, refresh your page by hitting the refresh button in your browser / device. 29 | 30 | Then continue to: Step 2: Install Query Builder 31 | 32 | **If you didn't see a yellow "Stop this" button, proceed follow these instructions:** 33 | 34 | Find where you have installed Discourse Graph currently via `{{[[roam/js]]}}`. If you are unsure where you have Discourse Graph currently installed you can go to the `[[roam/js]]` page and look through the linked references. It will look something like: 35 | 36 | ![](media/discourse-graph-linked-ref.jpg) 37 | 38 | ### **Step 2**: Install Query Builder 39 | 40 | ![](media/settings-roam-depot.png) 41 | 42 | ![](media/settings-browse-extensions.png) 43 | 44 | ![](media/settings-install-query-builder.png) 45 | 46 | ### Step 3: Enable Discourse Graph within Query Builder 47 | 48 | ![](media/settings-enable-discourse-graph.png) 49 | -------------------------------------------------------------------------------- /src/utils/matchDiscourseNode.ts: -------------------------------------------------------------------------------- 1 | import compileDatalog from "./compileDatalog"; 2 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 3 | import normalizePageTitle from "roamjs-components/queries/normalizePageTitle"; 4 | import conditionToDatalog from "./conditionToDatalog"; 5 | import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; 6 | import type { DiscourseNode } from "./getDiscourseNodes"; 7 | import replaceDatalogVariables from "./replaceDatalogVariables"; 8 | 9 | const matchDiscourseNode = ({ 10 | format, 11 | specification, 12 | text, 13 | ...rest 14 | }: Pick & 15 | ( 16 | | { 17 | title: string; 18 | } 19 | | { uid: string } 20 | )) => { 21 | // Handle specification with single "has title" clause 22 | if ( 23 | specification.length === 1 && 24 | specification[0].type === "clause" && 25 | specification[0].relation === "has title" 26 | ) { 27 | const title = 28 | "title" in rest ? rest.title : getPageTitleByPageUid(rest.uid); 29 | const regex = new RegExp(specification[0].target.slice(1, -1)); 30 | return !specification[0].not && regex.test(title); 31 | } 32 | 33 | // Handle any other specification 34 | if (specification.length) { 35 | const where = replaceDatalogVariables( 36 | [{ from: text, to: "node" }], 37 | specification.flatMap((c) => conditionToDatalog(c)) 38 | ).map((c) => compileDatalog(c, 0)); 39 | const firstClause = 40 | "title" in rest 41 | ? `[or-join [?node] [?node :node/title "${normalizePageTitle( 42 | rest.title 43 | )}"] [?node :block/string "${normalizePageTitle(rest.title)}"]]` 44 | : `[?node :block/uid "${rest.uid}"]`; 45 | return !!window.roamAlphaAPI.data.fast.q( 46 | `[:find ?node :where ${firstClause} ${where.join(" ")}]` 47 | ).length; 48 | } 49 | 50 | // Fallback to format expression 51 | const title = "title" in rest ? rest.title : getPageTitleByPageUid(rest.uid); 52 | return getDiscourseNodeFormatExpression(format).test(title); 53 | }; 54 | 55 | export default matchDiscourseNode; 56 | -------------------------------------------------------------------------------- /patches/@tldraw+tldraw++@tldraw+ui+2.0.0-canary.ffda4cfb.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/cjs/index.d.ts b/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/cjs/index.d.ts 2 | index 0f93cb5..0e42591 100644 3 | --- a/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/cjs/index.d.ts 4 | +++ b/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/cjs/index.d.ts 5 | @@ -737,6 +737,7 @@ export declare interface ToolItem { 6 | meta?: { 7 | [key: string]: any; 8 | }; 9 | + style?: CSSProperties 10 | } 11 | 12 | /** @public */ 13 | diff --git a/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/esm/lib/components/Toolbar/Toolbar.mjs b/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/esm/lib/components/Toolbar/Toolbar.mjs 14 | index 8c9610d..03a9b7c 100644 15 | --- a/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/esm/lib/components/Toolbar/Toolbar.mjs 16 | +++ b/node_modules/@tldraw/tldraw/node_modules/@tldraw/ui/dist/esm/lib/components/Toolbar/Toolbar.mjs 17 | @@ -151,7 +151,7 @@ const OverflowToolsContent = track(function OverflowToolsContent2({ 18 | toolbarItems 19 | }) { 20 | const msg = useTranslation(); 21 | - return /* @__PURE__ */ jsx("div", { className: "tlui-button-grid__four tlui-button-grid__reverse", children: toolbarItems.map(({ toolItem: { id, meta, kbd, label, onSelect, icon } }) => { 22 | + return /* @__PURE__ */ jsx("div", { className: "tlui-button-grid__four tlui-button-grid__reverse", children: toolbarItems.map(({ toolItem: { id, meta, kbd, label, onSelect, icon, style } }) => { 23 | return /* @__PURE__ */ jsx( 24 | M.Item, 25 | { 26 | @@ -162,7 +162,8 @@ const OverflowToolsContent = track(function OverflowToolsContent2({ 27 | "aria-label": label, 28 | onClick: onSelect, 29 | title: label ? `${msg(label)} ${kbd ? kbdStr(kbd) : ""}` : "", 30 | - icon 31 | + icon, 32 | + style 33 | }, 34 | id 35 | ); 36 | @@ -188,7 +189,8 @@ function ToolbarButton({ 37 | onTouchStart: (e) => { 38 | e.preventDefault(); 39 | item.onSelect(); 40 | - } 41 | + }, 42 | + style: item.style, 43 | }, 44 | item.id 45 | ); 46 | -------------------------------------------------------------------------------- /src/components/QueryPagesPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button, InputGroup } from "@blueprintjs/core"; 2 | import React, { useState } from "react"; 3 | import type { OnloadArgs } from "roamjs-components/types"; 4 | 5 | export const getQueryPages = (extensionAPI: OnloadArgs["extensionAPI"]) => { 6 | const value = extensionAPI.settings.get("query-pages") as 7 | | string[] 8 | | string 9 | | Record; 10 | return typeof value === "string" 11 | ? [value] 12 | : Array.isArray(value) 13 | ? value 14 | : typeof value === "object" && value !== null 15 | ? Object.keys(value) 16 | : ["queries/*"]; 17 | }; 18 | 19 | const QueryPagesPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { 20 | const [texts, setTexts] = useState(() => getQueryPages(extensionAPI)); 21 | const [value, setValue] = useState(""); 22 | return ( 23 |
30 |
31 | setValue(e.target.value)} 35 | /> 36 |
48 | {texts.map((p, index) => ( 49 |
50 | 57 | {p} 58 | 59 |
69 | ))} 70 |
71 | ); 72 | }; 73 | 74 | export default QueryPagesPanel; 75 | -------------------------------------------------------------------------------- /src/utils/replaceDatalogVariables.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DatalogClause, 3 | DatalogVariable, 4 | } from "roamjs-components/types/native"; 5 | 6 | const replaceDatalogVariables = ( 7 | replacements: ( 8 | | { from: string; to: string } 9 | | { from: true; to: (v: string) => string } 10 | )[] = [], 11 | clauses: DatalogClause[] 12 | ): DatalogClause[] => { 13 | const replaceDatalogVariable = (a: DatalogVariable): DatalogVariable => { 14 | const rep = replacements.find( 15 | (rep) => a.value === rep.from || rep.from === true 16 | ); 17 | if (!rep) { 18 | return { ...a }; 19 | } else if (a.value === rep.from) { 20 | a.value = rep.to; 21 | return { 22 | ...a, 23 | value: rep.to, 24 | }; 25 | } else if (rep.from === true) { 26 | return { 27 | ...a, 28 | value: rep.to(a.value), 29 | }; 30 | } 31 | return a; 32 | }; 33 | return clauses.map((c): DatalogClause => { 34 | switch (c.type) { 35 | case "data-pattern": 36 | case "fn-expr": 37 | case "pred-expr": 38 | case "rule-expr": 39 | return { 40 | ...c, 41 | arguments: c.arguments.map((a) => { 42 | if (a.type !== "variable") { 43 | return { ...a }; 44 | } 45 | return replaceDatalogVariable(a); 46 | }), 47 | ...(c.type === "fn-expr" 48 | ? { 49 | binding: 50 | c.binding.type === "bind-scalar" 51 | ? { 52 | variable: replaceDatalogVariable(c.binding.variable), 53 | type: "bind-scalar", 54 | } 55 | : c.binding, 56 | } 57 | : {}), 58 | }; 59 | case "not-join-clause": 60 | case "or-join-clause": 61 | return { 62 | ...c, 63 | variables: c.variables.map(replaceDatalogVariable), 64 | clauses: replaceDatalogVariables(replacements, c.clauses), 65 | }; 66 | case "not-clause": 67 | case "or-clause": 68 | case "and-clause": 69 | return { 70 | ...c, 71 | clauses: replaceDatalogVariables(replacements, c.clauses), 72 | }; 73 | default: 74 | throw new Error(`Unknown clause type: ${c["type"]}`); 75 | } 76 | }); 77 | }; 78 | 79 | export default replaceDatalogVariables; 80 | -------------------------------------------------------------------------------- /src/utils/toCellValue.ts: -------------------------------------------------------------------------------- 1 | import { BLOCK_REF_REGEX } from "roamjs-components/dom/constants"; 2 | import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid"; 3 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 4 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; 5 | import extractTag from "roamjs-components/util/extractTag"; 6 | 7 | const namespaceSettingCache: Record = {}; 8 | 9 | const getNamespaceSetting = () => { 10 | const user = getCurrentUserUid(); 11 | if (namespaceSettingCache[user]) return namespaceSettingCache[user]; 12 | 13 | const value = 14 | ( 15 | window.roamAlphaAPI.data.fast.q( 16 | `[:find [pull ?u [:user/settings]] :where [?u :user/uid "${user}"]]` 17 | )?.[0]?.[0] as { 18 | ":user/settings": { 19 | ":namespace-options": ("partial" | "none" | "full")[]; 20 | }; 21 | } 22 | )?.[":user/settings"]?.[":namespace-options"]?.[0] || "full"; 23 | namespaceSettingCache[user] = value; 24 | setTimeout(() => delete namespaceSettingCache[user], 1000 * 60 * 1); 25 | return value; 26 | }; 27 | 28 | const resolveRefs = (text: string, refs = new Set()): string => { 29 | return text.replace(new RegExp(BLOCK_REF_REGEX, "g"), (_, blockUid) => { 30 | if (refs.has(blockUid)) return ""; 31 | refs.add(blockUid); 32 | const reference = getTextByBlockUid(blockUid); 33 | return resolveRefs(reference, new Set(refs)); 34 | }); 35 | }; 36 | 37 | const toCellValue = ({ 38 | value, 39 | uid, 40 | defaultValue = "", 41 | }: { 42 | value: number | Date | string; 43 | defaultValue?: string; 44 | uid: string; 45 | }) => { 46 | const initialValue = 47 | value instanceof Date 48 | ? window.roamAlphaAPI.util.dateToPageTitle(value) 49 | : typeof value === "undefined" || value === null 50 | ? defaultValue 51 | : extractTag(resolveRefs(value.toString())); 52 | const namespaceSetting = getNamespaceSetting(); 53 | 54 | const formattedValue = 55 | typeof value === "string" && !!getPageTitleByPageUid(uid) 56 | ? namespaceSetting === "full" 57 | ? initialValue.split("/").slice(-1)[0] 58 | : namespaceSetting === "partial" 59 | ? initialValue 60 | .split("/") 61 | .map((v, i, a) => (i === a.length - 1 ? v : v.slice(0, 1))) 62 | .join("/") 63 | : initialValue 64 | : initialValue; 65 | return formattedValue; 66 | }; 67 | 68 | export default toCellValue; 69 | -------------------------------------------------------------------------------- /src/components/ReferenceContext.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from "react"; 2 | import ReactDOM from "react-dom"; 3 | import type { PullBlock } from "roamjs-components/types/native"; 4 | 5 | type Props = { title: string }; 6 | 7 | const Content = ({ title, uid }: { title: string; uid: string }) => { 8 | const sectionRef = useRef(null); 9 | useEffect(() => { 10 | const el = sectionRef.current; 11 | if (el) 12 | window.roamAlphaAPI.ui.components.renderBlock({ 13 | el, 14 | uid, 15 | }); 16 | }, [uid]); 17 | return ( 18 |
19 |
{title}
20 |
21 |
22 | ); 23 | }; 24 | 25 | const ContextContent = ({ title }: Props) => { 26 | const queryResults = useMemo( 27 | () => 28 | ( 29 | window.roamAlphaAPI.data.fast.q( 30 | `[:find (pull ?pr [:node/title]) (pull ?r [:block/uid :block/children :create/time]) :where [?p :node/title "${title}"] [?r :block/refs ?p] [?r :block/page ?pr]]` 31 | ) as [PullBlock, PullBlock][] 32 | ) 33 | .filter( 34 | ([, { [":block/children"]: children = [] }]) => !!children.length 35 | ) 36 | .sort( 37 | ([, { [":create/time"]: a = 0 }], [, { [":create/time"]: b = 0 }]) => 38 | a - b 39 | ), 40 | [title] 41 | ); 42 | return ( 43 | <> 44 | {queryResults.map( 45 | ([{ [":node/title"]: title = "" }, { [":block/uid"]: uid = "" }]) => ( 46 | 47 | ) 48 | )} 49 | 50 | ); 51 | }; 52 | 53 | const ReferenceContext = ({ title }: Props) => { 54 | const [caretShown, setCaretShown] = useState(false); 55 | const [caretOpen, setCaretOpen] = useState(false); 56 | return ( 57 | <> 58 |
setCaretShown(true)} 61 | onMouseLeave={() => setCaretShown(false)} 62 | style={{ marginBottom: 4 }} 63 | > 64 | setCaretOpen(!caretOpen)} 71 | /> 72 |
73 |
74 | Reference Context 75 |
76 |
77 | {caretOpen && } 78 | 79 | ); 80 | }; 81 | 82 | export const render = (props: Props) => { 83 | const container = document.createElement("div"); 84 | ReactDOM.render(, container); 85 | return container; 86 | }; 87 | 88 | export default ReferenceContext; 89 | -------------------------------------------------------------------------------- /src/components/ResizableDrawer.tsx: -------------------------------------------------------------------------------- 1 | import { Drawer, Position, Classes } from "@blueprintjs/core"; 2 | import React, { useCallback, useEffect, useRef, useState } from "react"; 3 | 4 | const getPixelValue = ( 5 | el: HTMLElement | null, 6 | field: "width" | "paddingLeft" 7 | ) => 8 | el ? Number((getComputedStyle(el)[field] || "0px").replace(/px$/, "")) : 0; 9 | 10 | const ResizableDrawer = ({ 11 | onClose, 12 | children, 13 | title = "Resizable Drawer", 14 | }: { 15 | onClose: () => void; 16 | children: React.ReactNode; 17 | title?: string; 18 | }) => { 19 | const [width, setWidth] = useState(0); 20 | const drawerRef = useRef(null); 21 | const calculateWidth = useCallback(() => { 22 | const width = getPixelValue(drawerRef.current, "width"); 23 | const paddingLeft = getPixelValue( 24 | document.querySelector(".rm-article-wrapper"), 25 | "paddingLeft" 26 | ); 27 | setWidth(width - paddingLeft); 28 | }, [setWidth, drawerRef]); 29 | useEffect(() => { 30 | setTimeout(calculateWidth, 1); 31 | }, [calculateWidth]); 32 | const onMouseMove = useCallback( 33 | (e: MouseEvent) => { 34 | if (drawerRef.current?.parentElement) 35 | drawerRef.current.parentElement.style.width = `${Math.max( 36 | e.clientX, 37 | 100 38 | )}px`; 39 | calculateWidth(); 40 | }, 41 | [calculateWidth, drawerRef] 42 | ); 43 | const onMouseUp = useCallback(() => { 44 | document.removeEventListener("mousemove", onMouseMove); 45 | document.removeEventListener("mouseup", onMouseUp); 46 | }, [onMouseMove]); 47 | const onMouseDown = useCallback(() => { 48 | document.addEventListener("mousemove", onMouseMove); 49 | document.addEventListener("mouseup", onMouseUp); 50 | }, [onMouseMove, onMouseUp]); 51 | useEffect(() => { 52 | drawerRef.current && drawerRef.current.scroll(-1000, 0); 53 | }, [drawerRef]); 54 | return ( 55 | 69 | 74 |
79 | {children} 80 |
81 |
92 | 93 | ); 94 | }; 95 | 96 | export default ResizableDrawer; 97 | -------------------------------------------------------------------------------- /src/components/ImportDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Classes, 4 | Dialog, 5 | FileInput, 6 | Intent, 7 | Spinner, 8 | SpinnerSize, 9 | } from "@blueprintjs/core"; 10 | import React, { useMemo, useState } from "react"; 11 | import createBlock from "roamjs-components/writes/createBlock"; 12 | import getChildrenLengthByPageUid from "roamjs-components/queries/getChildrenLengthByPageUid"; 13 | import createOverlayRender from "roamjs-components/util/createOverlayRender"; 14 | import importDiscourseGraph from "../utils/importDiscourseGraph"; 15 | 16 | const ImportDialog = ({ onClose }: { onClose: () => void }) => { 17 | const [loading, setLoading] = useState(false); 18 | const [value, setValue] = useState(""); 19 | const [file, setFile] = useState(); 20 | const title = useMemo(() => value.split(/[/\\]/).slice(-1)[0], [value]); 21 | return ( 22 | 29 |
30 | { 33 | setValue((e.target as HTMLInputElement).value); 34 | setFile((e.target as HTMLInputElement).files?.[0]); 35 | }} 36 | inputProps={{ 37 | accept: "application/json", 38 | value, 39 | }} 40 | /> 41 |
{value.split(/[/\\]/).slice(-1)[0]}
42 |
43 |
44 |
45 | {loading && } 46 |
76 |
77 |
78 | ); 79 | }; 80 | 81 | type Props = {}; 82 | 83 | export const render = createOverlayRender( 84 | "discourse-import", 85 | ImportDialog 86 | ); 87 | 88 | export default ImportDialog; 89 | -------------------------------------------------------------------------------- /src/utils/compileDatalog.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | DatalogArgument, 3 | DatalogBinding, 4 | DatalogClause, 5 | } from "roamjs-components/types/native"; 6 | 7 | const indent = (n: number) => "".padStart(n * 2, " "); 8 | 9 | const toVar = (v = "undefined") => v.replace(/[\s"()[\]{}/\\^@,~`]/g, ""); 10 | 11 | const compileDatalog = ( 12 | d: DatalogClause | DatalogArgument | DatalogBinding, 13 | level = 0 14 | ): string => { 15 | switch (d.type) { 16 | case "data-pattern": 17 | return `${indent(level)}[${ 18 | d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : "" 19 | }${(d.arguments || []).map((a) => compileDatalog(a, level)).join(" ")}]`; 20 | case "src-var": 21 | return `$${toVar(d.value)}`; 22 | case "constant": 23 | case "underscore": 24 | return d.value || "_"; 25 | case "variable": 26 | return `?${toVar(d.value)}`; 27 | case "fn-expr": 28 | return `${indent(level)}[(${d.fn} ${(d.arguments || []) 29 | .map((a) => compileDatalog(a, level)) 30 | .join(" ")}) ${compileDatalog(d.binding, level)}]`; 31 | case "pred-expr": 32 | return `${indent(level)}[(${d.pred} ${(d.arguments || []) 33 | .map((a) => compileDatalog(a, level)) 34 | .join(" ")})]`; 35 | case "rule-expr": 36 | return `[${d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : ""}${( 37 | d.arguments || [] 38 | ) 39 | .map((a) => compileDatalog(a, level)) 40 | .join(" ")}]`; 41 | case "not-clause": 42 | return `${indent(level)}(${ 43 | d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : "" 44 | }not\n${(d.clauses || []) 45 | .map((a) => compileDatalog(a, level + 1)) 46 | .join(" ")}\n${indent(level)})`; 47 | case "or-clause": 48 | return `${indent(level)}(${ 49 | d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : "" 50 | }or ${(d.clauses || []) 51 | .map((a) => compileDatalog(a, level + 1)) 52 | .join("\n")})`; 53 | case "and-clause": 54 | return `${indent(level)}(and\n${(d.clauses || []) 55 | .map((c) => compileDatalog(c, level + 1)) 56 | .join("\n")}\n${indent(level)})`; 57 | case "not-join-clause": 58 | return `${indent(level)}(${ 59 | d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : "" 60 | }not-join [${(d.variables || []) 61 | .map((v) => compileDatalog(v, level)) 62 | .join(" ")}] ${(d.clauses || []) 63 | .map((a) => compileDatalog(a, level + 1)) 64 | .join(" ")})`; 65 | case "or-join-clause": 66 | return `${indent(level)}(${ 67 | d.srcVar ? `${compileDatalog(d.srcVar, level)} ` : "" 68 | }or-join [${(d.variables || []) 69 | .map((v) => compileDatalog(v, level)) 70 | .join(" ")}]\n${(d.clauses || []) 71 | .map((a) => compileDatalog(a, level + 1)) 72 | .join("\n")})`; 73 | case "bind-scalar": 74 | if (!d.variable) return ""; 75 | return compileDatalog(d.variable, level); 76 | case "bind-rel": 77 | return `[[${d.args.map((a) => compileDatalog(a, level)).join(" ")}]]`; 78 | default: 79 | return ""; 80 | } 81 | }; 82 | 83 | export default compileDatalog; 84 | -------------------------------------------------------------------------------- /src/components/tldraw/CanvasReferences.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { PullBlock } from "roamjs-components/types"; 3 | import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; 4 | 5 | const CanvasReferencesList = ({ 6 | uid, 7 | setReferenceCount, 8 | }: { 9 | uid: string; 10 | setReferenceCount: (n: number) => void; 11 | }) => { 12 | const [references, setReferences] = React.useState< 13 | { uid: string; text: string }[] 14 | >([]); 15 | React.useEffect(() => { 16 | const results = window.roamAlphaAPI.data.fast.q(`[:find 17 | (pull ?c [:block/uid :block/string :node/title]) 18 | :where 19 | [?c :block/props ?props] 20 | [(get ?props :roamjs-query-builder) ?rqb] 21 | [(get ?rqb :tldraw) [[?k ?v]]] 22 | [(get ?v :props) ?shape-props] 23 | [(get ?shape-props :uid) ?uid] 24 | [(= ?uid "${uid}")] 25 | ]`) as [PullBlock][]; 26 | setReferences( 27 | results.map((res) => ({ 28 | uid: res[0][":block/uid"] || "", 29 | text: res[0][":block/string"] || res[0][":node/title"] || "", 30 | })) 31 | ); 32 | setReferenceCount(results.length); 33 | }, [setReferences, uid, setReferenceCount]); 34 | return ( 35 | 59 | ); 60 | }; 61 | 62 | const CanvasReferences = ({ uid }: { uid: string }) => { 63 | const [caretShown, setCaretShown] = React.useState(false); 64 | const [caretOpen, setCaretOpen] = React.useState(false); 65 | const [referenceCount, setReferenceCount] = React.useState(0); 66 | return ( 67 | <> 68 |
setCaretShown(true)} 71 | onMouseLeave={() => setCaretShown(false)} 72 | style={{ marginBottom: 4 }} 73 | > 74 | setCaretOpen(!caretOpen)} 81 | /> 82 |
83 |
84 | {caretOpen && referenceCount} Canvas References 85 |
86 |
87 |
88 | {caretOpen && ( 89 | 93 | )} 94 |
95 | 96 | ); 97 | }; 98 | 99 | export default CanvasReferences; 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Query Builder 2 | 3 | Introduces new user interfaces for building queries in Roam. 4 | 5 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FYERyR8FnXO.png?alt=media&token=7522a921-3e17-424f-b141-08e4109f9b75) 6 | 7 | For more information, check out our docs at [https://github.com/RoamJS/query-builder](https://github.com/RoamJS/query-builder) 8 | 9 | ## Table of Contents 10 | 11 | - [RoamJS Query Builder](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#roamjs-query-builder) 12 | - [Query Blocks](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#query-blocks) 13 | - [Query Pages](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#query-pages) 14 | - [Query Drawer](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#query-drawer) 15 | - [Usage](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#usage) 16 | - [Conditions](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#conditions) 17 | - [Selections](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#selections) 18 | - [Manipulating Results](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#manipulating-results) 19 | - [Layouts](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#layouts) 20 | - [Exporting](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#exporting) 21 | - [Styling](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#styling) 22 | - [SmartBlocks Integration](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#smartblocks-integration) 23 | - [Developer API](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#developer-api) 24 | - [Examples](https://github.com/RoamJS/query-builder/blob/main/docs/query-builder.md#examples) 25 | - [Discourse Graphs](https://github.com/RoamJS/query-builder/blob/main/docs/discourse-graphs.md) 26 | - [Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#native-roam-queries) 27 | - [Creating Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#creating-native-roam-queries) 28 | - [Manipulating Native Roam Queries](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#manipulating-native-roam-queries) 29 | - [Sorting](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#sorting) 30 | - [Randomization](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#randomization) 31 | - [Context](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#context) 32 | - [Aliases](https://github.com/RoamJS/query-builder/blob/main/docs/roam-queries.md#aliases) 33 | 34 | ## Nomenclature 35 | 36 | There are some important terms to know and have exact definitions on since they will be used throughout the docs. 37 | 38 | - `Page` - A Page is anything in Roam that was created with `[[brackets]]`, `#hashtag`, `#[[hashtag with brackets]]`, or `Attribute::`. Clicking on these links in your graph takes you to its designated page, each with its own unique title, and they have no parent. 39 | - `Block` - A bullet or line of text in Roam. While you can also go to pages that have a zoomed in block view, their content is not unique, and they always have one parent. 40 | - `Node` - A superset of `Block`s and `Page`s. 41 | 42 | This project is tested with BrowserStack. 43 | -------------------------------------------------------------------------------- /src/components/DiscourseNodeConfigPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button, H6, InputGroup, Intent, Label } from "@blueprintjs/core"; 2 | import React, { useState } from "react"; 3 | import getDiscourseNodes from "../utils/getDiscourseNodes"; 4 | import refreshConfigTree from "../utils/refreshConfigTree"; 5 | import createPage from "roamjs-components/writes/createPage"; 6 | import type { CustomField } from "roamjs-components/components/ConfigPanels/types"; 7 | 8 | const DiscourseNodeConfigPanel: CustomField["options"]["component"] = ({}) => { 9 | const [nodes, setNodes] = useState(() => 10 | getDiscourseNodes().filter((n) => n.backedBy === "user") 11 | ); 12 | const [label, setLabel] = useState(""); 13 | return ( 14 | <> 15 | 23 |
103 | 104 | ); 105 | })} 106 | 107 | 108 | ); 109 | }; 110 | 111 | export default DiscourseNodeConfigPanel; 112 | -------------------------------------------------------------------------------- /src/utils/calcCanvasNodeSizeAndImg.ts: -------------------------------------------------------------------------------- 1 | import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; 2 | import { OnloadArgs, TreeNode } from "roamjs-components/types"; 3 | import { DEFAULT_STYLE_PROPS, MAX_WIDTH } from "../components/tldraw/Tldraw"; 4 | import { measureCanvasNodeText } from "./measureCanvasNodeText"; 5 | import resolveQueryBuilderRef from "./resolveQueryBuilderRef"; 6 | import runQuery from "./runQuery"; 7 | import getDiscourseNodes from "./getDiscourseNodes"; 8 | import resolveRefs from "roamjs-components/dom/resolveRefs"; 9 | import { render as renderToast } from "roamjs-components/components/Toast"; 10 | import { loadImage } from "./loadImage"; 11 | import sendErrorEmail from "./sendErrorEmail"; 12 | 13 | const extractFirstImageUrl = (text: string): string | null => { 14 | const regex = /!\[.*?\]\((https:\/\/[^)]+)\)/; 15 | const result = text.match(regex) || resolveRefs(text).match(regex); 16 | return result ? result[1] : null; 17 | }; 18 | 19 | const getFirstImageByUid = (uid: string): string | null => { 20 | const tree = getFullTreeByParentUid(uid); 21 | 22 | const findFirstImage = (node: TreeNode): string | null => { 23 | const imageUrl = extractFirstImageUrl(node.text); 24 | if (imageUrl) return imageUrl; 25 | 26 | if (node.children) { 27 | for (const child of node.children) { 28 | const childImageUrl = findFirstImage(child); 29 | if (childImageUrl) return childImageUrl; 30 | } 31 | } 32 | 33 | return null; 34 | }; 35 | 36 | return findFirstImage(tree); 37 | }; 38 | 39 | const calcCanvasNodeSizeAndImg = async ({ 40 | nodeText, 41 | uid, 42 | nodeType, 43 | extensionAPI, 44 | }: { 45 | nodeText: string; 46 | uid: string; 47 | nodeType: string; 48 | extensionAPI: OnloadArgs["extensionAPI"]; 49 | }) => { 50 | const allNodes = getDiscourseNodes(); 51 | const canvasSettings = Object.fromEntries( 52 | allNodes.map((n) => [n.type, { ...n.canvasSettings }]) 53 | ); 54 | const { 55 | "query-builder-alias": qbAlias = "", 56 | "key-image": isKeyImage = "", 57 | "key-image-option": keyImageOption = "", 58 | } = canvasSettings[nodeType] || {}; 59 | 60 | const { w, h } = measureCanvasNodeText({ 61 | ...DEFAULT_STYLE_PROPS, 62 | maxWidth: MAX_WIDTH, 63 | text: nodeText, 64 | }); 65 | 66 | if (!isKeyImage) return { w, h, imageUrl: "" }; 67 | 68 | let imageUrl; 69 | if (keyImageOption === "query-builder") { 70 | const parentUid = resolveQueryBuilderRef({ 71 | queryRef: qbAlias, 72 | extensionAPI, 73 | }); 74 | const results = await runQuery({ 75 | extensionAPI, 76 | parentUid, 77 | inputs: { NODETEXT: nodeText, NODEUID: uid }, 78 | }); 79 | const result = results.allProcessedResults[0]?.text || ""; 80 | imageUrl = extractFirstImageUrl(result); 81 | } else { 82 | imageUrl = getFirstImageByUid(uid); 83 | } 84 | if (!imageUrl) return { w, h, imageUrl: "" }; 85 | 86 | const padding = Number(DEFAULT_STYLE_PROPS.padding.replace("px", "")); 87 | const maxWidth = Number(MAX_WIDTH.replace("px", "")); 88 | const effectiveWidth = maxWidth - 2 * padding; 89 | 90 | try { 91 | const { width, height } = await loadImage(imageUrl); 92 | const aspectRatio = width / height; 93 | const nodeImageHeight = effectiveWidth / aspectRatio; 94 | 95 | return { 96 | w, 97 | h: h + nodeImageHeight + padding * 2, 98 | imageUrl, 99 | }; 100 | } catch (e) { 101 | const error = e as Error; 102 | sendErrorEmail({ 103 | type: "Canvas Image Load Failed", 104 | error, 105 | data: { 106 | uid, 107 | nodeText, 108 | imageUrl, 109 | }, 110 | }); 111 | renderToast({ 112 | id: "tldraw-image-load-fail", 113 | content: error.message, 114 | intent: "warning", 115 | }); 116 | return { w, h, imageUrl: "" }; 117 | } 118 | }; 119 | 120 | export default calcCanvasNodeSizeAndImg; 121 | -------------------------------------------------------------------------------- /src/components/DiscourseNodeAttributes.tsx: -------------------------------------------------------------------------------- 1 | import { Button, InputGroup, Label } from "@blueprintjs/core"; 2 | import React, { useRef, useState } from "react"; 3 | import createBlock from "roamjs-components/writes/createBlock"; 4 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 5 | import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid"; 6 | import updateBlock from "roamjs-components/writes/updateBlock"; 7 | import deleteBlock from "roamjs-components/writes/deleteBlock"; 8 | 9 | type Attribute = { 10 | uid: string; 11 | label: string; 12 | value: string; 13 | }; 14 | 15 | const NodeAttribute = ({ 16 | uid, 17 | label, 18 | value, 19 | onChange, 20 | onDelete, 21 | }: Attribute & { onChange: (v: string) => void; onDelete: () => void }) => { 22 | const timeoutRef = useRef(0); 23 | return ( 24 |
31 | 32 | { 36 | clearTimeout(timeoutRef.current); 37 | onChange(e.target.value); 38 | timeoutRef.current = window.setTimeout(() => { 39 | updateBlock({ 40 | text: e.target.value, 41 | uid: getFirstChildUidByBlockUid(uid), 42 | }); 43 | }, 500); 44 | }} 45 | /> 46 |
53 | ); 54 | }; 55 | 56 | const NodeAttributes = ({ uid }: { uid: string }) => { 57 | const [attributes, setAttributes] = useState(() => 58 | getBasicTreeByParentUid(uid).map((t) => ({ 59 | uid: t.uid, 60 | label: t.text, 61 | value: t.children[0]?.text, 62 | })) 63 | ); 64 | const [newAttribute, setNewAttribute] = useState(""); 65 | return ( 66 |
67 |
68 | {attributes.map((a) => ( 69 | 73 | setAttributes( 74 | attributes.map((aa) => 75 | a.uid === aa.uid ? { ...a, value: v } : aa 76 | ) 77 | ) 78 | } 79 | onDelete={() => 80 | deleteBlock(a.uid).then(() => 81 | setAttributes(attributes.filter((aa) => a.uid !== aa.uid)) 82 | ) 83 | } 84 | /> 85 | ))} 86 |
87 |
88 | 95 |
118 |
119 | ); 120 | }; 121 | 122 | export default NodeAttributes; 123 | -------------------------------------------------------------------------------- /src/utils/getDiscourseNodes.ts: -------------------------------------------------------------------------------- 1 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 2 | import getSubTree from "roamjs-components/util/getSubTree"; 3 | import discourseConfigRef from "./discourseConfigRef"; 4 | import getDiscourseRelations from "./getDiscourseRelations"; 5 | import parseQuery from "./parseQuery"; 6 | import { Condition } from "./types"; 7 | 8 | // TODO - only text and type should be required 9 | export type DiscourseNode = { 10 | text: string; 11 | type: string; 12 | shortcut: string; 13 | specification: Condition[]; 14 | backedBy: "user" | "default" | "relation"; 15 | canvasSettings: { 16 | [k: string]: string; 17 | }; 18 | // @deprecated - use specification instead 19 | format: string; 20 | graphOverview?: boolean; 21 | }; 22 | 23 | const DEFAULT_NODES: DiscourseNode[] = [ 24 | { 25 | text: "Page", 26 | type: "page-node", 27 | shortcut: "p", 28 | format: "{content}", 29 | specification: [ 30 | { 31 | type: "clause", 32 | source: "Page", 33 | relation: "has title", 34 | target: "/^(.*)$/", 35 | uid: window.roamAlphaAPI.util.generateUID(), 36 | }, 37 | ], 38 | canvasSettings: { color: "#000000" }, 39 | backedBy: "default", 40 | }, 41 | { 42 | text: "Block", 43 | type: "blck-node", 44 | shortcut: "b", 45 | format: "{content}", 46 | specification: [ 47 | { 48 | type: "clause", 49 | source: "Block", 50 | relation: "is in page", 51 | target: "_", 52 | uid: window.roamAlphaAPI.util.generateUID(), 53 | }, 54 | ], 55 | canvasSettings: { color: "#505050" }, 56 | backedBy: "default", 57 | }, 58 | ]; 59 | 60 | const getDiscourseNodes = (relations = getDiscourseRelations()) => { 61 | const configuredNodes = Object.entries(discourseConfigRef.nodes) 62 | .map(([type, { text, children }]): DiscourseNode => { 63 | const spec = getSubTree({ 64 | tree: children, 65 | key: "specification", 66 | }); 67 | const specTree = spec.children; 68 | return { 69 | format: getSettingValueFromTree({ tree: children, key: "format" }), 70 | text, 71 | shortcut: getSettingValueFromTree({ tree: children, key: "shortcut" }), 72 | type, 73 | specification: !!getSubTree({ tree: specTree, key: "enabled" }).uid 74 | ? parseQuery(spec.uid).conditions 75 | : [], 76 | backedBy: "user", 77 | canvasSettings: Object.fromEntries( 78 | getSubTree({ tree: children, key: "canvas" }).children.map( 79 | (c) => [c.text, c.children[0]?.text || ""] as const 80 | ) 81 | ), 82 | graphOverview: 83 | children.filter((c) => c.text === "Graph Overview").length > 0, 84 | }; 85 | }) 86 | .concat( 87 | relations 88 | .filter((r) => r.triples.some((t) => t.some((n) => /anchor/i.test(n)))) 89 | .map((r) => ({ 90 | format: "", 91 | text: r.label, 92 | type: r.id, 93 | shortcut: r.label.slice(0, 1), 94 | specification: r.triples.map(([source, relation, target]) => ({ 95 | type: "clause", 96 | source: /anchor/i.test(source) ? r.label : source, 97 | relation, 98 | target: 99 | target === "source" 100 | ? r.source 101 | : target === "destination" 102 | ? r.destination 103 | : /anchor/i.test(target) 104 | ? r.label 105 | : target, 106 | uid: window.roamAlphaAPI.util.generateUID(), 107 | })), 108 | backedBy: "relation", 109 | canvasSettings: {}, 110 | })) 111 | ); 112 | const configuredNodeTexts = new Set(configuredNodes.map((n) => n.text)); 113 | const defaultNodes = DEFAULT_NODES.filter( 114 | (n) => !configuredNodeTexts.has(n.text) 115 | ); 116 | return configuredNodes.concat(defaultNodes); 117 | }; 118 | 119 | export default getDiscourseNodes; 120 | -------------------------------------------------------------------------------- /src/utils/parseQuery.ts: -------------------------------------------------------------------------------- 1 | import { RoamBasicNode } from "roamjs-components/types/native"; 2 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 3 | import getSubTree from "roamjs-components/util/getSubTree"; 4 | import createBlock from "roamjs-components/writes/createBlock"; 5 | import { Column, Condition, Selection } from "./types"; 6 | 7 | const roamNodeToCondition = ({ 8 | uid, 9 | children, 10 | text, 11 | }: RoamBasicNode): Condition => { 12 | const type = ( 13 | isNaN(Number(text)) 14 | ? text 15 | : !!getSubTree({ tree: children, key: "not" }).uid 16 | ? "not" 17 | : "clause" 18 | ) as Condition["type"]; 19 | return type === "clause" || type === "not" 20 | ? { 21 | uid, 22 | source: getSettingValueFromTree({ tree: children, key: "source" }), 23 | target: getSettingValueFromTree({ tree: children, key: "target" }), 24 | relation: getSettingValueFromTree({ 25 | tree: children, 26 | key: "relation", 27 | }), 28 | type, 29 | 30 | // @deprecated 31 | not: type === "not" || !!getSubTree({ tree: children, key: "not" }).uid, 32 | } 33 | : { 34 | uid, 35 | type, 36 | conditions: children.map((node) => 37 | node.children.map(roamNodeToCondition) 38 | ), 39 | }; 40 | }; 41 | 42 | type ParseQuery = (q: RoamBasicNode | string) => { 43 | returnNode: string; 44 | conditions: Condition[]; 45 | selections: Selection[]; 46 | customNode: string; 47 | returnNodeUid: string; 48 | conditionsNodesUid: string; 49 | selectionsNodesUid: string; 50 | customNodeUid: string; 51 | isCustomEnabled: boolean; 52 | isSamePageEnabled: boolean; 53 | columns: Column[]; 54 | }; 55 | 56 | export const DEFAULT_RETURN_NODE = "node"; 57 | 58 | export const parseQuery: ParseQuery = (parentUidOrNode) => { 59 | const queryNode = 60 | typeof parentUidOrNode === "string" 61 | ? getSubTree({ key: "scratch", parentUid: parentUidOrNode }) 62 | : parentUidOrNode; 63 | const { uid, children } = queryNode; 64 | const getOrCreateUid = (sub: RoamBasicNode, text: string) => { 65 | if (sub.uid) return sub.uid; 66 | const newUid = window.roamAlphaAPI.util.generateUID(); 67 | createBlock({ 68 | node: { text, uid: newUid }, 69 | parentUid: uid, 70 | }); 71 | return newUid; 72 | }; 73 | const conditionsNode = getSubTree({ 74 | tree: children, 75 | key: "conditions", 76 | }); 77 | const conditionsNodesUid = getOrCreateUid(conditionsNode, "conditions"); 78 | const conditions = conditionsNode.children.map(roamNodeToCondition); 79 | 80 | const selectionsNode = getSubTree({ tree: children, key: "selections" }); 81 | const selectionsNodesUid = getOrCreateUid(selectionsNode, "selections"); 82 | 83 | const selections = selectionsNode.children.map(({ uid, text, children }) => ({ 84 | uid, 85 | text, 86 | label: children?.[0]?.text || "", 87 | })); 88 | 89 | const customBlock = getSubTree({ tree: children, key: "custom" }); 90 | const customNodeUid = getOrCreateUid(customBlock, "custom"); 91 | const samePageBlock = getSubTree({ tree: children, key: "samepage" }); 92 | const returnNodeUid = `returnuid`; 93 | return { 94 | returnNode: DEFAULT_RETURN_NODE, 95 | conditions, 96 | selections, 97 | customNode: customBlock.children[0]?.text || "", 98 | returnNodeUid, 99 | conditionsNodesUid, 100 | selectionsNodesUid, 101 | customNodeUid, 102 | isCustomEnabled: customBlock.children[1]?.text === "enabled", 103 | isSamePageEnabled: !!samePageBlock.uid, 104 | columns: [ 105 | { 106 | key: 107 | selections.find((s) => s.text === DEFAULT_RETURN_NODE)?.label || 108 | "text", 109 | uid: returnNodeUid, 110 | selection: DEFAULT_RETURN_NODE, 111 | }, 112 | ].concat( 113 | selections 114 | .filter((s) => s.text !== DEFAULT_RETURN_NODE) 115 | .map((s) => ({ uid: s.uid, key: s.label, selection: s.text })) 116 | ), 117 | }; 118 | }; 119 | 120 | export default parseQuery; 121 | -------------------------------------------------------------------------------- /src/components/DiscourseNodeSpecification.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import getSubTree from "roamjs-components/util/getSubTree"; 3 | import createBlock from "roamjs-components/writes/createBlock"; 4 | import { Switch } from "@blueprintjs/core"; 5 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 6 | import deleteBlock from "roamjs-components/writes/deleteBlock"; 7 | import refreshConfigTree from "../utils/refreshConfigTree"; 8 | import getDiscourseNodes from "../utils/getDiscourseNodes"; 9 | import getDiscourseNodeFormatExpression from "../utils/getDiscourseNodeFormatExpression"; 10 | import QueryEditor from "./QueryEditor"; 11 | 12 | const NodeSpecification = ({ 13 | parentUid, 14 | node, 15 | }: { 16 | parentUid: string; 17 | node: ReturnType[number]; 18 | }) => { 19 | const [migrated, setMigrated] = React.useState(false); 20 | const [enabled, setEnabled] = React.useState( 21 | () => 22 | getSubTree({ tree: getBasicTreeByParentUid(parentUid), key: "enabled" }) 23 | ?.uid 24 | ); 25 | React.useEffect(() => { 26 | if (enabled) { 27 | const scratchNode = getSubTree({ parentUid, key: "scratch" }); 28 | if ( 29 | !scratchNode.children.length || 30 | !getSubTree({ tree: scratchNode.children, key: "conditions" }).children 31 | .length 32 | ) { 33 | const conditionsUid = getSubTree({ 34 | parentUid: scratchNode.uid, 35 | key: "conditions", 36 | }).uid; 37 | const returnUid = getSubTree({ 38 | parentUid: scratchNode.uid, 39 | key: "return", 40 | }).uid; 41 | createBlock({ 42 | parentUid: returnUid, 43 | node: { 44 | text: node.text, 45 | }, 46 | }) 47 | .then(() => 48 | createBlock({ 49 | parentUid: conditionsUid, 50 | node: { 51 | text: "clause", 52 | children: [ 53 | { text: "source", children: [{ text: node.text }] }, 54 | { text: "relation", children: [{ text: "has title" }] }, 55 | { 56 | text: "target", 57 | children: [ 58 | { 59 | text: `/${ 60 | getDiscourseNodeFormatExpression(node.format).source 61 | }/`, 62 | }, 63 | ], 64 | }, 65 | ], 66 | }, 67 | }) 68 | ) 69 | .then(() => setMigrated(true)); 70 | } 71 | } else { 72 | const tree = getBasicTreeByParentUid(parentUid); 73 | const scratchNode = getSubTree({ tree, key: "scratch" }); 74 | Promise.all(scratchNode.children.map((c) => deleteBlock(c.uid))); 75 | } 76 | return () => { 77 | refreshConfigTree(); 78 | }; 79 | }, [parentUid, setMigrated, enabled]); 80 | return ( 81 |
82 | 85 |

86 | { 90 | const flag = (e.target as HTMLInputElement).checked; 91 | if (flag) { 92 | createBlock({ 93 | parentUid, 94 | order: 2, 95 | node: { text: "enabled" }, 96 | }).then(setEnabled); 97 | } else { 98 | deleteBlock(enabled).then(() => setEnabled("")); 99 | } 100 | }} 101 | /> 102 |

103 |
106 | 111 |
112 |
113 | ); 114 | }; 115 | 116 | export default NodeSpecification; 117 | -------------------------------------------------------------------------------- /src/components/tldraw/DiscourseRelationsUtil.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | App as TldrawApp, 4 | TLBaseShape, 5 | TLArrowShapeProps, 6 | TLArrowUtil, 7 | } from "@tldraw/tldraw"; 8 | import { COLOR_ARRAY, discourseContext } from "./Tldraw"; 9 | 10 | export type AddReferencedNodeType = Record; 11 | type ReferenceFormatType = { 12 | format: string; 13 | sourceName: string; 14 | sourceType: string; 15 | destinationType: string; 16 | destinationName: string; 17 | }; 18 | 19 | export type DiscourseRelationShape = TLBaseShape; 20 | 21 | // Helper to add referenced nodes to node titles 22 | // EG: [[EVD]] - {content} - {Source} 23 | // {Source} is a referenced node 24 | export type DiscourseReferencedNodeShape = TLBaseShape< 25 | string, 26 | TLArrowShapeProps 27 | >; 28 | // @ts-ignore 29 | export class DiscourseReferencedNodeUtil extends TLArrowUtil { 30 | constructor(app: TldrawApp, type: string) { 31 | super(app, type); 32 | } 33 | override canBind = () => true; 34 | override canEdit = () => false; 35 | defaultProps() { 36 | return { 37 | opacity: "1" as const, 38 | dash: "draw" as const, 39 | size: "s" as const, 40 | fill: "none" as const, 41 | color: COLOR_ARRAY[0], 42 | labelColor: COLOR_ARRAY[1], 43 | bend: 0, 44 | start: { type: "point" as const, x: 0, y: 0 }, 45 | end: { type: "point" as const, x: 0, y: 0 }, 46 | arrowheadStart: "none" as const, 47 | arrowheadEnd: "arrow" as const, 48 | text: "for", 49 | font: "mono" as const, 50 | }; 51 | } 52 | render(shape: DiscourseReferencedNodeShape) { 53 | return ( 54 | <> 55 | 65 | {super.render(shape)} 66 | 67 | ); 68 | } 69 | } 70 | // @ts-ignore 71 | export class DiscourseRelationUtil extends TLArrowUtil { 72 | constructor(app: TldrawApp, type: string) { 73 | super(app, type); 74 | } 75 | override canBind = () => true; 76 | override canEdit = () => false; 77 | defaultProps() { 78 | const relations = Object.values(discourseContext.relations); 79 | // TODO - add canvas settings to relations config 80 | const relationIndex = relations.findIndex((rs) => 81 | rs.some((r) => r.id === this.type) 82 | ); 83 | const isValid = relationIndex >= 0 && relationIndex < relations.length; 84 | const color = isValid ? COLOR_ARRAY[relationIndex + 1] : COLOR_ARRAY[0]; 85 | return { 86 | opacity: "1" as const, 87 | dash: "draw" as const, 88 | size: "s" as const, 89 | fill: "none" as const, 90 | color, 91 | labelColor: color, 92 | bend: 0, 93 | start: { type: "point" as const, x: 0, y: 0 }, 94 | end: { type: "point" as const, x: 0, y: 0 }, 95 | arrowheadStart: "none" as const, 96 | arrowheadEnd: "arrow" as const, 97 | text: isValid 98 | ? Object.keys(discourseContext.relations)[relationIndex] 99 | : "", 100 | font: "mono" as const, 101 | }; 102 | } 103 | override onBeforeCreate = (shape: DiscourseRelationShape) => { 104 | // TODO - propsForNextShape is clobbering our choice of color 105 | const relations = Object.values(discourseContext.relations); 106 | const relationIndex = relations.findIndex((rs) => 107 | rs.some((r) => r.id === this.type) 108 | ); 109 | const isValid = relationIndex >= 0 && relationIndex < relations.length; 110 | const color = isValid ? COLOR_ARRAY[relationIndex + 1] : COLOR_ARRAY[0]; 111 | return { 112 | ...shape, 113 | props: { 114 | ...shape.props, 115 | color, 116 | labelColor: color, 117 | }, 118 | }; 119 | }; 120 | render(shape: DiscourseRelationShape) { 121 | return ( 122 | <> 123 | 133 | {super.render(shape)} 134 | 135 | ); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/components/Timeline.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo, useRef, useEffect } from "react"; 2 | import { 3 | VerticalTimeline, 4 | VerticalTimelineElement, 5 | } from "react-vertical-timeline-component"; 6 | import "react-vertical-timeline-component/style.min.css"; 7 | import { Icon } from "@blueprintjs/core"; 8 | import PageLink from "roamjs-components/components/PageLink"; 9 | import { Result } from "roamjs-components/types/query-builder"; 10 | 11 | type TimelineProps = { timelineElements: Result[] }; 12 | 13 | // const LAYOUTS = { 14 | // LEFT: "1-column-left", 15 | // RIGHT: "1-column-right", 16 | // ALT: "2-columns", 17 | // }; 18 | 19 | // const getLayout = (blockUid: string) => { 20 | // const tree = getFullTreeByParentUid(blockUid); 21 | // const layoutNode = tree.children.find((t) => /layout/i.test(t.text)); 22 | // if (layoutNode && layoutNode.children.length) { 23 | // return ( 24 | // LAYOUTS[ 25 | // layoutNode.children[0].text.toUpperCase() as keyof typeof LAYOUTS 26 | // ] || "2-columns" 27 | // ); 28 | // } 29 | // return "2-columns"; 30 | // }; 31 | 32 | // const getColors = (blockUid: string) => { 33 | // const tree = getFullTreeByParentUid(blockUid); 34 | // const colorNode = tree.children.find((t) => /colors/i.test(t.text)); 35 | // if (colorNode && colorNode.children.length) { 36 | // return colorNode.children.map((c) => c.text); 37 | // } 38 | // return ["#2196f3"]; 39 | // }; 40 | 41 | const TimelineElement = ({ 42 | color, 43 | t, 44 | }: { 45 | color: string; 46 | t: Result & { date: Date }; 47 | }) => { 48 | const containerRef = useRef(null); 49 | useEffect(() => { 50 | if (!containerRef.current) return; 51 | window.roamAlphaAPI.ui.components.renderBlock({ 52 | uid: t.uid, 53 | el: containerRef.current, 54 | }); 55 | }, [t.uid, containerRef]); 56 | return ( 57 | 69 | {window.roamAlphaAPI.util.dateToPageTitle(t.date)} 70 | 71 | } 72 | dateClassName={"roamjs-timeline-date"} 73 | iconStyle={{ 74 | backgroundColor: color, 75 | color: "#fff", 76 | }} 77 | icon={} 78 | > 79 |

80 | {t.text} 81 |

82 |

83 | 84 | ); 85 | }; 86 | 87 | const colors = ["#7F1D1D", "#14532d", "#1e3a8a"]; 88 | 89 | const Timeline: React.FunctionComponent = ({ 90 | timelineElements, 91 | }) => { 92 | const datedTimelineElements = useMemo( 93 | () => 94 | timelineElements 95 | .map((t) => 96 | Object.fromEntries( 97 | Object.entries(t).map(([k, v]) => [ 98 | k.toLowerCase(), 99 | /date/i.test(k) 100 | ? typeof v === "string" 101 | ? window.roamAlphaAPI.util.pageTitleToDate(v) 102 | : new Date(v) 103 | : v, 104 | ]) 105 | ) 106 | ) 107 | .filter( 108 | (t): t is Result & { date: Date } => !!t.date && !!t.date.valueOf() 109 | ), 110 | [timelineElements] 111 | ); 112 | return datedTimelineElements.length < timelineElements.length ? ( 113 |

114 | Some of the results in this query are missing a Date column. 115 | To use the Timeline layout, make sure that you add a selections labelled{" "} 116 | Date and that all results return a valid date value for that 117 | selection. 118 |

119 | ) : ( 120 | 121 | 132 | {datedTimelineElements.map((t, i) => ( 133 | 138 | ))} 139 | 140 | ); 141 | }; 142 | 143 | export default Timeline; 144 | -------------------------------------------------------------------------------- /src/utils/deriveDiscourseNodeAttribute.ts: -------------------------------------------------------------------------------- 1 | import getSubTree from "roamjs-components/util/getSubTree"; 2 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 3 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 4 | import getAttributeValueByBlockAndName from "roamjs-components/queries/getAttributeValueByBlockAndName"; 5 | import getDiscourseContextResults from "./getDiscourseContextResults"; 6 | import findDiscourseNode from "./findDiscourseNode"; 7 | import getDiscourseNodes from "./getDiscourseNodes"; 8 | import getDiscourseRelations from "./getDiscourseRelations"; 9 | 10 | export const ANY_RELATION_REGEX = /Has Any Relation To/i; 11 | 12 | const evaluate = async (s: string) => 13 | window.RoamLazy 14 | ? window.RoamLazy.Insect().then((insect) => 15 | Number(insect.repl(insect.fmtPlain)(insect.initialEnvironment)(s).msg) 16 | ) 17 | : 0; 18 | 19 | const getRelatedResults = ({ 20 | uid, 21 | nodes, 22 | relations = [], 23 | relationLabel, 24 | target, 25 | }: Parameters[0] & { 26 | relationLabel: string; 27 | target: string; 28 | }) => 29 | getDiscourseContextResults({ 30 | uid, 31 | nodes, 32 | relations: ANY_RELATION_REGEX.test(relationLabel) 33 | ? relations 34 | : relations.filter( 35 | (r) => relationLabel === r.label || relationLabel === r.complement 36 | ), 37 | }).then((results) => 38 | results 39 | .flatMap((r) => Object.values(r.results)) 40 | .filter((r) => /any/i.test(target) || r.target === target) 41 | ); 42 | 43 | const deriveNodeAttribute = async ({ 44 | attribute, 45 | uid, 46 | }: { 47 | attribute: string; 48 | uid: string; 49 | }): Promise => { 50 | const relations = getDiscourseRelations(); 51 | const nodes = getDiscourseNodes(relations); 52 | const discourseNode = findDiscourseNode(uid, nodes); 53 | if (!discourseNode) return 0; 54 | const nodeType = discourseNode.type; 55 | const attributeNode = getSubTree({ 56 | tree: getBasicTreeByParentUid(nodeType || ""), 57 | key: "Attributes", 58 | }); 59 | const scoreFormula = getSettingValueFromTree({ 60 | tree: attributeNode.children, 61 | key: attribute, 62 | defaultValue: "{count:Has Any Relation To:any}", 63 | }); 64 | let postProcess = scoreFormula; 65 | let totalOffset = 0; 66 | const matches = scoreFormula.matchAll(/{([^}]+)}/g); 67 | for (const match of matches) { 68 | const [op, ...args] = match[1].split(":"); 69 | const value = 70 | op === "count" 71 | ? await getRelatedResults({ 72 | uid, 73 | nodes, 74 | relations, 75 | relationLabel: args[0], 76 | target: args[1], 77 | }).then((results) => results.length) 78 | : op === "attribute" 79 | ? getAttributeValueByBlockAndName({ 80 | name: args[0], 81 | uid, 82 | }) 83 | : op === "discourse" 84 | ? await deriveNodeAttribute({ 85 | uid, 86 | attribute: args[0], 87 | }) 88 | : op === "sum" 89 | ? await getRelatedResults({ 90 | uid, 91 | nodes, 92 | relations, 93 | relationLabel: args[0], 94 | target: args[1], 95 | }) 96 | .then((results) => 97 | Promise.all( 98 | results.map((r) => 99 | deriveNodeAttribute({ attribute: args[2], uid: r.uid || "" }) 100 | ) 101 | ) 102 | ) 103 | .then((values) => 104 | values.map((v) => Number(v) || 0).reduce((p, c) => p + c, 0) 105 | ) 106 | : op === "average" 107 | ? await getRelatedResults({ 108 | uid, 109 | nodes, 110 | relations, 111 | relationLabel: args[0], 112 | target: args[1], 113 | }) 114 | .then((results) => 115 | Promise.all( 116 | results.map((r) => 117 | deriveNodeAttribute({ attribute: args[2], uid: r.uid || "" }) 118 | ) 119 | ) 120 | ) 121 | .then( 122 | (values) => 123 | values.map((v) => Number(v) || 0).reduce((p, c) => p + c, 0) / 124 | values.length 125 | ) 126 | : "0"; 127 | const postOp = `${postProcess.slice( 128 | 0, 129 | (match.index || 0) + totalOffset 130 | )}${value}${postProcess.slice( 131 | (match.index || 0) + match[0].length + totalOffset 132 | )}`; 133 | totalOffset = totalOffset + postOp.length - postProcess.length; 134 | postProcess = postOp; 135 | } 136 | try { 137 | return evaluate(postProcess); 138 | } catch { 139 | return postProcess; 140 | } 141 | }; 142 | 143 | export default deriveNodeAttribute; 144 | -------------------------------------------------------------------------------- /src/utils/postProcessResults.ts: -------------------------------------------------------------------------------- 1 | import { DAILY_NOTE_PAGE_TITLE_REGEX } from "roamjs-components/date/constants"; 2 | import { Result } from "roamjs-components/types/query-builder"; 3 | import extractTag from "roamjs-components/util/extractTag"; 4 | import parseResultSettings from "./parseResultSettings"; 5 | 6 | const transform = (_val: Result[string]) => 7 | typeof _val === "string" 8 | ? DAILY_NOTE_PAGE_TITLE_REGEX.test(extractTag(_val)) 9 | ? window.roamAlphaAPI.util.pageTitleToDate(extractTag(_val)) || new Date() 10 | : /^(-)?\d+(\.\d*)?$/.test(_val) 11 | ? Number(_val) 12 | : _val 13 | : _val; 14 | const sortFunction = 15 | (key: string, descending?: boolean) => (a: Result, b: Result) => { 16 | const _aVal = a[key]; 17 | const _bVal = b[key]; 18 | const aVal = transform(_aVal); 19 | const bVal = transform(_bVal); 20 | if (aVal instanceof Date && bVal instanceof Date) { 21 | return descending 22 | ? bVal.valueOf() - aVal.valueOf() 23 | : aVal.valueOf() - bVal.valueOf(); 24 | } else if (typeof aVal === "number" && typeof bVal === "number") { 25 | return descending ? bVal - aVal : aVal - bVal; 26 | } else if (typeof aVal !== "undefined" && typeof bVal !== "undefined") { 27 | return descending 28 | ? bVal.toString().localeCompare(aVal.toString()) 29 | : aVal.toString().localeCompare(bVal.toString()); 30 | } else { 31 | return 0; 32 | } 33 | }; 34 | 35 | const postProcessResults = ( 36 | results: Result[], 37 | settings: Omit< 38 | ReturnType, 39 | "views" | "layout" | "resultNodeUid" 40 | > 41 | ) => { 42 | const sortedResults = results 43 | .filter((r) => { 44 | const deprecatedFilter = Object.keys(settings.filters).every( 45 | (filterKey) => { 46 | const includeValues = 47 | settings.filters[filterKey].includes.values || new Set(); 48 | const excludeValues = 49 | settings.filters[filterKey].excludes.values || new Set(); 50 | return ( 51 | (includeValues.size === 0 && 52 | (typeof r[filterKey] !== "string" || 53 | !excludeValues.has(extractTag(r[filterKey] as string))) && 54 | (r[filterKey] instanceof Date || 55 | !excludeValues.has( 56 | window.roamAlphaAPI.util.dateToPageTitle(r[filterKey] as Date) 57 | )) && 58 | !excludeValues.has(r[filterKey] as string)) || 59 | (typeof r[filterKey] === "string" && 60 | includeValues.has(extractTag(r[filterKey] as string))) || 61 | (r[filterKey] instanceof Date && 62 | includeValues.has( 63 | window.roamAlphaAPI.util.dateToPageTitle(r[filterKey] as Date) 64 | )) || 65 | includeValues.has(r[filterKey] as string) 66 | ); 67 | } 68 | ); 69 | if (!deprecatedFilter) return false; 70 | return settings.columnFilters.every((columnFilter) => { 71 | switch (columnFilter.type) { 72 | case "contains": 73 | const resultValue = r[columnFilter.key]; 74 | const resultValueString = 75 | typeof resultValue === "string" ? resultValue : `${resultValue}`; 76 | return resultValueString.includes(columnFilter.value[0]); 77 | case "equals": 78 | if ( 79 | columnFilter.value.length === 1 && 80 | columnFilter.value[0] === "" 81 | ) { 82 | return true; 83 | } 84 | return columnFilter.value.some((v) => r[columnFilter.key] === v); 85 | case "greater than": 86 | const gtFilter = transform(columnFilter.value[0]); 87 | const gtResult = transform(r[columnFilter.key]); 88 | return gtResult > gtFilter; 89 | case "less than": 90 | const ltFilter = transform(columnFilter.value[0]); 91 | const ltResult = transform(r[columnFilter.key]); 92 | return ltResult < ltFilter; 93 | default: 94 | return true; 95 | } 96 | }); 97 | }) 98 | .filter((r) => { 99 | return settings.searchFilter 100 | ? Object.keys(r) 101 | .filter((key) => !key.endsWith("-uid") && key !== "uid") 102 | .some((key) => 103 | String(r[key]) 104 | .toLowerCase() 105 | .includes(settings.searchFilter.toLowerCase()) 106 | ) 107 | : true; 108 | }) 109 | .sort((a, b) => { 110 | for (const sort of settings.activeSort) { 111 | const cmpResult = sortFunction(sort.key, sort.descending)(a, b); 112 | if (cmpResult !== 0) return cmpResult; 113 | } 114 | return 0; 115 | }); 116 | const allProcessedResults = 117 | settings.random > 0 118 | ? sortedResults.sort(() => 0.5 - Math.random()).slice(0, settings.random) 119 | : sortedResults; 120 | const paginatedResults = allProcessedResults.slice( 121 | (settings.page - 1) * settings.pageSize, 122 | settings.page * settings.pageSize 123 | ); 124 | return { results, allProcessedResults, paginatedResults }; 125 | }; 126 | 127 | export default postProcessResults; 128 | -------------------------------------------------------------------------------- /src/components/LivePreview.tsx: -------------------------------------------------------------------------------- 1 | // TODO POST MIGRATE - See if we could reuse the Live Preview from workbench instead 2 | import { Button, Tooltip } from "@blueprintjs/core"; 3 | import React, { 4 | useCallback, 5 | useEffect, 6 | useMemo, 7 | useRef, 8 | useState, 9 | } from "react"; 10 | import ReactDOM from "react-dom"; 11 | import getChildrenLengthByPageUid from "roamjs-components/queries/getChildrenLengthByPageUid"; 12 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 13 | import { render as renderReferenceContext } from "./ReferenceContext"; 14 | import isDiscourseNode from "../utils/isDiscourseNode"; 15 | 16 | const sizes = [300, 400, 500, 600]; 17 | 18 | const TooltipContent = ({ 19 | tag, 20 | open, 21 | close, 22 | }: { 23 | tag: string; 24 | open: (e: boolean) => void; 25 | close: () => void; 26 | }) => { 27 | const uid = useMemo(() => getPageUidByPageTitle(tag), [tag]); 28 | const numChildren = useMemo(() => getChildrenLengthByPageUid(uid), [uid]); 29 | const [isEmpty, setIsEmpty] = useState(false); 30 | const containerRef = useRef(null); 31 | const [sizeIndex, setSizeIndex] = useState(0); 32 | const size = useMemo(() => sizes[sizeIndex % sizes.length], [sizeIndex]); 33 | useEffect(() => { 34 | document 35 | .getElementById("roamjs-discourse-live-preview-container") 36 | ?.remove?.(); 37 | let newIsEmpty = true; 38 | if ( 39 | numChildren && 40 | containerRef.current && 41 | containerRef.current.parentElement 42 | ) { 43 | const el = document.createElement("div"); 44 | el.id = "roamjs-discourse-live-preview-container"; 45 | window.roamAlphaAPI.ui.components.renderBlock({ 46 | uid, 47 | el, 48 | }); 49 | containerRef.current.appendChild(el); 50 | containerRef.current.parentElement.style.padding = "0"; 51 | newIsEmpty = false; 52 | } 53 | if (isDiscourseNode(uid) && containerRef.current) { 54 | const refs = renderReferenceContext({ title: tag }); 55 | containerRef.current.appendChild(refs); 56 | newIsEmpty = newIsEmpty && !refs.childElementCount; 57 | } 58 | setIsEmpty(newIsEmpty); 59 | }, [uid, containerRef, numChildren, tag, setIsEmpty]); 60 | return ( 61 |
open(e.ctrlKey)} 64 | onMouseLeave={close} 65 | > 66 | {!isEmpty && ( 67 |
90 | ); 91 | }; 92 | 93 | export type Props = { 94 | tag: string; 95 | registerMouseEvents: (a: { 96 | open: (ctrl: boolean) => void; 97 | close: () => void; 98 | span: HTMLSpanElement | null; 99 | }) => void; 100 | }; 101 | 102 | const LivePreview = ({ tag, registerMouseEvents }: Props) => { 103 | const [isOpen, setIsOpen] = useState(false); 104 | const [loaded, setLoaded] = useState(false); 105 | const spanRef = useRef(null); 106 | const openRef = useRef(false); 107 | const timeoutRef = useRef(0); 108 | const open = useCallback( 109 | (ctrlKey: boolean) => { 110 | if (ctrlKey || timeoutRef.current) { 111 | clearTimeout(timeoutRef.current); 112 | timeoutRef.current = window.setTimeout(() => { 113 | setIsOpen(true); 114 | openRef.current = true; 115 | timeoutRef.current = 0; 116 | }, 100); 117 | } 118 | }, 119 | [setIsOpen, timeoutRef, openRef] 120 | ); 121 | const close = useCallback(() => { 122 | clearTimeout(timeoutRef.current); 123 | if (openRef.current) { 124 | timeoutRef.current = window.setTimeout(() => { 125 | setIsOpen(false); 126 | openRef.current = false; 127 | timeoutRef.current = 0; 128 | }, 1000); 129 | } 130 | }, [setIsOpen, timeoutRef, openRef]); 131 | useEffect(() => { 132 | if (!loaded) setLoaded(true); 133 | }, [loaded, setLoaded]); 134 | useEffect(() => { 135 | if (loaded) { 136 | registerMouseEvents({ open, close, span: spanRef.current }); 137 | } 138 | }, [spanRef, loaded, close, open, registerMouseEvents]); 139 | const ref = useRef(null); 140 | useEffect(() => { 141 | ref.current?.reposition(); 142 | }, [tag]); 143 | return ( 144 | } 146 | placement={"right"} 147 | isOpen={isOpen} 148 | ref={ref} 149 | > 150 | 151 | 152 | ); 153 | }; 154 | 155 | export const render = ({ 156 | parent, 157 | ...props 158 | }: { 159 | parent: HTMLSpanElement; 160 | } & Props) => ReactDOM.render(, parent); 161 | 162 | export default LivePreview; 163 | -------------------------------------------------------------------------------- /src/utils/triplesToBlocks.ts: -------------------------------------------------------------------------------- 1 | import type { InputTextNode } from "roamjs-components/types"; 2 | import { Condition } from "./types"; 3 | 4 | // TODO - this needs to be massively reworked to incorporate inverse functions on the conditionToDatalog mapping itself 5 | // similar to `update` on defaultSelections.ts. Something like: 6 | // const deserializeBlock = ({ conditions, target }: { target: string, conditions: Condition[] }) => 7 | // conditions.reduce((graph, condition) => { 8 | // }, {}); 9 | const triplesToBlocks = 10 | ({ 11 | defaultPageTitle, 12 | toPage, 13 | nodeFormatsByLabel = {}, 14 | }: { 15 | defaultPageTitle: string; 16 | toPage: (title: string, blocks: InputTextNode[]) => Promise; 17 | nodeSpecificationsByLabel?: Record; 18 | nodeFormatsByLabel?: Record; 19 | }) => 20 | ( 21 | triples: { 22 | source: string; 23 | target: string; 24 | relation: string; 25 | }[] 26 | ) => 27 | () => { 28 | const relationToTitle = (source: string) => { 29 | const rel = triples.find( 30 | (h) => 31 | h.source === source && 32 | [/is a/i, /has title/i, /with text/i, /with uid/i].some((r) => 33 | r.test(h.relation) 34 | ) 35 | ) || { 36 | relation: "", 37 | target: "", 38 | }; 39 | return /is a/i.test(rel.relation) 40 | ? { 41 | text: (nodeFormatsByLabel[rel.target] || "") 42 | .replace("{content}", `This is a ${rel.target} page.`) 43 | .replace(".+", "This is a page of any node type"), 44 | isPage: true, 45 | } 46 | : /has title/i.test(rel.relation) 47 | ? { text: rel.target, isPage: true } 48 | : /with text/i.test(rel.relation) 49 | ? { text: rel.target, isPage: false } 50 | : /with uid/i.test(rel.relation) 51 | ? { text: rel.target, isPage: false } 52 | : { text: source, isPage: true }; 53 | }; 54 | const blockReferences = new Set<{ 55 | uid: string; 56 | text: string; 57 | }>(); 58 | const toBlock = (source: string): InputTextNode => ({ 59 | text: `${[ 60 | ...triples 61 | .filter((e) => /with text/i.test(e.relation) && e.source === source) 62 | .map((e) => e.target), 63 | ...triples 64 | .filter((e) => /references/i.test(e.relation) && e.source === source) 65 | .map((e) => { 66 | const target = relationToTitle(e.target); 67 | if (target.isPage && target.text) return `[[${target.text}]]`; 68 | else if (target.text) return `((${target.text}))`; 69 | const text = triples.find( 70 | (h) => h.source === e.target && /with text/i.test(h.relation) 71 | )?.target; 72 | if (text) { 73 | const uid = window.roamAlphaAPI.util.generateUID(); 74 | blockReferences.add({ uid, text }); 75 | return `((${uid}))`; 76 | } 77 | return "Invalid Reference Target"; 78 | }), 79 | ].join(" ")}`, 80 | children: [ 81 | ...triples 82 | .filter( 83 | (c) => 84 | [/has child/i, /has descendant/i].some((r) => 85 | r.test(c.relation) 86 | ) && c.source === source 87 | ) 88 | .map((c) => toBlock(c.target)), 89 | ...triples 90 | .filter( 91 | (c) => /has ancestor/i.test(c.relation) && c.target === source 92 | ) 93 | .map((c) => toBlock(c.source)), 94 | ], 95 | }); 96 | const pageTriples = triples.filter((e) => /is in page/i.test(e.relation)); 97 | if (pageTriples.length) { 98 | const pages = pageTriples.reduce( 99 | (prev, cur) => ({ 100 | ...prev, 101 | [cur.target]: [...(prev[cur.target] || []), cur.source], 102 | }), 103 | {} as Record 104 | ); 105 | return Promise.all( 106 | Object.entries(pages).map((p) => 107 | toPage( 108 | relationToTitle(p[0]).text || p[0], 109 | p[1].map(toBlock).concat(Array.from(blockReferences)) 110 | ) 111 | ) 112 | ).then(() => Promise.resolve()); 113 | } else { 114 | return toPage( 115 | defaultPageTitle, 116 | Array.from( 117 | triples.reduce( 118 | (prev, cur) => { 119 | if ( 120 | [ 121 | /has attribute/i, 122 | /has child/i, 123 | /references/i, 124 | /with text/i, 125 | /has descendant/i, 126 | ].some((r) => r.test(cur.relation)) 127 | ) { 128 | if (!prev.leaves.has(cur.source)) { 129 | prev.roots.add(cur.source); 130 | } 131 | prev.leaves.add(cur.target); 132 | prev.roots.delete(cur.target); 133 | } else if (/has ancestor/i.test(cur.relation)) { 134 | if (!prev.leaves.has(cur.target)) { 135 | prev.roots.add(cur.target); 136 | } 137 | prev.leaves.add(cur.source); 138 | prev.roots.delete(cur.source); 139 | } 140 | return prev; 141 | }, 142 | { 143 | roots: new Set(), 144 | leaves: new Set(), 145 | } 146 | ).roots 147 | ) 148 | .map(toBlock) 149 | .concat(Array.from(blockReferences)) 150 | ); 151 | } 152 | }; 153 | 154 | export default triplesToBlocks; 155 | -------------------------------------------------------------------------------- /src/utils/getDiscourseContextResults.ts: -------------------------------------------------------------------------------- 1 | // TODO POST MIGRATE - Merge into a single query 2 | import { Result } from "roamjs-components/types/query-builder"; 3 | import findDiscourseNode from "./findDiscourseNode"; 4 | import fireQuery, { FireQueryArgs } from "./fireQuery"; 5 | import getDiscourseNodes, { DiscourseNode } from "./getDiscourseNodes"; 6 | import getDiscourseRelations, { 7 | DiscourseRelation, 8 | } from "./getDiscourseRelations"; 9 | import { OnloadArgs } from "roamjs-components/types"; 10 | 11 | const resultCache: Record>> = {}; 12 | const CACHE_TIMEOUT = 1000 * 60 * 5; 13 | 14 | const getDiscourseContextResults = async ({ 15 | uid, 16 | relations = getDiscourseRelations(), 17 | nodes = getDiscourseNodes(relations), 18 | ignoreCache, 19 | isSamePageEnabled: isSamePageEnabledExternal, 20 | args, 21 | }: { 22 | uid: string; 23 | nodes?: ReturnType; 24 | relations?: ReturnType; 25 | ignoreCache?: true; 26 | isSamePageEnabled?: boolean; 27 | args?: OnloadArgs; 28 | }) => { 29 | const useSamePageFlag = !!args?.extensionAPI.settings.get( 30 | "use-backend-samepage-discourse-context" 31 | ); 32 | const isSamePageEnabled = 33 | isSamePageEnabledExternal ?? useSamePageFlag ?? false; 34 | const discourseNode = findDiscourseNode(uid); 35 | if (!discourseNode) return []; 36 | const nodeType = discourseNode?.type; 37 | const nodeTextByType = Object.fromEntries( 38 | nodes.map(({ type, text }) => [type, text]) 39 | ); 40 | nodeTextByType["*"] = "Any"; 41 | const resultsWithRelation = await Promise.all( 42 | relations 43 | .flatMap((r) => { 44 | const queries = []; 45 | if (r.source === nodeType || r.source === "*") { 46 | queries.push({ 47 | r, 48 | complement: false, 49 | }); 50 | } 51 | if (r.destination === nodeType || r.destination === "*") { 52 | queries.push({ 53 | r, 54 | complement: true, 55 | }); 56 | } 57 | return queries; 58 | }) 59 | .map(({ r, complement: isComplement }) => { 60 | const target = isComplement ? r.source : r.destination; 61 | const text = isComplement ? r.complement : r.label; 62 | const returnNode = nodeTextByType[target]; 63 | const cacheKey = `${uid}~${text}~${target}`; 64 | const conditionUid = window.roamAlphaAPI.util.generateUID(); 65 | const selections = []; 66 | if (r.triples.some((t) => t.some((a) => /context/i.test(a)))) { 67 | selections.push({ 68 | uid: window.roamAlphaAPI.util.generateUID(), 69 | label: "context", 70 | text: `node:${conditionUid}-Context`, 71 | }); 72 | } else if (r.triples.some((t) => t.some((a) => /anchor/i.test(a)))) { 73 | selections.push({ 74 | uid: window.roamAlphaAPI.util.generateUID(), 75 | label: "anchor", 76 | text: `node:${conditionUid}-Anchor`, 77 | }); 78 | } 79 | const relation = { 80 | id: r.id, 81 | text, 82 | target, 83 | isComplement, 84 | }; 85 | const rawResults = 86 | resultCache[cacheKey] && !ignoreCache 87 | ? Promise.resolve(resultCache[cacheKey]) 88 | : fireQuery({ 89 | returnNode, 90 | conditions: [ 91 | { 92 | source: returnNode, 93 | // NOTE! This MUST be the OPPOSITE of `label` 94 | relation: isComplement ? r.label : r.complement, 95 | target: uid, 96 | uid: conditionUid, 97 | type: "clause", 98 | }, 99 | ], 100 | selections, 101 | isSamePageEnabled, 102 | context: { 103 | relationsInQuery: [relation], 104 | customNodes: nodes, 105 | customRelations: relations, 106 | }, 107 | }).then((results) => { 108 | resultCache[cacheKey] = results; 109 | setTimeout(() => { 110 | delete resultCache[cacheKey]; 111 | }, CACHE_TIMEOUT); 112 | return results; 113 | }); 114 | return rawResults.then((results) => ({ 115 | relation: { 116 | text, 117 | isComplement, 118 | target, 119 | id: r.id, 120 | }, 121 | results, 122 | })); 123 | }) 124 | ).catch((e) => { 125 | console.error(e); 126 | return [] as const; 127 | }); 128 | const groupedResults = Object.fromEntries( 129 | resultsWithRelation.map((r) => [ 130 | r.relation.text, 131 | {} as Record< 132 | string, 133 | Partial 134 | >, 135 | ]) 136 | ); 137 | resultsWithRelation.forEach((r) => 138 | r.results 139 | .filter((a) => a.uid !== uid) 140 | .forEach( 141 | (res) => 142 | // TODO POST MIGRATE - set result to its own field 143 | (groupedResults[r.relation.text][res.uid] = { 144 | ...res, 145 | target: nodeTextByType[r.relation.target], 146 | complement: r.relation.isComplement ? 1 : 0, 147 | id: r.relation.id, 148 | }) 149 | ) 150 | ); 151 | return Object.entries(groupedResults).map(([label, results]) => ({ 152 | label, 153 | results, 154 | })); 155 | }; 156 | 157 | export default getDiscourseContextResults; 158 | -------------------------------------------------------------------------------- /src/utils/formatUtils.ts: -------------------------------------------------------------------------------- 1 | // To be removed when format is migrated to specification 2 | // https://github.com/RoamJS/query-builder/issues/189 3 | 4 | import { PullBlock } from "roamjs-components/types"; 5 | import getDiscourseNodes, { DiscourseNode } from "../utils/getDiscourseNodes"; 6 | import compileDatalog from "./compileDatalog"; 7 | import discourseNodeFormatToDatalog from "./discourseNodeFormatToDatalog"; 8 | import createOverlayRender from "roamjs-components/util/createOverlayRender"; 9 | import { render as renderToast } from "roamjs-components/components/Toast"; 10 | import FormDialog from "roamjs-components/components/FormDialog"; 11 | import { QBClause, Result } from "./types"; 12 | import findDiscourseNode from "./findDiscourseNode"; 13 | import extractTag from "roamjs-components/util/extractTag"; 14 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 15 | 16 | type FormDialogProps = Parameters[0]; 17 | const renderFormDialog = createOverlayRender( 18 | "form-dialog", 19 | FormDialog 20 | ); 21 | 22 | export const getNewDiscourseNodeText = async ({ 23 | text, 24 | nodeType, 25 | blockUid, 26 | }: { 27 | text: string; 28 | nodeType: string; 29 | blockUid?: string; 30 | }) => { 31 | const discourseNodes = getDiscourseNodes(); 32 | let newText = text; 33 | if (!text) { 34 | newText = await new Promise((resolve) => { 35 | const nodeName = 36 | discourseNodes.find((n) => n.type === nodeType)?.text || "Discourse"; 37 | renderFormDialog({ 38 | title: `Create ${nodeName} Node`, 39 | fields: { 40 | textField: { 41 | type: "text", 42 | label: `Create ${nodeName} Node`, 43 | }, 44 | }, 45 | onSubmit: (data: Record) => { 46 | resolve(data.textField as string); 47 | }, 48 | onClose: () => { 49 | resolve(""); 50 | }, 51 | isOpen: true, 52 | }); 53 | }); 54 | } 55 | if (!newText) { 56 | renderToast({ 57 | content: "No text provided.", 58 | id: "roamjs-create-discourse-node-dialog-error", 59 | intent: "warning", 60 | }); 61 | } 62 | 63 | const indexedByType = Object.fromEntries( 64 | discourseNodes.map((mi, i) => [mi.type, mi]) 65 | ); 66 | 67 | const format = indexedByType[nodeType]?.format || ""; 68 | const formattedText = format.replace(/{([\w\d-]*)}/g, (_, val) => { 69 | if (/content/i.test(val)) return newText; 70 | const referencedNode = discourseNodes.find(({ text: newText }) => 71 | new RegExp(newText, "i").test(val) 72 | ); 73 | if (referencedNode) { 74 | const referenced = window.roamAlphaAPI.data.fast.q( 75 | `[:find (pull ?r [:node/title :block/string]) :where [?b :block/uid "${blockUid}"] (or-join [?b ?r] (and [?b :block/parents ?p] [?p :block/refs ?r]) (and [?b :block/page ?r])) ${discourseNodeFormatToDatalog( 76 | { 77 | freeVar: "r", 78 | ...referencedNode, 79 | } 80 | ) 81 | .map((c) => compileDatalog(c, 0)) 82 | .join(" ")}]` 83 | )?.[0]?.[0] as PullBlock; 84 | return referenced?.[":node/title"] 85 | ? `[[${referenced?.[":node/title"]}]]` 86 | : referenced?.[":block/string"] || ""; 87 | } 88 | return ""; 89 | }); 90 | return formattedText; 91 | }; 92 | 93 | export const getReferencedNodeInFormat = ({ 94 | uid, 95 | format: providedFormat, 96 | discourseNodes = getDiscourseNodes(), 97 | }: { 98 | uid?: string; 99 | format?: string; 100 | discourseNodes?: DiscourseNode[]; 101 | }) => { 102 | let format = providedFormat; 103 | if (!format) { 104 | const discourseNode = findDiscourseNode(uid); 105 | if (discourseNode) format = discourseNode.format; 106 | } 107 | if (!format) return null; 108 | 109 | const regex = /{([\w\d-]*)}/g; 110 | const matches = [...format.matchAll(regex)]; 111 | 112 | for (const match of matches) { 113 | const val = match[1]; 114 | if (val.toLowerCase() === "context") continue; 115 | 116 | const referencedNode = Object.values(discourseNodes).find(({ text }) => 117 | new RegExp(text, "i").test(val) 118 | ); 119 | 120 | if (referencedNode) return referencedNode; 121 | } 122 | 123 | return null; 124 | }; 125 | 126 | export const findReferencedNodeInText = ({ 127 | text, 128 | discourseNode, 129 | }: { 130 | text: string; 131 | discourseNode: DiscourseNode; 132 | }) => { 133 | // assumes that the referenced node in format has a specification 134 | // which includes: 135 | // has title relation 136 | // a (.*?) pattern in it's target 137 | // eg: Source: /^@(.*?)$/ 138 | 139 | const specification = discourseNode.specification; 140 | const titleCondition = specification.find( 141 | (s): s is QBClause => s.type === "clause" && s.relation === "has title" 142 | ); 143 | if (!titleCondition) return null; 144 | 145 | // Remove leading and trailing slashes and start/end modifiers 146 | const patternStr = titleCondition.target.slice(1, -1).replace(/^\^|\$$/g, ""); 147 | 148 | // Since we assume there's always a (.*?), we replace it with a specific pattern to capture text within [[ ]] 149 | // This assumes (.*?) is meant to capture the relevant content 150 | const modifiedPatternStr = patternStr.replace(/\(\.\*\?\)/, "(.*?)"); 151 | const dynamicPattern = new RegExp(`\\[\\[${modifiedPatternStr}\\]\\]`, "g"); 152 | const match = text.match(dynamicPattern)?.[0] || ""; 153 | if (!match) return null; 154 | 155 | const pageTitle = extractTag(match); 156 | const uid = getPageUidByPageTitle(pageTitle); 157 | 158 | return { 159 | uid, 160 | text: pageTitle, 161 | } as Result; 162 | }; 163 | -------------------------------------------------------------------------------- /src/components/DiscourseNodeCanvasSettings.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | InputGroup, 3 | Label, 4 | Radio, 5 | RadioGroup, 6 | Switch, 7 | Tooltip, 8 | Icon, 9 | ControlGroup, 10 | } from "@blueprintjs/core"; 11 | import React, { useRef, useState, useMemo } from "react"; 12 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 13 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 14 | import setInputSetting from "roamjs-components/util/setInputSetting"; 15 | 16 | export const formatHexColor = (color: string) => { 17 | if (!color) return ""; 18 | const COLOR_TEST = /^[0-9a-f]{6}$/i; 19 | if (color.startsWith("#")) { 20 | // handle legacy color format 21 | return color; 22 | } else if (COLOR_TEST.test(color)) { 23 | return "#" + color; 24 | } 25 | return ""; 26 | }; 27 | 28 | const DiscourseNodeCanvasSettings = ({ uid }: { uid: string }) => { 29 | const tree = useMemo(() => getBasicTreeByParentUid(uid), [uid]); 30 | const [color, setColor] = useState(() => { 31 | const color = getSettingValueFromTree({ tree, key: "color" }); 32 | return formatHexColor(color); 33 | }); 34 | const [alias, setAlias] = useState(() => 35 | getSettingValueFromTree({ tree, key: "alias" }) 36 | ); 37 | const [queryBuilderAlias, setQueryBuilderAlias] = useState(() => 38 | getSettingValueFromTree({ tree, key: "query-builder-alias" }) 39 | ); 40 | const [isKeyImage, setIsKeyImage] = useState( 41 | () => getSettingValueFromTree({ tree, key: "key-image" }) === "true" 42 | ); 43 | const [keyImageOption, setKeyImageOption] = useState(() => 44 | getSettingValueFromTree({ tree, key: "key-image-option" }) 45 | ); 46 | return ( 47 |
48 |
49 | 50 | 51 | { 56 | setColor(e.target.value); 57 | setInputSetting({ 58 | blockUid: uid, 59 | key: "color", 60 | value: e.target.value.replace("#", ""), // remove hash to not create roam link 61 | }); 62 | }} 63 | /> 64 | 65 | { 69 | setColor(""); 70 | setInputSetting({ 71 | blockUid: uid, 72 | key: "color", 73 | value: "", 74 | }); 75 | }} 76 | /> 77 | 78 | 79 |
80 | 94 | { 99 | const target = e.target as HTMLInputElement; 100 | setIsKeyImage(target.checked); 101 | if (target.checked) { 102 | if (!keyImageOption) setKeyImageOption("first-image"); 103 | setInputSetting({ 104 | blockUid: uid, 105 | key: "key-image", 106 | value: "true", 107 | }); 108 | } else { 109 | setInputSetting({ 110 | blockUid: uid, 111 | key: "key-image", 112 | value: "false", 113 | }); 114 | } 115 | }} 116 | > 117 | Key Image 118 | 119 | 124 | 125 | 126 | {/* */} 127 | { 132 | const target = e.target as HTMLInputElement; 133 | setKeyImageOption(target.value); 134 | setInputSetting({ 135 | blockUid: uid, 136 | key: "key-image-option", 137 | value: target.value, 138 | }); 139 | }} 140 | > 141 | 142 | 143 | Query Builder reference 144 | 145 | 150 | 151 | 152 | 153 | { 158 | setQueryBuilderAlias(e.target.value); 159 | setInputSetting({ 160 | blockUid: uid, 161 | key: "query-builder-alias", 162 | value: e.target.value, 163 | }); 164 | }} 165 | /> 166 |
167 | ); 168 | }; 169 | 170 | export default DiscourseNodeCanvasSettings; 171 | -------------------------------------------------------------------------------- /docs/roam-queries.md: -------------------------------------------------------------------------------- 1 | # Native Roam Queries 2 | 3 | In addition to new [RoamJS Query Builder](roamjs-query-builder.md) components, this extension enhances Roam's native querying experience by providing features such as an intuitive UI for creating and editing queries, sorting and randomizing query results, and displaying more context in these results. 4 | 5 | ## Creating Native Roam Queries 6 | 7 | In a block, type `{{qb}}`. 8 | 9 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FSNq4QmaRxy.png?alt=media&token=5b7c1173-da57-4d83-851b-1719edffab02) 10 | 11 | Similar to date picker, there will be an overlay that appears next to the QUERY button. After specifying different query components that you're interested in searching, hit save to insert the query syntax into the block. 12 | 13 | The overlay is fully keyboard accessible. Each input is focusable and you can `tab` and `shift+tab` through them. For the query component dropdown, you could use the following key strokes to navigate: 14 | 15 | - Arrow Up/Arrow Down - Navigate Options 16 | - Enter - Open Dropdown 17 | - a - Select 'AND' 18 | - o - Select 'OR' 19 | - b - Select 'BETWEEN' 20 | - t - Select 'TAG' 21 | - n - Select 'NOT' 22 | 23 | On any deletable component, you could hit `ctrl+Backspace` or `cmd+Backspace` to delete the icon. Hitting `enter` on the save button will output the query into the block. 24 | 25 | There will also be an edit button rendered on any existing query. Clicking the edit icon will overlay the builder to edit the existing query! 26 | 27 | ![](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FPjJhLRaE28.png?alt=media&token=a94026f9-b7f7-494b-af74-15c93aa0f500) 28 | 29 | ## Manipulating Native Roam Queries 30 | 31 | The legacy Query Tools extension was merged with this one to bring all native query manipulation features under Query Builder. These features could be configured within the Roam Depot Settings for Query Builder. 32 | 33 | - `Default Sort` - The default sorting all native queries in your graph should use 34 | - `Sort Blocks` - If set to 'True', sort the query results by blocks instead of pages. 35 | - `Context` - The default value for Context for all queries. See below. 36 | 37 | 38 | 39 | [Direct Link to Video](https://firebasestorage.googleapis.com/v0/b/firescript-577a2.appspot.com/o/imgs%2Fapp%2Froamjs%2FKSJOK_DMOD.mp4?alt=media&token=4ffea2b3-c6d8-4ec6-aa39-7186333a4be2) 40 | 41 | ### Sorting 42 | 43 | On expanded queries, there will be a sort icon that appears next to the results text. Clicking on the sort icon will make a sort menu visible to the user. 44 | 45 | To persist a particular sort on a query, create a block on the page with the text `Default Sort`. Nest the value under that block. 46 | 47 | To configure sorting by blocks on a single query instead of on all queries in your DB, add a block that says `Sort Blocks` as a child to the specific query. 48 | 49 | Here are the options you can sort by: 50 | 51 | | Option | Description | 52 | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | 53 | | Sort By Created Date | This will sort all the query results in ascending order that the page was created. | 54 | | Sort By Created Date Descending | This will sort all the query results in descending order that the page was created. | 55 | | Sort By Daily Note | This will sort all the query results in ascending order by Daily Note, followed by created date of non-daily note pages. | 56 | | Sort By Daily Note Descending | This will sort all the query results in descending order by Daily Note, followed by created date of non-daily note pages. | 57 | | Sort By Edited Date | This will sort all the query results in ascending order that the page was last edited. | 58 | | Sort By Edited Date Descending | This will sort all the query results in descending order that the page was last edited. | 59 | | Sort By Page Title | This will sort all the query results in ascending alphabetical order of the page title. | 60 | | Sort By Page Title Descending | This will sort all the query results in descending alphabetical order of the page title. | 61 | | Sort By Word Count | This will sort all the query results in ascending order of the word count. | 62 | | Sort By Word Count Descending | This will sort all the query results in descending order of the word count. | 63 | 64 | ### Randomization 65 | 66 | Sometimes we have queries with hundreds of results and want to return a random element from that query. Returning random results from multiple queries could lead to serendipitous connections. To return a random result from a query, add a block that says `Random` as a child of the query. Nest the value representing the number of random results you'd like returned from the query. 67 | 68 | ### Context 69 | 70 | By default, query results only display the most nested block in the result. To display more context in a given query, add a block that says `Context` as a child block of the query. Set the value to the number of levels you'd like displayed, or `Top` to display full context, as a child of this Context block. 71 | 72 | ### Aliases 73 | 74 | By default, query results display the query logic itself for the label. To display an alias for the given query, add a block that says Alias as a child block of the query, with the value of the alias nested below that. 75 | -------------------------------------------------------------------------------- /src/utils/parseResultSettings.ts: -------------------------------------------------------------------------------- 1 | import { Filters } from "roamjs-components/components/Filter"; 2 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 3 | import { OnloadArgs, RoamBasicNode } from "roamjs-components/types/native"; 4 | import getSettingIntFromTree from "roamjs-components/util/getSettingIntFromTree"; 5 | import getSubTree from "roamjs-components/util/getSubTree"; 6 | import toFlexRegex from "roamjs-components/util/toFlexRegex"; 7 | import { StoredFilters } from "../components/DefaultFilters"; 8 | import { Column } from "./types"; 9 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 10 | import getSettingValuesFromTree from "roamjs-components/util/getSettingValuesFromTree"; 11 | 12 | export type Sorts = { key: string; descending: boolean }[]; 13 | export type FilterData = Record; 14 | export type Views = { 15 | column: string; 16 | mode: string; 17 | value: string; 18 | }[]; 19 | 20 | const getFilterEntries = ( 21 | n: Pick 22 | ): [string, Filters][] => 23 | n.children.map((c) => [ 24 | c.text, 25 | { 26 | includes: { 27 | values: new Set( 28 | getSubTree({ tree: c.children, key: "includes" }).children.map( 29 | (t) => t.text 30 | ) 31 | ), 32 | }, 33 | excludes: { 34 | values: new Set( 35 | getSubTree({ tree: c.children, key: "excludes" }).children.map( 36 | (t) => t.text 37 | ) 38 | ), 39 | }, 40 | uid: c.uid, 41 | }, 42 | ]); 43 | 44 | const getSettings = (extensionAPI?: OnloadArgs["extensionAPI"]) => { 45 | return { 46 | globalFiltersData: Object.fromEntries( 47 | Object.entries( 48 | (extensionAPI?.settings.get("default-filters") as Record< 49 | string, 50 | StoredFilters 51 | >) || {} 52 | ).map(([k, v]) => [ 53 | k, 54 | { 55 | includes: Object.fromEntries( 56 | Object.entries(v.includes || {}).map(([k, v]) => [k, new Set(v)]) 57 | ), 58 | excludes: Object.fromEntries( 59 | Object.entries(v.excludes || {}).map(([k, v]) => [k, new Set(v)]) 60 | ), 61 | }, 62 | ]) 63 | ), 64 | globalPageSize: 65 | Number(extensionAPI?.settings.get("default-page-size")) || 10, 66 | }; 67 | }; 68 | 69 | const parseResultSettings = ( 70 | // TODO - this should be the resultNode uid 71 | parentUid: string, 72 | columns: Column[], 73 | extensionAPI?: OnloadArgs["extensionAPI"] 74 | ) => { 75 | const { globalFiltersData, globalPageSize } = getSettings(extensionAPI); 76 | const tree = getBasicTreeByParentUid(parentUid); 77 | const resultNode = getSubTree({ tree, key: "results" }); 78 | const sortsNode = getSubTree({ tree: resultNode.children, key: "sorts" }); 79 | const filtersNode = getSubTree({ tree: resultNode.children, key: "filters" }); 80 | const columnFiltersNode = getSubTree({ 81 | tree: resultNode.children, 82 | key: "columnFilters", 83 | }); 84 | const searchFilterNode = getSubTree({ 85 | tree: resultNode.children, 86 | key: "searchFilter", 87 | }); 88 | const interfaceNode = getSubTree({ 89 | tree: resultNode.children, 90 | key: "interface", 91 | }); 92 | const filterEntries = getFilterEntries(filtersNode); 93 | const savedFilterData = filterEntries.length 94 | ? Object.fromEntries(filterEntries) 95 | : globalFiltersData; 96 | const random = getSettingIntFromTree({ 97 | tree: resultNode.children, 98 | key: "random", 99 | }); 100 | const pageSize = 101 | getSettingIntFromTree({ tree: resultNode.children, key: "size" }) || 102 | globalPageSize; 103 | const viewsNode = getSubTree({ tree: resultNode.children, key: "views" }); 104 | const savedViewData = Object.fromEntries( 105 | viewsNode.children.map((c) => [ 106 | c.text, 107 | { 108 | mode: c.children[0]?.text, 109 | value: c.children[0]?.children?.[0]?.text || "", 110 | }, 111 | ]) 112 | ); 113 | const layoutNode = getSubTree({ 114 | tree: resultNode.children, 115 | key: "layout", 116 | }); 117 | const layout = Object.fromEntries( 118 | layoutNode.children 119 | .filter((c) => c.children.length) 120 | .map((c) => [ 121 | c.text, 122 | c.children.length === 1 123 | ? c.children[0].text 124 | : c.children.map((cc) => cc.text), 125 | ]) 126 | ); 127 | if (!layout.mode) 128 | layout.mode = 129 | layoutNode.children[0]?.children.length === 0 130 | ? layoutNode.children[0].text 131 | : "table"; 132 | layout.uid = layoutNode.uid; 133 | return { 134 | resultNodeUid: resultNode.uid, 135 | activeSort: sortsNode.children.map((s) => ({ 136 | key: s.text, 137 | descending: toFlexRegex("true").test(s.children[0]?.text || ""), 138 | })), 139 | searchFilter: searchFilterNode.children[0]?.text, 140 | showInterface: interfaceNode.children[0]?.text !== "hide", 141 | filters: Object.fromEntries( 142 | columns.map(({ key }) => [ 143 | key, 144 | savedFilterData[key] || { 145 | includes: { values: new Set() }, 146 | excludes: { values: new Set() }, 147 | }, 148 | ]) 149 | ), 150 | columnFilters: columnFiltersNode.children.map((c) => { 151 | return { 152 | key: c.text, 153 | uid: c.uid, 154 | value: getSettingValuesFromTree({ tree: c.children, key: "value" }), 155 | type: getSettingValueFromTree({ tree: c.children, key: "type" }), 156 | }; 157 | }), 158 | views: columns.map(({ key: column }) => ({ 159 | column, 160 | mode: 161 | savedViewData[column]?.mode || (column === "text" ? "link" : "plain"), 162 | value: savedViewData[column]?.value || "", 163 | })), 164 | random, 165 | pageSize, 166 | layout, 167 | page: 1, // TODO save in roam data 168 | }; 169 | }; 170 | 171 | export default parseResultSettings; 172 | -------------------------------------------------------------------------------- /src/utils/createDiscourseNode.ts: -------------------------------------------------------------------------------- 1 | import { render as renderToast } from "roamjs-components/components/Toast"; 2 | import createBlock from "roamjs-components/writes/createBlock"; 3 | import stripUid from "roamjs-components/util/stripUid"; 4 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 5 | import createPage from "roamjs-components/writes/createPage"; 6 | import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; 7 | import getSubTree from "roamjs-components/util/getSubTree"; 8 | import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar"; 9 | import getDiscourseNodes from "./getDiscourseNodes"; 10 | import isFlagEnabled from "./isFlagEnabled"; 11 | import resolveQueryBuilderRef from "./resolveQueryBuilderRef"; 12 | import { OnloadArgs, RoamBasicNode } from "roamjs-components/types"; 13 | import runQuery from "./runQuery"; 14 | import updateBlock from "roamjs-components/writes/updateBlock"; 15 | 16 | type Props = { 17 | text: string; 18 | configPageUid: string; 19 | newPageUid?: string; 20 | imageUrl?: string; 21 | extensionAPI?: OnloadArgs["extensionAPI"]; 22 | }; 23 | 24 | const createDiscourseNode = async ({ 25 | text, 26 | configPageUid, 27 | newPageUid, 28 | imageUrl, 29 | extensionAPI, 30 | }: Props) => { 31 | const handleOpenInSidebar = (uid: string) => { 32 | if (isFlagEnabled("disable sidebar open")) return; 33 | openBlockInSidebar(uid); 34 | setTimeout(() => { 35 | const sidebarTitle = document.querySelector( 36 | ".rm-sidebar-outline .rm-title-display" 37 | ); 38 | sidebarTitle?.dispatchEvent( 39 | new MouseEvent("mousedown", { bubbles: true }) 40 | ); 41 | setTimeout(() => { 42 | const ta = document.activeElement as HTMLTextAreaElement; 43 | if (ta.tagName === "TEXTAREA") { 44 | const index = ta.value.length; 45 | ta.setSelectionRange(index, index); 46 | } 47 | }, 1); 48 | }, 100); 49 | }; 50 | const handleImageCreation = async (pageUid: string) => { 51 | const canvasSettings = Object.fromEntries( 52 | discourseNodes.map((n) => [n.type, { ...n.canvasSettings }]) 53 | ); 54 | const { 55 | "query-builder-alias": qbAlias = "", 56 | "key-image": isKeyImage = "", 57 | "key-image-option": keyImageOption = "", 58 | } = canvasSettings[configPageUid] || {}; 59 | 60 | if (isKeyImage && imageUrl) { 61 | const createOrUpdateImageBlock = async (imagePlaceholderUid?: string) => { 62 | const imageMarkdown = `![](${imageUrl})`; 63 | if (imagePlaceholderUid) { 64 | await updateBlock({ 65 | uid: imagePlaceholderUid, 66 | text: imageMarkdown, 67 | }); 68 | } else { 69 | await createBlock({ 70 | node: { text: imageMarkdown }, 71 | order: 0, 72 | parentUid: pageUid, 73 | }); 74 | } 75 | }; 76 | 77 | if (keyImageOption === "query-builder") { 78 | if (!extensionAPI) return; 79 | 80 | const parentUid = resolveQueryBuilderRef({ 81 | queryRef: qbAlias, 82 | extensionAPI, 83 | }); 84 | const results = await runQuery({ 85 | extensionAPI, 86 | parentUid, 87 | inputs: { NODETEXT: text, NODEUID: pageUid }, 88 | }); 89 | const imagePlaceholderUid = results.allProcessedResults[0]?.uid; 90 | await createOrUpdateImageBlock(imagePlaceholderUid); 91 | } else { 92 | await createOrUpdateImageBlock(); 93 | } 94 | } 95 | }; 96 | 97 | const discourseNodes = getDiscourseNodes(); 98 | const specification = discourseNodes?.find( 99 | (n) => n.type === configPageUid 100 | )?.specification; 101 | // This handles blck-type and creates block in the DNP 102 | // but could have unintended consequences for other defined discourse nodes 103 | if ( 104 | specification?.find( 105 | (spec) => spec.type === "clause" && spec.relation === "is in page" 106 | ) 107 | ) { 108 | const blockUid = await createBlock({ 109 | // TODO: for canvas, create in `Auto generated from ${title}` 110 | parentUid: window.roamAlphaAPI.util.dateToPageUid(new Date()), 111 | node: { text, uid: newPageUid }, 112 | }); 113 | handleOpenInSidebar(blockUid); 114 | return blockUid; 115 | } 116 | 117 | let pageUid: string; 118 | if (newPageUid) { 119 | await createPage({ title: text, uid: newPageUid }); 120 | pageUid = newPageUid; 121 | } else { 122 | pageUid = 123 | getPageUidByPageTitle(text) || (await createPage({ title: text })); 124 | } 125 | 126 | const nodeTree = getFullTreeByParentUid(configPageUid).children; 127 | const templateNode = getSubTree({ 128 | tree: nodeTree, 129 | key: "template", 130 | }); 131 | 132 | const createBlocksFromTemplate = async () => { 133 | await Promise.all( 134 | stripUid(templateNode.children).map(({ uid, ...node }, order) => 135 | createBlock({ 136 | node, 137 | order, 138 | parentUid: pageUid, 139 | }) 140 | ) 141 | ); 142 | 143 | // Add image to page if imageUrl is provided 144 | await handleImageCreation(pageUid); 145 | }; 146 | 147 | const hasSmartBlockSyntax = (node: RoamBasicNode) => { 148 | if (node.text.includes("<%")) return true; 149 | if (node.children) return node.children.some(hasSmartBlockSyntax); 150 | return false; 151 | }; 152 | const useSmartBlocks = hasSmartBlockSyntax(templateNode); 153 | 154 | if (useSmartBlocks && !window.roamjs?.extension?.smartblocks) { 155 | renderToast({ 156 | content: 157 | "This template requires SmartBlocks. Enable SmartBlocks in Roam Depot to use this template.", 158 | id: "smartblocks-extension-disabled", 159 | intent: "warning", 160 | }); 161 | await createBlocksFromTemplate(); 162 | } else if (useSmartBlocks && window.roamjs?.extension?.smartblocks) { 163 | window.roamjs.extension.smartblocks?.triggerSmartblock({ 164 | srcUid: templateNode.uid, 165 | targetUid: pageUid, 166 | }); 167 | await handleImageCreation(pageUid); 168 | } else { 169 | await createBlocksFromTemplate(); 170 | } 171 | handleOpenInSidebar(pageUid); 172 | return pageUid; 173 | }; 174 | 175 | export default createDiscourseNode; 176 | -------------------------------------------------------------------------------- /src/components/DefaultFilters.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Intent, InputGroup } from "@blueprintjs/core"; 2 | import React, { useEffect, useState } from "react"; 3 | import type { OnloadArgs } from "roamjs-components/types"; 4 | import type { Filters } from "roamjs-components/components/Filter"; 5 | 6 | export type StoredFilters = { 7 | includes: { values: string[] }; 8 | excludes: { values: string[] }; 9 | }; 10 | 11 | const Filter = ({ 12 | column, 13 | data, 14 | onFilterAdd, 15 | onFilterRemove, 16 | onRemove, 17 | }: { 18 | column: string; 19 | data: Filters; 20 | onFilterAdd: (args: { text: string; type: "includes" | "excludes" }) => void; 21 | onFilterRemove: (args: { 22 | text: string; 23 | type: "includes" | "excludes"; 24 | }) => void; 25 | onRemove: () => void; 26 | }) => { 27 | const [newFilter, setNewFilter] = useState(""); 28 | return ( 29 |
37 |

{column}

38 |
39 |
    40 | {Array.from(data.includes.values || new Set()).map((v) => ( 41 |
  • onFilterRemove({ text: v, type: "includes" })} 44 | style={{ cursor: "pointer" }} 45 | > 46 | {v} 47 |
  • 48 | ))} 49 |
50 |
    51 | {Array.from(data.excludes.values || new Set()).map((v) => ( 52 |
  • onFilterRemove({ text: v, type: "excludes" })} 55 | style={{ cursor: "pointer" }} 56 | > 57 | {v} 58 |
  • 59 | ))} 60 |
61 |
62 |
63 | setNewFilter(e.target.value)} 67 | /> 68 |
72 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | const DefaultFilters = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { 96 | const [newColumn, setNewColumn] = useState(""); 97 | const [filters, setFilters] = useState(() => 98 | Object.fromEntries( 99 | Object.entries( 100 | (extensionAPI.settings.get("default-filters") as Record< 101 | string, 102 | StoredFilters 103 | >) || {} 104 | ).map(([k, v]) => [ 105 | k, 106 | { 107 | includes: Object.fromEntries( 108 | Object.entries(v.includes || {}).map(([k, v]) => [k, new Set(v)]) 109 | ), 110 | excludes: Object.fromEntries( 111 | Object.entries(v.excludes || {}).map(([k, v]) => [k, new Set(v)]) 112 | ), 113 | }, 114 | ]) 115 | ) 116 | ); 117 | 118 | useEffect(() => { 119 | extensionAPI.settings.set( 120 | "default-filters", 121 | Object.fromEntries( 122 | Object.entries(filters).map(([k, v]) => [ 123 | k, 124 | { 125 | includes: Object.fromEntries( 126 | Object.entries(v.includes || {}).map(([k, v]) => [k, Array.from(v)]) 127 | ), 128 | excludes: Object.fromEntries( 129 | Object.entries(v.excludes || {}).map(([k, v]) => [k, Array.from(v)]) 130 | ), 131 | }, 132 | ]) 133 | ) 134 | ); 135 | }, [filters]); 136 | return ( 137 |
142 | {Object.entries(filters).map(([column, data]) => ( 143 | { 148 | data[type].values.add(text); 149 | const newFilters = { 150 | ...filters, 151 | }; 152 | setFilters(newFilters); 153 | }} 154 | onFilterRemove={({ text, type }) => { 155 | data[type].values.delete(text); 156 | const newFilters = { 157 | ...filters, 158 | }; 159 | setFilters(newFilters); 160 | }} 161 | onRemove={() => { 162 | const newFilters = Object.fromEntries( 163 | Object.entries(filters).filter(([col]) => col !== column) 164 | ); 165 | setFilters(newFilters); 166 | }} 167 | /> 168 | ))} 169 |
170 | setNewColumn(e.target.value)} 174 | /> 175 |
193 |
194 | ); 195 | }; 196 | 197 | export default DefaultFilters; 198 | -------------------------------------------------------------------------------- /src/components/tldraw/CanvasDrawer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from "react"; 2 | import ResizableDrawer from "../ResizableDrawer"; 3 | import renderOverlay from "roamjs-components/util/renderOverlay"; 4 | import { Button, Collapse, Checkbox } from "@blueprintjs/core"; 5 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; 6 | import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; 7 | import getDiscourseNodes from "../../utils/getDiscourseNodes"; 8 | import getCurrentPageUid from "roamjs-components/dom/getCurrentPageUid"; 9 | import getBlockProps from "../../utils/getBlockProps"; 10 | import { TLBaseShape } from "@tldraw/tldraw"; 11 | import { DiscourseNodeShape } from "./DiscourseNodeUtil"; 12 | 13 | export type GroupedShapes = Record; 14 | 15 | type Props = { 16 | groupedShapes: GroupedShapes; 17 | pageUid: string; 18 | }; 19 | 20 | const CanvasDrawerContent = ({ groupedShapes, pageUid }: Props) => { 21 | const [openSections, setOpenSections] = useState>({}); 22 | const [showDuplicates, setShowDuplicates] = useState(false); 23 | const [filterType, setFilterType] = useState("All"); 24 | const [filteredShapes, setFilteredShapes] = useState({}); 25 | 26 | const pageTitle = useMemo(() => getPageTitleByPageUid(pageUid), []); 27 | const noResults = Object.keys(groupedShapes).length === 0; 28 | const typeToTitleMap = useMemo(() => { 29 | const nodes = getDiscourseNodes(); 30 | const map: { [key: string]: string } = {}; 31 | nodes.forEach((node) => { 32 | map[node.type] = node.text; 33 | }); 34 | return map; 35 | }, []); 36 | const shapeTypes = useMemo(() => { 37 | const allTypes = new Set(["All"]); 38 | Object.values(groupedShapes).forEach((shapes) => 39 | shapes.forEach((shape) => 40 | allTypes.add(typeToTitleMap[shape.type] || shape.type) 41 | ) 42 | ); 43 | return Array.from(allTypes); 44 | }, [groupedShapes, typeToTitleMap]); 45 | const hasDuplicates = useMemo(() => { 46 | return Object.values(groupedShapes).some((shapes) => shapes.length > 1); 47 | }, [groupedShapes]); 48 | 49 | useEffect(() => { 50 | const filtered = Object.entries(groupedShapes).reduce( 51 | (acc, [uid, shapes]) => { 52 | const filteredShapes = shapes.filter( 53 | (shape) => 54 | filterType === "All" || typeToTitleMap[shape.type] === filterType 55 | ); 56 | if ( 57 | filteredShapes.length > 0 && 58 | (!showDuplicates || filteredShapes.length > 1) 59 | ) { 60 | acc[uid] = filteredShapes; 61 | } 62 | return acc; 63 | }, 64 | {} 65 | ); 66 | setFilteredShapes(filtered); 67 | }, [groupedShapes, showDuplicates, filterType, typeToTitleMap]); 68 | 69 | const toggleCollapse = (uid: string) => { 70 | setOpenSections((prevState) => ({ 71 | ...prevState, 72 | [uid]: !prevState[uid], 73 | })); 74 | }; 75 | const moveCameraToShape = (shapeId: string) => { 76 | document.dispatchEvent( 77 | new CustomEvent("roamjs:query-builder:action", { 78 | detail: { 79 | action: "move-camera-to-shape", 80 | shapeId, 81 | }, 82 | }) 83 | ); 84 | }; 85 | 86 | return ( 87 |
88 |
89 | setFilterType(type)} 91 | activeItem={filterType} 92 | items={shapeTypes} 93 | /> 94 | {hasDuplicates && ( 95 | setShowDuplicates(!showDuplicates)} 99 | /> 100 | )} 101 |
102 | {noResults ? ( 103 |
No nodes found for {pageTitle}
104 | ) : ( 105 | Object.entries(filteredShapes).map(([uid, shapes]) => { 106 | const title = shapes[0].props.title; 107 | const isExpandable = shapes.length > 1; 108 | return ( 109 |
110 | 128 | 129 |
130 | {shapes.map((shape) => ( 131 | 142 | ))} 143 |
144 |
145 |
146 | ); 147 | }) 148 | )} 149 |
150 | ); 151 | }; 152 | 153 | const CanvasDrawer = ({ 154 | onClose, 155 | ...props 156 | }: { onClose: () => void } & Props) => ( 157 | 158 | 159 | 160 | ); 161 | 162 | export const openCanvasDrawer = () => { 163 | const pageUid = getCurrentPageUid(); 164 | const props = getBlockProps(pageUid) as Record; 165 | const rjsqb = props["roamjs-query-builder"] as Record; 166 | const tldraw = (rjsqb?.tldraw as Record) || {}; 167 | const shapes = Object.values(tldraw).filter((s) => { 168 | const shape = s as TLBaseShape; 169 | const uid = shape.props?.uid; 170 | return !!uid; 171 | }) as DiscourseNodeShape[]; 172 | 173 | const groupShapesByUid = (shapes: DiscourseNodeShape[]) => { 174 | const groupedShapes = shapes.reduce((acc: GroupedShapes, shape) => { 175 | const uid = shape.props.uid; 176 | if (!acc[uid]) acc[uid] = []; 177 | acc[uid].push(shape); 178 | return acc; 179 | }, {}); 180 | 181 | return groupedShapes; 182 | }; 183 | 184 | const groupedShapes = groupShapesByUid(shapes); 185 | renderOverlay({ Overlay: CanvasDrawer, props: { groupedShapes, pageUid } }); 186 | }; 187 | 188 | export default CanvasDrawer; 189 | -------------------------------------------------------------------------------- /src/components/QueryPage.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Spinner } from "@blueprintjs/core"; 2 | import React, { 3 | useCallback, 4 | useEffect, 5 | useMemo, 6 | useRef, 7 | useState, 8 | } from "react"; 9 | import fireQuery from "../utils/fireQuery"; 10 | import parseQuery from "../utils/parseQuery"; 11 | import type { Result } from "roamjs-components/types/query-builder"; 12 | import ResultsView from "./ResultsView"; 13 | import ReactDOM from "react-dom"; 14 | import QueryEditor from "./QueryEditor"; 15 | import getSubTree from "roamjs-components/util/getSubTree"; 16 | import { createComponentRender } from "roamjs-components/components/ComponentContainer"; 17 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 18 | import createBlock from "roamjs-components/writes/createBlock"; 19 | import deleteBlock from "roamjs-components/writes/deleteBlock"; 20 | import { OnloadArgs } from "roamjs-components/types/native"; 21 | import ExtensionApiContextProvider, { 22 | useExtensionAPI, 23 | } from "roamjs-components/components/ExtensionApiContext"; 24 | import { Column, ExportTypes } from "../utils/types"; 25 | 26 | type QueryPageComponent = (props: { 27 | pageUid: string; 28 | isEditBlock?: boolean; 29 | showAlias?: boolean; 30 | }) => JSX.Element; 31 | 32 | type Props = Parameters[0]; 33 | 34 | const QueryPage = ({ pageUid, isEditBlock, showAlias }: Props) => { 35 | const extensionAPI = useExtensionAPI(); 36 | const hideMetadata = useMemo( 37 | () => !!extensionAPI && !!extensionAPI.settings.get("hide-metadata"), 38 | [extensionAPI] 39 | ); 40 | const tree = useMemo(() => getBasicTreeByParentUid(pageUid), [pageUid]); 41 | const [isEdit, _setIsEdit] = useState( 42 | () => !!getSubTree({ tree, key: "editing" }).uid 43 | ); 44 | const [hasResults, setHasResults] = useState( 45 | () => !!getSubTree({ tree, key: "results" }).uid 46 | ); 47 | const setIsEdit = useCallback( 48 | (b: boolean) => { 49 | _setIsEdit(b); 50 | return b 51 | ? createBlock({ 52 | parentUid: pageUid, 53 | node: { text: "editing" }, 54 | order: 2, 55 | }) 56 | : deleteBlock(getSubTree({ parentUid: pageUid, key: "editing" }).uid); 57 | }, 58 | [pageUid] 59 | ); 60 | const [loading, setLoading] = useState(false); 61 | const [error, setError] = useState(""); 62 | const [columns, setColumns] = useState([]); 63 | const [results, setResults] = useState([]); 64 | const containerRef = useRef(null); 65 | const onRefresh = useCallback( 66 | (loadInBackground = false) => { 67 | setError(""); 68 | setLoading(!loadInBackground); 69 | const args = parseQuery(pageUid); 70 | setTimeout(() => { 71 | fireQuery(args) 72 | .then((results) => { 73 | setColumns(args.columns); 74 | setResults(results); 75 | }) 76 | .catch(() => { 77 | setError( 78 | `Query failed to run. Try running a new query from the editor.` 79 | ); 80 | }) 81 | .finally(() => { 82 | const tree = getBasicTreeByParentUid(pageUid); 83 | const node = getSubTree({ tree, key: "results" }); 84 | return ( 85 | node.uid 86 | ? Promise.resolve(node.uid) 87 | : createBlock({ 88 | parentUid: pageUid, 89 | node: { text: "results" }, 90 | }) 91 | ).then(() => { 92 | setLoading(false); 93 | }); 94 | }); 95 | }, 1); 96 | }, 97 | [setResults, pageUid, setLoading, setColumns] 98 | ); 99 | useEffect(() => { 100 | if (!isEdit) { 101 | if (hasResults) { 102 | onRefresh(); 103 | } else { 104 | setIsEdit(true); 105 | } 106 | } 107 | }, [isEdit, onRefresh, setIsEdit, hasResults]); 108 | useEffect(() => { 109 | const roamBlock = containerRef.current?.closest(".rm-block-main"); 110 | if (roamBlock) { 111 | const sep = roamBlock.querySelector( 112 | ".rm-block-separator" 113 | ); 114 | if (sep) { 115 | sep.style.minWidth = "0"; 116 | } 117 | } 118 | }, []); 119 | useEffect(() => { 120 | const main = 121 | containerRef.current?.closest(".rm-block-main") || 122 | containerRef.current?.closest(".roamjs-query-page")?.parentElement; 123 | if ( 124 | main && 125 | main.nextElementSibling && 126 | main.nextElementSibling.classList.contains("rm-block-children") 127 | ) { 128 | main.nextElementSibling.classList.add("roamjs-query-builder-metadata"); 129 | } 130 | const container = containerRef.current?.closest( 131 | "div.roamjs-query-builder-parent" 132 | ); 133 | if (container) { 134 | container.style.width = "unset"; 135 | } 136 | }, []); 137 | return ( 138 | 142 |
143 | {hideMetadata && ( 144 | 149 | )} 150 | {isEdit && ( 151 | <> 152 | { 155 | setHasResults(true); 156 | setIsEdit(false); 157 | }} 158 | setHasResults={() => { 159 | setHasResults(true); 160 | onRefresh(); 161 | }} 162 | showAlias={showAlias} 163 | /> 164 | 165 | )} 166 | {loading ? ( 167 |

168 | Loading Results... 169 |

170 | ) : hasResults ? ( 171 | setIsEdit(true)} 174 | header={ 175 | error ? ( 176 |
{error}
177 | ) : undefined 178 | } 179 | columns={columns} 180 | results={results.map(({ id, ...a }) => a)} 181 | onRefresh={onRefresh} 182 | isEditBlock={isEditBlock} 183 | onDeleteQuery={() => deleteBlock(pageUid)} 184 | /> 185 | ) : ( 186 | <> 187 | )} 188 |
189 |
190 | ); 191 | }; 192 | 193 | export const renderQueryBlock = createComponentRender( 194 | ({ blockUid }) => , 195 | "roamjs-query-builder-parent" 196 | ); 197 | 198 | export const render = ({ 199 | parent, 200 | onloadArgs, 201 | ...props 202 | }: { parent: HTMLElement; onloadArgs: OnloadArgs } & Props) => 203 | ReactDOM.render( 204 | 205 | 206 | , 207 | parent 208 | ); 209 | 210 | export default QueryPage; 211 | -------------------------------------------------------------------------------- /src/data/defaultDiscourseRelations.ts: -------------------------------------------------------------------------------- 1 | import type { InputTextNode } from "roamjs-components/types/native"; 2 | 3 | const DEFAULT_RELATION_VALUES: InputTextNode[] = [ 4 | { 5 | text: "Informs", 6 | children: [ 7 | { text: "Source", children: [{ text: "_EVD-node" }] }, 8 | { text: "Destination", children: [{ text: "_QUE-node" }] }, 9 | { text: "complement", children: [{ text: "Informed By" }] }, 10 | { 11 | text: "If", 12 | children: [ 13 | { 14 | text: "And", 15 | children: [ 16 | { 17 | text: "Page", 18 | children: [{ text: "is a", children: [{ text: "source" }] }], 19 | }, 20 | { 21 | text: "Block", 22 | children: [ 23 | { text: "references", children: [{ text: "Page" }] }, 24 | ], 25 | }, 26 | { 27 | text: "Block", 28 | children: [ 29 | { text: "is in page", children: [{ text: "ParentPage" }] }, 30 | ], 31 | }, 32 | { 33 | text: "ParentPage", 34 | children: [ 35 | { text: "is a", children: [{ text: "destination" }] }, 36 | ], 37 | }, 38 | ], 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | { 45 | text: "Supports", 46 | children: [ 47 | { text: "Source", children: [{ text: "_EVD-node", children: [] }] }, 48 | { text: "Destination", children: [{ text: "_CLM-node", children: [] }] }, 49 | { text: "complement", children: [{ text: "Supported By" }] }, 50 | { 51 | text: "If", 52 | children: [ 53 | { 54 | text: "And", 55 | children: [ 56 | { 57 | text: "Page", 58 | children: [ 59 | { 60 | text: "is a", 61 | children: [{ text: "source", children: [] }], 62 | }, 63 | ], 64 | }, 65 | { 66 | text: "Block", 67 | children: [ 68 | { 69 | text: "references", 70 | children: [{ text: "Page", children: [] }], 71 | }, 72 | ], 73 | }, 74 | { 75 | text: "SBlock", 76 | children: [ 77 | { 78 | text: "references", 79 | children: [{ text: "SPage", children: [] }], 80 | }, 81 | ], 82 | }, 83 | { 84 | text: "SPage", 85 | children: [ 86 | { 87 | text: "has title", 88 | children: [{ text: "SupportedBy", children: [] }], 89 | }, 90 | ], 91 | }, 92 | { 93 | text: "SBlock", 94 | children: [ 95 | { 96 | text: "has child", 97 | children: [{ text: "Block", children: [] }], 98 | }, 99 | ], 100 | }, 101 | { 102 | text: "PBlock", 103 | children: [ 104 | { 105 | text: "references", 106 | children: [{ text: "ParentPage", children: [] }], 107 | }, 108 | ], 109 | }, 110 | { 111 | text: "PBlock", 112 | children: [ 113 | { 114 | text: "has child", 115 | children: [{ text: "SBlock", children: [] }], 116 | }, 117 | ], 118 | }, 119 | { 120 | text: "ParentPage", 121 | children: [ 122 | { 123 | text: "is a", 124 | children: [{ text: "destination", children: [] }], 125 | }, 126 | ], 127 | }, 128 | ], 129 | }, 130 | ], 131 | }, 132 | ], 133 | }, 134 | { 135 | text: "Opposes", 136 | children: [ 137 | { text: "Source", children: [{ text: "_EVD-node", children: [] }] }, 138 | { text: "Destination", children: [{ text: "_CLM-node", children: [] }] }, 139 | { text: "complement", children: [{ text: "Opposed By" }] }, 140 | { 141 | text: "If", 142 | children: [ 143 | { 144 | text: "And", 145 | children: [ 146 | { 147 | text: "Page", 148 | children: [ 149 | { 150 | text: "is a", 151 | children: [{ text: "source", children: [] }], 152 | }, 153 | ], 154 | }, 155 | { 156 | text: "Block", 157 | children: [ 158 | { 159 | text: "references", 160 | children: [{ text: "Page", children: [] }], 161 | }, 162 | ], 163 | }, 164 | { 165 | text: "SBlock", 166 | children: [ 167 | { 168 | text: "references", 169 | children: [{ text: "SPage", children: [] }], 170 | }, 171 | ], 172 | }, 173 | { 174 | text: "SPage", 175 | children: [ 176 | { 177 | text: "has title", 178 | children: [{ text: "OpposedBy", children: [] }], 179 | }, 180 | ], 181 | }, 182 | { 183 | text: "SBlock", 184 | children: [ 185 | { 186 | text: "has child", 187 | children: [{ text: "Block", children: [] }], 188 | }, 189 | ], 190 | }, 191 | { 192 | text: "PBlock", 193 | children: [ 194 | { 195 | text: "references", 196 | children: [{ text: "ParentPage", children: [] }], 197 | }, 198 | ], 199 | }, 200 | { 201 | text: "PBlock", 202 | children: [ 203 | { 204 | text: "has child", 205 | children: [{ text: "SBlock", children: [] }], 206 | }, 207 | ], 208 | }, 209 | { 210 | text: "ParentPage", 211 | children: [ 212 | { 213 | text: "is a", 214 | children: [{ text: "destination", children: [] }], 215 | }, 216 | ], 217 | }, 218 | ], 219 | }, 220 | ], 221 | }, 222 | ], 223 | }, 224 | ]; 225 | 226 | export default DEFAULT_RELATION_VALUES; 227 | -------------------------------------------------------------------------------- /src/components/MessageBlock.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Card } from "@blueprintjs/core"; 2 | import React from "react"; 3 | import getSubTree from "roamjs-components/util/getSubTree"; 4 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 5 | import { OnloadArgs, PullBlock } from "roamjs-components/types/native"; 6 | import renderWithUnmount from "roamjs-components/util/renderWithUnmount"; 7 | import getBlockUidsReferencingBlock from "roamjs-components/queries/getBlockUidsReferencingBlock"; 8 | import getPageUidByBlockUid from "roamjs-components/queries/getPageUidByBlockUid"; 9 | 10 | type QueryPageComponent = (props: { blockUid: string }) => JSX.Element; 11 | 12 | type Props = Parameters[0]; 13 | 14 | const getThread = (blockUid: string) => { 15 | const allBlockUidsInThread = new Set([blockUid]); 16 | const getMessageInfo = (uid: string) => { 17 | const tree = getBasicTreeByParentUid(uid); 18 | if (!tree.length) return; 19 | const pull = window.roamAlphaAPI.pull( 20 | "[:block/string :create/user :create/time]", 21 | [":block/uid", uid] 22 | ); 23 | const fromPage = window.roamAlphaAPI.pull( 24 | "[:user/display-page]", 25 | pull[":create/user"]?.[":db/id"] || 0 26 | )?.[":user/display-page"]?.[":db/id"]; 27 | const from = 28 | window.roamAlphaAPI.pull("[:node/title]", fromPage || 0)?.[ 29 | ":node/title" 30 | ] || ""; 31 | const subject = pull[":block/string"] || ""; 32 | const when = new Date(pull[":create/time"] || 0); 33 | const recipients = getSubTree({ tree, key: "to::" }) 34 | .children.map((c) => /#([^\s]+)\s+\[\[([^\]]+)\]\]/.exec(c.text)) 35 | .filter((s): s is RegExpExecArray => !!s) 36 | .map(([_, to, status]) => ({ to, status })); 37 | const body = getSubTree({ tree, key: "body::" }).uid; 38 | const { text: actionsText, uid: actionsUid } = getSubTree({ 39 | tree, 40 | key: "actions::", 41 | }).children[0]; 42 | const buttons = Array.from( 43 | actionsText.matchAll(/{{([^:]+):SmartBlock:([^:]+)[^}]*}}/g) 44 | ).map((m) => ({ 45 | text: m[1], 46 | trigger: m[2], 47 | })); 48 | return { 49 | subject, 50 | from, 51 | when, 52 | body, 53 | recipients, 54 | buttons, 55 | actionsUid, 56 | uid, 57 | }; 58 | }; 59 | const getReplies = (uid: string): string[] => { 60 | const referringUids = getBlockUidsReferencingBlock(uid).filter( 61 | (u) => u && !allBlockUidsInThread.has(u) 62 | ); 63 | referringUids.forEach((u) => allBlockUidsInThread.add(u)); 64 | return [uid, ...referringUids.flatMap(getReplies)]; 65 | }; 66 | const getAncestors = (uid: string): string[] => { 67 | const refs = ( 68 | window.roamAlphaAPI.data.fast.q( 69 | `[:find (pull ?ref [:block/uid]) :where [?block :block/uid "${uid}"] [?block :block/refs ?ref]]` 70 | ) as [PullBlock][] 71 | ) 72 | .map((r) => r[0]?.[":block/uid"]) 73 | .filter((u): u is string => !!u && !allBlockUidsInThread.has(u)); 74 | refs.forEach((u) => allBlockUidsInThread.add(u)); 75 | return [...refs.flatMap(getAncestors), uid]; 76 | }; 77 | const ancestors = getAncestors(blockUid); 78 | const replies = getReplies(blockUid); 79 | const uids = [...ancestors.slice(0, -1), blockUid, ...replies.slice(1)]; 80 | return uids 81 | .map(getMessageInfo) 82 | .filter( 83 | (i): i is Exclude, undefined> => !!i 84 | ); 85 | }; 86 | 87 | const SingleMessage = ( 88 | t: ReturnType[number] & { focused: boolean } 89 | ) => { 90 | const bodyRef = React.useRef(null); 91 | React.useEffect(() => { 92 | if (bodyRef.current) { 93 | window.roamAlphaAPI.ui.components.renderPage({ 94 | uid: t.body, 95 | el: bodyRef.current, 96 | }); 97 | } 98 | }, [bodyRef.current, t.body]); 99 | return ( 100 |
105 |
{ 113 | window.roamAlphaAPI.ui.mainWindow.openPage({ 114 | page: { 115 | uid: getPageUidByBlockUid(t.uid), 116 | }, 117 | }); 118 | }} 119 | > 120 |
121 | {t.from} 122 | 123 | to{" "} 124 | {t.recipients 125 | .map((r) => `${r.to}${/unread/i.test(r.status) ? "*" : ""}`) 126 | .join(", ")} 127 | 128 |
129 |
133 | {t.when.toLocaleString()} 134 |
135 |
136 |
140 |
141 | ); 142 | }; 143 | 144 | /** 145 | * This component is hyper custom towards a single user's use case, so we want to keep it mostly hidden for now. 146 | * Once Roam releases custom block views, we'll be able to expose a way (via SamePage) to allow users to build 147 | * their own custom views (UI) in a No-Code way, similar to SmartBlocks (automations) and Query Builder (Requests) today. 148 | */ 149 | const MessageBlock = ({ blockUid }: Props) => { 150 | const [thread, setThread] = React.useState(() => getThread(blockUid)); 151 | return ( 152 | 156 | 165 |

{thread[0].subject}

166 | {thread.map((t, i) => ( 167 | 168 | ))} 169 |
170 | {thread.slice(-1)[0].buttons.map((b, i) => ( 171 |
191 |
192 | ); 193 | }; 194 | 195 | export const render = ({ 196 | parent, 197 | onloadArgs, 198 | ...props 199 | }: { parent: HTMLElement; onloadArgs: OnloadArgs } & Props) => { 200 | parent.onmousedown = (e) => e.stopPropagation(); 201 | const root = parent.closest(".roam-block-container"); 202 | if (root) { 203 | root.setAttribute("data-roamjs-message-block", "true"); 204 | } 205 | return renderWithUnmount(, parent, onloadArgs); 206 | }; 207 | 208 | export default MessageBlock; 209 | -------------------------------------------------------------------------------- /src/components/tldraw/useRoamStore.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useMemo, useEffect } from "react"; 2 | import { TLRecord, TLStore } from "@tldraw/tlschema"; 3 | import { TLInstance, TLUser, TldrawEditorConfig } from "@tldraw/tldraw"; 4 | import { StoreSnapshot } from "@tldraw/tlstore"; 5 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 6 | import getSubTree from "roamjs-components/util/getSubTree"; 7 | import setInputSetting from "roamjs-components/util/setInputSetting"; 8 | import createBlock from "roamjs-components/writes/createBlock"; 9 | import { AddPullWatch } from "roamjs-components/types"; 10 | import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid"; 11 | import nanoid from "nanoid"; 12 | import getBlockProps, { json, normalizeProps } from "../../utils/getBlockProps"; 13 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 14 | 15 | const THROTTLE = 350; 16 | 17 | export const useRoamStore = ({ 18 | config, 19 | title, 20 | }: { 21 | title: string; 22 | config: TldrawEditorConfig; 23 | }) => { 24 | const pageUid = useMemo(() => getPageUidByPageTitle(title), [title]); 25 | const tree = useMemo(() => getBasicTreeByParentUid(pageUid), [pageUid]); 26 | 27 | const localStateIds = useRef([]); 28 | const serializeRef = useRef(0); 29 | const deserializeRef = useRef(0); 30 | 31 | const initialData = useMemo(() => { 32 | const persisted = getSubTree({ 33 | parentUid: pageUid, 34 | tree, 35 | key: "State", 36 | }); 37 | if (!persisted.uid) { 38 | // we create a block so that the page is not garbage collected 39 | createBlock({ 40 | node: { 41 | text: "State", 42 | }, 43 | parentUid: pageUid, 44 | }); 45 | } 46 | const instanceId = TLInstance.createCustomId(pageUid); 47 | const userId = TLUser.createCustomId(getCurrentUserUid()); 48 | const props = getBlockProps(pageUid) as Record; 49 | const rjsqb = props["roamjs-query-builder"] as Record; 50 | const data = rjsqb?.tldraw as Parameters[0]; 51 | return { data, instanceId, userId }; 52 | }, [tree, pageUid]); 53 | 54 | const store = useMemo(() => { 55 | const _store = config.createStore({ 56 | initialData: initialData.data, 57 | instanceId: initialData.instanceId, 58 | userId: initialData.userId, 59 | }); 60 | _store.listen((rec) => { 61 | if (rec.source !== "user") return; 62 | const validChanges = Object.keys(rec.changes.added) 63 | .concat(Object.keys(rec.changes.removed)) 64 | .concat(Object.keys(rec.changes.updated)) 65 | .filter( 66 | (k) => 67 | !/^(user_presence|camera|instance|instance_page_state):/.test(k) 68 | ); 69 | if (!validChanges.length) return; 70 | clearTimeout(serializeRef.current); 71 | serializeRef.current = window.setTimeout(async () => { 72 | const state = _store.serialize(); 73 | const props = getBlockProps(pageUid) as Record; 74 | const rjsqb = 75 | typeof props["roamjs-query-builder"] === "object" 76 | ? props["roamjs-query-builder"] 77 | : {}; 78 | await setInputSetting({ 79 | blockUid: pageUid, 80 | key: "timestamp", 81 | value: new Date().valueOf().toString(), 82 | }); 83 | const newstateId = nanoid(); 84 | localStateIds.current.push(newstateId); 85 | localStateIds.current.splice(0, localStateIds.current.length - 25); 86 | window.roamAlphaAPI.updatePage({ 87 | page: { 88 | uid: pageUid, 89 | props: { 90 | ...props, 91 | ["roamjs-query-builder"]: { 92 | ...rjsqb, 93 | stateId: newstateId, 94 | tldraw: state, 95 | }, 96 | }, 97 | }, 98 | }); 99 | }, THROTTLE); 100 | }); 101 | return _store; 102 | }, [initialData, serializeRef]); 103 | 104 | const personalRecordTypes = new Set([ 105 | "camera", 106 | "instance", 107 | "instance_page_state", 108 | ]); 109 | 110 | const pruneState = (state: StoreSnapshot) => 111 | Object.fromEntries( 112 | Object.entries(state).filter( 113 | ([_, record]) => !personalRecordTypes.has(record.typeName) 114 | ) 115 | ); 116 | 117 | const diffObjects = ( 118 | oldRecord: Record, 119 | newRecord: Record 120 | ): Record => { 121 | const allKeys = Array.from( 122 | new Set(Object.keys(oldRecord).concat(Object.keys(newRecord))) 123 | ); 124 | return Object.fromEntries( 125 | allKeys 126 | .map((key) => { 127 | const oldValue = oldRecord[key]; 128 | const newValue = newRecord[key]; 129 | if (typeof oldValue !== typeof newValue) { 130 | return [key, newValue]; 131 | } 132 | if ( 133 | typeof oldValue === "object" && 134 | oldValue !== null && 135 | newValue !== null 136 | ) { 137 | const diffed = diffObjects(oldValue, newValue); 138 | if (Object.keys(diffed).length) { 139 | return [key, diffed]; 140 | } 141 | return null; 142 | } 143 | if (oldValue !== newValue) { 144 | return [key, newValue]; 145 | } 146 | return null; 147 | }) 148 | .filter((e): e is [string, any] => !!e) 149 | ); 150 | }; 151 | const calculateDiff = ( 152 | _newState: StoreSnapshot, 153 | _oldState: StoreSnapshot 154 | ) => { 155 | const newState = pruneState(_newState); 156 | const oldState = pruneState(_oldState); 157 | return { 158 | added: Object.fromEntries( 159 | Object.keys(newState) 160 | .filter((id) => !oldState[id]) 161 | .map((id) => [id, newState[id]]) 162 | ), 163 | removed: Object.fromEntries( 164 | Object.keys(oldState) 165 | .filter((id) => !newState[id]) 166 | .map((key) => [key, oldState[key]]) 167 | ), 168 | updated: Object.fromEntries( 169 | Object.keys(newState) 170 | .map((id) => { 171 | const oldRecord = oldState[id]; 172 | const newRecord = newState[id]; 173 | if (!oldRecord || !newRecord) { 174 | return null; 175 | } 176 | 177 | const diffed = diffObjects(oldRecord, newRecord); 178 | if (Object.keys(diffed).length) { 179 | return [id, [oldRecord, newRecord]]; 180 | } 181 | return null; 182 | }) 183 | .filter((e): e is [string, any] => !!e) 184 | ), 185 | }; 186 | }; 187 | 188 | useEffect(() => { 189 | const pullWatchProps: Parameters = [ 190 | "[:edit/user :block/props :block/string {:block/children ...}]", 191 | `[:block/uid "${pageUid}"]`, 192 | (_, after) => { 193 | const props = normalizeProps( 194 | (after?.[":block/props"] || {}) as json 195 | ) as Record; 196 | const rjsqb = props["roamjs-query-builder"] as Record; 197 | const propsStateId = rjsqb?.stateId as string; 198 | if (localStateIds.current.some((s) => s === propsStateId)) return; 199 | const newState = rjsqb?.tldraw as Parameters< 200 | typeof store.deserialize 201 | >[0]; 202 | if (!newState) return; 203 | clearTimeout(deserializeRef.current); 204 | deserializeRef.current = window.setTimeout(() => { 205 | store.mergeRemoteChanges(() => { 206 | const currentState = store.serialize(); 207 | const diff = calculateDiff(newState, currentState); 208 | store.applyDiff(diff); 209 | }); 210 | }, THROTTLE); 211 | }, 212 | ]; 213 | window.roamAlphaAPI.data.addPullWatch(...pullWatchProps); 214 | return () => { 215 | window.roamAlphaAPI.data.removePullWatch(...pullWatchProps); 216 | }; 217 | }, [pageUid, store]); 218 | 219 | return { 220 | store, 221 | instanceId: initialData.instanceId, 222 | userId: initialData.userId, 223 | }; 224 | }; 225 | -------------------------------------------------------------------------------- /src/components/DiscourseContextOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Popover, Position, Tooltip } from "@blueprintjs/core"; 2 | import React, { useCallback, useEffect, useMemo, useState } from "react"; 3 | import ReactDOM from "react-dom"; 4 | import { ContextContent } from "./DiscourseContext"; 5 | import useInViewport from "react-in-viewport/dist/es/lib/useInViewport"; 6 | import normalizePageTitle from "roamjs-components/queries/normalizePageTitle"; 7 | import deriveDiscourseNodeAttribute from "../utils/deriveDiscourseNodeAttribute"; 8 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 9 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 10 | import nanoid from "nanoid"; 11 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; 12 | import { getNodeEnv } from "roamjs-components/util/env"; 13 | import getDiscourseContextResults from "../utils/getDiscourseContextResults"; 14 | import findDiscourseNode from "../utils/findDiscourseNode"; 15 | import getDiscourseNodes from "../utils/getDiscourseNodes"; 16 | import getDiscourseRelations from "../utils/getDiscourseRelations"; 17 | import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext"; 18 | import { OnloadArgs } from "roamjs-components/types/native"; 19 | // import localStorageGet from "roamjs-components/util/localStorageGet"; 20 | // import fireWorkerQuery from "../utils/fireWorkerQuery"; 21 | 22 | type DiscourseData = { 23 | results: Awaited>; 24 | refs: number; 25 | }; 26 | 27 | const cache: { 28 | [title: string]: DiscourseData; 29 | } = {}; 30 | const overlayQueue: { 31 | tag: string; 32 | callback: () => Promise; 33 | start: number; 34 | queued: number; 35 | end: number; 36 | mid: number; 37 | id: string; 38 | }[] = []; 39 | 40 | const getOverlayInfo = (tag: string, id: string): Promise => { 41 | if (cache[tag]) return Promise.resolve(cache[tag]); 42 | const relations = getDiscourseRelations(); 43 | const nodes = getDiscourseNodes(relations); 44 | return new Promise((resolve) => { 45 | const triggerNow = overlayQueue.length === 0; 46 | overlayQueue.push({ 47 | id, 48 | start: 0, 49 | end: 0, 50 | mid: 0, 51 | queued: new Date().valueOf(), 52 | callback() { 53 | const self = this; 54 | const start = (self.start = new Date().valueOf()); 55 | return getDiscourseContextResults({ 56 | uid: getPageUidByPageTitle(tag), 57 | nodes, 58 | relations, 59 | }).then(function resultCallback(results) { 60 | self.mid = new Date().valueOf(); 61 | const output = (cache[tag] = { 62 | results, 63 | refs: window.roamAlphaAPI.data.fast.q( 64 | `[:find ?a :where [?b :node/title "${normalizePageTitle( 65 | tag 66 | )}"] [?a :block/refs ?b]]` 67 | ).length, 68 | }); 69 | const runTime = (self.end = new Date().valueOf() - start); 70 | setTimeout(() => { 71 | overlayQueue.splice(0, 1); 72 | if (overlayQueue.length) { 73 | overlayQueue[0].callback(); 74 | } 75 | }, runTime * 4); 76 | resolve(output); 77 | }); 78 | }, 79 | tag, 80 | }); 81 | if (triggerNow) overlayQueue[0].callback?.(); 82 | }); 83 | }; 84 | 85 | // const experimentalGetOverlayInfo = (title: string) => 86 | // Promise.all([ 87 | // getDiscourseContextResults({ uid: getPageUidByPageTitle(title) }), 88 | // fireWorkerQuery({ 89 | // where: [ 90 | // { 91 | // type: "data-pattern", 92 | // arguments: [ 93 | // { type: "variable", value: "b" }, 94 | // { type: "constant", value: ":node/title" }, 95 | // { type: "constant", value: `"${title}"` }, 96 | // ], 97 | // }, 98 | // { 99 | // type: "data-pattern", 100 | // arguments: [ 101 | // { type: "variable", value: "a" }, 102 | // { type: "constant", value: ":block/refs" }, 103 | // { type: "variable", value: `b` }, 104 | // ], 105 | // }, 106 | // ], 107 | // pull: [], 108 | // }), 109 | // ]).then(([results, allrefs]) => ({ results, refs: allrefs.length })); 110 | 111 | export const refreshUi: { [k: string]: () => void } = {}; 112 | const refreshAllUi = () => 113 | Object.entries(refreshUi).forEach(([k, v]) => { 114 | if (document.getElementById(k)) { 115 | v(); 116 | } else { 117 | delete refreshUi[k]; 118 | } 119 | }); 120 | 121 | const DiscourseContextOverlay = ({ tag, id }: { tag: string; id: string }) => { 122 | const tagUid = useMemo(() => getPageUidByPageTitle(tag), [tag]); 123 | const [loading, setLoading] = useState(true); 124 | const [results, setResults] = useState([]); 125 | const [refs, setRefs] = useState(0); 126 | const [score, setScore] = useState(0); 127 | const getInfo = useCallback( 128 | () => 129 | // localStorageGet("experimental") === "true" 130 | // ? experimentalGetOverlayInfo(tag) 131 | // : 132 | getOverlayInfo(tag, id) 133 | .then(({ refs, results }) => { 134 | const discourseNode = findDiscourseNode(tagUid); 135 | if (discourseNode) { 136 | const attribute = getSettingValueFromTree({ 137 | tree: getBasicTreeByParentUid(discourseNode.type), 138 | key: "Overlay", 139 | defaultValue: "Overlay", 140 | }); 141 | return deriveDiscourseNodeAttribute({ 142 | uid: tagUid, 143 | attribute, 144 | }).then((score) => { 145 | setResults(results); 146 | setRefs(refs); 147 | setScore(score); 148 | }); 149 | } 150 | }) 151 | .finally(() => setLoading(false)), 152 | [tag, setResults, setLoading, setRefs, setScore] 153 | ); 154 | const refresh = useCallback(() => { 155 | setLoading(true); 156 | getInfo(); 157 | }, [getInfo, setLoading]); 158 | useEffect(() => { 159 | refreshUi[id] = refresh; 160 | getInfo(); 161 | }, [refresh, getInfo]); 162 | return ( 163 | 174 | 175 | 176 | 177 |
188 | } 189 | target={ 190 |
195 | {showGitHubLogin && ( 196 |
246 | ); 247 | }; 248 | --------------------------------------------------------------------------------