├── .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 | {
28 | const newTexts = [...texts, value];
29 | setTexts(newTexts);
30 | extensionAPI.settings.set("query-pages", newTexts);
31 | setValue("");
32 | }}
33 | />
34 |
35 | {texts.map((p, index) => (
36 |
37 |
44 | {p}
45 |
46 | {
50 | const newTexts = texts.filter((_, jndex) => index !== jndex);
51 | setTexts(newTexts);
52 | extensionAPI.settings.set("query-pages", newTexts);
53 | }}
54 | />
55 |
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 | {
75 | onFilterAdd({ text: newFilter, type: "includes" });
76 | setNewFilter("");
77 | }}
78 | />
79 | {
83 | onFilterAdd({ text: newFilter, type: "excludes" });
84 | setNewFilter("");
85 | }}
86 | />
87 |
88 |
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 | {
179 | const newFilters = {
180 | ...filters,
181 | [newColumn]: {
182 | includes: { values: new Set() },
183 | excludes: { values: new Set() },
184 | },
185 | };
186 | setFilters(newFilters);
187 | extensionAPI.settings.set("default-filters", newFilters);
188 | setNewColumn("");
189 | }}
190 | />
191 |
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 |
162 | Export Type
163 | e.name)
167 | .filter(
168 | (t) =>
169 | window.roamjs.loaded.has("samepage") ||
170 | window.roamjs.loaded.has("multiplayer") ||
171 | t !== "graph"
172 | ),
173 | ]}
174 | activeItem={activeExportType}
175 | onItemSelect={(et) => setActiveExportType(et)}
176 | />
177 |
178 | {activeExportType === "graph" && (
179 |
180 | Graph
181 | setGraph(et.target.value)}
184 | />
185 |
186 | )}
187 |
188 | Filename
189 | setFilename(e.target.value)}
192 | />
193 |
194 |
195 | Exporting{" "}
196 | {typeof results === "function" ? "unknown number of" : results.length}{" "}
197 | results
198 |
199 |
200 |
201 |
202 | {error}
203 | {loading && }
204 | {
208 | setLoading(true);
209 | setError("");
210 | setTimeout(async () => {
211 | try {
212 | const exportType = exportTypes.find(
213 | (e) => e.name === activeExportType
214 | );
215 | if (exportType) {
216 | const zip = await window.RoamLazy.JSZip().then(
217 | (j) => new j()
218 | );
219 | const files = await exportType.callback({
220 | filename,
221 | graph,
222 | });
223 | if (!files.length) {
224 | onClose();
225 | } else {
226 | files.forEach(({ title, content }) =>
227 | zip.file(title, content)
228 | );
229 | zip.generateAsync({ type: "blob" }).then((content) => {
230 | saveAs(content, `${filename}.zip`);
231 | onClose();
232 | });
233 | }
234 | }
235 | } catch (e) {
236 | setError(e.message);
237 | setLoading(false);
238 | }
239 | }, 1);
240 | }}
241 | style={{ minWidth: 64 }}
242 | disabled={loading}
243 | />
244 |
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 |
154 |
155 | {queryState.type === NODES.TAG && (
156 |
157 |
160 | setQueryState({
161 | type: queryState.type,
162 | value,
163 | key: queryState.key,
164 | })
165 | }
166 | />
167 |
168 | )}
169 | {!!onDelete && (
170 |
178 | )}
179 |
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 |
224 | )}
225 |
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 |
378 |
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 | : "QUERY"}
407 | id={`roamjs-query-builder-button-${blockId}`}
408 | onClick={open}
409 | />
410 | }
411 | isOpen={isOpen}
412 | onInteraction={setIsOpen}
413 | />
414 | );
415 | };
416 |
417 | export const renderQueryBuilder = ({
418 | blockId,
419 | parent,
420 | initialValue,
421 | }: {
422 | blockId: string;
423 | parent: HTMLElement;
424 | initialValue?: string;
425 | }): void =>
426 | ReactDOM.render(
427 | ,
432 | parent
433 | );
434 |
435 | export default QueryBuilder;
436 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import createButtonObserver from "roamjs-components/dom/createButtonObserver";
2 | import createHTMLObserver from "roamjs-components/dom/createHTMLObserver";
3 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
4 | import getUidsFromId from "roamjs-components/dom/getUidsFromId";
5 | import { renderQueryBuilder } from "./components/QueryBuilder";
6 | import runExtension from "roamjs-components/util/runExtension";
7 | import addStyle from "roamjs-components/dom/addStyle";
8 | import getSubTree from "roamjs-components/util/getSubTree";
9 | import getPageTitleValueByHtmlElement from "roamjs-components/dom/getPageTitleValueByHtmlElement";
10 | import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle";
11 | import QueryPage, {
12 | render as renderQueryPage,
13 | renderQueryBlock,
14 | } from "./components/QueryPage";
15 | import QueryEditor from "./components/QueryEditor";
16 | import ResultsView from "./components/ResultsView";
17 | import fireQuery, {
18 | registerSelection,
19 | getWhereClauses,
20 | getDatalogQueryComponents,
21 | } from "./utils/fireQuery";
22 | import parseQuery from "./utils/parseQuery";
23 | import conditionToDatalog, {
24 | getConditionLabels,
25 | registerDatalogTranslator,
26 | unregisterDatalogTranslator,
27 | } from "./utils/conditionToDatalog";
28 | import runQueryTools from "./utils/runQueryTools";
29 | import { ExportDialog } from "./components/Export";
30 | import DefaultFilters from "./components/DefaultFilters";
31 | import registerSmartBlocksCommand from "roamjs-components/util/registerSmartBlocksCommand";
32 | import extractRef from "roamjs-components/util/extractRef";
33 | import type { InputTextNode, PullBlock } from "roamjs-components/types/native";
34 | import migrateLegacySettings from "roamjs-components/util/migrateLegacySettings";
35 | import QueryPagesPanel, { getQueryPages } from "./components/QueryPagesPanel";
36 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree";
37 | import runQuery from "./utils/runQuery";
38 | import ExtensionApiContextProvider from "roamjs-components/components/ExtensionApiContext";
39 | import React from "react";
40 |
41 | const loadedElsewhere = document.currentScript
42 | ? !!document.currentScript.getAttribute("data-source")
43 | : false;
44 |
45 | export default runExtension({
46 | migratedTo: loadedElsewhere ? undefined : "Query Builder",
47 | run: async (onloadArgs) => {
48 | const { extensionAPI } = onloadArgs;
49 | const style = addStyle(`.bp3-button:focus {
50 | outline-width: 2px;
51 | }
52 |
53 | .roamjs-query-condition-source,
54 | .roamjs-query-condition-relation,
55 | .roamjs-query-return-node {
56 | min-width: 144px;
57 | max-width: 144px;
58 | }
59 |
60 | .roamjs-query-condition-relation,
61 | .roamjs-query-return-node {
62 | padding-right: 8px;
63 | }
64 |
65 | .roamjs-query-condition-target {
66 | flex-grow: 1;
67 | min-width: 260px;
68 | }
69 |
70 | .roamjs-query-condition-relation .bp3-popover-target,
71 | .roamjs-query-condition-target .roamjs-autocomplete-input-target {
72 | width: 100%
73 | }
74 |
75 | .roamjs-query-hightlighted-result {
76 | background: #FFFF00;
77 | }
78 |
79 | .roamjs-query-embed .rm-block-separator {
80 | display: none;
81 | }
82 |
83 | /* width */
84 | .roamjs-query-results-view ul::-webkit-scrollbar {
85 | width: 6px;
86 | }
87 |
88 | /* Handle */
89 | .roamjs-query-results-view ul::-webkit-scrollbar-thumb {
90 | background: #888;
91 | }
92 |
93 | .roamjs-query-builder-parent .roamjs-edit-component {
94 | display: none;
95 | }
96 |
97 | .roamjs-query-results-view thead td .bp3-button,
98 | .roamjs-query-results-view thead td .bp3-button svg,
99 | .roamjs-query-results-view thead td .bp3-icon svg {
100 | width: 12px;
101 | height: 12px;
102 | min-width: 12px;
103 | min-height: 12px;
104 | }`);
105 | migrateLegacySettings({
106 | extensionAPI,
107 | extensionId: process.env.ROAMJS_EXTENSION_ID,
108 | specialKeys: {
109 | "Query Pages": (n) => [
110 | { value: n.children.map((c) => c.text), key: "query-pages" },
111 | ],
112 | "Default Filters": (n) => [
113 | {
114 | key: "default-filters",
115 | value: Object.fromEntries(
116 | n.children.map((c) => [
117 | c.text,
118 | {
119 | includes: {
120 | values: new Set(
121 | getSubTree({
122 | tree: c.children,
123 | key: "includes",
124 | }).children.map((i) => i.text)
125 | ),
126 | },
127 | excludes: {
128 | values: new Set(
129 | getSubTree({
130 | tree: c.children,
131 | key: "excludes",
132 | }).children.map((i) => i.text)
133 | ),
134 | },
135 | },
136 | ])
137 | ),
138 | },
139 | ],
140 | "Native Queries": (n) =>
141 | [
142 | {
143 | key: "sort-blocks",
144 | value: !!getSubTree({ key: "Sort Blocks", tree: n.children }).uid,
145 | },
146 | {
147 | key: "context",
148 | value: getSettingValueFromTree({
149 | key: "Context",
150 | tree: n.children,
151 | }),
152 | },
153 | ].filter((o) => typeof o.value !== "undefined"),
154 | },
155 | });
156 |
157 | extensionAPI.settings.panel.create({
158 | tabTitle: "Query Builder",
159 | settings: [
160 | {
161 | id: "query-pages",
162 | name: "Query Pages",
163 | description:
164 | "The title formats of pages that you would like to serve as pages that generate queries",
165 | action: {
166 | type: "reactComponent",
167 | component: QueryPagesPanel(extensionAPI),
168 | },
169 | },
170 | {
171 | id: "hide-metadata",
172 | name: "Hide Metadata",
173 | description: "Hide the Roam blocks that are used to power each query",
174 | action: {
175 | type: "switch",
176 | },
177 | },
178 | {
179 | id: "default-filters",
180 | name: "Default Filters",
181 | description:
182 | "Any filters that should be applied to your results by default",
183 | action: {
184 | type: "reactComponent",
185 | component: DefaultFilters(extensionAPI),
186 | },
187 | },
188 | {
189 | id: "default-page-size",
190 | name: "Default Page Size",
191 | description: "The default page size used for query results",
192 | action: {
193 | type: "input",
194 | placeholder: "10",
195 | },
196 | },
197 | {
198 | id: "sort-blocks",
199 | name: "Sort Blocks",
200 | action: { type: "switch" },
201 | description:
202 | "Whether to sort the blocks within the pages returned by native roam queries instead of the pages themselves.",
203 | },
204 | {
205 | id: "context",
206 | name: "Context",
207 | action: { type: "input", placeholder: "1" },
208 | description:
209 | "How many levels of context to include with each query result for all queries by default",
210 | },
211 | {
212 | id: "default-sort",
213 | action: {
214 | type: "select",
215 | items: [
216 | "Alphabetically",
217 | "Alphabetically Descending",
218 | "Word Count",
219 | "Word Count Descending",
220 | "Created Date",
221 | "Created Date Descending",
222 | "Edited Date",
223 | "Edited Date Descending",
224 | "Daily Note",
225 | "Daily Note Descending",
226 | ],
227 | },
228 | name: "Default Sort",
229 | description:
230 | "The default sorting all native queries in your graph should use",
231 | },
232 | ],
233 | });
234 |
235 | const h1Observer = createHTMLObserver({
236 | tag: "H1",
237 | className: "rm-title-display",
238 | callback: (h1: HTMLHeadingElement) => {
239 | const title = getPageTitleValueByHtmlElement(h1);
240 | if (
241 | getQueryPages(extensionAPI)
242 | .map(
243 | (t) =>
244 | new RegExp(
245 | `^${t.replace(/\*/g, ".*").replace(/([()])/g, "\\$1")}$`
246 | )
247 | )
248 | .some((r) => r.test(title))
249 | ) {
250 | const uid = getPageUidByPageTitle(title);
251 | const attribute = `data-roamjs-${uid}`;
252 | const containerParent = h1.parentElement?.parentElement;
253 | if (containerParent && !containerParent.hasAttribute(attribute)) {
254 | containerParent.setAttribute(attribute, "true");
255 | const parent = document.createElement("div");
256 | const configPageId = title.split("/").slice(-1)[0];
257 | parent.id = `${configPageId}-config`;
258 | containerParent.insertBefore(
259 | parent,
260 | h1.parentElement?.nextElementSibling || null
261 | );
262 | renderQueryPage({
263 | pageUid: uid,
264 | parent,
265 | defaultReturnNode: "node",
266 | onloadArgs,
267 | });
268 | }
269 | }
270 | },
271 | });
272 |
273 | const queryBlockObserver = createButtonObserver({
274 | attribute: "query-block",
275 | render: (b) => renderQueryBlock(b, onloadArgs),
276 | });
277 |
278 | const originalQueryBuilderObserver = createButtonObserver({
279 | shortcut: "qb",
280 | attribute: "query-builder",
281 | render: (b: HTMLButtonElement) =>
282 | renderQueryBuilder({
283 | blockId: b.closest(".roam-block").id,
284 | parent: b.parentElement,
285 | }),
286 | });
287 |
288 | const dataAttribute = "data-roamjs-edit-query";
289 | const editQueryBuilderObserver = createHTMLObserver({
290 | callback: (b) => {
291 | if (!b.getAttribute(dataAttribute)) {
292 | b.setAttribute(dataAttribute, "true");
293 | const editButtonRoot = document.createElement("div");
294 | b.appendChild(editButtonRoot);
295 | const blockId = b.closest(".roam-block").id;
296 | const initialValue = getTextByBlockUid(
297 | getUidsFromId(blockId).blockUid
298 | );
299 | renderQueryBuilder({
300 | blockId,
301 | parent: editButtonRoot,
302 | initialValue,
303 | });
304 | const editButton = document.getElementById(
305 | `roamjs-query-builder-button-${blockId}`
306 | );
307 | editButton.addEventListener("mousedown", (e) => e.stopPropagation());
308 | }
309 | },
310 | tag: "DIV",
311 | className: "rm-query-title",
312 | });
313 |
314 | const qtObserver = runQueryTools(extensionAPI);
315 |
316 | registerSmartBlocksCommand({
317 | text: "QUERYBUILDER",
318 | delayArgs: true,
319 | help: "Run an existing query block and output the results.\n\n1. The reference to the query block\n2. The format to output each result",
320 | handler:
321 | ({ proccessBlockText }) =>
322 | (queryUid, format = "(({uid}))") => {
323 | const parentUid = extractRef(queryUid);
324 | return runQuery(parentUid, extensionAPI).then(({ allResults }) => {
325 | return allResults
326 | .map((r) =>
327 | format.replace(/{([^}]+)}/, (_, i: string) => {
328 | const value = r[i];
329 | return typeof value === "string"
330 | ? value
331 | : typeof value === "number"
332 | ? value.toString()
333 | : value instanceof Date
334 | ? window.roamAlphaAPI.util.dateToPageTitle(value)
335 | : "";
336 | })
337 | )
338 | .map((s) => () => proccessBlockText(s))
339 | .reduce(
340 | (prev, cur) => prev.then((p) => cur().then((c) => p.concat(c))),
341 | Promise.resolve([] as InputTextNode[])
342 | );
343 | });
344 | },
345 | });
346 |
347 | window.roamjs.extension.queryBuilder = {
348 | ExportDialog,
349 | QueryEditor,
350 | QueryPage: (props) =>
351 | React.createElement(
352 | ExtensionApiContextProvider,
353 | onloadArgs,
354 | React.createElement(QueryPage, props)
355 | ),
356 | ResultsView: (props) =>
357 | React.createElement(
358 | ExtensionApiContextProvider,
359 | onloadArgs,
360 | React.createElement(ResultsView, props)
361 | ),
362 | fireQuery,
363 | parseQuery,
364 | conditionToDatalog,
365 | getConditionLabels,
366 | registerSelection,
367 | registerDatalogTranslator,
368 | unregisterDatalogTranslator,
369 |
370 | // @ts-ignore This is used in d-g for the "involved with query" condition. Will be migrated here after idea is proven
371 | getWhereClauses,
372 | // @ts-ignore This is highly experimental - exposing this method for use in D-G.
373 | getDatalogQueryComponents,
374 |
375 | // ALL TYPES ABOVE THIS COMMENT ARE SCHEDULED TO MOVE BACK INTO QUERY BUILDER AS INTERNAL
376 |
377 | runQuery: (parentUid: string) =>
378 | runQuery(parentUid, extensionAPI).then(({ allResults }) => allResults),
379 | listActiveQueries: () =>
380 | (
381 | window.roamAlphaAPI.data.fast.q(
382 | `[:find (pull ?b [:block/uid]) :where [or-join [?b]
383 | [and [?b :block/string ?s] [[clojure.string/includes? ?s "{{query block}}"]] ]
384 | ${getQueryPages(extensionAPI).map(
385 | (p) =>
386 | `[and [?b :node/title ?t] [[re-pattern "^${p.replace(
387 | /\*/,
388 | ".*"
389 | )}$"] ?regex] [[re-find ?regex ?t]]]`
390 | )}
391 | ]]`
392 | ) as [PullBlock][]
393 | ).map((b) => ({ uid: b[0][":block/uid"] })),
394 | };
395 |
396 | return {
397 | elements: [style],
398 | observers: [
399 | h1Observer,
400 | qtObserver,
401 | originalQueryBuilderObserver,
402 | editQueryBuilderObserver,
403 | queryBlockObserver,
404 | ],
405 | };
406 | },
407 | unload: () => {
408 | delete window.roamjs.extension.queryBuilder;
409 | window.roamjs.extension?.smartblocks?.unregisterCommand("QUERYBUILDER");
410 | },
411 | });
412 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Query Builder
2 |
3 | Introduces new user interfaces for building queries in Roam.
4 |
5 | 
6 |
7 | For more information, check out our docs at [https://roamjs.com/extensions/query-builder](https://roamjs.com/extensions/query-builder)
8 |
9 | > NOTE: If your are a user of the RoamJS extension [Discourse Graph](https://roamjs.com/extensions/discourse-graph), do NOT install this extension, as Discourse Graph bundles its own version of Query Builder. In the future, Discourse Graph will be reintegrated back into Query Builder as an advanced set of features.
10 |
11 | ## Nomenclature
12 |
13 | There are some important terms to know and have exact definitions on since they will be used throughout the docs.
14 | - `Page` - A Page is anything in Roam that was created with `[[brackets]]`, `#hashtag`, `#[[hashtag with brackts]]`, 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.
15 | - `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.
16 | - `Node` - A superset of `Block`s and `Page`s.
17 |
18 | ## Query Pages
19 |
20 | With Query Pages, you could designate certain pages in your Roam graph as "views" into your data. These queries are far more powerful than vanilla Roam queries, as it taps into Roam's underlying query language surfaced through an approachable UI.
21 |
22 | ### Setup
23 |
24 | On the Roam Depot Settings page for Query Builder, you should see a setting called `Query Pages`. You could use this to denote which page titles in your Roam Graph will be used to create query pages. Use the `*` as a wildcard.
25 |
26 | By default, Query Pages is set to be titled with `queries/*`. This means any page in your graph prefixed with `queries/` can be used to save a query. You can denote multiple page title formats.
27 |
28 | ### Usage
29 |
30 | Navigate to any valid query page in your graph and you should see a Query Editor on the page:
31 |
32 | 
33 |
34 | You can use this editor to create and save a query. There are two important parts to a query: **Conditions** and **Selections**.
35 |
36 | After specifying conditions and selections, hit the `Query` button to return results in your graph. These results will always include a `text` field which will link to the relevant block or page reference. Hitting `Query` also effectively "Saves" the query to the graph.
37 |
38 | The results returned will be organized in a table with sortable and filterable columns. Click on the columns to sort the data and use the input on the top right to filter your table to desired results:
39 |
40 | 
41 |
42 | ### Conditions
43 |
44 | **Conditions** specify which blocks you want to return. They determine the **rows** of the table. The anatomy of a Condition is a triple: `source`, `relationship`, & `target`:
45 |
46 | 
47 |
48 | You will use a combination of multiple conditions to select the data you want. Here are all the supported relationships:
49 |
50 | - `references` - The `source` block or page references the `target` block or page.
51 | - `references title` - The `source` block or page references a page with `target` as the title. If `target` is equal to `{date}`, then it matches any Daily Note Page. Supports date NLP, e.g. `{date:today}`.
52 | - `is referenced by` - The `source` block or page is referenced by the `target` block or page.
53 | - `is in page` - The `source` block is in the `target` page.
54 | - `is in page with title` - The `source` block is in a page with title `target`. If `target` is equal to `{date}`, then it matches any Daily Note Page. Supports date NLP, e.g. `{date:today}`.
55 | - `has title` - The `source` page has the exact text `target` as a title. If `target` is equal to `{date}`, then it matches any Daily Note Page. Supports date NLP, e.g. `{date:today}`.
56 | - `has attribute` - The `source` block or page has an attribute with value `target`.
57 | - `has child` - The `source` block or page has the `target` block as a child.
58 | - `has ancestor` - The `source` block has the `target` block or page as an ancestor up the outliner tree.
59 | - `has descendent` - The `source` block or page has the `target` block as a descendant somewhere down the outliner tree
60 | - `with text` - The `source` block or page has the exact text `target` somewhere in its block text or page title
61 | - `created by` - The `source` block or page was created by the user with a display name of `target`
62 | - `edited by` - The `source` block or page was last edited by the user with a display name of `target`
63 | - `with title in text` - The `source` page has the exact text `target` somewhere in its page title.
64 | - `created before` - The `source` block or page was created before the naturally specified `target`
65 | - `created after` - The `source` block or page was created after the naturally specified `target`
66 | - `edited before` - The `source` block or page was edited before the naturally specified `target`
67 | - `edited after` - The `source` block or page was edited after the naturally specified `target`
68 | - `titled before` - The `source` page is a DNP that is befor the naturally specified `target`
69 | - `titled after` - The `source` page is a DNP that is after the naturally specified `target`
70 |
71 | ### Selections
72 |
73 | **Selections** specify what data from the blocks that match your conditions get returned. They determine the **columns** of the table. By default, the block text or page title is always returned and hyperlinked. Every selection is made up of two parts: the `label` and the `data`:
74 |
75 | 
76 |
77 | The `label`, which gets specified after **AS**, denotes the name of the column that gets used. The `data`, which gets specified after **Select**, denotes what kind of data to return. The following data types are supported:
78 |
79 | - `Created Date` - The date the block or page was created
80 | - `Created Time` - Same as above, but in `hh:mm` format
81 | - `Edited Date` - The date the block or page was edited
82 | - `Edited Time` - Same as above, but in `hh:mm` format
83 | - `Author` - The user who created the block or page
84 | - `Last Edited By` - The user who created the block or page
85 | - `node:{node}` - Returns any intermediary node you defined in one of the conditions. For example, `node:page` will return the title of a `page` referenced in a condition.
86 | - `node:{node}:{field}` - Specify one of the first five options as the field to return the related metadata for the intermediary node. For example:
87 | - `node:page:Author` will return the user who created the `page` defined in a condition.
88 | - `node:page:Client` will return the value of the `Client` attribute from the `page` node defined in a condition.
89 | - Anything else is assumed to be an attribute of the exact text
90 |
91 | You can also use the aliases in previous selects to derive values for future columns. The following derived selects are supported:
92 |
93 | - `add({alias1}, {alias2})` - Add the values of two columns. Supports adding values to dates. If one of the aliases is `today`, then today's date will be used. - `subtract({alias1}, {alias2})`
94 | - Subtract the values betweenn two columns. Supports adding values to dates. If one of the aliases is `today`, then today's date will be used.
95 |
96 | 
97 |
98 | ### Manipulating Results
99 |
100 | After you fire a query, the results will output in a table view. There are multiple ways to post process these results after they output to the screen.
101 |
102 | Clicking on the table header for a given column will trigger an alphabetical sort. Clicking again will toggle descending order. Clicking once more will toggle the sort off. You could have multiple columns selected for sorting:
103 |
104 | 
105 |
106 | Each column is also filterable. The filter works just like the page and reference filters in native Roam, where you could pick values to include and remove:
107 |
108 | 
109 |
110 | Each column also has a view type. Choosing a view type will change how the cell is displayed in the table. The supported view types are:
111 |
112 | - `plain` - Outputted as just plain text
113 | - `link` - If the column is a block, cells will be outputted as a link to the block. If the column is a page, cells will be outputted as a link to the page.
114 | - `embed` - Embeds the contents of the block or page in the cell.
115 |
116 | At any point, you could save the selected filters, sorts, and views so that any time you return to the query, they are applied automatically:
117 |
118 | 
119 |
120 | ### Layouts
121 |
122 | By default, the query builder will use the `Table` layout. You can switch to a different layout by hitting the more menu on the top right of the results and clicking on the `Layout` option. The following values are also supported:
123 | - `Line` - Displays your data as a line chart. You need to have at least **two** selections for this layout to work, where the first is a selection that returns date values and all subsequent selections return numeric values.
124 | - `Bar` - Displays your data as a bar chart. You need to have at least **two** selections for this layout to work, where the first is a selection that returns date values and all subsequent selections return numeric values.
125 | - `Timeline` - Displays your data as an interactive timeline view. You need to have a selection chosen labelled **Date** that returns date values for this layout to work.
126 |
127 | ### Exporting
128 |
129 | Next to the save button is a button that will allow you to export your results. There are currently two formats available to export to:
130 |
131 | - CSV - All the columns in the table will become columns in the CSV
132 | - Markdown - The columns will become frontmatter data and the children of the block or page will become markdown content.
133 |
134 | ### Query Blocks
135 |
136 | The above component is also available as a block component. This allows you to create several on a page, wherever on the page you want. To create one, simply add `{{query block}}` to any block on the page.
137 |
138 | ### Styling
139 |
140 | Every Query Page is rooted with a `div` that has an `id` of `roamjs-query-page-${uid}` where `uid` is the block refence of the query block or the page reference of the page. You could use this id to style individual queries with affecting other ones.
141 |
142 | ### Developer API
143 |
144 | For developers of other extensions who want to use the queries defined by users, we expose the following API, available on the global `window.roamjs.extension.queryBuilder` object:
145 | - `listActiveQueries` - `() => { uid: string }[]` Returns an array of blocks or pages where the user has a query defined from query builder.
146 | - `runQuery` - `(uid: string) => Promise` Runs the query defined at the input `uid` and returns a promise that resolves to the array of results from the user's graphs. `Result`s have the following schema:
147 | - `text` - `string` The page title or block text of the primary node involved in the result.
148 | - `uid` - `string` The reference of the primary node involved in the result.
149 | - `${string}-uid` - `string` If the users define selections that return intermediary nodes, the reference of those nodes will always end in `-uid` and will always be of type `string`.
150 | - `{string}` - `string | number | Date` All other fields returned in the result can be any of the primitive value types.
151 |
152 | ### Demo
153 |
154 |
155 |
156 | [View on Loom](https://www.loom.com/share/12bdc4c42cf8449e8b7a712fe285a072)
157 |
158 | ## Creating Vanilla Roam Queries
159 |
160 | In a block, type `{{query builder}}`. Similar to date picker, there will be an overlay that appears next to the query builder button. After specifying different query components that you're interested in searching, hit save to insert the query syntax into the block.
161 |
162 | 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:
163 |
164 | - Arrow Up/Arrow Down - Navigate Options
165 | - Enter - Open Dropdown
166 | - a - Select 'AND'
167 | - o - Select 'OR'
168 | - b - Select 'BETWEEN'
169 | - t - Select 'TAG'
170 | - n - Select 'NOT'
171 |
172 | 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.
173 | There will also be an edit button rendered on any existing query. Clicking the builder will overlay the Query Builder to edit the existing query!
174 |
175 | [Demo](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)
176 |
177 | ---
178 |
179 | ## Manipulating Native Roam Queries
180 |
181 | 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.
182 |
183 | - `Default Sort` - The default sorting all native queries in your graph should use
184 | - `Sort Blocks` - If set to 'True', sort the query results by blocks instead of pages.
185 | - `Context` - The default value for Context for all queries. See below.
186 |
187 | ### Sorting
188 |
189 | 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 with the following options:
190 |
191 | - Sort By Page Title - This will sort all the query results in ascending alphabetical order of the page title.
192 | - Sort By Page Title Descending - This will sort all the query results in descending alphabetical order of the page title.
193 | - Sort By Word Count - This will sort all the query results in ascending order of the word count.
194 | - Sort By Word Count Descending - This will sort all the query results in descending alphabetical order of the word count.
195 | - Sort By Created Date - This will sort all the query results in ascending order that the page was created.
196 | - Sort By Created Date Descending - This will sort all the query results in descending order that the page was created.
197 | - Sort By Edited Date - This will sort all the query results in ascending order that the page was last edited.
198 | - Sort By Edited Date Descending - This will sort all the query results in descending order that the page was last edited.
199 | - 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.
200 | - 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.
201 |
202 | 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.
203 |
204 | 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.
205 |
206 | ### Randomization
207 |
208 | 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.
209 |
210 | ### Context
211 |
212 | 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.
213 |
214 | ### Aliases
215 |
216 | 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.
217 |
--------------------------------------------------------------------------------
/src/utils/fireQuery.ts:
--------------------------------------------------------------------------------
1 | import conditionToDatalog from "../utils/conditionToDatalog";
2 | import type {
3 | RegisterSelection,
4 | Result as SearchResult,
5 | } from "roamjs-components/types/query-builder";
6 | import normalizePageTitle from "roamjs-components/queries/normalizePageTitle";
7 | import type {
8 | PullBlock,
9 | DatalogAndClause,
10 | DatalogClause,
11 | } from "roamjs-components/types";
12 | import compileDatalog from "roamjs-components/queries/compileDatalog";
13 | import { DAILY_NOTE_PAGE_REGEX } from "roamjs-components/date/constants";
14 | import { getNodeEnv } from "roamjs-components/util/env";
15 |
16 | type PredefinedSelection = Parameters[0];
17 |
18 | const isVariableExposed = (
19 | clauses: (DatalogClause | DatalogAndClause)[],
20 | variable: string
21 | ): boolean =>
22 | clauses.some((c) => {
23 | switch (c.type) {
24 | case "data-pattern":
25 | case "fn-expr":
26 | case "pred-expr":
27 | case "rule-expr":
28 | return c.arguments.some((a) => a.value === variable);
29 | case "not-clause":
30 | case "or-clause":
31 | case "and-clause":
32 | return isVariableExposed(c.clauses, variable);
33 | case "not-join-clause":
34 | case "or-join-clause":
35 | return c.variables.some((v) => v.value === variable);
36 | default:
37 | return false;
38 | }
39 | });
40 |
41 | const firstVariable = (clause: DatalogClause | DatalogAndClause): string => {
42 | if (
43 | clause.type === "data-pattern" ||
44 | clause.type === "fn-expr" ||
45 | clause.type === "pred-expr" ||
46 | clause.type === "rule-expr"
47 | ) {
48 | return [...clause.arguments].find((v) => v.type === "variable")?.value;
49 | } else if (
50 | clause.type === "not-clause" ||
51 | clause.type === "or-clause" ||
52 | clause.type === "and-clause"
53 | ) {
54 | return firstVariable(clause.clauses[0]);
55 | } else if (
56 | clause.type === "not-join-clause" ||
57 | clause.type === "or-join-clause"
58 | ) {
59 | return clause.variables[0]?.value;
60 | }
61 | };
62 |
63 | const getVariables = (
64 | clause: DatalogClause | DatalogAndClause
65 | ): Set => {
66 | if (
67 | clause.type === "data-pattern" ||
68 | clause.type === "fn-expr" ||
69 | clause.type === "pred-expr" ||
70 | clause.type === "rule-expr"
71 | ) {
72 | return new Set(
73 | [...clause.arguments]
74 | .filter((v) => v.type === "variable")
75 | .map((v) => v.value)
76 | );
77 | } else if (
78 | clause.type === "not-clause" ||
79 | clause.type === "or-clause" ||
80 | clause.type === "and-clause"
81 | ) {
82 | return new Set(clause.clauses.flatMap((c) => Array.from(getVariables(c))));
83 | } else if (
84 | clause.type === "not-join-clause" ||
85 | clause.type === "or-join-clause"
86 | ) {
87 | return new Set(clause.variables.map((c) => c.value));
88 | }
89 | };
90 |
91 | const optimizeQuery = (
92 | clauses: (DatalogClause | DatalogAndClause)[],
93 | capturedVariables: Set
94 | ): (DatalogClause | DatalogAndClause)[] => {
95 | const marked = clauses.map(() => false);
96 | const orderedClauses: (DatalogClause | DatalogAndClause)[] = [];
97 | const variablesByIndex: Record> = {};
98 | for (let i = 0; i < clauses.length; i++) {
99 | let bestClauseIndex = clauses.length;
100 | let bestClauseScore = Number.MAX_VALUE;
101 | clauses.forEach((c, j) => {
102 | if (marked[j]) return;
103 | let score = bestClauseScore;
104 | if (c.type === "data-pattern") {
105 | if (
106 | c.arguments[0]?.type === "variable" &&
107 | c.arguments[1]?.type === "constant"
108 | ) {
109 | if (c.arguments[2]?.type === "constant") {
110 | score = 1;
111 | } else if (
112 | c.arguments[2]?.type === "variable" &&
113 | (capturedVariables.has(c.arguments[0].value) ||
114 | capturedVariables.has(c.arguments[2].value))
115 | ) {
116 | score = 2;
117 | } else {
118 | score = 100000;
119 | }
120 | } else {
121 | score = 100001;
122 | }
123 | } else if (
124 | c.type === "not-clause" ||
125 | c.type === "or-clause" ||
126 | c.type === "and-clause"
127 | ) {
128 | const allVars =
129 | variablesByIndex[j] || (variablesByIndex[j] = getVariables(c));
130 | if (Array.from(allVars).every((v) => capturedVariables.has(v))) {
131 | score = 10;
132 | } else {
133 | score = 100002;
134 | }
135 | } else if (c.type === "not-join-clause" || c.type === "or-join-clause") {
136 | if (c.variables.every((v) => capturedVariables.has(v.value))) {
137 | score = 100;
138 | } else {
139 | score = 100003;
140 | }
141 | } else if (
142 | c.type === "fn-expr" ||
143 | c.type === "pred-expr" ||
144 | c.type === "rule-expr"
145 | ) {
146 | if (
147 | [...c.arguments].every(
148 | (a) => a.type !== "variable" || capturedVariables.has(a.value)
149 | )
150 | ) {
151 | score = 1000;
152 | } else {
153 | score = 100004;
154 | }
155 | } else {
156 | score = 100005;
157 | }
158 | if (score < bestClauseScore) {
159 | bestClauseScore = score;
160 | bestClauseIndex = j;
161 | }
162 | });
163 | marked[bestClauseIndex] = true;
164 | const bestClause = clauses[bestClauseIndex];
165 | orderedClauses.push(clauses[bestClauseIndex]);
166 | if (
167 | bestClause.type === "not-join-clause" ||
168 | bestClause.type === "or-join-clause" ||
169 | bestClause.type === "not-clause" ||
170 | bestClause.type === "or-clause" ||
171 | bestClause.type === "and-clause"
172 | ) {
173 | bestClause.clauses = optimizeQuery(
174 | bestClause.clauses,
175 | new Set(capturedVariables)
176 | );
177 | } else if (bestClause.type === "data-pattern") {
178 | bestClause.arguments
179 | .filter((v) => v.type === "variable")
180 | .forEach((v) => capturedVariables.add(v.value));
181 | }
182 | }
183 | return orderedClauses;
184 | };
185 |
186 | const CREATE_DATE_TEST = /^\s*created?\s*(date|time)\s*$/i;
187 | const EDIT_DATE_TEST = /^\s*edit(?:ed)?\s*(date|time)\s*$/i;
188 | const CREATE_BY_TEST = /^\s*(author|create(d)?\s*by)\s*$/i;
189 | const EDIT_BY_TEST = /^\s*(last\s*)?edit(ed)?\s*by\s*$/i;
190 | const SUBTRACT_TEST = /^subtract\(([^,)]+),([^,)]+)\)$/i;
191 | const ADD_TEST = /^add\(([^,)]+),([^,)]+)\)$/i;
192 | const NODE_TEST = /^node:(\s*[^:]+\s*)(?::([^:]+))?$/i;
193 | const MILLISECONDS_IN_DAY = 1000 * 60 * 60 * 24;
194 |
195 | const getArgValue = (key: string, result: SearchResult) => {
196 | if (/^today$/i.test(key)) return new Date();
197 | const val = result[key];
198 | if (typeof val === "string" && DAILY_NOTE_PAGE_REGEX.test(val))
199 | return window.roamAlphaAPI.util.pageTitleToDate(
200 | DAILY_NOTE_PAGE_REGEX.exec(val)?.[0]
201 | );
202 | return val;
203 | };
204 |
205 | const getUserDisplayNameById = (id = 0) => {
206 | const pageId =
207 | window.roamAlphaAPI.pull("[:user/display-page]", id)?.[
208 | ":user/display-page"
209 | ]?.[":db/id"] || 0;
210 | return (
211 | window.roamAlphaAPI.pull("[:node/title]", pageId)?.[":node/title"] ||
212 | "Anonymous User"
213 | );
214 | };
215 |
216 | const formatDate = ({
217 | regex,
218 | key,
219 | value,
220 | }: {
221 | regex: RegExp;
222 | key: string;
223 | value?: number;
224 | }) => {
225 | const exec = regex.exec(key);
226 | const date = new Date(value || 0);
227 | return /time/i.test(exec?.[1] || "")
228 | ? `${date.getHours().toString().padStart(2, "0")}:${date
229 | .getMinutes()
230 | .toString()
231 | .padStart(2, "0")}`
232 | : date;
233 | };
234 |
235 | const getBlockAttribute = (key: string, r: PullBlock) => {
236 | const block = window.roamAlphaAPI.data.fast.q(
237 | `[:find (pull ?b [:block/string :block/uid]) :where [?a :node/title "${normalizePageTitle(
238 | key
239 | )}"] [?p :block/uid "${
240 | r[":block/uid"]
241 | }"] [?b :block/refs ?a] [?b :block/parents ?p]]`
242 | )?.[0]?.[0] as PullBlock;
243 | return {
244 | "": (block?.[":block/string"] || "").slice(key.length + 2).trim(),
245 | "-uid": block?.[":block/uid"],
246 | };
247 | };
248 |
249 | const predefinedSelections: PredefinedSelection[] = [
250 | {
251 | test: CREATE_DATE_TEST,
252 | pull: ({ returnNode }) => `(pull ?${returnNode} [:create/time])`,
253 | mapper: (r, key) => {
254 | return formatDate({
255 | regex: CREATE_DATE_TEST,
256 | key,
257 | value: r?.[":create/time"],
258 | });
259 | },
260 | },
261 | {
262 | test: EDIT_DATE_TEST,
263 | pull: ({ returnNode }) => `(pull ?${returnNode} [:edit/time])`,
264 | mapper: (r, key) => {
265 | return formatDate({
266 | regex: EDIT_DATE_TEST,
267 | key,
268 | value: r?.[":edit/time"],
269 | });
270 | },
271 | },
272 | {
273 | test: CREATE_BY_TEST,
274 | pull: ({ returnNode }) => `(pull ?${returnNode} [:create/user])`,
275 | mapper: (r) => {
276 | return getUserDisplayNameById(r?.[":create/user"]?.[":db/id"]);
277 | },
278 | },
279 | {
280 | test: EDIT_BY_TEST,
281 | pull: ({ returnNode }) => `(pull ?${returnNode} [:edit/user])`,
282 | mapper: (r) => {
283 | return getUserDisplayNameById(r?.[":edit/user"]?.[":db/id"]);
284 | },
285 | },
286 | {
287 | test: NODE_TEST,
288 | pull: ({ match, returnNode, where }) => {
289 | const node = (match[1] || returnNode)?.trim();
290 | const field = (match[2] || "").trim();
291 | const fields = CREATE_BY_TEST.test(field)
292 | ? `[:create/user]`
293 | : EDIT_BY_TEST.test(field)
294 | ? `[:edit/user]`
295 | : CREATE_DATE_TEST.test(field)
296 | ? `[:create/time]`
297 | : EDIT_DATE_TEST.test(field)
298 | ? `[:edit/time]`
299 | : field
300 | ? `[:block/uid]`
301 | : `[:node/title :block/uid :block/string]`;
302 |
303 | return isVariableExposed(where, node) ? `(pull ?${node} ${fields})` : "";
304 | },
305 | mapper: (r, key) => {
306 | const match = NODE_TEST.exec(key)?.[2];
307 | const field = Object.keys(r)[0];
308 | return field === ":create/time"
309 | ? formatDate({
310 | regex: CREATE_DATE_TEST,
311 | key: match,
312 | value: r?.[":create/time"],
313 | })
314 | : field === ":edit/time"
315 | ? formatDate({
316 | regex: EDIT_DATE_TEST,
317 | key: match,
318 | value: r?.[":edit/time"],
319 | })
320 | : field === ":create/user"
321 | ? getUserDisplayNameById(r?.[":create/user"]?.[":db/id"])
322 | : field === ":edit/user"
323 | ? getUserDisplayNameById(r?.[":edit/user"]?.[":db/id"])
324 | : match
325 | ? getBlockAttribute(match, r)
326 | : {
327 | "": r?.[":node/title"] || r[":block/string"] || "",
328 | "-uid": r[":block/uid"],
329 | };
330 | },
331 | },
332 | {
333 | test: SUBTRACT_TEST,
334 | pull: ({ returnNode }) => `(pull ?${returnNode} [:db/id])`,
335 | mapper: (_, key, result) => {
336 | const exec = SUBTRACT_TEST.exec(key);
337 | const arg0 = exec?.[1] || "";
338 | const arg1 = exec?.[2] || "";
339 | const val0 = getArgValue(arg0, result);
340 | const val1 = getArgValue(arg1, result);
341 | if (val0 instanceof Date && val1 instanceof Date) {
342 | return Math.floor(
343 | (val0.valueOf() - val1.valueOf()) / MILLISECONDS_IN_DAY
344 | );
345 | } else if (val0 instanceof Date) {
346 | return new Date(
347 | val0.valueOf() - MILLISECONDS_IN_DAY * (Number(val1) || 0)
348 | );
349 | } else {
350 | return (Number(val0) || 0) - (Number(val1) || 0);
351 | }
352 | },
353 | },
354 | {
355 | test: ADD_TEST,
356 | pull: ({ returnNode }) => `(pull ?${returnNode} [:db/id])`,
357 | mapper: (_, key, result) => {
358 | const exec = ADD_TEST.exec(key);
359 | const arg0 = exec?.[1] || "";
360 | const arg1 = exec?.[2] || "";
361 | const val0 = getArgValue(arg0, result);
362 | const val1 = getArgValue(arg1, result);
363 | if (val0 instanceof Date && val1 instanceof Date) {
364 | return val1;
365 | } else if (val0 instanceof Date) {
366 | return new Date(
367 | val0.valueOf() + MILLISECONDS_IN_DAY * (Number(val1) || 0)
368 | );
369 | } else if (val1 instanceof Date) {
370 | return new Date(
371 | val1.valueOf() + MILLISECONDS_IN_DAY * (Number(val0) || 0)
372 | );
373 | } else {
374 | return (Number(val0) || 0) + (Number(val1) || 0);
375 | }
376 | },
377 | },
378 | {
379 | test: /.*/,
380 | pull: ({ returnNode }) => `(pull ?${returnNode} [:block/uid])`,
381 | mapper: (r, key) => {
382 | return getBlockAttribute(key, r);
383 | },
384 | },
385 | ];
386 |
387 | export const registerSelection: RegisterSelection = (args) => {
388 | predefinedSelections.splice(predefinedSelections.length - 1, 0, args);
389 | };
390 |
391 | type FireQueryArgs = Parameters<
392 | typeof window.roamjs.extension.queryBuilder.fireQuery
393 | >[0];
394 |
395 | export const getWhereClauses = ({
396 | conditions,
397 | returnNode,
398 | }: Omit) => {
399 | return conditions.length
400 | ? conditions.flatMap(conditionToDatalog)
401 | : conditionToDatalog({
402 | relation: "self",
403 | source: returnNode,
404 | target: returnNode,
405 | uid: "",
406 | not: false,
407 | type: "clause",
408 | });
409 | };
410 |
411 | export const getDatalogQueryComponents = ({
412 | conditions,
413 | returnNode,
414 | selections,
415 | }: FireQueryArgs): {
416 | where: DatalogClause[];
417 | definedSelections: {
418 | mapper: PredefinedSelection["mapper"];
419 | pull: string;
420 | label: string;
421 | key: string;
422 | }[];
423 | } => {
424 | const where = optimizeQuery(
425 | getWhereClauses({ conditions, returnNode }),
426 | new Set([])
427 | ) as DatalogClause[];
428 |
429 | const defaultSelections: {
430 | mapper: PredefinedSelection["mapper"];
431 | pull: string;
432 | label: string;
433 | key: string;
434 | }[] = [
435 | {
436 | mapper: (r) => {
437 | return {
438 | "": r?.[":node/title"] || r?.[":block/string"] || "",
439 | "-uid": r[":block/uid"],
440 | };
441 | },
442 | pull: `(pull ?${returnNode} [:block/string :node/title :block/uid])`,
443 | label: "text",
444 | key: "",
445 | },
446 | {
447 | mapper: (r) => {
448 | return r?.[":block/uid"] || "";
449 | },
450 | pull: `(pull ?${returnNode} [:block/uid])`,
451 | label: "uid",
452 | key: "",
453 | },
454 | ];
455 | const definedSelections = defaultSelections.concat(
456 | selections
457 | .map((s) => ({
458 | defined: predefinedSelections.find((p) => p.test.test(s.text)),
459 | s,
460 | }))
461 | .filter((p) => !!p.defined)
462 | .map((p) => ({
463 | mapper: p.defined.mapper,
464 | pull: p.defined.pull({
465 | where,
466 | returnNode,
467 | match: p.defined.test.exec(p.s.text),
468 | }),
469 | label: p.s.label || p.s.text,
470 | key: p.s.text,
471 | }))
472 | .filter((p) => !!p.pull)
473 | );
474 | return { definedSelections, where };
475 | };
476 |
477 | export const getDatalogQuery = (
478 | args: ReturnType
479 | ) => {
480 | const find = args.definedSelections.map((p) => p.pull).join("\n ");
481 | const query = `[:find\n ${find}\n:where\n${args.where
482 | .map((c) => compileDatalog(c, 0))
483 | .join("\n")}\n]`;
484 | return query;
485 | };
486 |
487 | const getEnglishQuery = (args: FireQueryArgs) => {
488 | const parts = getDatalogQueryComponents(args);
489 | return {
490 | query: getDatalogQuery(parts),
491 | formatResult: (result: unknown[]) =>
492 | parts.definedSelections
493 | .map((c, i) => (prev: SearchResult) => {
494 | const pullResult = result[i];
495 | return typeof pullResult === "object" && pullResult !== null
496 | ? Promise.resolve(
497 | c.mapper(pullResult as PullBlock, c.key, prev)
498 | ).then((output) => ({
499 | output,
500 | label: c.label,
501 | }))
502 | : typeof pullResult === "string" || typeof pullResult === "number"
503 | ? Promise.resolve({ output: pullResult, label: c.label })
504 | : Promise.resolve({ output: "", label: c.label });
505 | })
506 | .reduce(
507 | (prev, c) =>
508 | prev.then((p) =>
509 | c(p).then(({ output, label }) => {
510 | if (typeof output === "object" && !(output instanceof Date)) {
511 | Object.entries(output).forEach(([k, v]) => {
512 | p[label + k] = v;
513 | });
514 | } else {
515 | p[label] = output;
516 | }
517 | return p;
518 | })
519 | ),
520 | Promise.resolve({} as SearchResult)
521 | ),
522 | };
523 | };
524 |
525 | const backend = (query: string) =>
526 | fetch(
527 | `https://api.roamresearch.com/api/graph/${window.roamAlphaAPI.graph.name}/q`,
528 | {
529 | headers: {
530 | Accept: "application/json",
531 | Authorization:
532 | "Bearer roam-graph-token-3tVTfiimleRQ8z52foEClRCzfZ5lobiZog6u2z-N",
533 | "Content-Type": "application/json",
534 | },
535 | redirect: "manual",
536 | method: "POST",
537 | body: JSON.stringify({
538 | query,
539 | }),
540 | }
541 | );
542 | //@ts-ignore
543 | window.backend = backend;
544 |
545 | const fireQuery: typeof window.roamjs.extension.queryBuilder.fireQuery = async (
546 | args
547 | ) => {
548 | // @ts-ignore
549 | const { isCustomEnabled, customNode } = args as {
550 | isCustomEnabled: boolean;
551 | custom: string;
552 | };
553 | const { query, formatResult } = isCustomEnabled
554 | ? {
555 | query: customNode as string,
556 | formatResult: (r: unknown[]) =>
557 | Promise.resolve({
558 | text: "",
559 | uid: "",
560 | ...Object.fromEntries(
561 | r.flatMap((p, index) =>
562 | typeof p === "object"
563 | ? Object.entries(p)
564 | : [[index.toString(), p]]
565 | )
566 | ),
567 | }),
568 | }
569 | : getEnglishQuery(args);
570 | try {
571 | if (getNodeEnv() === "development") {
572 | console.log("Query to Roam:");
573 | console.log(query);
574 | }
575 | return Promise.all(
576 | window.roamAlphaAPI.data.fast.q(query).map(formatResult)
577 | );
578 | } catch (e) {
579 | console.error("Error from Roam:");
580 | console.error(e.message);
581 | return [];
582 | }
583 | };
584 |
585 | export default fireQuery;
586 |
--------------------------------------------------------------------------------
/src/utils/runQueryTools.ts:
--------------------------------------------------------------------------------
1 | import getBasicTreeByParentUid from "roamjs-components/queries/getBasicTreeByParentUid";
2 | import getUids from "roamjs-components/dom/getUids";
3 | import getSubTree from "roamjs-components/util/getSubTree";
4 | import getTextByBlockUid from "roamjs-components/queries/getTextByBlockUid";
5 | import type { OnloadArgs, RoamBlock } from "roamjs-components/types";
6 | import getCreateTimeByBlockUid from "roamjs-components/queries/getCreateTimeByBlockUid";
7 | import getEditTimeByBlockUid from "roamjs-components/queries/getEditTimeByBlockUid";
8 | import getSettingValueFromTree from "roamjs-components/util/getSettingValueFromTree";
9 | import getSettingIntFromTree from "roamjs-components/util/getSettingIntFromTree";
10 | import createObserver from "roamjs-components/dom/createObserver";
11 |
12 | export const getCreatedTimeByTitle = (title: string): number => {
13 | const result = window.roamAlphaAPI.q(
14 | `[:find (pull ?e [:create/time]) :where [?e :node/title "${title.replace(
15 | /"/g,
16 | '\\"'
17 | )}"]]`
18 | )[0][0] as RoamBlock;
19 | return result?.time || getEditTimeByTitle(title);
20 | };
21 |
22 | export const getEditTimeByTitle = (title: string): number => {
23 | const result = window.roamAlphaAPI.q(
24 | `[:find (pull ?e [:edit/time]) :where [?e :node/title "${title.replace(
25 | /"/g,
26 | '\\"'
27 | )}"]]`
28 | )[0][0] as RoamBlock;
29 | return result?.time;
30 | };
31 |
32 | export const getWordCount = (str = ""): number =>
33 | str.trim().split(/\s+/).length;
34 |
35 | const getWordCountByBlockId = (blockId: number): number => {
36 | const block = window.roamAlphaAPI.pull(
37 | "[:block/children, :block/string]",
38 | blockId
39 | );
40 | const children = block[":block/children"] || [];
41 | const count = getWordCount(block[":block/string"]);
42 | return (
43 | count +
44 | children
45 | .map((c) => getWordCountByBlockId(c[":db/id"]))
46 | .reduce((total, cur) => cur + total, 0)
47 | );
48 | };
49 |
50 | const getWordCountByBlockUid = (blockUid: string): number => {
51 | const block = window.roamAlphaAPI.q(
52 | `[:find (pull ?e [:block/children, :block/string]) :where [?e :block/uid "${blockUid}"]]`
53 | )[0][0] as RoamBlock;
54 | const children = block.children || [];
55 | const count = getWordCount(block.string);
56 | return (
57 | count +
58 | children
59 | .map((c) => getWordCountByBlockId(c.id))
60 | .reduce((total, cur) => cur + total, 0)
61 | );
62 | };
63 |
64 | const getWordCountByPageTitle = (title: string): number => {
65 | const page = window.roamAlphaAPI.q(
66 | `[:find (pull ?e [:block/children]) :where [?e :node/title "${title}"]]`
67 | )[0][0] as RoamBlock;
68 | const children = page.children || [];
69 | return children
70 | .map((c) => getWordCountByBlockId(c.id))
71 | .reduce((total, cur) => cur + total, 0);
72 | };
73 |
74 | const runQueryTools = (extensionAPI: OnloadArgs["extensionAPI"]) => {
75 | let isSortByBlocks = false;
76 |
77 | const menuItemCallback =
78 | (sortContainer: Element, sortBy: (a: string, b: string) => number) =>
79 | () => {
80 | const blockConfig = getBasicTreeByParentUid(
81 | getUids(sortContainer as HTMLDivElement).blockUid
82 | );
83 | isSortByBlocks =
84 | !!getSubTree({ tree: blockConfig, key: "Sort Blocks" }).uid ||
85 | !!extensionAPI.settings.get("sort-blocks");
86 | const refContainer =
87 | sortContainer.getElementsByClassName("refs-by-page-view")[0] ||
88 | sortContainer;
89 | const refsInView =
90 | refContainer === sortContainer
91 | ? Array.from(refContainer.children).filter((n) => n.tagName === "DIV")
92 | : Array.from(refContainer.getElementsByClassName("rm-ref-page-view"));
93 | refsInView.forEach((r) => refContainer.removeChild(r));
94 | if (isSortByBlocks) {
95 | const blocksInView = refsInView.flatMap((r) =>
96 | Array.from(r.lastElementChild.children).filter(
97 | (c) => (c as HTMLDivElement).style.display !== "none"
98 | ).length === 1
99 | ? [r]
100 | : Array.from(r.lastElementChild.children).map((c) => {
101 | const refClone = r.cloneNode(true) as HTMLDivElement;
102 | Array.from(refClone.lastElementChild.children).forEach((cc) => {
103 | const ccDiv = cc as HTMLDivElement;
104 | if (
105 | cc.getElementsByClassName("roam-block")[0]?.id ===
106 | c.getElementsByClassName("roam-block")[0]?.id
107 | ) {
108 | ccDiv.style.display = "flex";
109 | } else {
110 | ccDiv.style.display = "none";
111 | }
112 | });
113 | return refClone;
114 | })
115 | );
116 | const getRoamBlock = (e: Element) =>
117 | Array.from(e.lastElementChild.children)
118 | .filter((c) => (c as HTMLDivElement).style.display != "none")[0]
119 | .getElementsByClassName("roam-block")[0] as HTMLDivElement;
120 | blocksInView.sort((a, b) => {
121 | const { blockUid: aUid } = getUids(getRoamBlock(a));
122 | const { blockUid: bUid } = getUids(getRoamBlock(b));
123 | return sortBy(aUid, bUid);
124 | });
125 | blocksInView.forEach((r) => refContainer.appendChild(r));
126 | } else {
127 | refsInView.sort((a, b) => {
128 | const aTitle = (a.getElementsByClassName(
129 | "rm-ref-page-view-title"
130 | )[0] || a.querySelector(".rm-zoom-item-content")) as HTMLDivElement;
131 | const bTitle = (b.getElementsByClassName(
132 | "rm-ref-page-view-title"
133 | )[0] || b.querySelector(".rm-zoom-item-content")) as HTMLDivElement;
134 | return sortBy(aTitle.textContent, bTitle.textContent);
135 | });
136 | refsInView.forEach((r) => refContainer.appendChild(r));
137 | }
138 | };
139 |
140 | const sortCallbacks = {
141 | Alphabetically: (refContainer: Element) =>
142 | menuItemCallback(refContainer, (a, b) =>
143 | isSortByBlocks
144 | ? getTextByBlockUid(a).localeCompare(getTextByBlockUid(b))
145 | : a.localeCompare(b)
146 | ),
147 | "Alphabetically Descending": (refContainer: Element) =>
148 | menuItemCallback(refContainer, (a, b) =>
149 | isSortByBlocks
150 | ? getTextByBlockUid(b).localeCompare(getTextByBlockUid(a))
151 | : b.localeCompare(a)
152 | ),
153 | "Word Count": (refContainer: Element) =>
154 | menuItemCallback(refContainer, (a, b) =>
155 | isSortByBlocks
156 | ? getWordCountByBlockUid(a) - getWordCountByBlockUid(b)
157 | : getWordCountByPageTitle(a) - getWordCountByPageTitle(b)
158 | ),
159 | "Word Count Descending": (refContainer: Element) =>
160 | menuItemCallback(refContainer, (a, b) =>
161 | isSortByBlocks
162 | ? getWordCountByBlockUid(b) - getWordCountByBlockUid(a)
163 | : getWordCountByPageTitle(b) - getWordCountByPageTitle(a)
164 | ),
165 | "Created Date": (refContainer: Element) =>
166 | menuItemCallback(refContainer, (a, b) =>
167 | isSortByBlocks
168 | ? getCreateTimeByBlockUid(a) - getCreateTimeByBlockUid(b)
169 | : getCreatedTimeByTitle(a) - getCreatedTimeByTitle(b)
170 | ),
171 | "Created Date Descending": (refContainer: Element) =>
172 | menuItemCallback(refContainer, (a, b) =>
173 | isSortByBlocks
174 | ? getCreateTimeByBlockUid(b) - getCreateTimeByBlockUid(a)
175 | : getCreatedTimeByTitle(b) - getCreatedTimeByTitle(a)
176 | ),
177 | "Edited Date": (refContainer: Element) =>
178 | menuItemCallback(refContainer, (a, b) =>
179 | isSortByBlocks
180 | ? getEditTimeByBlockUid(a) - getEditTimeByBlockUid(b)
181 | : getEditTimeByTitle(a) - getEditTimeByTitle(b)
182 | ),
183 | "Edited Date Descending": (refContainer: Element) =>
184 | menuItemCallback(refContainer, (a, b) =>
185 | isSortByBlocks
186 | ? getEditTimeByBlockUid(b) - getEditTimeByBlockUid(a)
187 | : getEditTimeByTitle(b) - getEditTimeByTitle(a)
188 | ),
189 | "Daily Note": (refContainer: Element) =>
190 | menuItemCallback(refContainer, (a, b) => {
191 | const aText = isSortByBlocks ? getTextByBlockUid(a) : a;
192 | const bText = isSortByBlocks ? getTextByBlockUid(b) : b;
193 | const aDate = window.roamAlphaAPI.util.pageTitleToDate(aText);
194 | const bDate = window.roamAlphaAPI.util.pageTitleToDate(bText);
195 | if (!aDate && !bDate) {
196 | return isSortByBlocks
197 | ? getCreateTimeByBlockUid(a) - getCreateTimeByBlockUid(b)
198 | : getCreatedTimeByTitle(a) - getCreatedTimeByTitle(b);
199 | } else if (!aDate) {
200 | return 1;
201 | } else if (!bDate) {
202 | return -1;
203 | } else {
204 | return aDate.valueOf() - bDate.valueOf();
205 | }
206 | }),
207 | "Daily Note Descending": (refContainer: Element) =>
208 | menuItemCallback(refContainer, (a, b) => {
209 | const aText = isSortByBlocks ? getTextByBlockUid(a) : a;
210 | const bText = isSortByBlocks ? getTextByBlockUid(b) : b;
211 | const aDate = window.roamAlphaAPI.util.pageTitleToDate(aText);
212 | const bDate = window.roamAlphaAPI.util.pageTitleToDate(bText);
213 | if (!aDate && !bDate) {
214 | return isSortByBlocks
215 | ? getCreateTimeByBlockUid(b) - getCreateTimeByBlockUid(a)
216 | : getCreatedTimeByTitle(b) - getCreatedTimeByTitle(a);
217 | } else if (!aDate) {
218 | return 1;
219 | } else if (!bDate) {
220 | return -1;
221 | } else {
222 | return bDate.valueOf() - aDate.valueOf();
223 | }
224 | }),
225 | };
226 |
227 | const onCreateSortIcons = (container: HTMLDivElement) => {
228 | const blockConfig = getBasicTreeByParentUid(getUids(container).blockUid);
229 |
230 | const defaultSort = (getSettingValueFromTree({
231 | tree: blockConfig,
232 | key: "Default Sort",
233 | }) ||
234 | extensionAPI.settings.get("default-sort")) as keyof typeof sortCallbacks;
235 | if (defaultSort && sortCallbacks[defaultSort]) {
236 | sortCallbacks[defaultSort](container)();
237 | }
238 | };
239 |
240 | const randomize = (q: HTMLDivElement) => {
241 | const blockConfig = getBasicTreeByParentUid(
242 | getUids(q.closest(".roam-block")).blockUid
243 | );
244 | const numRandomResults = getSettingIntFromTree({
245 | key: "Random",
246 | tree: blockConfig,
247 | });
248 | const refsByPageView = q.querySelector(".refs-by-page-view");
249 | const allChildren = Array.from(
250 | q.getElementsByClassName("rm-reference-item")
251 | );
252 | const selected = allChildren
253 | .sort(() => 0.5 - Math.random())
254 | .slice(0, numRandomResults);
255 | Array.from(refsByPageView.children).forEach((c: HTMLElement) => {
256 | if (selected.find((s) => c.contains(s))) {
257 | const itemContainer = c.lastElementChild;
258 | Array.from(itemContainer.children).forEach((cc: HTMLElement) => {
259 | if (selected.find((s) => cc.contains(s))) {
260 | cc.style.display = "flex";
261 | c.style.display = "block";
262 | } else {
263 | cc.style.display = "none";
264 | }
265 | });
266 | } else {
267 | c.style.display = "none";
268 | }
269 | });
270 | };
271 |
272 | const createIconButton = (icon: string) => {
273 | const popoverButton = document.createElement("span");
274 | popoverButton.className = "bp3-button bp3-minimal bp3-small";
275 | popoverButton.tabIndex = 0;
276 | const popoverIcon = document.createElement("span");
277 | popoverIcon.className = `bp3-icon bp3-icon-${icon}`;
278 | popoverButton.appendChild(popoverIcon);
279 | return popoverButton;
280 | };
281 |
282 | const createSortIcon = (
283 | refContainer: HTMLDivElement,
284 | sortCallbacks: { [key: string]: (refContainer: Element) => () => void }
285 | ): HTMLSpanElement => {
286 | // Icon Button
287 | const popoverWrapper = document.createElement("span");
288 | popoverWrapper.className = `bp3-popover-wrapper sort-popover-wrapper`;
289 |
290 | const popoverTarget = document.createElement("span");
291 | popoverTarget.className = "bp3-popover-target";
292 | popoverWrapper.appendChild(popoverTarget);
293 |
294 | const popoverButton = createIconButton("sort");
295 | popoverTarget.appendChild(popoverButton);
296 |
297 | // Overlay Content
298 | const popoverOverlay = document.createElement("div");
299 | popoverOverlay.className = "bp3-overlay bp3-overlay-inline";
300 | popoverWrapper.appendChild(popoverOverlay);
301 |
302 | const transitionContainer = document.createElement("div");
303 | transitionContainer.className =
304 | "bp3-transition-container bp3-popover-enter-done";
305 | transitionContainer.style.position = "absolute";
306 | transitionContainer.style.willChange = "transform";
307 | transitionContainer.style.top = "0";
308 | transitionContainer.style.left = "0";
309 |
310 | const popover = document.createElement("div");
311 | popover.className = "bp3-popover";
312 | popover.style.transformOrigin = "162px top";
313 | transitionContainer.appendChild(popover);
314 |
315 | const popoverContent = document.createElement("div");
316 | popoverContent.className = "bp3-popover-content";
317 | popover.appendChild(popoverContent);
318 |
319 | const menuUl = document.createElement("ul");
320 | menuUl.className = "bp3-menu";
321 | popoverContent.appendChild(menuUl);
322 |
323 | let selectedMenuItem: HTMLAnchorElement;
324 | const createMenuItem = (text: string, sortCallback: () => void) => {
325 | const liItem = document.createElement("li");
326 | const aMenuItem = document.createElement("a");
327 | aMenuItem.className = "bp3-menu-item bp3-popover-dismiss";
328 | liItem.appendChild(aMenuItem);
329 | const menuItemText = document.createElement("div");
330 | menuItemText.className = "bp3-text-overflow-ellipsis bp3-fill";
331 | menuItemText.innerText = text;
332 | aMenuItem.appendChild(menuItemText);
333 | menuUl.appendChild(liItem);
334 | aMenuItem.onclick = (e) => {
335 | sortCallback();
336 | aMenuItem.style.fontWeight = "600";
337 | if (selectedMenuItem) {
338 | selectedMenuItem.style.fontWeight = null;
339 | }
340 | selectedMenuItem = aMenuItem;
341 | e.stopImmediatePropagation();
342 | e.preventDefault();
343 | };
344 | aMenuItem.onmousedown = (e) => {
345 | e.stopImmediatePropagation();
346 | e.preventDefault();
347 | };
348 | };
349 | Object.keys(sortCallbacks).forEach((k: keyof typeof sortCallbacks) =>
350 | createMenuItem(`Sort By ${k}`, sortCallbacks[k](refContainer))
351 | );
352 |
353 | let popoverOpen = false;
354 | const documentEventListener = (e: MouseEvent) => {
355 | if (
356 | (!e.target || !popoverOverlay.contains(e.target as HTMLElement)) &&
357 | popoverOpen
358 | ) {
359 | closePopover();
360 | }
361 | };
362 |
363 | const closePopover = () => {
364 | popoverOverlay.className = "bp3-overlay bp3-overlay-inline";
365 | popoverOverlay.removeChild(transitionContainer);
366 | document.removeEventListener("click", documentEventListener);
367 | popoverOpen = false;
368 | };
369 |
370 | popoverButton.onmousedown = (e) => {
371 | e.stopImmediatePropagation();
372 | e.preventDefault();
373 | };
374 |
375 | popoverButton.onclick = (e) => {
376 | if (!popoverOpen) {
377 | transitionContainer.style.transform = `translate3d(${
378 | popoverButton.offsetLeft <= 240
379 | ? popoverButton.offsetLeft
380 | : popoverButton.offsetLeft - 240
381 | }px, ${popoverButton.offsetTop + 24}px, 0px)`;
382 | popoverOverlay.className =
383 | "bp3-overlay bp3-overlay-open bp3-overlay-inline";
384 | popoverOverlay.appendChild(transitionContainer);
385 | e.stopImmediatePropagation();
386 | e.preventDefault();
387 | document.addEventListener("click", documentEventListener);
388 | popoverOpen = true;
389 | } else {
390 | closePopover();
391 | }
392 | };
393 | return popoverWrapper;
394 | };
395 |
396 | const observerCallback = () => {
397 | const sortButtonContainers = Array.from(
398 | document.getElementsByClassName("rm-query-content")
399 | ) as HTMLDivElement[];
400 | sortButtonContainers.forEach((sortButtonContainer) => {
401 | const exists =
402 | sortButtonContainer.getElementsByClassName("sort-popover-wrapper")
403 | .length > 0;
404 | if (exists) {
405 | return;
406 | }
407 |
408 | const popoverWrapper = createSortIcon(sortButtonContainer, sortCallbacks);
409 | const before = sortButtonContainer.children[1];
410 | sortButtonContainer.insertBefore(popoverWrapper, before);
411 |
412 | onCreateSortIcons(sortButtonContainer);
413 | });
414 |
415 | // Randomization
416 | const queries = Array.from(
417 | document.getElementsByClassName("rm-query-content")
418 | ).filter(
419 | (e) => !e.getAttribute("data-is-random-results")
420 | ) as HTMLDivElement[];
421 | queries.forEach((q) => {
422 | const config = getBasicTreeByParentUid(
423 | getUids(q.closest(".roam-block")).blockUid
424 | );
425 | if (getSettingIntFromTree({ tree: config, key: "Random" })) {
426 | q.setAttribute("data-is-random-results", "true");
427 | const randomIcon = createIconButton("reset");
428 | q.insertBefore(randomIcon, q.lastElementChild);
429 | randomIcon.onclick = (e) => {
430 | randomize(q);
431 | e.stopPropagation();
432 | e.preventDefault();
433 | };
434 | randomIcon.onmousedown = (e) => {
435 | e.stopImmediatePropagation();
436 | e.preventDefault();
437 | };
438 | randomize(q);
439 | }
440 | });
441 |
442 | // Alias
443 | const queryTitles = Array.from(
444 | document.getElementsByClassName("rm-query-title-text")
445 | ).filter(
446 | (e) => !e.getAttribute("data-roamjs-query-alias")
447 | ) as HTMLDivElement[];
448 | queryTitles.forEach((q) => {
449 | const block = q.closest(".roam-block");
450 | if (block) {
451 | const config = getBasicTreeByParentUid(getUids(block).blockUid);
452 | const alias = getSettingValueFromTree({ tree: config, key: "Alias" });
453 | if (alias) {
454 | q.setAttribute("data-roamjs-query-alias", "true");
455 | q.innerText = alias;
456 | }
457 | }
458 | });
459 |
460 | // Context
461 | const unContextedQueries = Array.from(
462 | document.getElementsByClassName("rm-query-content")
463 | ).filter(
464 | (e) => !e.getAttribute("data-is-contexted-results")
465 | ) as HTMLDivElement[];
466 | if (unContextedQueries.length) {
467 | unContextedQueries.forEach((q) => {
468 | const config = getBasicTreeByParentUid(
469 | getUids(q.closest(".roam-block")).blockUid
470 | );
471 | const configContext =
472 | getSettingValueFromTree({ tree: config, key: "Context" }) ||
473 | extensionAPI.settings.get("context") as string;
474 | if (configContext) {
475 | q.setAttribute("data-is-contexted-results", "true");
476 | const context = Number.isNaN(configContext)
477 | ? configContext
478 | : parseInt(configContext);
479 | const contexts = Array.from(
480 | q.getElementsByClassName("zoom-mentions-view")
481 | ).filter((c) => c.childElementCount);
482 | contexts.forEach((ctx) => {
483 | const children = Array.from(
484 | ctx.children
485 | ).reverse() as HTMLDivElement[];
486 | const index = !Number.isNaN(context)
487 | ? Math.min(Number(context), children.length)
488 | : children.length;
489 | children[index - 1].click();
490 | });
491 | }
492 | });
493 | }
494 | };
495 |
496 | observerCallback();
497 | return createObserver(observerCallback);
498 | };
499 |
500 | export default runQueryTools;
501 |
--------------------------------------------------------------------------------
/src/utils/conditionToDatalog.ts:
--------------------------------------------------------------------------------
1 | import { DAILY_NOTE_PAGE_TITLE_REGEX } from "roamjs-components/date/constants";
2 | import parseNlpDate from "roamjs-components/date/parseNlpDate";
3 | import getAllPageNames from "roamjs-components/queries/getAllPageNames";
4 | import normalizePageTitle from "roamjs-components/queries/normalizePageTitle";
5 | import startOfDay from "date-fns/startOfDay";
6 | import endOfDay from "date-fns/endOfDay";
7 | import type { DatalogClause } from "roamjs-components/types";
8 | import type { RegisterDatalogTranslator } from "roamjs-components/types/query-builder";
9 |
10 | const getTitleDatalog = ({
11 | source,
12 | target,
13 | }: {
14 | source: string;
15 | target: string;
16 | }): DatalogClause[] => {
17 | const dateMatch = /^\s*{date(?::([^}]+))?}\s*$/i.exec(target);
18 | if (dateMatch) {
19 | const nlp = dateMatch[1] || "";
20 | if (nlp) {
21 | const date = parseNlpDate(nlp);
22 | return [
23 | {
24 | type: "data-pattern",
25 | arguments: [
26 | { type: "variable", value: source },
27 | { type: "constant", value: ":node/title" },
28 | {
29 | type: "constant",
30 | value: `"${window.roamAlphaAPI.util.dateToPageTitle(date)}"`,
31 | },
32 | ],
33 | },
34 | ];
35 | } else {
36 | return [
37 | {
38 | type: "data-pattern",
39 | arguments: [
40 | { type: "variable", value: source },
41 | { type: "constant", value: ":node/title" },
42 | { type: "variable", value: `${source}-Title` },
43 | ],
44 | },
45 | {
46 | type: "fn-expr",
47 | fn: "re-pattern",
48 | arguments: [
49 | {
50 | type: "constant",
51 | value: `"${DAILY_NOTE_PAGE_TITLE_REGEX.source}"`,
52 | },
53 | ],
54 | binding: {
55 | type: "bind-scalar",
56 | variable: { type: "variable", value: `date-regex` },
57 | },
58 | },
59 | {
60 | type: "pred-expr",
61 | pred: "re-find",
62 | arguments: [
63 | { type: "variable", value: "date-regex" },
64 | { type: "variable", value: `${source}-Title` },
65 | ],
66 | },
67 | ];
68 | }
69 | }
70 | if (target.startsWith("/") && target.endsWith("/")) {
71 | return [
72 | {
73 | type: "data-pattern",
74 | arguments: [
75 | { type: "variable", value: source },
76 | { type: "constant", value: ":node/title" },
77 | { type: "variable", value: `${source}-Title` },
78 | ],
79 | },
80 | {
81 | type: "fn-expr",
82 | fn: "re-pattern" as const,
83 | arguments: [
84 | {
85 | type: "constant",
86 | value: `"${target.slice(1, -1).replace(/\\/g, "\\\\")}"`,
87 | },
88 | ],
89 | binding: {
90 | type: "bind-scalar",
91 | variable: { type: "variable", value: `${target}-regex` },
92 | },
93 | },
94 | {
95 | type: "pred-expr",
96 | pred: "re-find",
97 | arguments: [
98 | { type: "variable", value: `${target}-regex` },
99 | { type: "variable", value: `${source}-Title` },
100 | ],
101 | },
102 | ];
103 | }
104 | return [
105 | {
106 | type: "data-pattern",
107 | arguments: [
108 | { type: "variable", value: source },
109 | { type: "constant", value: ":node/title" },
110 | { type: "constant", value: `"${normalizePageTitle(target)}"` },
111 | ],
112 | },
113 | ];
114 | };
115 |
116 | const translator: Record<
117 | string,
118 | Omit[0], "key">
119 | > = {
120 | self: {
121 | callback: ({ source }) => [
122 | {
123 | type: "data-pattern",
124 | arguments: [
125 | { type: "variable", value: source },
126 | { type: "constant", value: ":block/uid" },
127 | { type: "constant", value: `"${source}"` },
128 | ],
129 | },
130 | ],
131 | },
132 | references: {
133 | callback: ({ source, target }) => [
134 | {
135 | type: "data-pattern",
136 | arguments: [
137 | { type: "variable", value: source },
138 | { type: "constant", value: ":block/refs" },
139 | { type: "variable", value: target },
140 | ],
141 | },
142 | ],
143 | placeholder: "Enter any placeholder for the node",
144 | isVariable: true,
145 | },
146 | "is referenced by": {
147 | callback: ({ source, target }) => [
148 | {
149 | type: "data-pattern",
150 | arguments: [
151 | { type: "variable", value: target },
152 | { type: "constant", value: ":block/refs" },
153 | { type: "variable", value: source },
154 | ],
155 | },
156 | ],
157 | placeholder: "Enter any placeholder for the node",
158 | isVariable: true,
159 | },
160 | "is in page": {
161 | callback: ({ source, target }) => [
162 | {
163 | type: "data-pattern",
164 | arguments: [
165 | { type: "variable", value: source },
166 | { type: "constant", value: ":block/page" },
167 | { type: "variable", value: target },
168 | ],
169 | },
170 | ],
171 | placeholder: "Enter any placeholder for the node",
172 | isVariable: true,
173 | },
174 | "has title": {
175 | callback: getTitleDatalog,
176 | targetOptions: () => getAllPageNames().concat(["{date}", "{date:today}"]),
177 | placeholder: "Enter a page name or {date} for any DNP",
178 | },
179 | "with text in title": {
180 | callback: ({ source, target }) => [
181 | {
182 | type: "data-pattern",
183 | arguments: [
184 | { type: "variable", value: source },
185 | { type: "constant", value: ":node/title" },
186 | { type: "variable", value: `${source}-Title` },
187 | ],
188 | },
189 | {
190 | type: "pred-expr",
191 | pred: "clojure.string/includes?",
192 | arguments: [
193 | { type: "variable", value: `${source}-Title` },
194 | { type: "constant", value: `"${normalizePageTitle(target)}"` },
195 | ],
196 | },
197 | ],
198 | placeholder: "Enter any text",
199 | },
200 | "has attribute": {
201 | callback: ({ source, target }) => [
202 | {
203 | type: "data-pattern",
204 | arguments: [
205 | { type: "variable", value: `${target}-Attribute` },
206 | { type: "constant", value: ":node/title" },
207 | { type: "constant", value: `"${target}"` },
208 | ],
209 | },
210 | {
211 | type: "data-pattern",
212 | arguments: [
213 | { type: "variable", value: target },
214 | { type: "constant", value: ":block/refs" },
215 | { type: "variable", value: `${target}-Attribute` },
216 | ],
217 | },
218 | {
219 | type: "data-pattern",
220 | arguments: [
221 | { type: "variable", value: target },
222 | { type: "constant", value: ":block/parents" },
223 | { type: "variable", value: source },
224 | ],
225 | },
226 | ],
227 | targetOptions: getAllPageNames,
228 | placeholder: "Enter any attribute name",
229 | isVariable: true,
230 | },
231 | "has child": {
232 | callback: ({ source, target }) => [
233 | {
234 | type: "data-pattern",
235 | arguments: [
236 | { type: "variable", value: source },
237 | { type: "constant", value: ":block/children" },
238 | { type: "variable", value: target },
239 | ],
240 | },
241 | ],
242 | placeholder: "Enter any placeholder for the node",
243 | isVariable: true,
244 | },
245 | "has parent": {
246 | callback: ({ source, target }) => [
247 | {
248 | type: "data-pattern",
249 | arguments: [
250 | { type: "variable", value: target },
251 | { type: "constant", value: ":block/children" },
252 | { type: "variable", value: source },
253 | ],
254 | },
255 | ],
256 | placeholder: "Enter any placeholder for the node",
257 | isVariable: true,
258 | },
259 | "has ancestor": {
260 | callback: ({ source, target }) => [
261 | {
262 | type: "data-pattern",
263 | arguments: [
264 | { type: "variable", value: source },
265 | { type: "constant", value: ":block/parents" },
266 | { type: "variable", value: target },
267 | ],
268 | },
269 | ],
270 | placeholder: "Enter any placeholder for the node",
271 | isVariable: true,
272 | },
273 | "has descendant": {
274 | callback: ({ source, target }) => [
275 | {
276 | type: "data-pattern",
277 | arguments: [
278 | { type: "variable", value: target },
279 | { type: "constant", value: ":block/parents" },
280 | { type: "variable", value: source },
281 | ],
282 | },
283 | ],
284 | placeholder: "Enter any placeholder for the node",
285 | isVariable: true,
286 | },
287 | "with text": {
288 | callback: ({ source, target }) => [
289 | {
290 | type: "or-clause",
291 | clauses: [
292 | {
293 | type: "data-pattern",
294 | arguments: [
295 | { type: "variable", value: source },
296 | { type: "constant", value: ":block/string" },
297 | { type: "variable", value: `${source}-String` },
298 | ],
299 | },
300 | {
301 | type: "data-pattern",
302 | arguments: [
303 | { type: "variable", value: source },
304 | { type: "constant", value: ":node/title" },
305 | { type: "variable", value: `${source}-String` },
306 | ],
307 | },
308 | ],
309 | },
310 | {
311 | type: "pred-expr",
312 | pred: "clojure.string/includes?",
313 | arguments: [
314 | { type: "variable", value: `${source}-String` },
315 | { type: "constant", value: `"${normalizePageTitle(target)}"` },
316 | ],
317 | },
318 | ],
319 | placeholder: "Enter any text",
320 | },
321 | "created by": {
322 | callback: ({ source, target }) => [
323 | {
324 | type: "data-pattern",
325 | arguments: [
326 | { type: "variable", value: source },
327 | { type: "constant", value: ":create/user" },
328 | { type: "variable", value: `${source}-User` },
329 | ],
330 | },
331 | {
332 | type: "data-pattern",
333 | arguments: [
334 | { type: "variable", value: `${source}-User` },
335 | { type: "constant", value: ":user/display-page" },
336 | { type: "variable", value: `${source}-User-Display` },
337 | ],
338 | },
339 | {
340 | type: "data-pattern",
341 | arguments: [
342 | { type: "variable", value: `${source}-User-Display` },
343 | { type: "constant", value: ":node/title" },
344 | { type: "constant", value: `"${normalizePageTitle(target)}"` },
345 | ],
346 | },
347 | ],
348 | targetOptions: () =>
349 | window.roamAlphaAPI.data.fast
350 | .q(`[:find ?n :where [?u :user/display-name ?n]]`)
351 | .map((a) => a[0] as string),
352 | placeholder: "Enter the display name of any user with access to this graph",
353 | },
354 | "edited by": {
355 | callback: ({ source, target }) => [
356 | {
357 | type: "data-pattern",
358 | arguments: [
359 | { type: "variable", value: source },
360 | { type: "constant", value: ":edit/user" },
361 | { type: "variable", value: `${source}-User` },
362 | ],
363 | },
364 | {
365 | type: "data-pattern",
366 | arguments: [
367 | { type: "variable", value: `${source}-User` },
368 | { type: "constant", value: ":user/display-page" },
369 | { type: "variable", value: `${source}-User-Display` },
370 | ],
371 | },
372 | {
373 | type: "data-pattern",
374 | arguments: [
375 | { type: "variable", value: `${source}-User-Display` },
376 | { type: "constant", value: ":node/title" },
377 | { type: "constant", value: `"${normalizePageTitle(target)}"` },
378 | ],
379 | },
380 | ],
381 | targetOptions: () =>
382 | window.roamAlphaAPI.data.fast
383 | .q(`[:find ?n :where [?u :user/display-name ?n]]`)
384 | .map((a) => a[0] as string),
385 | placeholder: "Enter the display name of any user with access to this graph",
386 | },
387 | "references title": {
388 | callback: ({ source, target }) => [
389 | {
390 | type: "data-pattern",
391 | arguments: [
392 | { type: "variable", value: source },
393 | { type: "constant", value: ":block/refs" },
394 | { type: "variable", value: target },
395 | ],
396 | },
397 | ...getTitleDatalog({ source: target, target }),
398 | ],
399 | targetOptions: () => getAllPageNames().concat(["{date}", "{date:today}"]),
400 | placeholder: "Enter a page name or {date} for any DNP",
401 | },
402 | "has heading": {
403 | callback: ({ source, target }) => [
404 | {
405 | type: "data-pattern",
406 | arguments: [
407 | { type: "variable", value: source },
408 | { type: "constant", value: ":block/heading" },
409 | { type: "constant", value: target },
410 | ],
411 | },
412 | ],
413 | targetOptions: ["1", "2", "3", "0"],
414 | placeholder: "Enter a heading value (0, 1, 2, 3)",
415 | },
416 | "is in page with title": {
417 | callback: ({ source, target }) => [
418 | {
419 | type: "data-pattern",
420 | arguments: [
421 | { type: "variable", value: source },
422 | { type: "constant", value: ":block/page" },
423 | { type: "variable", value: target },
424 | ],
425 | },
426 | ...getTitleDatalog({ source: target, target }),
427 | ],
428 | targetOptions: () => getAllPageNames().concat(["{date}", "{date:today}"]),
429 | placeholder: "Enter a page name or {date} for any DNP",
430 | },
431 | "created after": {
432 | callback: ({ source, target }) => [
433 | {
434 | type: "data-pattern",
435 | arguments: [
436 | { type: "variable", value: source },
437 | { type: "constant", value: ":create/time" },
438 | { type: "variable", value: `${source}-CreateTime` },
439 | ],
440 | },
441 | {
442 | type: "pred-expr",
443 | pred: "<",
444 | arguments: [
445 | { type: "constant", value: `${parseNlpDate(target).valueOf()}` },
446 | { type: "variable", value: `${source}-CreateTime` },
447 | ],
448 | },
449 | ],
450 | placeholder: "Enter any natural language date value",
451 | },
452 | "created before": {
453 | callback: ({ source, target }) => [
454 | {
455 | type: "data-pattern",
456 | arguments: [
457 | { type: "variable", value: source },
458 | { type: "constant", value: ":create/time" },
459 | { type: "variable", value: `${source}-CreateTime` },
460 | ],
461 | },
462 | {
463 | type: "pred-expr",
464 | pred: ">",
465 | arguments: [
466 | { type: "constant", value: `${parseNlpDate(target).valueOf()}` },
467 | { type: "variable", value: `${source}-CreateTime` },
468 | ],
469 | },
470 | ],
471 | placeholder: "Enter any natural language date value",
472 | },
473 | "edited after": {
474 | callback: ({ source, target }) => [
475 | {
476 | type: "data-pattern",
477 | arguments: [
478 | { type: "variable", value: source },
479 | { type: "constant", value: ":edit/time" },
480 | { type: "variable", value: `${source}-EditTime` },
481 | ],
482 | },
483 | {
484 | type: "pred-expr",
485 | pred: "<",
486 | arguments: [
487 | { type: "constant", value: `${parseNlpDate(target).valueOf()}` },
488 | { type: "variable", value: `${source}-EditTime` },
489 | ],
490 | },
491 | ],
492 | placeholder: "Enter any natural language date value",
493 | },
494 | "edited before": {
495 | callback: ({ source, target }) => [
496 | {
497 | type: "data-pattern",
498 | arguments: [
499 | { type: "variable", value: source },
500 | { type: "constant", value: ":edit/time" },
501 | { type: "variable", value: `${source}-EditTime` },
502 | ],
503 | },
504 | {
505 | type: "pred-expr",
506 | pred: ">",
507 | arguments: [
508 | { type: "constant", value: `${parseNlpDate(target).valueOf()}` },
509 | { type: "variable", value: `${source}-EditTime` },
510 | ],
511 | },
512 | ],
513 | placeholder: "Enter any natural language date value",
514 | },
515 | "titled before": {
516 | callback: ({ source, target }) => [
517 | {
518 | type: "data-pattern",
519 | arguments: [
520 | { type: "variable", value: source },
521 | { type: "constant", value: ":log/id" },
522 | { type: "variable", value: `${source}-Log` },
523 | ],
524 | },
525 | {
526 | type: "pred-expr",
527 | pred: ">",
528 | arguments: [
529 | {
530 | type: "constant",
531 | value: `${startOfDay(parseNlpDate(target)).valueOf()}`,
532 | },
533 | { type: "variable", value: `${source}-Log` },
534 | ],
535 | },
536 | ],
537 | placeholder: "Enter any natural language date value",
538 | },
539 | "titled after": {
540 | callback: ({ source, target }) => [
541 | {
542 | type: "data-pattern",
543 | arguments: [
544 | { type: "variable", value: source },
545 | { type: "constant", value: ":log/id" },
546 | { type: "variable", value: `${source}-Log` },
547 | ],
548 | },
549 | {
550 | type: "pred-expr",
551 | pred: "<",
552 | arguments: [
553 | {
554 | type: "constant",
555 | value: `${endOfDay(parseNlpDate(target)).valueOf()}`,
556 | },
557 | { type: "variable", value: `${source}-Log` },
558 | ],
559 | },
560 | ],
561 | placeholder: "Enter any natural language date value",
562 | },
563 | };
564 |
565 | export const registerDatalogTranslator: RegisterDatalogTranslator = ({
566 | key,
567 | ...translation
568 | }) => {
569 | translator[key] = translation;
570 | };
571 |
572 | export const unregisterDatalogTranslator = ({ key }: { key: string }) =>
573 | delete translator[key];
574 |
575 | export const getConditionLabels = () =>
576 | Object.keys(translator)
577 | .filter((k) => k !== "self")
578 | .sort((a, b) => b.length - a.length);
579 |
580 | const conditionToDatalog: typeof window.roamjs.extension.queryBuilder.conditionToDatalog =
581 | (con) => {
582 | if (con.type === "or" || con.type === "not or") {
583 | const datalog = [
584 | {
585 | type: "or-join-clause",
586 | clauses: con.conditions.map((branch) => ({
587 | type: "and-clause",
588 | clauses: branch.flatMap((c) => conditionToDatalog(c)),
589 | })),
590 | variables: [],
591 | },
592 | ] as DatalogClause[];
593 | if (con.type === "not or")
594 | return [{ type: "not-clause", clauses: datalog }];
595 | return datalog;
596 | }
597 | const { relation, ...condition } = con;
598 | const datalogTranslator =
599 | translator[relation] ||
600 | Object.entries(translator).find(([k]) =>
601 | new RegExp(relation, "i").test(k)
602 | )?.[1];
603 | const datalog = datalogTranslator?.callback?.(condition) || [];
604 | if (datalog.length && (con.type === "not" || con.not))
605 | return [{ type: "not-clause", clauses: datalog }];
606 | return datalog;
607 | };
608 |
609 | export const sourceToTargetOptions = ({
610 | source,
611 | relation,
612 | }: {
613 | source: string;
614 | relation: string;
615 | }): string[] => {
616 | const targetOptions = translator[relation]?.targetOptions;
617 | if (!targetOptions) return [];
618 | if (typeof targetOptions === "function") return targetOptions(source);
619 | return targetOptions;
620 | };
621 |
622 | export const sourceToTargetPlaceholder = ({
623 | relation,
624 | }: {
625 | relation: string;
626 | }): string => {
627 | return translator[relation]?.placeholder || "Enter Value";
628 | };
629 |
630 | export const isTargetVariable = ({
631 | relation,
632 | }: {
633 | relation: string;
634 | }): boolean => {
635 | return translator[relation]?.isVariable || false;
636 | };
637 |
638 | export default conditionToDatalog;
639 |
--------------------------------------------------------------------------------
/src/components/QueryEditor.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | H6,
4 | InputGroup,
5 | Switch,
6 | Tabs,
7 | Tab,
8 | TextArea,
9 | } from "@blueprintjs/core";
10 | import React, { useMemo, useRef, useState } from "react";
11 | import createBlock from "roamjs-components/writes/createBlock";
12 | import setInputSetting from "roamjs-components/util/setInputSetting";
13 | import deleteBlock from "roamjs-components/writes/deleteBlock";
14 | import updateBlock from "roamjs-components/writes/updateBlock";
15 | import getFirstChildUidByBlockUid from "roamjs-components/queries/getFirstChildUidByBlockUid";
16 | import MenuItemSelect from "roamjs-components/components/MenuItemSelect";
17 | import {
18 | getConditionLabels,
19 | isTargetVariable,
20 | sourceToTargetOptions,
21 | sourceToTargetPlaceholder,
22 | } from "../utils/conditionToDatalog";
23 | import getSubTree from "roamjs-components/util/getSubTree";
24 | import {
25 | Condition,
26 | QBClause,
27 | QBClauseData,
28 | QBNestedData,
29 | QBNot,
30 | Selection,
31 | } from "roamjs-components/types/query-builder";
32 | import AutocompleteInput from "roamjs-components/components/AutocompleteInput";
33 | import getNthChildUidByBlockUid from "roamjs-components/queries/getNthChildUidByBlockUid";
34 | import getChildrenLengthByPageUid from "roamjs-components/queries/getChildrenLengthByPageUid";
35 | import parseQuery from "../utils/parseQuery";
36 | import { getDatalogQuery, getDatalogQueryComponents } from "../utils/fireQuery";
37 |
38 | const getSourceCandidates = (cs: Condition[]): string[] =>
39 | cs.flatMap((c) =>
40 | c.type === "clause" || c.type === "not"
41 | ? isTargetVariable({ relation: c.relation })
42 | ? [c.target]
43 | : []
44 | : getSourceCandidates(c.conditions.flat())
45 | );
46 |
47 | const QueryClause = ({
48 | con,
49 | index,
50 | setConditions,
51 | conditions,
52 | availableSources,
53 | }: {
54 | con: QBClause | QBNot;
55 | index: number;
56 | setConditions: (cons: Condition[]) => void;
57 | conditions: Condition[];
58 | availableSources: string[];
59 | }) => {
60 | const debounceRef = useRef(0);
61 | const conditionLabels = useMemo(getConditionLabels, []);
62 | const targetOptions = useMemo(
63 | () => sourceToTargetOptions({ source: con.source, relation: con.relation }),
64 | [con.source, con.relation]
65 | );
66 | const targetPlaceholder = useMemo(
67 | () => sourceToTargetPlaceholder({ relation: con.relation }),
68 | [con.source, con.relation]
69 | );
70 | return (
71 | <>
72 | {
81 | setInputSetting({
82 | blockUid: con.uid,
83 | key: "source",
84 | value,
85 | });
86 | setConditions(
87 | conditions.map((c) =>
88 | c.uid === con.uid ? { ...con, source: value } : c
89 | )
90 | );
91 | }}
92 | />
93 |
94 |
{
97 | window.clearTimeout(debounceRef.current);
98 | setConditions(
99 | conditions.map((c) =>
100 | c.uid === con.uid ? { ...con, relation: e } : c
101 | )
102 | );
103 | debounceRef.current = window.setTimeout(() => {
104 | setInputSetting({
105 | blockUid: con.uid,
106 | key: "Relation",
107 | value: e,
108 | index: 1,
109 | });
110 | }, 1000);
111 | }}
112 | options={conditionLabels}
113 | placeholder={"Choose relationship"}
114 | />
115 |
116 | {
124 | const not = (e.target as HTMLInputElement).checked;
125 | setConditions(
126 | conditions.map((c) =>
127 | c.uid === con.uid
128 | ? { ...con, not, type: not ? "not" : "clause" }
129 | : c
130 | )
131 | );
132 | if (not)
133 | createBlock({
134 | parentUid: con.uid,
135 | node: { text: "not" },
136 | order: 4,
137 | });
138 | else deleteBlock(getSubTree({ key: "not", parentUid: con.uid }).uid);
139 | }}
140 | />
141 |
142 |
{
145 | window.clearTimeout(debounceRef.current);
146 | setConditions(
147 | conditions.map((c) =>
148 | c.uid === con.uid ? { ...con, target: e } : c
149 | )
150 | );
151 | debounceRef.current = window.setTimeout(() => {
152 | setInputSetting({
153 | blockUid: con.uid,
154 | value: e,
155 | key: "target",
156 | index: 2,
157 | });
158 | }, 1000);
159 | }}
160 | options={targetOptions}
161 | placeholder={targetPlaceholder}
162 | />
163 |
164 | >
165 | );
166 | };
167 |
168 | const QueryNestedData = ({
169 | con,
170 | setView,
171 | }: {
172 | con: QBNestedData;
173 | setView: (s: { uid: string; branch: number }) => void;
174 | }) => {
175 | return (
176 | <>
177 | setView({ uid: con.uid, branch: 0 })}
181 | style={{
182 | minWidth: 144,
183 | maxWidth: 144,
184 | paddingRight: 8,
185 | }}
186 | />
187 |
194 | ({con.conditions.length}) Branches
195 |
196 |
204 | >
205 | );
206 | };
207 |
208 | const QueryCondition = ({
209 | con,
210 | index,
211 | setConditions,
212 | conditions,
213 | availableSources,
214 | setView,
215 | }: {
216 | con: Condition;
217 | index: number;
218 | setConditions: (cons: Condition[]) => void;
219 | conditions: Condition[];
220 | availableSources: string[];
221 | setView: (s: { uid: string; branch: number }) => void;
222 | }) => {
223 | return (
224 |
225 | {(con.type === "clause" || con.type === "not") && (
226 |
233 | )}
234 | {(con.type === "not or" || con.type === "or") && (
235 |
236 | )}
237 | {
240 | deleteBlock(con.uid);
241 | setConditions(conditions.filter((c) => c.uid !== con.uid));
242 | }}
243 | minimal
244 | style={{ alignSelf: "end", minWidth: 30 }}
245 | />
246 |
247 | );
248 | };
249 |
250 | const QuerySelection = ({
251 | sel,
252 | setSelections,
253 | selections,
254 | }: {
255 | sel: Selection;
256 | setSelections: (cons: Selection[]) => void;
257 | selections: Selection[];
258 | }) => {
259 | const debounceRef = useRef(0);
260 | return (
261 |
262 |
269 | AS
270 |
271 |
272 | {
275 | window.clearTimeout(debounceRef.current);
276 | const label = e.target.value;
277 | setSelections(
278 | selections.map((c) => (c.uid === sel.uid ? { ...sel, label } : c))
279 | );
280 | debounceRef.current = window.setTimeout(() => {
281 | const firstChild = getFirstChildUidByBlockUid(sel.uid);
282 | if (firstChild) updateBlock({ uid: firstChild, text: label });
283 | else createBlock({ parentUid: sel.uid, node: { text: label } });
284 | }, 1000);
285 | }}
286 | />
287 |
288 |
295 | Select
296 |
297 |
303 | {
307 | window.clearTimeout(debounceRef.current);
308 | setSelections(
309 | selections.map((c) =>
310 | c.uid === sel.uid ? { ...sel, text: e.target.value } : c
311 | )
312 | );
313 | debounceRef.current = window.setTimeout(() => {
314 | updateBlock({ uid: sel.uid, text: e.target.value });
315 | }, 1000);
316 | }}
317 | />
318 |
319 |
{
322 | deleteBlock(sel.uid).then(() =>
323 | setSelections(selections.filter((c) => c.uid !== sel.uid))
324 | );
325 | }}
326 | minimal
327 | style={{ alignSelf: "end", minWidth: 30 }}
328 | />
329 |
330 | );
331 | };
332 |
333 | const getConditionByUid = (uid: string, conditions: Condition[]): Condition => {
334 | for (const con of conditions) {
335 | if (con.uid === uid) return con;
336 | if (con.type === "or" || con.type === "not or") {
337 | const c = getConditionByUid(uid, con.conditions.flat());
338 | if (c) return c;
339 | }
340 | }
341 | return undefined;
342 | };
343 |
344 | const QueryEditor: typeof window.roamjs.extension.queryBuilder.QueryEditor = ({
345 | parentUid,
346 | onQuery,
347 | defaultReturnNode, // returnNodeDisabled
348 | }) => {
349 | const conditionLabels = useMemo(() => new Set(getConditionLabels()), []);
350 | const {
351 | returnNodeUid,
352 | conditionsNodesUid,
353 | selectionsNodesUid,
354 | returnNode: initialReturnNode,
355 | conditions: initialConditions,
356 | selections: initialSelections,
357 | // @ts-ignore
358 | customNodeUid,
359 | // @ts-ignore
360 | customNode: initialCustom,
361 | // @ts-ignore
362 | isCustomEnabled: initialIsCustomEnabled,
363 | } = useMemo(() => parseQuery(parentUid), [parentUid]);
364 | const [returnNode, setReturnNode] = useState(() => initialReturnNode);
365 | const debounceRef = useRef(0);
366 | const returnNodeOnChange = (value: string) => {
367 | window.clearTimeout(debounceRef.current);
368 | setReturnNode(value);
369 | debounceRef.current = window.setTimeout(() => {
370 | const childUid = getFirstChildUidByBlockUid(returnNodeUid);
371 | if (childUid)
372 | updateBlock({
373 | uid: childUid,
374 | text: value,
375 | });
376 | else createBlock({ parentUid: returnNodeUid, node: { text: value } });
377 | }, 1000);
378 | };
379 | const [conditions, setConditions] = useState(initialConditions);
380 | const [selections, setSelections] = useState(initialSelections);
381 | const [custom, setCustom] = useState(initialCustom);
382 | const customNodeOnChange = (value: string) => {
383 | window.clearTimeout(debounceRef.current);
384 | setCustom(value);
385 | debounceRef.current = window.setTimeout(async () => {
386 | const childUid = getFirstChildUidByBlockUid(customNodeUid);
387 | if (childUid)
388 | updateBlock({
389 | uid: childUid,
390 | text: value,
391 | });
392 | else createBlock({ parentUid: returnNodeUid, node: { text: value } });
393 | }, 1000);
394 | };
395 | const [views, setViews] = useState([{ uid: parentUid, branch: 0 }]);
396 | const view = useMemo(() => views.slice(-1)[0], [views]);
397 | const viewCondition = useMemo(
398 | () =>
399 | view.uid === parentUid
400 | ? undefined
401 | : (getConditionByUid(view.uid, conditions) as QBNestedData),
402 | [view, conditions]
403 | );
404 | const viewConditions = useMemo(
405 | () =>
406 | view.uid === parentUid
407 | ? conditions
408 | : viewCondition?.conditions?.[view.branch] || [],
409 | [view, viewCondition, conditions]
410 | );
411 | const disabledMessage = useMemo(() => {
412 | for (let index = 0; index < conditions.length; index++) {
413 | const condition = conditions[index];
414 | if (condition.type === "clause" || condition.type === "not") {
415 | if (!condition.relation) {
416 | return `Condition ${index + 1} must not have an empty relation.`;
417 | }
418 | if (!conditionLabels.has(condition.relation)) {
419 | return `Condition ${index + 1} has an unsupported relation ${
420 | condition.relation
421 | }.`;
422 | }
423 | if (!condition.target) {
424 | return `Condition ${index + 1} must not have an empty target.`;
425 | }
426 | } else if (!condition.conditions.length) {
427 | return `Condition ${index + 1} must have at least one sub-condition.`;
428 | }
429 | }
430 | if (!returnNode) {
431 | return `Query must have a value specified in the "Find ... Where" input`;
432 | }
433 | for (let index = 0; index < selections.length; index++) {
434 | const selection = selections[index];
435 | if (!selection.text) {
436 | return `Selection ${index + 1} must not have an empty select value.`;
437 | }
438 | if (!selection.label) {
439 | return `Selection ${index + 1} must not have an empty select alias.`;
440 | }
441 | }
442 | return "";
443 | }, [returnNode, selections, conditionLabels, conditions]);
444 | const [isCustomEnabled, setIsCustomEnabled] = useState(
445 | initialIsCustomEnabled
446 | );
447 | const [showDisabledMessage, setShowDisabledMessage] = useState(false);
448 | return view.uid === parentUid ? (
449 |
450 |
457 | {!isCustomEnabled && (
458 | <>
459 |
465 | Find
466 |
467 | {
472 | returnNodeOnChange(e.target.value);
473 | }}
474 | placeholder={"Enter Label..."}
475 | className="roamjs-query-return-node"
476 | />
477 |
484 | Where
485 |
486 | >
487 | )}
488 |
491 | {
495 | const enabled = !(e.target as HTMLInputElement).checked;
496 | const contentUid = getNthChildUidByBlockUid({
497 | blockUid: customNodeUid,
498 | order: 0,
499 | });
500 | const enabledUid = getNthChildUidByBlockUid({
501 | blockUid: customNodeUid,
502 | order: 1,
503 | });
504 | if (enabled) {
505 | const text = getDatalogQuery(
506 | getDatalogQueryComponents({
507 | conditions,
508 | selections,
509 | returnNode,
510 | })
511 | );
512 | if (contentUid) updateBlock({ text, uid: contentUid });
513 | else
514 | createBlock({
515 | parentUid: customNodeUid,
516 | order: 0,
517 | node: {
518 | text,
519 | },
520 | });
521 | setCustom(text);
522 | if (enabledUid) updateBlock({ text: "enabled", uid: enabledUid });
523 | else
524 | createBlock({
525 | parentUid: customNodeUid,
526 | order: 1,
527 | node: { text: "enabled" },
528 | });
529 | } else {
530 | if (contentUid) {
531 | // TODO - translate from custom back into english - seems very hard!
532 | }
533 | if (enabledUid) deleteBlock(enabledUid);
534 | }
535 | setIsCustomEnabled(enabled);
536 | }}
537 | innerLabelChecked={"ENG"}
538 | innerLabel={"DATA"}
539 | />
540 |
541 | {isCustomEnabled ? (
542 |
642 | ) : (
643 |
644 |
645 |
OR Branches
646 |
649 | setViews(
650 | views.slice(0, -1).concat([{ uid: view.uid, branch: Number(e) }])
651 | )
652 | }
653 | >
654 | {Array(viewCondition.conditions.length)
655 | .fill(null)
656 | .map((_, j) => (
657 |
663 | {viewConditions.map((con, index) => (
664 | c.type === "clause" || c.type === "not"
674 | )
675 | .map((c) => (c as QBClauseData).target),
676 | ]}
677 | setConditions={(cons) => {
678 | viewCondition.conditions[view.branch] = cons;
679 | setConditions([...conditions]);
680 | }}
681 | setView={(v) => setViews([...views, v])}
682 | />
683 | ))}
684 | >
685 | }
686 | />
687 | ))}
688 |
689 |
690 |
691 | {
696 | setViews(views.slice(0, -1));
697 | }}
698 | />
699 |
700 |
701 | {
706 | createBlock({
707 | parentUid: view.uid,
708 | order: viewConditions.length,
709 | node: {
710 | text: `AND`,
711 | },
712 | }).then(() => {
713 | viewCondition.conditions.push([]);
714 | setConditions([...conditions]);
715 | });
716 | }}
717 | />
718 |
719 |
720 | {
725 | const branchUid = getNthChildUidByBlockUid({
726 | blockUid: view.uid,
727 | order: view.branch,
728 | });
729 | (branchUid
730 | ? Promise.resolve(branchUid)
731 | : createBlock({
732 | parentUid: view.uid,
733 | order: view.branch,
734 | node: { text: "AND" },
735 | })
736 | )
737 | .then((branchUid) =>
738 | createBlock({
739 | parentUid: branchUid,
740 | order: getChildrenLengthByPageUid(branchUid),
741 | node: {
742 | text: `clause`,
743 | },
744 | })
745 | )
746 | .then((uid) => {
747 | viewCondition.conditions[view.branch] = [
748 | ...viewConditions,
749 | {
750 | uid,
751 | source: "",
752 | relation: "",
753 | target: "",
754 | type: "clause",
755 | },
756 | ];
757 | setConditions([...conditions]);
758 | });
759 | }}
760 | />
761 |
762 |
763 |
764 |
765 | );
766 | };
767 |
768 | export default QueryEditor;
769 |
--------------------------------------------------------------------------------
/src/components/ResultsView.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useMemo, useRef, useState } from "react";
2 | import getRoamUrl from "roamjs-components/dom/getRoamUrl";
3 | import openBlockInSidebar from "roamjs-components/writes/openBlockInSidebar";
4 | import {
5 | Button,
6 | Icon,
7 | IconName,
8 | Tooltip,
9 | HTMLTable,
10 | InputGroup,
11 | Popover,
12 | Menu,
13 | MenuItem,
14 | Switch,
15 | } from "@blueprintjs/core";
16 | import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid";
17 | import Filter, { Filters } from "roamjs-components/components/Filter";
18 | import getSubTree from "roamjs-components/util/getSubTree";
19 | import deleteBlock from "roamjs-components/writes/deleteBlock";
20 | import createBlock from "roamjs-components/writes/createBlock";
21 | import extractTag from "roamjs-components/util/extractTag";
22 | import Export from "./Export";
23 | import parseQuery from "../utils/parseQuery";
24 | import { getDatalogQuery, getDatalogQueryComponents } from "../utils/fireQuery";
25 | import getCurrentUserUid from "roamjs-components/queries/getCurrentUserUid";
26 | import type {
27 | QBClauseData,
28 | Result,
29 | } from "roamjs-components/types/query-builder";
30 | import parseResultSettings from "../utils/parseResultSettings";
31 | import { useExtensionAPI } from "roamjs-components/components/ExtensionApiContext";
32 | import postProcessResults from "../utils/postProcessResults";
33 | import setInputSetting from "roamjs-components/util/setInputSetting";
34 | import getUids from "roamjs-components/dom/getUids";
35 | import Charts from "./Charts";
36 | import Timeline from "./Timeline";
37 | import {BlockResult} from './BlockResult'
38 |
39 | type Sorts = { key: string; descending: boolean }[];
40 | type FilterData = Record;
41 |
42 | const VIEWS = ["link", "plain", "embed"];
43 |
44 | const ResultHeader = ({
45 | c,
46 | results,
47 | activeSort,
48 | setActiveSort,
49 | filters,
50 | setFilters,
51 | initialFilter,
52 | view,
53 | onViewChange,
54 | }: {
55 | c: string;
56 | results: Result[];
57 | activeSort: Sorts;
58 | setActiveSort: (s: Sorts) => void;
59 | filters: FilterData;
60 | setFilters: (f: FilterData) => void;
61 | initialFilter: Filters;
62 | view: string;
63 | onViewChange: (s: string) => void;
64 | }) => {
65 | const filterData = useMemo(
66 | () => ({
67 | values: Array.from(new Set(results.map((r) => toCellValue(r[c])))),
68 | }),
69 | [results, c]
70 | );
71 | const sortIndex = activeSort.findIndex((s) => s.key === c);
72 | return (
73 | {
80 | if (sortIndex >= 0) {
81 | if (activeSort[sortIndex].descending) {
82 | setActiveSort(activeSort.filter((s) => s.key !== c));
83 | } else {
84 | setActiveSort(
85 | activeSort.map((s) =>
86 | s.key === c ? { key: c, descending: true } : s
87 | )
88 | );
89 | }
90 | } else {
91 | setActiveSort([...activeSort, { key: c, descending: false }]);
92 | }
93 | }}
94 | >
95 |
96 |
{c}
97 |
98 |
102 | setFilters({ ...filters, [c]: newFilters })
103 | }
104 | renderButtonText={(s) =>
105 | s ? s.toString() : (Empty)
106 | }
107 | small
108 | />
109 | e.stopPropagation()}>
110 |
111 | }
113 | content={
114 |
115 | {VIEWS.map((c) => (
116 | onViewChange(c)}
119 | text={c}
120 | />
121 | ))}
122 |
123 | }
124 | />
125 |
126 |
127 | {sortIndex >= 0 && (
128 |
129 |
134 | ({sortIndex + 1})
135 |
136 | )}
137 |
138 |
139 |
140 | );
141 | };
142 |
143 | export const CellEmbed = ({ uid }: { uid: string }) => {
144 | const contentRef = useRef(null);
145 | useEffect(() => {
146 | window.roamAlphaAPI.ui.components.renderBlock({
147 | uid,
148 | el: contentRef.current,
149 | });
150 | }, [contentRef]);
151 | return
;
152 | };
153 |
154 | const ResultView = ({
155 | r,
156 | ctrlClick,
157 | views,
158 | extraColumn,
159 | }: {
160 | r: Result;
161 | ctrlClick?: (e: Result) => void;
162 | views: Record;
163 | extraColumn?: { row: (e: Result) => React.ReactNode; reserved: RegExp[] };
164 | }) => {
165 | const rowCells = Object.keys(r).filter(
166 | (k) =>
167 | !UID_REGEX.test(k) &&
168 | !(extraColumn && extraColumn.reserved.some((t) => t.test(k)))
169 | );
170 | const namespaceSetting = useMemo(
171 | () =>
172 | (
173 | window.roamAlphaAPI.data.fast.q(
174 | `[:find [pull ?u [:user/settings]] :where [?u :user/uid "${getCurrentUserUid()}"]]`
175 | )?.[0]?.[0] as {
176 | ":user/settings": {
177 | ":namespace-options": { name: "partial" | "none" | "full" }[];
178 | };
179 | }
180 | )?.[":user/settings"]?.[":namespace-options"]?.[0]?.name,
181 | []
182 | );
183 | const cell = (key: string) => {
184 | const value = toCellValue(r[key] || "");
185 | const formattedValue =
186 | typeof value === "string" &&
187 | r[`${key}-uid`] &&
188 | !!getPageTitleByPageUid((r[`${key}-uid`] || "").toString())
189 | ? namespaceSetting === "full"
190 | ? value.split("/").slice(-1)[0]
191 | : namespaceSetting === "partial"
192 | ? value
193 | .split("/")
194 | .map((v, i, a) => (i === a.length - 1 ? v : v.slice(0, 1)))
195 | .join("/")
196 | : value
197 | : value;
198 | return formattedValue
199 | .toString()
200 | .split("")
201 | .map((s, i) => (
202 |
206 | {s}
207 |
208 | ));
209 | };
210 | return (
211 | <>
212 |
213 | {rowCells.map((k) => {
214 | const uid = (r[`${k}-uid`] || "").toString();
215 | const val = r[k] || "";
216 | return (
217 |
224 | {val === "" ? (
225 | [block is blank]
226 | ) : views[k] === "link" ? (
227 | {
232 | if (e.shiftKey) {
233 | openBlockInSidebar(uid);
234 | e.preventDefault();
235 | e.stopPropagation();
236 | } else if (e.ctrlKey) {
237 | ctrlClick?.({
238 | text: toCellValue(val),
239 | uid,
240 | });
241 | e.preventDefault();
242 | e.stopPropagation();
243 | }
244 | }}
245 | onClick={(e) => {
246 | if (e.shiftKey || e.ctrlKey) {
247 | e.preventDefault();
248 | e.stopPropagation();
249 | }
250 | }}
251 | onContextMenu={(e) => {
252 | if (e.ctrlKey) {
253 | e.preventDefault();
254 | e.stopPropagation();
255 | }
256 | }}
257 | >
258 | {cell(k)}
259 |
260 | ) : views[k] === "embed" ? (
261 |
262 | ) : (
263 | cell(k)
264 | )}
265 |
266 | );
267 | })}
268 | {extraColumn && {extraColumn.row(r)} }
269 |
270 | >
271 | );
272 | };
273 |
274 | const UID_REGEX = /uid/;
275 | const toCellValue = (v: number | Date | string) =>
276 | v instanceof Date
277 | ? window.roamAlphaAPI.util.dateToPageTitle(v)
278 | : typeof v === "undefined" || v === null
279 | ? ""
280 | : extractTag(v.toString());
281 |
282 | const QueryUsed = ({ parentUid }: { parentUid: string }) => {
283 | const { datalogQuery, englishQuery } = useMemo(() => {
284 | const args = parseQuery(parentUid);
285 | const datalogQuery = getDatalogQuery(getDatalogQueryComponents(args));
286 | const englishQuery = [
287 | `Find ${args.returnNode} Where`,
288 | ...(args.conditions as QBClauseData[]).map(
289 | (c) => `${c.not ? "NOT " : ""}${c.source} ${c.relation} ${c.target}`
290 | ),
291 | ...args.selections.map((s) => `Select ${s.text} AS ${s.label}`),
292 | ];
293 | return { datalogQuery, englishQuery };
294 | }, [parentUid]);
295 | const [isEnglish, setIsEnglish] = useState(true);
296 | return (
297 |
305 |
311 | {isEnglish
312 | ? englishQuery.map((q, i) => (
313 |
314 | {q}
315 |
316 | ))
317 | : datalogQuery.split("\n").map((q, i) => (
318 |
319 | {q}
320 |
321 | ))}
322 |
323 |
324 |
327 | setIsEnglish((e.target as HTMLInputElement).checked)}
331 | innerLabelChecked={"ENG"}
332 | innerLabel={"DATA"}
333 | />
334 |
335 |
336 | );
337 | };
338 |
339 | const SUPPORTED_LAYOUTS = [
340 | { id: "table", icon: "join-table" },
341 | { id: "block", icon: "join-table" },
342 | { id: "line", icon: "chart" },
343 | { id: "bar", icon: "vertical-bar-chart-asc" },
344 | { id: "timeline", icon: "timeline-events" },
345 | ] as const;
346 |
347 | function getResultsBlockUid(parentUid: any) {
348 | const node = getSubTree({key: 'block-result', parentUid})
349 | return node.uid
350 | }
351 |
352 | const ResultsView: typeof window.roamjs.extension.queryBuilder.ResultsView = ({
353 | parentUid,
354 | header,
355 | results,
356 | hideResults = false,
357 | resultFilter,
358 | ctrlClick,
359 | preventSavingSettings = false,
360 | preventExport,
361 | onEdit,
362 | onRefresh,
363 | getExportTypes,
364 | onResultsInViewChange,
365 |
366 | // @ts-ignore
367 | extraColumn,
368 | // @ts-ignore
369 | isEditBlock,
370 | }) => {
371 | const extensionAPI = useExtensionAPI();
372 | const columns = useMemo(
373 | () =>
374 | results.length
375 | ? Object.keys(results[0]).filter(
376 | (k) =>
377 | !UID_REGEX.test(k) &&
378 | !(
379 | extraColumn &&
380 | extraColumn.reserved.some((t: RegExp) => t.test(k))
381 | )
382 | )
383 | : ["text"],
384 | [results, extraColumn]
385 | );
386 | const settings = useMemo(
387 | () => parseResultSettings(parentUid, columns, extensionAPI),
388 | [parentUid]
389 | );
390 | const [activeSort, setActiveSort] = useState(settings.activeSort);
391 | const [filters, setFilters] = useState(() => settings.filters);
392 | const randomRef = useRef(settings.random);
393 | const [random, setRandom] = useState({ count: settings.random });
394 | const [page, setPage] = useState(settings.page);
395 | const [pageSize, setPageSize] = useState(settings.pageSize);
396 | const pageSizeTimeoutRef = useRef(0);
397 | const [views, setViews] = useState(settings.views);
398 |
399 | const preProcessedResults = useMemo(
400 | () => (resultFilter ? results.filter(resultFilter) : results),
401 | [results, resultFilter]
402 | );
403 | const { allResults, paginatedResults } = useMemo(() => {
404 | return postProcessResults(preProcessedResults, {
405 | activeSort,
406 | filters,
407 | random: random.count,
408 | page,
409 | pageSize,
410 | });
411 | }, [preProcessedResults, activeSort, filters, page, pageSize, random.count]);
412 |
413 | const [showContent, setShowContent] = useState(false);
414 | useEffect(() => {
415 | onResultsInViewChange?.(paginatedResults);
416 | }, [paginatedResults]);
417 | const containerRef = useRef(null);
418 | const [moreMenuOpen, setMoreMenuOpen] = useState(false);
419 | const [isExportOpen, setIsExportOpen] = useState(false);
420 | const [isEditRandom, setIsEditRandom] = useState(false);
421 | const [isEditLayout, setIsEditLayout] = useState(false);
422 | const [layout, setLayout] = useState(
423 | settings.layout || SUPPORTED_LAYOUTS[0].id
424 | );
425 | return (
426 |
430 |
setIsExportOpen(false)}
433 | results={allResults}
434 | exportTypes={getExportTypes?.(allResults)}
435 | />
436 |
437 | {onRefresh && (
438 |
439 |
440 |
441 | )}
442 | setMoreMenuOpen(!moreMenuOpen)}
449 | />
450 | }
451 | content={
452 | isEditRandom ? (
453 |
454 |
455 | Get Random
456 | setIsEditRandom(false)}
459 | minimal
460 | small
461 | />
462 |
463 | (randomRef.current = Number(e.target.value))}
466 | rightElement={
467 |
468 | {
471 | setRandom({ count: randomRef.current });
472 |
473 | if (preventSavingSettings) return;
474 | const resultNode = getSubTree({
475 | key: "results",
476 | parentUid,
477 | });
478 | setInputSetting({
479 | key: "random",
480 | value: randomRef.current.toString(),
481 | blockUid: resultNode.uid,
482 | });
483 | }}
484 | minimal
485 | />
486 |
487 | }
488 | type={"number"}
489 | style={{ width: 80 }}
490 | />
491 |
492 | ) : isEditLayout ? (
493 |
494 |
495 | Layout
496 | setIsEditLayout(false)}
499 | minimal
500 | small
501 | />
502 |
503 |
504 | {SUPPORTED_LAYOUTS.map((l) => (
505 |
{
512 | setLayout(l.id);
513 | const resultNode = getSubTree({
514 | key: "results",
515 | parentUid,
516 | });
517 | setInputSetting({
518 | key: "layout",
519 | value: l.id,
520 | blockUid: resultNode.uid,
521 | });
522 | }}
523 | >
524 |
525 | {l.id}
526 |
527 | ))}
528 |
529 |
530 | ) : (
531 |
532 | {onEdit && (
533 | {
537 | setMoreMenuOpen(false);
538 | onEdit();
539 | }}
540 | />
541 | )}
542 | {
546 | setIsEditLayout(true);
547 | }}
548 | />
549 | {!preventExport && (
550 | {
554 | setMoreMenuOpen(false);
555 | setIsExportOpen(true);
556 | }}
557 | />
558 | )}
559 | setIsEditRandom(true)}
563 | />
564 | {isEditBlock && (
565 | {
569 | const location = getUids(
570 | containerRef.current.closest(
571 | ".roam-block"
572 | ) as HTMLDivElement
573 | );
574 | window.roamAlphaAPI.ui.setBlockFocusAndSelection({
575 | location: {
576 | "window-id": location.windowId,
577 | "block-uid": location.blockUid,
578 | },
579 | });
580 | }}
581 | />
582 | )}
583 |
584 | )
585 | }
586 | />
587 |
588 | {header && (
589 |
597 | {header}
598 |
599 | )}
600 | {!hideResults &&
601 | (results.length !== 0 ? (
602 | layout === "table" ? (
603 |
615 |
620 |
621 | {columns.map((c) => (
622 | {
628 | setActiveSort(as);
629 | if (preventSavingSettings) return;
630 | const resultNode = getSubTree({
631 | key: "results",
632 | parentUid,
633 | });
634 | const sortsNode = getSubTree({
635 | key: "sorts",
636 | parentUid: resultNode.uid,
637 | });
638 | sortsNode.children.forEach((c) => deleteBlock(c.uid));
639 | as.map((a) => ({
640 | text: a.key,
641 | children: [{ text: `${a.descending}` }],
642 | })).forEach((node, order) =>
643 | createBlock({ parentUid: sortsNode.uid, node, order })
644 | );
645 | }}
646 | filters={filters}
647 | setFilters={(fs) => {
648 | setFilters(fs);
649 |
650 | if (preventSavingSettings) return;
651 | const resultNode = getSubTree({
652 | key: "results",
653 | parentUid,
654 | });
655 | const filtersNode = getSubTree({
656 | key: "filters",
657 | parentUid: resultNode.uid,
658 | });
659 | filtersNode.children.forEach((c) => deleteBlock(c.uid));
660 | Object.entries(fs)
661 | .filter(
662 | ([, data]) =>
663 | data.includes.values.size ||
664 | data.excludes.values.size
665 | )
666 | .map(([column, data]) => ({
667 | text: column,
668 | children: [
669 | {
670 | text: "includes",
671 | children: Array.from(data.includes.values).map(
672 | (text) => ({ text })
673 | ),
674 | },
675 | {
676 | text: "excludes",
677 | children: Array.from(data.excludes.values).map(
678 | (text) => ({ text })
679 | ),
680 | },
681 | ],
682 | }))
683 | .forEach((node, order) =>
684 | createBlock({
685 | parentUid: filtersNode.uid,
686 | node,
687 | order,
688 | })
689 | );
690 | }}
691 | initialFilter={settings.filters[c]}
692 | view={views[c]}
693 | onViewChange={(v) => {
694 | const vs = { ...views, [c]: v };
695 | setViews(vs);
696 |
697 | if (preventSavingSettings) return;
698 | const resultNode = getSubTree({
699 | key: "results",
700 | parentUid,
701 | });
702 | const viewsNode = getSubTree({
703 | key: "views",
704 | parentUid: resultNode.uid,
705 | });
706 | viewsNode.children.forEach((c) => deleteBlock(c.uid));
707 |
708 | Object.entries(vs)
709 | .map(([key, value]) => ({
710 | text: key,
711 | children: [{ text: value }],
712 | }))
713 | .forEach((node, order) =>
714 | createBlock({
715 | node,
716 | order,
717 | parentUid: viewsNode.uid,
718 | })
719 | );
720 | }}
721 | />
722 | ))}
723 | {extraColumn && (
724 |
725 | {extraColumn.header}
726 |
727 | )}
728 |
729 |
730 |
731 | {paginatedResults.map((r) => (
732 |
739 | ))}
740 |
741 |
742 |
743 |
747 |
754 |
755 |
756 | setShowContent(!showContent)}
761 | style={{
762 | marginRight: 4,
763 | }}
764 | />
765 | Showing {paginatedResults.length} of {results.length}{" "}
766 | results
767 |
768 |
769 |
770 | Rows per page:
771 | {
774 | clearTimeout(pageSizeTimeoutRef.current);
775 | pageSizeTimeoutRef.current = window.setTimeout(
776 | () => {
777 | setPageSize(Number(e.target.value));
778 |
779 | if (preventSavingSettings) return;
780 | const resultNode = getSubTree({
781 | key: "results",
782 | parentUid,
783 | });
784 | setInputSetting({
785 | key: "size",
786 | value: e.target.value,
787 | blockUid: resultNode.uid,
788 | });
789 | },
790 | 1000
791 | );
792 | }}
793 | type="number"
794 | style={{
795 | width: 60,
796 | maxWidth: 60,
797 | marginRight: 32,
798 | marginLeft: 16,
799 | }}
800 | />
801 | setPage(1)}
805 | disabled={page === 1}
806 | />
807 | setPage(page - 1)}
811 | disabled={page === 1}
812 | />
813 | {page}
814 | setPage(page + 1)}
818 | disabled={
819 | page === Math.ceil(allResults.length / pageSize) ||
820 | allResults.length === 0
821 | }
822 | />
823 |
831 | setPage(Math.ceil(allResults.length / pageSize))
832 | }
833 | />
834 |
835 |
836 | {showContent && }
837 |
838 |
839 |
840 |
841 | ) : layout === "line" ? (
842 |
843 | ) : layout === "bar" ? (
844 |
845 | ) : layout === "timeline" ? (
846 |
847 | ) : layout === "block" ? (
848 |
849 | ) : (
850 | Layout `{layout}` is not supported
851 | )
852 | ) : (
853 |
854 | No Results Found
855 |
856 | ))}
857 |
858 | );
859 | };
860 |
861 | export default ResultsView;
862 |
--------------------------------------------------------------------------------