├── tsconfig.json
├── importUMD.js
├── index.html
├── bookmarklet-tools.js
├── lib
└── dom.js
├── .vscode
└── launch.json
├── package.json
├── YouTube.d.ts
├── download.js
├── renderStuff.js
├── LICENSE
├── RoamTypes.d.ts
├── README.md
├── youtube-tools.js
├── .gitignore
├── youtube-dl-transcript-csv.ts.js
├── roam-bookmarklet-jw.js
└── index.js
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["DOM", "ES2020"],
4 | "allowJs": true,
5 | "strict": true,
6 | "checkJs": true,
7 | "strictNullChecks": true,
8 | "declaration": true,
9 | "emitDeclarationOnly": true
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/importUMD.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {RequestInfo} url
3 | */
4 | export default async (url, module = { exports: {} }) =>
5 | (Function("module", "exports", await (await fetch(url)).text()).call(
6 | module,
7 | module,
8 | module.exports,
9 | ),
10 | module).exports
11 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Hello whirrled
5 |
6 |
13 |
14 |
--------------------------------------------------------------------------------
/bookmarklet-tools.js:
--------------------------------------------------------------------------------
1 | // Bookmarklet tools
2 |
3 | // javascript:import("http://work-aylott-win.local:5000/bookmarklet-tools.js").then(module=>console.log(module))
4 | // javascript:void(import("/bookmarklet-tools.js").then(({default:init})=>init()))
5 |
6 |
7 | export default function init() {
8 | const {href, hostname} = location
9 |
10 | console.log(href)
11 | }
12 |
--------------------------------------------------------------------------------
/lib/dom.js:
--------------------------------------------------------------------------------
1 | /** @type {Document['querySelector']} */
2 | // @ts-ignore
3 | export const $ = (selectors) => document.querySelector(selectors)
4 |
5 | /** @type {Document['querySelectorAll']} */
6 | // @ts-ignore
7 | export const $$ = (selectors) => document.querySelectorAll(selectors)
8 |
9 | /**
10 | * @param {Element} node
11 | */
12 | export const removeChild = node => node.parentElement?.removeChild(node)
13 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "type": "pwa-chrome",
9 | "request": "launch",
10 | "name": "Launch Chrome against localhost",
11 | "url": "http://localhost:8808",
12 | "webRoot": "${workspaceFolder}"
13 | }
14 | ]
15 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ao-roam",
3 | "version": "1.0.1",
4 | "description": "",
5 | "keywords": [],
6 | "main": "/index.mjs",
7 | "scripts": {
8 | "serve": "http-server --cors --port 8808",
9 | "ngrok": "ngrok http 8808",
10 | "init-mac": "npm i -g http-server && brew install --cask ngrok && open $(dirname `which ngrok`)"
11 | },
12 | "dependencies": {
13 | "serve": "^11.3.2"
14 | },
15 | "prettier": {
16 | "semi": false,
17 | "arrowParens": "avoid",
18 | "trailingComma": "all"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/YouTube.d.ts:
--------------------------------------------------------------------------------
1 | export interface TranscriptBody {
2 | cueGroups: CueGroup[]
3 | }
4 |
5 | export interface CueGroup {
6 | transcriptCueGroupRenderer: TranscriptCueGroupRenderer
7 | }
8 |
9 | export interface TranscriptCueGroupRenderer {
10 | formattedStartOffset: HasSimpleText
11 | cues: Cue[]
12 | }
13 |
14 | export interface Cue {
15 | transcriptCueRenderer: TranscriptCueRenderer
16 | }
17 |
18 | export interface TranscriptCueRenderer {
19 | cue: HasSimpleText
20 | startOffsetMs: string
21 | durationMs: string
22 | }
23 |
24 | export interface HasSimpleText {
25 | simpleText: string
26 | }
27 |
--------------------------------------------------------------------------------
/download.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @param {string} fileName
3 | * @param {any} data
4 | */
5 |
6 | export async function download(fileName, data) {
7 | const content =
8 | typeof data === "string" || data instanceof Blob
9 | ? data
10 | : JSON.stringify(data)
11 | const downloadURL = window.URL.createObjectURL(
12 | new Blob([content], { type: "application/json" }),
13 | )
14 | const anchor = document.createElement("a")
15 | anchor.download = fileName
16 | anchor.href = downloadURL
17 | await new Promise(resolve => setTimeout(resolve, 0))
18 | anchor.dispatchEvent(new MouseEvent("click"))
19 | }
20 |
--------------------------------------------------------------------------------
/renderStuff.js:
--------------------------------------------------------------------------------
1 | import { React, ReactDOM } from "https://unpkg.com/es-react@16"
2 | import htm from "https://unpkg.com/htm?module"
3 |
4 | const html = htm.bind(React.createElement)
5 |
6 | const Counter = props => {
7 | const [count, setCount] = React.useState(parseInt(props.count))
8 | return html`
9 |
10 |
${count}
11 |
12 |
13 |
14 | `
15 | }
16 |
17 | ReactDOM.render(
18 | html`
19 | Look Ma! No script tags, no build step
20 | <${Counter} count="0" />
21 | `,
22 | document.body,
23 | )
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Thomas Aylott
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 |
--------------------------------------------------------------------------------
/RoamTypes.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | roamAlphaAPI?: {
4 | q: (query: string, ...args: (string | number)[]) => [number][]
5 | pull: (keys: string, dbid: number) => RoamNode | null
6 | }
7 | }
8 | }
9 |
10 | export interface RoamDBRef {
11 | ":db/id": number
12 | }
13 |
14 | export interface RoamBlock extends RoamDBRef {
15 | ":block/uid": string
16 | ":block/order": number
17 | ":block/string": string
18 |
19 | ":block/refs"?: RoamDBRef[]
20 |
21 | ":block/open": boolean
22 | ":block/children"?: RoamDBRef[]
23 |
24 | ":create/email": string
25 | ":create/time": number
26 | ":edit/email": string
27 | ":edit/time": number
28 | }
29 |
30 | type RoamAttrItem = [":block/uid", string]
31 |
32 | type RoamAttr = {
33 | ":source": RoamAttrItem
34 | ":value": RoamAttrItem | string
35 | }
36 |
37 | export interface RoamNode extends RoamBlock {
38 | ":node/title": string
39 | ":attrs/lookup"?: RoamDBRef[]
40 | ":entity/attrs"?: [
41 | RoamAttr,
42 | RoamAttr,
43 | RoamAttr,
44 | ][]
45 | ":page/permissions"?: { ":public": null | any }
46 | }
47 |
48 | export type RoamId =
49 | | number
50 | | string
51 | | {
52 | ":block/uid"?: string
53 | ":db/id"?: number
54 | }
55 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # vivify for RoamReseach
2 |
3 | It turns your Roam graph into a personal rapid development environment for building itself.
4 |
5 | It's really hard to describe 😜
6 |
7 | Here's a video about one tiny piece of it:
8 |
9 | [Vivify Roam preview 1 - Watch Video
10 | ](https://www.loom.com/share/141fc687332a4ea3b163caebb259d148)
11 |
12 | ## How to enable it using `[[roam/js]]`
13 |
14 | 1. Add a `{{[[roam/js]]}}` tag
15 | 2. Add a child block to it with this code...
16 |
17 | ```js
18 | import("https://cdn.jsdelivr.net/gh/subtleGradient/ao-roam@fc266accb87db20abda8afcd885009de5bf58db1/index.js")
19 | .then(({ roam_onInit }) => roam_onInit())
20 | .catch((e) => console.error(e))
21 | ```
22 |
23 | 3. Press the big red button
24 | 4. Change `fc266accb87db20abda8afcd885009de5bf58db1` to the latest hash whenever you're ready to upgrade
25 |
26 |
27 | ## How to enable it using Tampermonkey
28 |
29 | ```js
30 | // ==UserScript==
31 | // @name vivify/onInit
32 | // @namespace https://subtlegradient.com
33 | // @version 0.2.4
34 | // @description init vivify
35 | // @author Thomas Aylott
36 | // @match https://roamresearch.com/
37 | // @grant none
38 | // ==/UserScript==
39 |
40 | import("https://cdn.jsdelivr.net/gh/subtleGradient/ao-roam@fc266accb87db20abda8afcd885009de5bf58db1/index.js")
41 | .then(({ roam_onInit }) => roam_onInit())
42 | .catch((e) => console.error(e))
43 |
44 | ```
45 |
46 | Be sure to change `fc266accb87db20abda8afcd885009de5bf58db1` to the latest hash whenever you're ready to upgrade
47 |
48 |
--------------------------------------------------------------------------------
/youtube-tools.js:
--------------------------------------------------------------------------------
1 | import qs from "https://jspm.dev/qs"
2 |
3 | /**
4 | * @param {string} selectors
5 | * @return {HTMLElement[]}
6 | */
7 | // @ts-ignore
8 | const $$ = selectors => [...document.querySelectorAll(selectors)]
9 | const waitFor = (isReady = () => true, delay = 10) => {
10 | return new Promise(done => {
11 | const loop = () => {
12 | const result = isReady()
13 | if (result != null) {
14 | done(result)
15 | } else {
16 | setTimeout(loop, delay)
17 | }
18 | }
19 | loop()
20 | })
21 | }
22 | const timeout = (time = 100) => new Promise(done => setTimeout(done, time))
23 | const nextFrame = () => new Promise(done => requestAnimationFrame(done))
24 |
25 | /** @return {Promise} */
26 | export async function getTranscriptCueGroups(TIMEOUT = 5000) {
27 | const startTime = Date.now()
28 | while (getTranscript().length === 0) {
29 | clickOpenMenu()
30 | // await nextFrame()
31 | await timeout(100)
32 | clickOpenTranscript()
33 | await timeout(100)
34 | if (Date.now() - startTime > TIMEOUT) {
35 | return null
36 | }
37 | }
38 | return getTranscript()
39 | }
40 | /** @return {import("./YouTube").CueGroup[]} */
41 | const getTranscript = () =>
42 | // @ts-ignore
43 | $$("ytd-transcript-renderer")[0]?.__data?.data?.body?.transcriptBodyRenderer
44 | ?.cueGroups ?? []
45 |
46 | const clickOpenTranscript = () =>
47 | [...$$("ytd-menu-service-item-renderer")]
48 | .filter(it => it.textContent?.includes("Open transcript"))
49 | .map(b => b.click()).length > 0
50 |
51 | const clickOpenMenu = () =>
52 | [...$$('[aria-label="More actions"]')].forEach(b => b.click())
53 |
54 | export const getCurrentPageUid = () => qs.parse(location.search, { ignoreQueryPrefix: true }).v
55 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | lerna-debug.log*
8 |
9 | # Diagnostic reports (https://nodejs.org/api/report.html)
10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
11 |
12 | # Runtime data
13 | pids
14 | *.pid
15 | *.seed
16 | *.pid.lock
17 |
18 | # Directory for instrumented libs generated by jscoverage/JSCover
19 | lib-cov
20 |
21 | # Coverage directory used by tools like istanbul
22 | coverage
23 | *.lcov
24 |
25 | # nyc test coverage
26 | .nyc_output
27 |
28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
29 | .grunt
30 |
31 | # Bower dependency directory (https://bower.io/)
32 | bower_components
33 |
34 | # node-waf configuration
35 | .lock-wscript
36 |
37 | # Compiled binary addons (https://nodejs.org/api/addons.html)
38 | build/Release
39 |
40 | # Dependency directories
41 | node_modules/
42 | jspm_packages/
43 |
44 | # TypeScript v1 declaration files
45 | typings/
46 |
47 | # TypeScript cache
48 | *.tsbuildinfo
49 |
50 | # Optional npm cache directory
51 | .npm
52 |
53 | # Optional eslint cache
54 | .eslintcache
55 |
56 | # Microbundle cache
57 | .rpt2_cache/
58 | .rts2_cache_cjs/
59 | .rts2_cache_es/
60 | .rts2_cache_umd/
61 |
62 | # Optional REPL history
63 | .node_repl_history
64 |
65 | # Output of 'npm pack'
66 | *.tgz
67 |
68 | # Yarn Integrity file
69 | .yarn-integrity
70 |
71 | # dotenv environment variables file
72 | .env
73 | .env.test
74 |
75 | # parcel-bundler cache (https://parceljs.org/)
76 | .cache
77 |
78 | # Next.js build output
79 | .next
80 |
81 | # Nuxt.js build / generate output
82 | .nuxt
83 | dist
84 |
85 | # Gatsby files
86 | .cache/
87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js
88 | # https://nextjs.org/blog/next-9-1#public-directory-support
89 | # public
90 |
91 | # vuepress build output
92 | .vuepress/dist
93 |
94 | # Serverless directories
95 | .serverless/
96 |
97 | # FuseBox cache
98 | .fusebox/
99 |
100 | # DynamoDB Local files
101 | .dynamodb/
102 |
103 | # TernJS port file
104 | .tern-port
105 |
--------------------------------------------------------------------------------
/youtube-dl-transcript-csv.ts.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Bookmarklet micro tool
3 | * Download the transcript of the current YouTube video as a CSV file
4 | *
5 | * Usage:
6 | javascript:import("https://cdn.jsdelivr.net/gh/subtleGradient/ao-roam@master/youtube-dl-transcript-csv.ts.js").then(({main})=>main())
7 |
8 | * Dev usage:
9 | javascript:import(`https://${`eaa40dfd6427.ngrok.io`}/youtube-dl-transcript-csv.ts.js?_=${Date.now().toString(36)}`).then(({main})=>main())
10 | */
11 | /*!
12 | Copyright 2021 Thomas Aylott
13 |
14 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
15 |
16 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
17 |
18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
19 | */
20 |
21 | import stringify from "https://jspm.dev/csv-stringify/lib/sync"
22 | import { download } from "./download.js"
23 | import { getCurrentPageUid, getTranscriptCueGroups } from "./youtube-tools.js"
24 |
25 | export async function main() {
26 | try {
27 | const id = getCurrentPageUid()
28 | const cueGroups = await getTranscriptCueGroups()
29 | await download(`youtube-${id}-cueGroups.json`, JSON.stringify(cueGroups))
30 | await download(
31 | `youtube-${id}-cueGroups.csv`,
32 | stringify([headers(cueGroups), ...getRows(cueGroups, id)]),
33 | )
34 | } catch (e) {
35 | console.error(e)
36 | }
37 | }
38 |
39 | /**
40 | * @param {import("./YouTube").CueGroup[] | null} cueGroups
41 | * @return {string[]}
42 | */
43 | function headers(cueGroups) {
44 | return [
45 | "formattedStartOffset",
46 | "startOffset",
47 | "duration",
48 | "text",
49 | "duration/length",
50 | "link",
51 | ]
52 | }
53 |
54 | /**
55 | * @param {import("./YouTube").CueGroup[] | null} cueGroups
56 | * @param {string} id
57 | * @return {(string | number)[][]}
58 | */
59 | function getRows(cueGroups, id) {
60 | /** @type {(string | number)[][]} */
61 | const rows = []
62 | cueGroups?.forEach(({ transcriptCueGroupRenderer: cg }) => {
63 | if (cg.cues.length > 1) {
64 | console.warn("ignoring additional cues", cg.cues)
65 | }
66 | const [formattedStartOffset, startOffset, duration, text] = [
67 | cg.formattedStartOffset.simpleText,
68 | +cg.cues[0].transcriptCueRenderer.startOffsetMs,
69 | +cg.cues[0].transcriptCueRenderer.durationMs,
70 | cg.cues[0].transcriptCueRenderer.cue.simpleText,
71 | ]
72 | rows.push([
73 | formattedStartOffset,
74 | startOffset,
75 | duration,
76 | text,
77 | Math.round(duration / text?.length || 0),
78 | !id
79 | ? ""
80 | : `https://www.youtube.com/watch?v=${id}&t=${Math.round(
81 | startOffset / 1000,
82 | )}`,
83 | ])
84 | })
85 | return rows
86 | }
87 |
--------------------------------------------------------------------------------
/roam-bookmarklet-jw.js:
--------------------------------------------------------------------------------
1 | import { html, render } from "https://unpkg.com/htm/preact/index.mjs?module"
2 | import {
3 | useState,
4 | useEffect,
5 | useRef,
6 | } from "https://unpkg.com/preact@latest/hooks/dist/hooks.module.js?module"
7 |
8 | /**
9 | * @param {string} selectors
10 | * @param {HTMLElement | Document} parent
11 | * @return {HTMLElement[]}
12 | */
13 | const $$ = (selectors, parent = document) => [
14 | // @ts-ignore
15 | ...parent.querySelectorAll(selectors),
16 | ]
17 |
18 | /**
19 | * @param {Element} node
20 | */
21 | export const parentRemoveChild = node => node.parentElement?.removeChild(node)
22 |
23 | /**
24 | * @param {HTMLElement} node
25 | */
26 | export const selectTextOf = node => {
27 | // Clear any current selection
28 | const selection = window.getSelection()
29 | if (!selection) return
30 | selection.removeAllRanges()
31 |
32 | // Select
33 | const range = document.createRange()
34 | range.selectNodeContents(node)
35 | selection.addRange(range)
36 | }
37 |
38 | // javascript:import(`https://cdn.jsdelivr.net/gh/subtleGradient/ao-roam@f59459c/roam-bookmarklet-jw.js`).then(({default:init})=>init())
39 | // javascript:import(`https://8eab5bddf934.ngrok.io/roam-bookmarklet-jw.js?_=${Date.now().toString(36)}`).then(({default:init})=>init())
40 |
41 | export default function main() {
42 | // const { href, hostname } = location
43 |
44 | $$("ao-modal-wrapper").forEach(parentRemoveChild)
45 | const modalWrap = document.createElement("ao-modal-wrapper")
46 | // modalWrap.onclick = () => parentRemoveChild(modalWrap)
47 | document.body.appendChild(modalWrap)
48 | render(html`<${App} document=${document} />`, modalWrap)
49 | }
50 |
51 | /** @param {{ document: Document }} props */
52 | function App({ document }) {
53 | const modal = useRef()
54 | useEffect(() => {
55 | modal.current.focus()
56 | }, [])
57 | const articles = $$("article")
58 | return html`
59 | selectTextOf(modal.current)}
62 | autofocus
63 | contenteditable
64 | style=${{
65 | zIndex: 999,
66 | position: "fixed",
67 | overflowY: "auto",
68 | top: 0,
69 | right: 0,
70 | bottom: 0,
71 | left: 0,
72 | background: "white",
73 | padding: "10vw",
74 | boxSizing: "border-box",
75 | }}
76 | >
77 | <${Things} things=${articles} Thing=${Article} />
78 |
79 | `
80 | }
81 | // <${Header} article=${article} />
82 | // <${Questions} article=${article} />
83 |
84 | /** @param {{ thing: HTMLElement }} props */
85 | function Article({ thing: article }) {
86 | const sections = $$(".section", article)
87 | return html`
88 |
89 | <${Header} article=${article} />
90 | <${Things} things=${sections} Thing=${Section} />
91 |
92 | `
93 | }
94 |
95 | /** @param {{ children: HTMLElement | string }} props */
96 | const SpanThing = ({ children }) =>
97 | html`
98 | ${clean(children.textContent?.toString() ?? children?.toString())}
99 |
`
100 |
101 | /** @param {{ children: HTMLElement }} props */
102 | const Paragraph = ({ children, ...props }) =>
103 | html`<${Things}
104 | Parent="div"
105 | ...${props}
106 | things=${clean(children.textContent?.toString() ?? children?.toString())
107 | ?.replace(/\.\s\.\s\.(?!\s\.)/g, "…")
108 | .replace(/([.?!)]”?)\s(?![()…0-9])|—/g, "$1;;;")
109 | .split(";;;")}
110 | />`
111 |
112 | /** @param {{ children: HTMLElement }} props */
113 | function Section({ children: section }) {
114 | const [heading] = $$("h1,h2,h3,h4,h5", section)
115 | const questions = $$("p.qu", section)
116 | const figures = $$(`figure`, section)
117 |
118 | const paragraphViews = html` <${Things}
119 | things=${questions}
120 | Thing=${({ children: q }) => html`
121 | ${clean(q.textContent)}
122 |
123 | <${Things}
124 | Parent="div"
125 | Li="span"
126 | things=${$$(`[data-rel-pid="[${q.dataset.pid}]" ]`, q.parentElement)}
127 | Thing=${Paragraph}
128 | />
129 |
130 | `}
131 | />`
132 | return html`
133 |
134 | ${heading ? clean(heading.textContent) : "INTRO"}
135 | ${figures.length === 0
136 | ? paragraphViews
137 | : html`<${Things}
138 | things=${[
139 | paragraphViews,
140 | html`<${Things} things=${figures} Thing=${Figure} />`,
141 | ]}
142 | />`}
143 |
144 | `
145 | }
146 |
147 | /** @param {{ children: HTMLElement }} props */
148 | function Figure({ children: figure }) {
149 | return html`
150 |
157 | `
158 | }
159 |
160 | /** @param {{ article: HTMLElement }} props */
161 | function Header({ article }) {
162 | const [contextTtl] = $$("header .contextTtl", article)
163 | const [h1] = $$("h1", article)
164 | const href = document.location.href.split("#")[0]
165 | return html`
166 |
167 | (wol)
168 |
169 | [[ ${clean(contextTtl.textContent)} ${" "} ${clean(h1.textContent)} ]]
170 |
171 | `
172 | }
173 |
174 | /**
175 | * @param {string | null} text
176 | */
177 | const clean = text => text?.trim().replace(/\s+/, " ")
178 |
179 | // /** @param {{ article: HTMLElement }} props */
180 | // function Questions({ article }) {
181 | // const questions = $$('p.qu', article)
182 | // return (
183 | // html`
184 | //
185 | // ${questions.map((q, index) => html`
186 | // <${QuestionAnd} key=${index} question=${q} />`)}
187 | //
188 | // `
189 | // )
190 | // }
191 |
192 | // /** @param {{ question: HTMLElement }} props */
193 | // function QuestionAnd({ question }) {
194 | // const paragraphs = $$('p.qu', question)
195 | // return (
196 | // html`
197 | //
198 | // ${question.textContent}
199 | //
200 | // ${paragraphs.map((q, index) => html`- ${q.textContent}
`)}
201 | //
202 | //
203 | // `
204 | // )
205 | // }
206 |
207 | /** @param {{
208 | * things: any[],
209 | * Thing?: function | string,
210 | * Parent?: function | string,
211 | * Li?: function | string,
212 | * }} props */
213 | function Things({ things, Thing = "div", Parent = "ul", Li = "li" }) {
214 | if (things.length === 0) return html``
215 | if (things.length === 1)
216 | return html`<${Thing} class=${`Things-0-innards`} children=${things[0]} />`
217 |
218 | const thingViews = things.map(
219 | (thing, index) => html`
220 | <${Li} key=${index} class=${`Things-${index}`}>
221 | <${Thing} class=${`Things-${index}-innards`}>${thing}/>
222 | />
223 | `,
224 | )
225 | return html` <${Parent} class="Things" children=${thingViews} /> `
226 | }
227 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | import { React, ReactDOM } from "https://jspm.dev/react@17"
2 | import htm from "https://unpkg.com/htm?module"
3 |
4 | console.log('hello')
5 |
6 | const html = htm.bind(React.createElement)
7 |
8 | /**
9 | * @param {{ count: string; }} props
10 | */
11 | const Counter = props => {
12 | const [count, setCount] = React.useState(parseInt(props.count))
13 | return html`
14 |
15 |
${count}
16 |
17 |
18 |
19 | `
20 | }
21 |
22 | console.debug("executing ao-roam index DEV")
23 |
24 | const nao = Date.now()
25 |
26 | /**
27 | * @param {string} query
28 | * @param {number|string|null|undefined} arg1
29 | * @param {(number|string)[]} args
30 | * @returns {[number][]}
31 | */
32 | export const q = (query, arg1, ...args) =>
33 | arg1 == null
34 | ? []
35 | : (window.roamAlphaAPI && window.roamAlphaAPI.q(query, arg1, ...args)) || []
36 |
37 | /**
38 | * @param {string} query
39 | * @param {number|string|null|undefined} arg1
40 | * @param {(number|string)[]} args
41 | * @returns {?number}
42 | */
43 | const q1 = (query, arg1, ...args) => {
44 | const result1 = q(query, arg1, ...args)[0]
45 | if (!Array.isArray(result1)) return null
46 | return result1[0]
47 | }
48 |
49 | /**
50 | * @deprecated
51 | * @param {string} props
52 | * @param {number | null | undefined} dbid
53 | */
54 | const PULL_DEPRECATED = (props, dbid) =>
55 | dbid == null
56 | ? null
57 | : window.roamAlphaAPI && window.roamAlphaAPI.pull(props, dbid)
58 |
59 | /**
60 | * get a node or block by its :db/id
61 | * @param { null | undefined | import('./RoamTypes').RoamId } id
62 | * @param { (keyof import('./RoamTypes').RoamNode)[] } props
63 | */
64 | export const get = (id, ...props) => {
65 | if (id == null) return null
66 | let uid, dbid
67 | switch (typeof id) {
68 | case "object":
69 | ;({ ":block/uid": uid, ":db/id": dbid } = id)
70 | break
71 | case "string":
72 | uid = id
73 | break
74 | case "number":
75 | dbid = id
76 | break
77 | }
78 | if (dbid == null && uid != null) dbid = getIdFromUid(uid)
79 | if (dbid == null) return null
80 | const propString =
81 | props.length === 0 ? "[*]" : `[${props.join(" ")} ${get.defaultProps}]`
82 | return PULL_DEPRECATED(propString, dbid)
83 | }
84 |
85 | get.defaultProps = ":block/uid :db/id"
86 |
87 | const getStuffThatRefsToId = (/**@type {?number} */ dbid) =>
88 | q("[:find ?e :in $ ?a :where [?e :block/refs ?a]]", dbid)
89 |
90 | const getIdForTitle = (/**@type {?string} */ title) =>
91 | q1("[:find ?e :in $ ?a :where [?e :node/title ?a]]", title)
92 |
93 | const getParentId = (/**@type {?number} */ dbid) =>
94 | q1("[:find ?e :in $ ?a :where [?e :block/children ?a]]", dbid)
95 |
96 | const getIdFromUid = (/**@type {string | null | undefined} */ uid) =>
97 | q1("[:find ?e :in $ ?a :where [?e :block/uid ?a]]", uid)
98 |
99 | const getCurrentPageUid = () => window.location.hash.split("/").reverse()[0]
100 |
101 | const getCurrentPage = () => get(getCurrentPageUid())
102 |
103 | const getStuffThatRefsTo = (/**@type {string} */ title) =>
104 | getStuffThatRefsToId(getIdForTitle(title))
105 |
106 | const getUrlToUid = (/**@type {string} */ uid) =>
107 | window.location.toString().replace(getCurrentPageUid(), uid)
108 |
109 | const forFirstChildOfEachThingThatRefsTo = (
110 | /**@type {string} */ tagName,
111 | /**@type {(dbid:number)=>void} */ fn,
112 | ) => {
113 | for (const [uid] of getStuffThatRefsTo(tagName)) {
114 | const firstChildId = get(uid, ":block/children")?.[":block/children"]?.[0][
115 | ":db/id"
116 | ]
117 | firstChildId && fn(firstChildId)
118 | }
119 | }
120 |
121 | const executeEverythingThatRefsTo = (/**@type {string} */ tagName) => {
122 | forFirstChildOfEachThingThatRefsTo(tagName, id =>
123 | executeBlock(id, {
124 | trigger: tagName,
125 | }),
126 | )
127 | }
128 |
129 | /**
130 | * @param {string | number | { ":block/uid"?: string; ":db/id"?: number; } | null | undefined} id
131 | * @param {{[key:string]:any}} extraArgs
132 | */
133 | const executeBlock = (id, extraArgs = {}) => {
134 | const codeBlock = get(id, ":block/string", ":block/uid")
135 | if (!codeBlock) return
136 | const { ":block/string": code, ":block/uid": uid } = codeBlock
137 | if (!code.includes(`\`\`\`javascript`)) return
138 | const [, js] = code.split(/[`]{3}(?:javascript\b)?/) || []
139 |
140 | const args = { ...extraArgs, codeBlock }
141 | const argKeys = Object.keys(args)
142 | const argVals = Object.values(args)
143 |
144 | requestAnimationFrame(() => {
145 | try {
146 | // eslint-disable-next-line no-new-func
147 | Function("vivify", "args", ...argKeys, js)(vivify, args, ...argVals)
148 | } catch (error) {
149 | console.error("code block at", getUrlToUid(uid), `threw`, error)
150 | if (error instanceof SyntaxError) console.debug(js)
151 | }
152 | })
153 | }
154 |
155 | // -body-outline-
156 | // -mentions-page-
157 | const uidFromElement = (/**@type {Element} */ element) => {
158 | // const [, currentPageUID] = element.baseURI.split("/page/")
159 | // const [, blockUID] = element.id.split(`${currentPageUID}-`)
160 | // TODO: add support for [[embed]] e.g. block-input-uuid11c136f1-a9b5-4ff4-9760-13fcdd0189a3-TXupdk-W_
161 | return element.id.match(/-([a-zA-Z0-9_-]{9}|\d{2}-\d{2}-\d{4})$/)?.[1]
162 | }
163 |
164 | /**
165 | * @param {Node} node
166 | * @param {MutationRecord} mutation
167 | * @param {'added'|'removed'} action
168 | */
169 | const handleNode = (action, node, mutation) => {
170 | if (node.nodeType !== Node.ELEMENT_NODE) return
171 | const el = /**@type {Element}*/ (/**@type {any}*/ node)
172 | const uid = uidFromElement(el)
173 | const block = get(uid, ":block/refs", ":block/string")
174 | block?.[":block/refs"]?.forEach(ref => {
175 | const attrs = getAttrs(ref, ":node/title", ":block/string", ":edit/time")
176 | if (!attrs) return
177 | const tag = get(ref, ":node/title", ":entity/attrs")
178 |
179 | console.groupCollapsed("Mutation for block", uid)
180 | console.log("Element added", el)
181 | console.log("block refs page", tag)
182 | console.log("whose attributes are", attrs)
183 |
184 | /**@type {import('./RoamTypes').RoamNode}*/
185 | const mutationHandlerCode = attrs["vivify/onMutation"]
186 | mutationHandlerCode &&
187 | executeBlock(mutationHandlerCode[":db/id"], {
188 | action,
189 | mutation,
190 | node,
191 | block,
192 | page: tag,
193 | attrs,
194 | })
195 |
196 | console.groupEnd()
197 | })
198 | }
199 |
200 | const observer = new MutationObserver((mutationsList, _observer) => {
201 | for (const mutation of mutationsList) {
202 | if (mutation.type === "childList") {
203 | mutation.addedNodes.forEach(node => handleNode("added", node, mutation))
204 | mutation.removedNodes.forEach(node =>
205 | handleNode("removed", node, mutation),
206 | )
207 |
208 | // mutation.removedNodes
209 | // } else if (mutation.type === "attributes") {
210 | // console.log("The " + mutation.attributeName + " attribute was modified.")
211 | }
212 | }
213 | })
214 |
215 | export const roam_onInit = () => {
216 | if (!window.roamAlphaAPI) {
217 | setTimeout(roam_onInit, 100)
218 | return
219 | }
220 | executeEverythingThatRefsTo("vivify/onInit")
221 |
222 | observer.observe(document.documentElement, {
223 | // attributes: true,
224 | childList: true,
225 | subtree: true,
226 | })
227 |
228 | // Later, you can stop observing
229 | // observer.disconnect();
230 | initRenderStuff()
231 | }
232 |
233 | function initRenderStuff() {
234 | const root =
235 | document.getElementsByTagName("vivify-root")[0] ||
236 | document.createElement("vivify-root")
237 | document.body.appendChild(root)
238 |
239 | ReactDOM.render(
240 | html`
241 | Look Ma! No script tags, no build step
242 | <${Counter} count=${0} />
243 | `,
244 | root,
245 | )
246 | }
247 |
248 | const vivify = {
249 | q,
250 | get,
251 | roam_onInit,
252 | }
253 |
254 | // @ts-ignore
255 | window["vivify"] = vivify
256 |
257 | /**
258 | * @param {string | number | { ":block/uid"?: string; ":db/id"?: number; } | null | undefined} id
259 | * @param { (keyof import('./RoamTypes').RoamNode)[] } props
260 | */
261 | const getAttrs = (id, ...props) => {
262 | return get(id, ":entity/attrs")?.[":entity/attrs"]?.reduce((
263 | /**@type {any}*/ acc = {},
264 | [page, attr, value],
265 | ) => {
266 | const key = get(attr[":value"][1], ":node/title")?.[":node/title"] || "key"
267 | const val =
268 | typeof value[":value"] === "string"
269 | ? value[":value"]
270 | : get(value[":value"][1], ...props)
271 |
272 | if (key in acc) acc[key] = [acc[key], val].flat()
273 | else acc[key] = val
274 | return acc
275 | }, undefined)
276 | }
277 |
278 | /**
279 | * @param {TemplateStringsArray} template
280 | * @param {any[]} vals
281 | */
282 | const esm = (template, ...vals) =>
283 | URL.createObjectURL(
284 | new Blob([String.raw(template, ...vals)], { type: "text/javascript" }),
285 | )
286 |
287 | // esm`export const lulz = true`
288 |
--------------------------------------------------------------------------------