├── .gitignore ├── tests └── query-pages.ts ├── tsconfig.json ├── .github └── workflows │ └── main.yaml ├── src ├── utils │ ├── runQuery.ts │ ├── parseQuery.ts │ ├── postProcessResults.ts │ ├── parseResultSettings.ts │ ├── fireQuery.ts │ ├── runQueryTools.ts │ └── conditionToDatalog.ts ├── components │ ├── BlockResult.tsx │ ├── Charts.tsx │ ├── QueryPagesPanel.tsx │ ├── Timeline.tsx │ ├── DefaultFilters.tsx │ ├── QueryPage.tsx │ ├── Export.tsx │ ├── QueryBuilder.tsx │ ├── QueryEditor.tsx │ └── ResultsView.tsx └── index.ts ├── package.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | out 5 | .env 6 | main.js 7 | extension.js 8 | extension.js.LICENSE.txt 9 | report.html 10 | stats.json 11 | -------------------------------------------------------------------------------- /tests/query-pages.ts: -------------------------------------------------------------------------------- 1 | import { describe, it } from "local-cypress"; 2 | 3 | describe("Query Pages", () => { 4 | it("Successfully queries the correct results on a page", () => {}); 5 | }); 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/roamjs-scripts/dist/default.tsconfig", 3 | "include": [ 4 | "src" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: Publish Extension 2 | on: 3 | push: 4 | branches: main 5 | paths: 6 | - "src/**" 7 | - "package.json" 8 | - ".github/workflows/main.yaml" 9 | 10 | env: 11 | ROAMJS_DEVELOPER_TOKEN: ${{ secrets.ROAMJS_DEVELOPER_TOKEN }} 12 | ROAMJS_EMAIL: support@roamjs.com 13 | ROAMJS_EXTENSION_ID: query-builder 14 | ROAMJS_RELEASE_TOKEN: ${{ secrets.ROAMJS_RELEASE_TOKEN }} 15 | 16 | jobs: 17 | deploy: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: install 22 | run: npm install 23 | - name: build 24 | run: npx roamjs-scripts build --depot 25 | - name: publish 26 | run: npx roamjs-scripts publish --depot 27 | -------------------------------------------------------------------------------- /src/utils/runQuery.ts: -------------------------------------------------------------------------------- 1 | import type { OnloadArgs } from "roamjs-components/types/native"; 2 | import fireQuery from "./fireQuery"; 3 | import parseQuery from "./parseQuery"; 4 | import parseResultSettings from "./parseResultSettings"; 5 | import postProcessResults from "./postProcessResults"; 6 | 7 | const runQuery = ( 8 | parentUid: string, 9 | extensionAPI: OnloadArgs["extensionAPI"] 10 | ) => { 11 | const queryArgs = parseQuery(parentUid); 12 | return fireQuery(queryArgs).then((results) => { 13 | const settings = parseResultSettings( 14 | parentUid, 15 | ["text"].concat(queryArgs.selections.map((s) => s.label)), 16 | extensionAPI 17 | ); 18 | return postProcessResults(results, settings); 19 | }); 20 | }; 21 | 22 | export default runQuery; 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "query-builder", 3 | "version": "1.0.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 | "start": "roamjs-scripts dev --depot", 12 | "dev:dep": "roamjs-scripts dev --port 3100", 13 | "prebuild:roam": "npm install", 14 | "build:roam": "roamjs-scripts build --depot" 15 | }, 16 | "license": "MIT", 17 | "devDependencies": { 18 | "@types/react-vertical-timeline-component": "^3.3.3", 19 | "roamjs-scripts": "^0.22.3" 20 | }, 21 | "tags": [ 22 | "queries", 23 | "widgets" 24 | ], 25 | "dependencies": { 26 | "react-charts": "^3.0.0-beta.48", 27 | "react-vertical-timeline-component": "^3.5.2", 28 | "roamjs-components": "^0.73.4" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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/components/BlockResult.tsx: -------------------------------------------------------------------------------- 1 | import getShallowTreeByParentUid from 'roamjs-components/queries/getShallowTreeByParentUid' 2 | import deleteBlock from 'roamjs-components/writes/deleteBlock' 3 | import {Result} from 'roamjs-components/types/query-builder' 4 | import createBlock from 'roamjs-components/writes/createBlock' 5 | import React, {useEffect} from 'react' 6 | import {CellEmbed} from './ResultsView' 7 | 8 | async function deleteAllChildren(blockUid: string) { 9 | await Promise.all(getShallowTreeByParentUid(blockUid).map((b) => deleteBlock(b.uid))) 10 | } 11 | 12 | function createResultBlocks(timelineElements: Result[], parentUid: string) { 13 | timelineElements.forEach((block, index) => { 14 | createBlock({ 15 | node: { 16 | text: `{{embed-path: ((${block.uid})) }}`, 17 | }, 18 | parentUid, 19 | order: index, 20 | }) 21 | }) 22 | } 23 | 24 | export const BlockResult = ({timelineElements, blockUid}: { timelineElements: Result[], blockUid: string }) => { 25 | // todo show loading indicator before results are ready 26 | 27 | useEffect(() => { 28 | (async () => { 29 | await deleteAllChildren(blockUid) 30 | createResultBlocks(timelineElements, blockUid) 31 | })() 32 | }, [timelineElements, blockUid]) 33 | 34 | return
35 |

{timelineElements.length} results

36 | 37 |
38 | } 39 | -------------------------------------------------------------------------------- /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 | 5 | const Charts = ({ 6 | data, 7 | type, 8 | columns, 9 | }: { 10 | type: AxisOptionsBase["elementType"]; 11 | data: Result[]; 12 | columns: string[]; 13 | }): JSX.Element => { 14 | const chartData = React.useMemo( 15 | () => 16 | columns.slice(1).map((col) => { 17 | return { 18 | label: col, 19 | data: data.map((d) => [d[columns[0]], d[col]]), 20 | }; 21 | }), 22 | [data, columns] 23 | ); 24 | const primaryAxis = React.useMemo< 25 | AxisOptions<[Result[string], Result[string]]> 26 | >( 27 | () => ({ 28 | primary: true, 29 | type: "timeLocal", 30 | position: "bottom" as const, 31 | getValue: ([d]) => 32 | d instanceof Date 33 | ? d 34 | : typeof d === "string" 35 | ? window.roamAlphaAPI.util.pageTitleToDate(d) 36 | : new Date(d), 37 | }), 38 | [] 39 | ); 40 | const secondaryAxes = React.useMemo< 41 | AxisOptions<[Result[string], Result[string]]>[] 42 | >( 43 | () => 44 | columns.slice(1).map(() => ({ 45 | type: "linear", 46 | position: "left" as const, 47 | getValue: (d) => Number(d[1]) || 0, 48 | elementType: type, 49 | })), 50 | [type] 51 | ); 52 | 53 | return ( 54 |
55 | 56 |
57 | ); 58 | }; 59 | 60 | export default Charts; 61 | -------------------------------------------------------------------------------- /src/components/QueryPagesPanel.tsx: -------------------------------------------------------------------------------- 1 | import { Button, InputGroup } from "@blueprintjs/core"; 2 | import React, { useEffect, useRef, 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 string[] | string; 7 | return typeof value === "string" ? [value] : value || ["queries/*"]; 8 | }; 9 | 10 | const QueryPagesPanel = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { 11 | const [texts, setTexts] = useState(() => getQueryPages(extensionAPI)); 12 | const [value, setValue] = useState(""); 13 | return ( 14 |
21 |
22 | setValue(e.target.value)} /> 23 |
35 | {texts.map((p, index) => ( 36 |
37 | 44 | {p} 45 | 46 |
56 | ))} 57 |
58 | ); 59 | }; 60 | 61 | export default QueryPagesPanel; 62 | -------------------------------------------------------------------------------- /src/utils/parseQuery.ts: -------------------------------------------------------------------------------- 1 | import { RoamBasicNode } from "roamjs-components/types/native"; 2 | import { Condition, ParseQuery } from "roamjs-components/types/query-builder"; 3 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 4 | import getSubTree from "roamjs-components/util/getSubTree"; 5 | import createBlock from "roamjs-components/writes/createBlock"; 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 | const parseQuery: ParseQuery = (parentUidOrNode) => { 43 | const queryNode = 44 | typeof parentUidOrNode === "string" 45 | ? getSubTree({ key: "scratch", parentUid: parentUidOrNode }) 46 | : parentUidOrNode; 47 | const { uid, children } = queryNode; 48 | const getOrCreateUid = (sub: RoamBasicNode, text: string) => { 49 | if (sub.uid) return sub.uid; 50 | const newUid = window.roamAlphaAPI.util.generateUID(); 51 | createBlock({ 52 | node: { text, uid: newUid }, 53 | parentUid: uid, 54 | }); 55 | return newUid; 56 | }; 57 | const returnBlock = getSubTree({ tree: children, key: "return" }); 58 | const returnNodeUid = getOrCreateUid(returnBlock, "return"); 59 | const returnNode = returnBlock.children?.[0]?.text; 60 | const conditionsNode = getSubTree({ 61 | tree: children, 62 | key: "conditions", 63 | }); 64 | const conditionsNodesUid = getOrCreateUid(conditionsNode, "conditions"); 65 | const conditions = conditionsNode.children.map(roamNodeToCondition); 66 | 67 | const selectionsNode = getSubTree({ tree: children, key: "selections" }); 68 | const selectionsNodesUid = getOrCreateUid(selectionsNode, "selections"); 69 | 70 | const selections = selectionsNode.children.map(({ uid, text, children }) => ({ 71 | uid, 72 | text, 73 | label: children?.[0]?.text || "", 74 | })); 75 | 76 | const customBlock = getSubTree({ tree: children, key: "custom" }); 77 | const customNodeUid = getOrCreateUid(customBlock, "custom"); 78 | return { 79 | returnNode, 80 | conditions, 81 | selections, 82 | customNode: customBlock.children[0]?.text || "", 83 | returnNodeUid, 84 | conditionsNodesUid, 85 | selectionsNodesUid, 86 | customNodeUid, 87 | isCustomEnabled: customBlock.children[1]?.text === "enabled", 88 | }; 89 | }; 90 | 91 | export default parseQuery; 92 | -------------------------------------------------------------------------------- /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 sortFunction = 7 | (key: string, descending?: boolean) => (a: Result, b: Result) => { 8 | const _aVal = a[key]; 9 | const _bVal = b[key]; 10 | const transform = (_val: Result[string]) => 11 | typeof _val === "string" 12 | ? DAILY_NOTE_PAGE_TITLE_REGEX.test(extractTag(_val)) 13 | ? window.roamAlphaAPI.util.pageTitleToDate(extractTag(_val)) 14 | : /^(-)?\d+(\.\d*)?$/.test(_val) 15 | ? Number(_val) 16 | : _val 17 | : _val; 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, "views" | "layout"> 38 | ) => { 39 | const sortedResults = results 40 | .filter((r) => { 41 | return Object.keys(settings.filters).every( 42 | (filterKey) => 43 | (settings.filters[filterKey].includes.values.size === 0 && 44 | (typeof r[filterKey] !== "string" || 45 | !settings.filters[filterKey].excludes.values.has( 46 | extractTag(r[filterKey] as string) 47 | )) && 48 | (r[filterKey] instanceof Date || 49 | !settings.filters[filterKey].excludes.values.has( 50 | window.roamAlphaAPI.util.dateToPageTitle(r[filterKey] as Date) 51 | )) && 52 | !settings.filters[filterKey].excludes.values.has( 53 | r[filterKey] as string 54 | )) || 55 | (typeof r[filterKey] === "string" && 56 | settings.filters[filterKey].includes.values.has( 57 | extractTag(r[filterKey] as string) 58 | )) || 59 | (r[filterKey] instanceof Date && 60 | settings.filters[filterKey].includes.values.has( 61 | window.roamAlphaAPI.util.dateToPageTitle(r[filterKey] as Date) 62 | )) || 63 | settings.filters[filterKey].includes.values.has( 64 | r[filterKey] as string 65 | ) 66 | ); 67 | }) 68 | .sort((a, b) => { 69 | for (const sort of settings.activeSort) { 70 | const cmpResult = sortFunction(sort.key, sort.descending)(a, b); 71 | if (cmpResult !== 0) return cmpResult; 72 | } 73 | return 0; 74 | }); 75 | const allResults = 76 | settings.random > 0 77 | ? sortedResults.sort(() => 0.5 - Math.random()).slice(0, settings.random) 78 | : sortedResults; 79 | const paginatedResults = allResults.slice( 80 | (settings.page - 1) * settings.pageSize, 81 | settings.page * settings.pageSize 82 | ); 83 | return { allResults, paginatedResults }; 84 | }; 85 | 86 | export default postProcessResults; 87 | -------------------------------------------------------------------------------- /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 getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree"; 6 | import getSubTree from "roamjs-components/util/getSubTree"; 7 | import toFlexRegex from "roamjs-components/util/toFlexRegex"; 8 | import { StoredFilters } from "../components/DefaultFilters"; 9 | 10 | const getFilterEntries = ( 11 | n: Pick 12 | ): [string, Filters][] => 13 | n.children.map((c) => [ 14 | c.text, 15 | { 16 | includes: { 17 | values: new Set( 18 | getSubTree({ tree: c.children, key: "includes" }).children.map( 19 | (t) => t.text 20 | ) 21 | ), 22 | }, 23 | excludes: { 24 | values: new Set( 25 | getSubTree({ tree: c.children, key: "excludes" }).children.map( 26 | (t) => t.text 27 | ) 28 | ), 29 | }, 30 | uid: c.uid, 31 | }, 32 | ]); 33 | 34 | const getSettings = (extensionAPI: OnloadArgs["extensionAPI"]) => { 35 | return { 36 | globalFiltersData: Object.fromEntries( 37 | Object.entries( 38 | (extensionAPI.settings.get("default-filters") as Record< 39 | string, 40 | StoredFilters 41 | >) || {} 42 | ).map(([k, v]) => [ 43 | k, 44 | { 45 | includes: Object.fromEntries( 46 | Object.entries(v.includes || {}).map(([k, v]) => [k, new Set(v)]) 47 | ), 48 | excludes: Object.fromEntries( 49 | Object.entries(v.excludes || {}).map(([k, v]) => [k, new Set(v)]) 50 | ), 51 | }, 52 | ]) 53 | ), 54 | globalPageSize: 55 | Number(extensionAPI.settings.get("default-page-size")) || 10, 56 | }; 57 | }; 58 | 59 | const parseResultSettings = ( 60 | parentUid: string, 61 | columns: string[], 62 | extensionAPI: OnloadArgs["extensionAPI"] 63 | ) => { 64 | const { globalFiltersData, globalPageSize } = getSettings(extensionAPI); 65 | const tree = getBasicTreeByParentUid(parentUid); 66 | const resultNode = getSubTree({ tree, key: "results" }); 67 | const sortsNode = getSubTree({ tree: resultNode.children, key: "sorts" }); 68 | const filtersNode = getSubTree({ tree: resultNode.children, key: "filters" }); 69 | const filterEntries = getFilterEntries(filtersNode); 70 | const savedFilterData = filterEntries.length 71 | ? Object.fromEntries(filterEntries) 72 | : globalFiltersData; 73 | const random = getSettingIntFromTree({ 74 | tree: resultNode.children, 75 | key: "random", 76 | }); 77 | const pageSize = 78 | getSettingIntFromTree({ tree: resultNode.children, key: "size" }) || 79 | globalPageSize; 80 | const viewsNode = getSubTree({ tree: resultNode.children, key: "views" }); 81 | const savedViewData = Object.fromEntries( 82 | viewsNode.children.map((c) => [c.text, c.children[0]?.text]) 83 | ); 84 | const layout = getSettingValueFromTree({ 85 | tree: resultNode.children, 86 | key: "layout", 87 | }); 88 | return { 89 | activeSort: sortsNode.children.map((s) => ({ 90 | key: s.text, 91 | descending: toFlexRegex("true").test(s.children[0]?.text || ""), 92 | })), 93 | filters: Object.fromEntries( 94 | columns.map((key) => [ 95 | key, 96 | savedFilterData[key] || { 97 | includes: { values: new Set() }, 98 | excludes: { values: new Set() }, 99 | }, 100 | ]) 101 | ), 102 | views: Object.fromEntries( 103 | columns.map((key) => [ 104 | key, 105 | savedViewData[key] || (key === "text" ? "link" : "plain"), 106 | ]) 107 | ), 108 | random, 109 | pageSize, 110 | layout, 111 | page: 1, // TODO save in roam data 112 | }; 113 | }; 114 | 115 | export default parseResultSettings; 116 | -------------------------------------------------------------------------------- /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 | window.roamAlphaAPI.ui.components.renderBlock({ 51 | uid: t.uid, 52 | el: containerRef.current, 53 | }); 54 | }, [t.uid, containerRef]); 55 | return ( 56 | 68 | {window.roamAlphaAPI.util.dateToPageTitle(t.date)} 69 | 70 | } 71 | dateClassName={"roamjs-timeline-date"} 72 | iconStyle={{ 73 | backgroundColor: color, 74 | color: "#fff", 75 | }} 76 | icon={} 77 | > 78 |

79 | {t.text} 80 |

81 |

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

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

118 | ) : ( 119 | 120 | 131 | {datedTimelineElements.map((t, i) => ( 132 | 137 | ))} 138 | 139 | ); 140 | }; 141 | 142 | export default Timeline; 143 | -------------------------------------------------------------------------------- /src/components/DefaultFilters.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Intent, InputGroup } from "@blueprintjs/core"; 2 | import { useEffect, useRef, 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)} 66 | /> 67 |
71 |
89 |
90 |
91 | ); 92 | }; 93 | 94 | const DefaultFilters = (extensionAPI: OnloadArgs["extensionAPI"]) => () => { 95 | const [newColumn, setNewColumn] = useState(""); 96 | const [filters, setFilters] = useState(() => 97 | Object.fromEntries( 98 | Object.entries( 99 | (extensionAPI.settings.get("default-filters") as Record< 100 | string, 101 | StoredFilters 102 | >) || {} 103 | ).map(([k, v]) => [ 104 | k, 105 | { 106 | includes: Object.fromEntries( 107 | Object.entries(v.includes || {}).map(([k, v]) => [k, new Set(v)]) 108 | ), 109 | excludes: Object.fromEntries( 110 | Object.entries(v.excludes || {}).map(([k, v]) => [k, new Set(v)]) 111 | ), 112 | }, 113 | ]) 114 | ) 115 | ); 116 | 117 | useEffect(() => { 118 | extensionAPI.settings.set( 119 | "default-filters", 120 | Object.fromEntries( 121 | Object.entries(filters).map(([k, v]) => [ 122 | k, 123 | { 124 | includes: Object.fromEntries( 125 | Object.entries(v.includes || {}).map(([k, v]) => [k, Array.from(v)]) 126 | ), 127 | excludes: Object.fromEntries( 128 | Object.entries(v.excludes || {}).map(([k, v]) => [k, Array.from(v)]) 129 | ), 130 | }, 131 | ]) 132 | ) 133 | ); 134 | }, [filters]); 135 | return ( 136 |
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)} 173 | /> 174 |
192 |
193 | ); 194 | }; 195 | 196 | export default DefaultFilters; 197 | -------------------------------------------------------------------------------- /src/components/QueryPage.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Spinner } from "@blueprintjs/core"; 2 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 3 | import fireQuery from "../utils/fireQuery"; 4 | import parseQuery from "../utils/parseQuery"; 5 | import type { 6 | QueryPageComponent, 7 | Result as SearchResult, 8 | } from "roamjs-components/types/query-builder"; 9 | import ResultsView from "./ResultsView"; 10 | import ReactDOM from "react-dom"; 11 | import QueryEditor from "./QueryEditor"; 12 | import getSubTree from "roamjs-components/util/getSubTree"; 13 | import { createComponentRender } from "roamjs-components/components/ComponentContainer"; 14 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid"; 15 | import createBlock from "roamjs-components/writes/createBlock"; 16 | import deleteBlock from "roamjs-components/writes/deleteBlock"; 17 | import setInputSetting from "roamjs-components/util/setInputSetting"; 18 | import { OnloadArgs } from "roamjs-components/types/native"; 19 | import ExtensionApiContextProvider, { 20 | useExtensionAPI, 21 | } from "roamjs-components/components/ExtensionApiContext"; 22 | 23 | type Props = Parameters[0]; 24 | 25 | const ensureSetting = ({ 26 | parentUid, 27 | key, 28 | value, 29 | }: { 30 | parentUid: string; 31 | key: string; 32 | value: string; 33 | }) => { 34 | const [first, second] = key.split("."); 35 | const tree = getBasicTreeByParentUid(parentUid); 36 | const node = getSubTree({ tree, key: first }); 37 | return ( 38 | node.uid 39 | ? Promise.resolve(node.uid) 40 | : createBlock({ parentUid, node: { text: first } }) 41 | ).then((blockUid) => 42 | setInputSetting({ 43 | blockUid, 44 | key: second, 45 | value, 46 | }) 47 | ); 48 | }; 49 | 50 | const QueryPage = ({ 51 | pageUid, 52 | defaultReturnNode, 53 | getExportTypes, 54 | // @ts-ignore 55 | isEditBlock, 56 | }: Props) => { 57 | const extensionAPI = useExtensionAPI(); 58 | const hideMetadata = useMemo( 59 | () => !!extensionAPI.settings.get("hide-metadata"), 60 | [extensionAPI] 61 | ); 62 | const tree = useMemo(() => getBasicTreeByParentUid(pageUid), [pageUid]); 63 | const [isEdit, _setIsEdit] = useState( 64 | () => !!getSubTree({ tree, key: "editing" }).uid 65 | ); 66 | const [hasResults, setHasResults] = useState( 67 | () => !!getSubTree({ tree, key: "results" }).uid 68 | ); 69 | const setIsEdit = useCallback( 70 | (b: boolean) => { 71 | _setIsEdit(b); 72 | return b 73 | ? createBlock({ 74 | parentUid: pageUid, 75 | node: { text: "editing" }, 76 | order: 2, 77 | }) 78 | : deleteBlock(getSubTree({ parentUid: pageUid, key: "editing" }).uid); 79 | }, 80 | [pageUid] 81 | ); 82 | const [loading, setLoading] = useState(false); 83 | const [error, setError] = useState(""); 84 | const [results, setResults] = useState([]); 85 | const containerRef = useRef(null); 86 | const onRefresh = useCallback(() => { 87 | setError(""); 88 | setLoading(true); 89 | const args = parseQuery(pageUid); 90 | setTimeout(() => { 91 | const runFireQuery = (a: Parameters[0]) => 92 | fireQuery(a) 93 | .then((results) => { 94 | setResults(results); 95 | }) 96 | .catch(() => { 97 | setError( 98 | `Query failed to run. Try running a new query from the editor.` 99 | ); 100 | }) 101 | .finally(() => { 102 | const tree = getBasicTreeByParentUid(pageUid); 103 | const node = getSubTree({ tree, key: "results" }); 104 | return ( 105 | node.uid 106 | ? Promise.resolve(node.uid) 107 | : createBlock({ parentUid: pageUid, node: { text: "results" } }) 108 | ).then(() => setHasResults(true)); 109 | }); 110 | (args.returnNode 111 | ? runFireQuery(args) 112 | : defaultReturnNode 113 | ? ensureSetting({ 114 | key: "scratch.return", 115 | value: defaultReturnNode, 116 | parentUid: pageUid, 117 | }).then(() => { 118 | if (defaultReturnNode === "block" || defaultReturnNode === "node") { 119 | setIsEdit(true); 120 | } else { 121 | runFireQuery({ ...args, returnNode: defaultReturnNode }); 122 | } 123 | }) 124 | : setIsEdit(true) 125 | ).finally(() => { 126 | setLoading(false); 127 | }); 128 | }, 1); 129 | }, [setResults, pageUid, setLoading, defaultReturnNode]); 130 | useEffect(() => { 131 | if (!isEdit) { 132 | onRefresh(); 133 | } 134 | }, [isEdit, onRefresh]); 135 | useEffect(() => { 136 | const roamBlock = containerRef.current.closest(".rm-block-main"); 137 | if (roamBlock) { 138 | const sep = roamBlock.querySelector( 139 | ".rm-block-separator" 140 | ); 141 | if (sep) { 142 | sep.style.minWidth = "0"; 143 | } 144 | } 145 | }, []); 146 | useEffect(() => { 147 | const main = 148 | containerRef.current.closest(".rm-block-main") || 149 | containerRef.current.closest(".roamjs-query-page")?.parentElement; 150 | if ( 151 | main.nextElementSibling && 152 | main.nextElementSibling.classList.contains("rm-block-children") 153 | ) { 154 | main.nextElementSibling.classList.add("roamjs-query-builder-metadata"); 155 | } 156 | const container = containerRef.current.closest( 157 | "div.roamjs-query-builder-parent" 158 | ); 159 | if (container) { 160 | container.style.width = "unset"; 161 | } 162 | }, []); 163 | return ( 164 | 168 |
169 | {hideMetadata && ( 170 | 175 | )} 176 | {isEdit && ( 177 | <> 178 | setIsEdit(false)} 181 | defaultReturnNode={defaultReturnNode} 182 | /> 183 | 184 | )} 185 | {loading ? ( 186 |

187 | Loading Results... 188 |

189 | ) : hasResults ? ( 190 | setIsEdit(true)} 193 | getExportTypes={getExportTypes} 194 | header={ 195 | error ? ( 196 |
{error}
197 | ) : undefined 198 | } 199 | results={results.map(({ id, ...a }) => a)} 200 | onRefresh={onRefresh} 201 | // @ts-ignore 202 | isEditBlock={isEditBlock} 203 | /> 204 | ) : ( 205 | <> 206 | )} 207 |
208 |
209 | ); 210 | }; 211 | 212 | export const renderQueryBlock = createComponentRender( 213 | ({ blockUid }) => ( 214 | 220 | ), 221 | "roamjs-query-builder-parent" 222 | ); 223 | 224 | export const render = ({ 225 | parent, 226 | onloadArgs, 227 | ...props 228 | }: { parent: HTMLElement; onloadArgs: OnloadArgs } & Props) => 229 | ReactDOM.render( 230 | 231 | 232 | , 233 | parent 234 | ); 235 | 236 | export default QueryPage; 237 | -------------------------------------------------------------------------------- /src/components/Export.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Button, 3 | Classes, 4 | Dialog, 5 | Icon, 6 | InputGroup, 7 | Intent, 8 | Label, 9 | MenuItem, 10 | Spinner, 11 | SpinnerSize, 12 | Tooltip, 13 | } from "@blueprintjs/core"; 14 | import React, { useState } from "react"; 15 | import { BLOCK_REF_REGEX } from "roamjs-components/dom/constants"; 16 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; 17 | import type { TreeNode, ViewType, PullBlock } from "roamjs-components/types"; 18 | import MenuItemSelect from "roamjs-components/components/MenuItemSelect"; 19 | import { saveAs } from "file-saver"; 20 | import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; 21 | import getRoamUrl from "roamjs-components/dom/getRoamUrl"; 22 | import { 23 | Result, 24 | ExportDialogComponent, 25 | } from "roamjs-components/types/query-builder"; 26 | 27 | type Props = Parameters[0]; 28 | 29 | const viewTypeToPrefix = { 30 | bullet: "- ", 31 | document: "", 32 | numbered: "1. ", 33 | }; 34 | 35 | const collectUids = (t: TreeNode): string[] => [ 36 | t.uid, 37 | ...t.children.flatMap(collectUids), 38 | ]; 39 | 40 | const normalize = (t: string) => `${t.replace(/[<>:"/\\|?*[]]/g, "")}.md`; 41 | 42 | const titleToFilename = (t: string) => { 43 | const name = normalize(t); 44 | return name.length > 64 45 | ? `${name.substring(0, 31)}...${name.slice(-30)}` 46 | : name; 47 | }; 48 | 49 | const toMarkdown = ({ 50 | c, 51 | i = 0, 52 | v = "bullet", 53 | }: { 54 | c: TreeNode; 55 | i?: number; 56 | v?: ViewType; 57 | }): string => 58 | `${"".padStart(i * 4, " ")}${viewTypeToPrefix[v]}${ 59 | c.heading ? `${"".padStart(c.heading, "#")} ` : "" 60 | }${c.text 61 | .replace(BLOCK_REF_REGEX, (_, blockUid) => { 62 | const reference = getTextByBlockUid(blockUid); 63 | return reference || blockUid; 64 | }) 65 | .trim()}${(c.children || []) 66 | .filter((nested) => !!nested.text || !!nested.children?.length) 67 | .map( 68 | (nested) => 69 | `\n\n${toMarkdown({ c: nested, i: i + 1, v: c.viewType || v })}` 70 | ) 71 | .join("")}`; 72 | 73 | export const ExportDialog: ExportDialogComponent = ({ 74 | onClose, 75 | isOpen = true, 76 | results = [], 77 | exportTypes = [ 78 | { 79 | name: "CSV", 80 | callback: async ({ filename }) => { 81 | const resolvedResults = Array.isArray(results) 82 | ? results 83 | : await results(); 84 | const keys = Object.keys(resolvedResults[0]).filter( 85 | (u) => !/uid/i.test(u) 86 | ); 87 | const header = `${keys.join(",")}\n`; 88 | const data = resolvedResults 89 | .map((r) => 90 | keys 91 | .map((k) => r[k].toString()) 92 | .map((v) => (v.includes(",") ? `"${v}"` : v)) 93 | ) 94 | .join("\n"); 95 | return [ 96 | { 97 | title: `${filename.replace(/\.csv/, "")}.csv`, 98 | content: `${header}${data}`, 99 | }, 100 | ]; 101 | }, 102 | }, 103 | { 104 | name: "Markdown", 105 | callback: async () => 106 | (Array.isArray(results) ? results : await results()) 107 | .map(({ uid, ...rest }) => { 108 | const v = ( 109 | ( 110 | window.roamAlphaAPI.data.fast.q( 111 | `[:find (pull ?b [:children/view-type]) :where [?b :block/uid "${uid}"]]` 112 | )[0]?.[0] as PullBlock 113 | )?.[":children/view-type"] || ":bullet" 114 | ).slice(1) as ViewType; 115 | const treeNode = getFullTreeByParentUid(uid); 116 | 117 | const content = `---\nurl: ${getRoamUrl(uid)}\n${Object.keys(rest) 118 | .filter((k) => !/uid/i.test(k)) 119 | .map( 120 | (k) => `${k}: ${rest[k].toString()}` 121 | )}---\n\n${treeNode.children 122 | .map((c) => toMarkdown({ c, v, i: 0 })) 123 | .join("\n")}\n`; 124 | return { title: rest.text, content }; 125 | }) 126 | .map(({ title, content }) => ({ 127 | title: titleToFilename(title), 128 | content, 129 | })), 130 | }, 131 | ], 132 | }) => { 133 | const [loading, setLoading] = useState(false); 134 | const [error, setError] = useState(""); 135 | const today = new Date(); 136 | const [filename, setFilename] = useState( 137 | `${ 138 | window.roamAlphaAPI.graph.name 139 | }_query-results_${`${today.getFullYear()}${(today.getMonth() + 1) 140 | .toString() 141 | .padStart(2, "0")}${today.getDate().toString().padStart(2, "0")}${today 142 | .getHours() 143 | .toString() 144 | .padStart(2, "0")}${today.getMinutes().toString().padStart(2, "0")}`}` 145 | ); 146 | const [activeExportType, setActiveExportType] = useState( 147 | exportTypes[0].name 148 | ); 149 | const [graph, setGraph] = useState(""); 150 | return ( 151 | 160 |
161 | 178 | {activeExportType === "graph" && ( 179 | 186 | )} 187 | 194 | 195 | Exporting{" "} 196 | {typeof results === "function" ? "unknown number of" : results.length}{" "} 197 | results 198 | 199 |
200 |
201 |
202 | {error} 203 | {loading && } 204 |
245 |
246 |
247 | ); 248 | }; 249 | 250 | export default ExportDialog; 251 | -------------------------------------------------------------------------------- /src/components/QueryBuilder.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useRef, useState } from "react"; 2 | import { Button, MenuItem, Popover } from "@blueprintjs/core"; 3 | import { Select } from "@blueprintjs/select"; 4 | import getUidsFromId from "roamjs-components/dom/getUidsFromId"; 5 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid"; 6 | import { Icon } from "@blueprintjs/core"; 7 | import isControl from "roamjs-components/util/isControl"; 8 | import ReactDOM from "react-dom"; 9 | import PageInput from "roamjs-components/components/PageInput"; 10 | 11 | enum NODES { 12 | OR = "OR", 13 | AND = "AND", 14 | NOT = "NOT", 15 | BETWEEN = "BETWEEN", 16 | TAG = "TAG", 17 | } 18 | 19 | const NodeSelect = Select.ofType(); 20 | 21 | type QueryState = { 22 | type: NODES; 23 | key: number; 24 | value?: string; 25 | children?: QueryState[]; 26 | }; 27 | 28 | const toQueryString = (queryState: QueryState): string => { 29 | if (queryState.type === NODES.TAG) { 30 | return `[[${queryState.value}]]`; 31 | } else { 32 | const operator = queryState.type.toLocaleString().toLowerCase(); 33 | const children = queryState.children.map((q) => toQueryString(q)).join(" "); 34 | return `{${operator}:${children}}`; 35 | } 36 | }; 37 | 38 | const areEqual = (a: QueryState, b: QueryState): boolean => { 39 | if (a.type !== b.type) { 40 | return false; 41 | } 42 | if (a.type === NODES.TAG) { 43 | return a.value === b.value; 44 | } 45 | const aChildren = a.children; 46 | const bChildren = b.children; 47 | return ( 48 | aChildren.length === bChildren.length && 49 | aChildren.every((aa, i) => areEqual(aa, bChildren[i])) 50 | ); 51 | }; 52 | 53 | const colors = ["red", "green", "blue"]; 54 | 55 | const SubqueryContent = ({ 56 | value, 57 | onChange, 58 | level, 59 | onDelete, 60 | }: { 61 | value: QueryState; 62 | onChange: (e: QueryState) => void; 63 | level: number; 64 | onDelete?: () => void; 65 | }) => { 66 | const [key, setKey] = useState(value.children?.length || 0); 67 | const incrementKey = useCallback(() => { 68 | setKey(key + 1); 69 | return key + 1; 70 | }, [key, setKey]); 71 | const [queryState, setQueryState] = useState(value); 72 | useEffect(() => { 73 | if (!areEqual(value, queryState)) { 74 | onChange(queryState); 75 | } 76 | }, [queryState, onChange, value]); 77 | const onItemSelect = useCallback( 78 | (item) => 79 | setQueryState({ 80 | ...(item === NODES.TAG 81 | ? { value: queryState.value || "" } 82 | : { children: queryState.children || [] }), 83 | type: item, 84 | key: queryState.key, 85 | }), 86 | [setQueryState, queryState] 87 | ); 88 | const onSelectKeyDown = useCallback( 89 | (e: React.KeyboardEvent) => { 90 | if (e.key === "a") { 91 | onItemSelect(NODES.AND); 92 | } else if (e.key === "o") { 93 | onItemSelect(NODES.OR); 94 | } else if (e.key === "b") { 95 | onItemSelect(NODES.BETWEEN); 96 | } else if (e.key === "t" && level > 0) { 97 | onItemSelect(NODES.TAG); 98 | } else if (e.key === "n" && level > 0) { 99 | onItemSelect(NODES.NOT); 100 | } 101 | }, 102 | [onItemSelect, level] 103 | ); 104 | const onContainerKeyDown = useCallback( 105 | (e: React.KeyboardEvent) => { 106 | if (!!onDelete && e.key === "Backspace" && isControl(e.nativeEvent)) { 107 | onDelete(); 108 | e.stopPropagation(); 109 | } 110 | }, 111 | [onDelete] 112 | ); 113 | const onAddChild = useCallback( 114 | () => 115 | setQueryState({ 116 | type: queryState.type, 117 | children: [ 118 | ...queryState.children, 119 | { type: NODES.TAG, children: [], key: incrementKey() }, 120 | ], 121 | key: queryState.key, 122 | }), 123 | [setQueryState, incrementKey, queryState] 124 | ); 125 | const addChildButtonRef = useRef(null); 126 | return ( 127 |
128 |
129 | ( 138 | 144 | )} 145 | filterable={false} 146 | popoverProps={{ minimal: true, captureDismiss: true }} 147 | > 148 |
180 | {queryState.type !== NODES.TAG && ( 181 |
187 | {queryState.children.map((q, i) => ( 188 | { 191 | const children = queryState.children; 192 | children[i] = newQ; 193 | setQueryState({ 194 | type: queryState.type, 195 | key: queryState.key, 196 | children, 197 | }); 198 | }} 199 | level={level + 1} 200 | key={q.key} 201 | onDelete={() => { 202 | const children = queryState.children; 203 | if (i === children.length - 1) { 204 | addChildButtonRef.current.focus(); 205 | } 206 | delete children[i]; 207 | setQueryState({ 208 | type: queryState.type, 209 | key: queryState.key, 210 | children: children.filter((c) => !!c), 211 | }); 212 | }} 213 | /> 214 | ))} 215 | {(queryState.type !== NODES.NOT || 216 | (queryState.children?.length || 0) < 1) && ( 217 |
226 | )} 227 |
228 | ); 229 | }; 230 | 231 | const toQueryStateChildren = (v: string): QueryState[] => { 232 | let inParent = 0; 233 | let inTag = 0; 234 | let inHashTag = false; 235 | const children = []; 236 | let content = ""; 237 | for (let pointer = 0; pointer < v.length; pointer++) { 238 | const c = v.charAt(pointer); 239 | if (c === "{") { 240 | inParent++; 241 | } else if ( 242 | !inTag && 243 | ((c === "#" && 244 | v.charAt(pointer + 1) === "[" && 245 | v.charAt(pointer + 2) === "[") || 246 | (c === "[" && v.charAt(pointer + 1) === "[")) 247 | ) { 248 | inTag++; 249 | } else if (!inHashTag && c === "#" && !inTag) { 250 | inHashTag = true; 251 | } 252 | if (inParent || inTag || inHashTag) { 253 | content = `${content}${c}`; 254 | } 255 | if (inParent > 0 && c === "}") { 256 | inParent--; 257 | } else if (inTag > 0 && c === "]" && v.charAt(pointer - 1) === "]") { 258 | inTag--; 259 | } else if (inHashTag && /(\s|\])/.test(c)) { 260 | inHashTag = false; 261 | } 262 | if (inParent === 0 && inTag === 0 && !inHashTag && content) { 263 | children.push({ ...toQueryState(content.trim()), key: children.length }); 264 | content = ""; 265 | } 266 | } 267 | 268 | return children; 269 | }; 270 | 271 | const QUERY_REGEX = /{{(?:query|\[\[query\]\]):(.*)}}/; 272 | const toQueryState = (v: string): QueryState => { 273 | if (!v) { 274 | return { 275 | type: NODES.AND, 276 | children: [] as QueryState[], 277 | key: 0, 278 | }; 279 | } 280 | if (QUERY_REGEX.test(v)) { 281 | const content = v.match(QUERY_REGEX)[1]; 282 | return toQueryState(content.trim()); 283 | } else if (v.startsWith("{and:")) { 284 | const andContent = v.substring("{and:".length, v.length - "}".length); 285 | const children = toQueryStateChildren(andContent.trim()); 286 | return { 287 | type: NODES.AND, 288 | children, 289 | key: 0, 290 | }; 291 | } else if (v.startsWith("{or:")) { 292 | const orContent = v.substring("{or:".length, v.length - "}".length); 293 | const children = toQueryStateChildren(orContent.trim()); 294 | return { 295 | type: NODES.OR, 296 | children, 297 | key: 0, 298 | }; 299 | } else if (v.startsWith("{between:")) { 300 | const betweenContent = v.substring( 301 | "{between:".length, 302 | v.length - "}".length 303 | ); 304 | const children = toQueryStateChildren(betweenContent.trim()); 305 | return { 306 | type: NODES.BETWEEN, 307 | children, 308 | key: 0, 309 | }; 310 | } else if (v.startsWith("{not:")) { 311 | const notContent = v.substring("{not:".length, v.length - "}".length); 312 | const children = toQueryStateChildren(notContent.trim()); 313 | return { 314 | type: NODES.NOT, 315 | children, 316 | key: 0, 317 | }; 318 | } else if (v.startsWith("#[[")) { 319 | const value = v.substring("#[[".length, v.length - "]]".length); 320 | return { 321 | type: NODES.TAG, 322 | value, 323 | key: 0, 324 | }; 325 | } else if (v.startsWith("[[")) { 326 | const value = v.substring("[[".length, v.length - "]]".length); 327 | return { 328 | type: NODES.TAG, 329 | value, 330 | key: 0, 331 | }; 332 | } else if (v.startsWith("#")) { 333 | const value = v.substring("#".length); 334 | return { 335 | type: NODES.TAG, 336 | value, 337 | key: 0, 338 | }; 339 | } else { 340 | return { 341 | type: NODES.AND, 342 | children: [] as QueryState[], 343 | key: 0, 344 | }; 345 | } 346 | }; 347 | 348 | const QueryContent = ({ 349 | blockId, 350 | initialValue, 351 | close, 352 | }: { 353 | blockId: string; 354 | initialValue: string; 355 | close: () => void; 356 | }) => { 357 | const [queryState, setQueryState] = useState( 358 | toQueryState(initialValue) 359 | ); 360 | const onSave = useCallback(async () => { 361 | const outputText = `{{[[query]]: ${toQueryString(queryState)}}}`; 362 | const { blockUid } = getUidsFromId(blockId); 363 | const text = getTextByBlockUid(blockUid); 364 | const newText = initialValue 365 | ? text.replace(initialValue, outputText) 366 | : text.replace(/{{(\[\[)?(qb|query builder)(\]\])?}}/, outputText); 367 | window.roamAlphaAPI.updateBlock({ 368 | block: { string: newText, uid: blockUid }, 369 | }); 370 | close(); 371 | }, [queryState, close, initialValue]); 372 | 373 | return ( 374 |
375 | 376 |
377 |
379 |
380 | ); 381 | }; 382 | 383 | const QueryBuilder = ({ 384 | blockId, 385 | defaultIsOpen, 386 | initialValue = "", 387 | }: { 388 | blockId: string; 389 | defaultIsOpen: boolean; 390 | initialValue?: string; 391 | }): JSX.Element => { 392 | const [isOpen, setIsOpen] = useState(defaultIsOpen); 393 | const open = useCallback(() => setIsOpen(true), [setIsOpen]); 394 | const close = useCallback(() => setIsOpen(false), [setIsOpen]); 395 | return ( 396 | 403 | } 404 | target={ 405 |