├── 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://cdn.loom.com/sessions/thumbnails/141fc687332a4ea3b163caebb259d148-with-play.gif)](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 | 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 |
151 |

152 |
    153 |
  • 154 | <${Paragraph} Parent="div" children=${$$(`img`, figure)[0]?.alt} /> 155 |
156 |

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 | // 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 | --------------------------------------------------------------------------------