├── .github └── FUNDING.yml ├── media ├── screen1.jpg ├── fuzzy_date.gif └── fuzzy_date_replace.gif ├── .gitignore ├── src ├── core │ ├── async.ts │ ├── roam.ts │ ├── random.ts │ ├── date.ts │ ├── config.ts │ └── swipe.ts ├── index.css ├── config.ts ├── srs │ ├── scheduler.ts │ ├── index.ts │ ├── SM2Node.ts │ └── AnkiScheduler.ts ├── navigation.ts ├── date-panel.css ├── components │ └── block.tsx ├── linked-reference-groups │ ├── index.tsx │ ├── config.tsx │ └── reference-groups.tsx ├── highlight-priority │ └── index.tsx ├── fuzzy-date.ts ├── date-panel.tsx ├── index.ts └── inc-dec-value.ts ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Stvad 2 | patreon: stvad 3 | -------------------------------------------------------------------------------- /media/screen1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-attention-manager/master/media/screen1.jpg -------------------------------------------------------------------------------- /media/fuzzy_date.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-attention-manager/master/media/fuzzy_date.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | out 5 | .env.local 6 | 7 | # Local Netlify folder 8 | .netlify 9 | -------------------------------------------------------------------------------- /media/fuzzy_date_replace.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-attention-manager/master/media/fuzzy_date_replace.gif -------------------------------------------------------------------------------- /src/core/async.ts: -------------------------------------------------------------------------------- 1 | export const delay = (millis: number): Promise => 2 | new Promise(resolve => setTimeout(resolve, millis)) 3 | -------------------------------------------------------------------------------- /src/core/roam.ts: -------------------------------------------------------------------------------- 1 | export function afterClosingBrackets(str: string, startingPosition?: number) { 2 | return str.indexOf(']]', startingPosition) + 2 3 | } 4 | -------------------------------------------------------------------------------- /src/core/random.ts: -------------------------------------------------------------------------------- 1 | export const randomFromInterval = ( 2 | min: number, 3 | max: number // min and max included 4 | ) => Math.random() * (max - min) + min 5 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | .roam-date-icon { 2 | padding: 0.3em; 3 | padding-top: 0.1em; 4 | background: gold; 5 | border-radius: 3px; 6 | margin-right: 0.2em; 7 | cursor: pointer; 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./node_modules/roamjs-scripts/dist/default.tsconfig", 3 | "compilerOptions": { 4 | "noImplicitAny": true, 5 | "strictNullChecks": true, 6 | "jsx": "react-jsx", 7 | "jsxImportSource": "@emotion/react" 8 | }, 9 | "include": [ 10 | "src" 11 | ], 12 | "exclude": [ 13 | "node_modules" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import {Page} from 'roam-api-wrappers/dist/data' 2 | import {RoamStorage} from 'roam-api-wrappers/dist/storage' 3 | 4 | 5 | export const configPageName = 'roam/js/attention' 6 | 7 | export const config = new RoamStorage(configPageName) 8 | 9 | export const createConfigPage = async (name = configPageName) => { 10 | return Page.getOrCreate(name) 11 | } 12 | 13 | -------------------------------------------------------------------------------- /src/srs/scheduler.ts: -------------------------------------------------------------------------------- 1 | import {SM2Node} from './SM2Node' 2 | 3 | export interface Scheduler { 4 | schedule(node: SM2Node, signal: SRSSignal): SM2Node 5 | } 6 | 7 | export enum SRSSignal { 8 | AGAIN = 1, 9 | HARD, 10 | GOOD, 11 | EASY, 12 | SOONER, 13 | } 14 | 15 | export const SRSSignals = [SRSSignal.AGAIN, SRSSignal.HARD, SRSSignal.GOOD, SRSSignal.EASY, SRSSignal.SOONER] 16 | -------------------------------------------------------------------------------- /src/navigation.ts: -------------------------------------------------------------------------------- 1 | import hotkeys from 'hotkeys-js' 2 | import 'roamjs-components/types' 3 | import {RoamDate} from "roam-api-wrappers/dist/date" 4 | import {openPageInSidebar} from 'roam-api-wrappers/dist/ui' 5 | 6 | 7 | export const setupNavigation = () => { 8 | hotkeys('ctrl+shift+`', () => 9 | void window.roamAlphaAPI.ui.mainWindow.openPage({page: {title: RoamDate.toRoam(new Date())}})) 10 | 11 | hotkeys('ctrl+shift+1', () => 12 | void openPageInSidebar(RoamDate.toRoam(new Date()))) 13 | } 14 | 15 | export const disableNavigation = () => { 16 | hotkeys.unbind() 17 | } 18 | -------------------------------------------------------------------------------- /src/date-panel.css: -------------------------------------------------------------------------------- 1 | .date-buttons { 2 | display: flex; 3 | justify-content: space-between; 4 | line-height: 2.5em; 5 | } 6 | 7 | .date-button { 8 | flex-grow: 2; 9 | margin-left: 0.2em; 10 | margin-right: 0.2em; 11 | margin-bottom: 1em; 12 | 13 | font-size: 1.5em; 14 | font-weight: 600; 15 | color: #5c7080; 16 | border-radius: 5px; 17 | border-width: thin; 18 | } 19 | 20 | .date-under-edit, .date-dialog-header { 21 | text-align: center; 22 | } 23 | 24 | .date-under-edit { 25 | margin-bottom: 0.7em; 26 | } 27 | 28 | .date-dialog-body { 29 | display: flex; 30 | flex-direction: column; 31 | } 32 | 33 | .date-dialog { 34 | margin-left: 0.5em; 35 | margin-right: 0.5em; 36 | } 37 | 38 | .srs-buttons { 39 | flex-wrap: wrap; 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Vladyslav Sitalo 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "roam-date", 3 | "version": "1.0.0", 4 | "description": "Date control widget (including SRS support)", 5 | "main": "./build/main.js", 6 | "scripts": { 7 | "build": "roamjs-scripts build", 8 | "dev": "roamjs-scripts dev", 9 | "dev:depot": "roamjs-scripts dev --depot", 10 | "deploy": "netlify deploy --prod -s attention-manager.netlify.app -d build", 11 | "prebuild:roam": "yarn install", 12 | "build:roam": "roamjs-scripts build --depot" 13 | }, 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/arrive": "^2.4.1", 17 | "@types/react": "^17.0.31", 18 | "@types/react-dom": "^17.0.10", 19 | "@types/react-tag-autocomplete": "^6.3.0", 20 | "roamjs-scripts": "^0.21.11", 21 | "typescript": "^4.8.2" 22 | }, 23 | "dependencies": { 24 | "@blueprintjs/popover2": "^0.12.7", 25 | "@emotion/react": "^11.10.5", 26 | "arrive": "^2.4.1", 27 | "chrono-node": "^2.4.1", 28 | "date-fns": "^2.29.2", 29 | "hotkeys-js": "^3.10.2", 30 | "react": "^17.0.2", 31 | "react-dom": "^17.0.2", 32 | "react-tag-autocomplete": "^6.3.0", 33 | "roam-api-wrappers": "^0.2.1", 34 | "roamjs-components": "^0.72.9" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/srs/index.ts: -------------------------------------------------------------------------------- 1 | import {SRSSignal, SRSSignals} from './scheduler' 2 | import {AnkiAttentionScheduler, AnkiScheduler} from './AnkiScheduler' 3 | import {Block} from 'roam-api-wrappers/dist/data' 4 | import {SM2Node} from './SM2Node' 5 | import {setupFeatureShortcuts} from '../core/config' 6 | 7 | export function rescheduleBlock(blockUid: string, signal: SRSSignal) { 8 | // todo make this configurable (knowledge vs attention) 9 | const scheduler = new AnkiAttentionScheduler() 10 | const block = Block.fromUid(blockUid) 11 | block.text = scheduler.schedule(new SM2Node(block.text), signal).text 12 | } 13 | 14 | export const config = { 15 | id: 'srs', 16 | name: 'Spaced Repetition', 17 | settings: SRSSignals.map(it => ({ 18 | type: 'shortcut', 19 | id: `srs_${SRSSignal[it]}`, 20 | label: `SRS: ${SRSSignal[it]}`, 21 | initValue: `ctrl+shift+${it},ctrl+shift+alt+command+${it},`, 22 | onPress: () => rescheduleCurrentNote(it), 23 | })), 24 | } 25 | 26 | export function rescheduleCurrentNote(signal: SRSSignal) { 27 | const currentBlockUid = window.roamAlphaAPI.ui.getFocusedBlock()?.['block-uid'] 28 | if (!currentBlockUid) return 29 | 30 | rescheduleBlock(currentBlockUid, signal) 31 | } 32 | 33 | export const setup = () => { 34 | setupFeatureShortcuts(config) 35 | } 36 | -------------------------------------------------------------------------------- /src/components/block.tsx: -------------------------------------------------------------------------------- 1 | import {useEffect, useRef} from 'react' 2 | import {createHTMLObserver} from 'roamjs-components/dom' 3 | 4 | interface BlockProps { 5 | uid: string 6 | showZoomPath?: boolean 7 | open?: boolean 8 | } 9 | 10 | export const Block = (props: BlockProps) => { 11 | const ref = useRef(null) 12 | 13 | useEffect(() => { 14 | if (!ref.current) return 15 | 16 | /** 17 | * A hack to expand the zoom path of the block as by default it is squished if it's too long, 18 | * but I basically never want that. 19 | * Disconnects after we do initial unfolding, so later manual squishing is still possible. 20 | */ 21 | const observer = createHTMLObserver({ 22 | tag: 'DIV', 23 | className: 'squish', 24 | callback: (b) => { 25 | b.click() 26 | observer?.disconnect() 27 | }, 28 | }) 29 | 30 | window.roamAlphaAPI.ui.components.renderBlock({ 31 | el: ref.current, 32 | uid: props.uid, 33 | // @ts-ignore todo on types 34 | 'zoom-path?': props.showZoomPath, 35 | 'open?': props.open, 36 | }) 37 | 38 | return () => observer.disconnect() 39 | }, [ref]) 40 | 41 | return
42 | } 43 | 44 | Block.defaultProps = { 45 | showZoomPath: true, 46 | open: true, 47 | } 48 | -------------------------------------------------------------------------------- /src/core/date.ts: -------------------------------------------------------------------------------- 1 | import {RoamDate} from 'roam-api-wrappers/dist/date' 2 | import {Block} from 'roam-api-wrappers/dist/data' 3 | 4 | const applyToDate = (date: Date, modifier: (input: number) => number): Date => { 5 | const newDate = new Date(date) 6 | newDate.setDate(modifier(date.getDate())) 7 | return newDate 8 | } 9 | 10 | export const createModifier = (change: number) => (num: number) => num + change 11 | 12 | export const daysFromNow = (days: number) => applyToDate(new Date(), createModifier(days)) 13 | 14 | export function modifyDateInBlock(blockUid: string, modifier: (input: number) => number, initWithTodayIfMissing = false) { 15 | replaceDateInBlock(blockUid, (oldDate) => applyToDate(oldDate, modifier), initWithTodayIfMissing) 16 | } 17 | 18 | export const replaceDateInBlock = (blockUid: string, transformer: (oldDate: Date) => Date, initWithTodayIfMissing = false) => { 19 | const block = Block.fromUid(blockUid) 20 | 21 | const datesInContent = block.text.match(RoamDate.referenceRegex) 22 | if (!datesInContent) { 23 | if (initWithTodayIfMissing) { 24 | block.text = block.text + ' ' + RoamDate.toDatePage(new Date()) 25 | } 26 | return 27 | } 28 | 29 | block.text = block.text.replace( 30 | datesInContent[0], 31 | RoamDate.toDatePage(transformer(RoamDate.parseFromReference(datesInContent[0]))), 32 | ) 33 | } 34 | 35 | 36 | export const addDays = (date: Date, days: number) => applyToDate(date, createModifier(days)) 37 | 38 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] 39 | 40 | export const getDayName = (date: Date) => days[date.getDay()] 41 | -------------------------------------------------------------------------------- /src/linked-reference-groups/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom' 2 | import {ReferenceGroups} from './reference-groups' 3 | import {Config} from './config' 4 | import {OnloadArgs} from 'roamjs-components/types' 5 | import 'arrive' 6 | 7 | const containerClass = 'rm-reference-group-container' 8 | 9 | export const setup = async (extensionAPI: OnloadArgs['extensionAPI']) => { 10 | const searchReferencesSelector = '.roam-article .rm-reference-main .rm-reference-container .flex-h-box .rm-mentions-search' 11 | 12 | document.arrive( 13 | searchReferencesSelector, 14 | {existing: true}, 15 | async referenceSearch => { 16 | const container = document.createElement('div') 17 | container.className = containerClass + ', rm-mentions' 18 | referenceSearch.parentElement?.after(container) 19 | 20 | void renderGroupsForCurrentPage(container, extensionAPI) 21 | }) 22 | } 23 | 24 | export const teardown = () => { 25 | const container = document.querySelector(`.${containerClass}`) 26 | container?.parentNode?.removeChild(container) 27 | } 28 | const renderGroupsForCurrentPage = async (container: HTMLElement, extensionAPI: OnloadArgs['extensionAPI']) => { 29 | const entityUid = await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid() 30 | console.log(`Setting up reference groups for ${entityUid}`) 31 | if (!entityUid) return 32 | 33 | const config = new Config(extensionAPI) 34 | ReactDOM.render(
39 | 40 |
41 |
, container) 42 | } 43 | -------------------------------------------------------------------------------- /src/core/config.ts: -------------------------------------------------------------------------------- 1 | import hotkeys from 'hotkeys-js' 2 | import {useState} from 'react' 3 | import {OnloadArgs} from 'roamjs-components/types' 4 | 5 | export interface Setting { 6 | type: string 7 | id: string 8 | label?: string 9 | initValue?: string 10 | placeholder?: string 11 | description?: string 12 | onPress(): void 13 | onSave?: (value: string) => void 14 | } 15 | 16 | export type Feature = { 17 | id: string 18 | name: string 19 | description?: string 20 | warning?: string 21 | settings?: Setting[] 22 | toggleable?: boolean 23 | enabledByDefault?: boolean 24 | toggle?: (active: boolean) => void 25 | } 26 | 27 | export const setupFeatureShortcuts = (feature: Feature) => { 28 | // todo need a better way to go about this, it seems like a bad way to configure things 29 | hotkeys.filter = () => true 30 | 31 | feature.settings?.forEach(it => hotkeys(it?.initValue!, it.onPress)) 32 | } 33 | 34 | 35 | export class ExtensionConfig { 36 | constructor(readonly extensionAPI: OnloadArgs['extensionAPI']) { 37 | } 38 | 39 | useConfigState(name: string, initial: T): [T, (value: T) => void] 40 | useConfigState(name: string, initial?: T): [T | undefined, (value: T | undefined) => void] { 41 | const initialValue = this.get(name, initial) 42 | // todo plausibly get should be delegated to classes get bc rn assumption is that the thing is not updated elsewhere 43 | 44 | const [get, set] = useState(initialValue) 45 | 46 | return [get, (value: T | undefined) => { 47 | set(value) 48 | this.extensionAPI.settings.set(name, JSON.stringify({value})) 49 | }] 50 | } 51 | 52 | get(name: string, defaultValue?: T) { 53 | const wrapper = JSON.parse(this.extensionAPI.settings.get(name) as string || '{}') 54 | return wrapper?.value !== undefined ? wrapper?.value as T : defaultValue 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/highlight-priority/index.tsx: -------------------------------------------------------------------------------- 1 | import * as ReactDOM from 'react-dom' 2 | import {Page, RoamEntity} from 'roam-api-wrappers/dist/data' 3 | import {Block} from '../components/block' 4 | import {config} from '../config' 5 | import 'arrive' 6 | 7 | 8 | const containerClass = 'priority-item-container' 9 | 10 | // todo unify with other config 11 | export const setup = async () => { 12 | document.arrive('.roam-article .rm-title-display', {existing: true}, async title => { 13 | const container = document.createElement('div') 14 | container.className = containerClass + ', rm-mentions' 15 | 16 | title?.after(container) 17 | 18 | void renderPriorityItemForDailyPages(container) 19 | }) 20 | } 21 | 22 | export const teardown = () => { 23 | const container = document.querySelector(`.${containerClass}`) 24 | container?.parentNode?.removeChild(container) 25 | } 26 | 27 | async function getFocusBlockUid(entity: Page) { 28 | const value = await config.get('focusBlockUid') 29 | // strip parents if present 30 | return value?.replace(/^\(\(/, '').replace(/\)\)$/, '') 31 | } 32 | 33 | const renderPriorityItemForDailyPages = async (container: HTMLElement) => { 34 | const entityUid = await window.roamAlphaAPI.ui.mainWindow.getOpenPageOrBlockUid() 35 | console.log(`Setting up focus items for ${entityUid}`) 36 | if (!entityUid) return 37 | const entity = RoamEntity.fromUid(entityUid) 38 | const shouldSkipRendering = !entity || !(entity instanceof Page) 39 | if (shouldSkipRendering) { 40 | console.log('Skipping rendering of focus item for non-daily page', entityUid, entity?.text) 41 | return 42 | } 43 | 44 | const blockOfInterest = await getFocusBlockUid(entity) 45 | 46 | ReactDOM.render(
57 | {blockOfInterest && } 58 |
, container) 59 | } 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Roam Date UX enhancements 2 | 3 | --- 4 | 5 | The development is supported by - a service that allows you to publish your Roam notes as a beautiful static website (digital garden) 6 | 7 | --- 8 | 9 | This plugin adds a few enhancements to the experience of interacting with dates in Roam. 10 | 11 | ## Features 12 | 13 | ### Date control widget (including SRS support) 14 | 15 | SRS behaviour is compatible with [Roam Toolkit](https://github.com/roam-unofficial/roam-toolkit) 16 | 17 | The extension will add the calendar icon close to each date link. Clicking on the icon will invoke the widget & allow you to edit the selected date 18 | 19 | ![](https://github.com/Stvad/roam-date/raw/master/media/screen1.jpg) 20 | 21 | ### Navigation Shortcuts 22 | 23 | - Ctrl + Shift + ` - go to today's page 24 | - Ctrl + Shift + 1 - open today's page in the right sidebar 25 | 26 | ### Date Manipulation 27 | 28 | You can create dates using [**natural language**](https://github.com/wanasit/chrono): 29 | 30 | ![](https://github.com/Stvad/roam-date/raw/master/media/fuzzy_date.gif) 31 | 32 | Replace mode: 33 | 34 | ![](https://github.com/Stvad/roam-date/raw/master/media/fuzzy_date_replace.gif) 35 | 36 | 37 | ## Installation 38 | 39 | This should be available in Roam Depot now, but if you want to install it manually, you can do so by following these steps: 40 | 41 | 1. [Install Roam plugin](https://roamstack.com/how-install-roam-plugin/) via the following code-block 42 | 43 | ```javascript 44 | /** roam-date-widget - date manipulation widget 45 | * Author: Vlad Sitalo 46 | * Docs: https://github.com/Stvad/roam-date 47 | */ 48 | 49 | 50 | const roamDateId = "roam-date-script" 51 | const oldRoamDate = document.getElementById(roamDateId) 52 | if (oldRoamDate) oldRoamDate.remove() 53 | var roamDate = document.createElement('script') 54 | roamDate.type = "text/javascript" 55 | roamDate.id = roamDateId 56 | roamDate.src = "https://roam-date.roam.garden/main.js" 57 | roamDate.async = true 58 | document.getElementsByTagName('head')[0].appendChild(roamDate) 59 | ``` 60 | 61 | ## Known issues 62 | 63 | - Currently, date widget, works only for blocks with 1 date in them 64 | -------------------------------------------------------------------------------- /src/core/swipe.ts: -------------------------------------------------------------------------------- 1 | export type SwipeEventHandler = (event?: Event) => void; 2 | 3 | export interface SwipeHandlers { 4 | onSwipeLeft?: SwipeEventHandler; 5 | onSwipeRight?: SwipeEventHandler; 6 | stopPropagation?: boolean; 7 | threshold?: number; 8 | maxVerticalThreshold?: number; 9 | } 10 | 11 | export const addSwipeListeners = ( 12 | element: HTMLElement, 13 | { 14 | onSwipeLeft, 15 | onSwipeRight, 16 | stopPropagation = false, 17 | threshold = 100, 18 | maxVerticalThreshold = 50, 19 | }: SwipeHandlers 20 | ) => { 21 | let touchStartX: number = 0; 22 | let touchEndX: number = 0; 23 | let touchStartY: number = 0; 24 | let touchEndY: number = 0; 25 | 26 | const handleTouchStart = (event: TouchEvent): void => { 27 | if (stopPropagation) { 28 | event.stopPropagation(); 29 | } 30 | touchStartX = event.changedTouches[0].screenX; 31 | touchStartY = event.changedTouches[0].screenY; 32 | }; 33 | 34 | const handleTouchEnd = (event: TouchEvent): void => { 35 | if (stopPropagation) { 36 | event.stopPropagation(); 37 | } 38 | touchEndX = event.changedTouches[0].screenX; 39 | touchEndY = event.changedTouches[0].screenY; 40 | handleSwipeGesture(event); 41 | }; 42 | 43 | const handleSwipeGesture = (event: Event): void => { 44 | const horizontalDistance = touchEndX - touchStartX; 45 | const verticalDistance = Math.abs(touchEndY - touchStartY); 46 | 47 | // Check that the swipe meets the horizontal threshold and does not exceed the vertical threshold 48 | if (Math.abs(horizontalDistance) > threshold && verticalDistance <= maxVerticalThreshold) { 49 | if (horizontalDistance < 0 && onSwipeLeft) { 50 | onSwipeLeft(event); 51 | } else if (horizontalDistance > 0 && onSwipeRight) { 52 | onSwipeRight(event); 53 | } 54 | } 55 | }; 56 | 57 | element.addEventListener('touchstart', handleTouchStart, false); 58 | element.addEventListener('touchend', handleTouchEnd, false); 59 | 60 | // Return a function to remove the listeners if needed 61 | return () => { 62 | element.removeEventListener('touchstart', handleTouchStart); 63 | element.removeEventListener('touchend', handleTouchEnd); 64 | }; 65 | }; 66 | -------------------------------------------------------------------------------- /src/fuzzy-date.ts: -------------------------------------------------------------------------------- 1 | import * as chrono from 'chrono-node' 2 | import {RoamDate} from 'roam-api-wrappers/dist/date' 3 | import {getSelectionInFocusedBlock} from 'roam-api-wrappers/dist/ui' 4 | import {getActiveEditElement} from 'roam-api-wrappers/dist/dom' 5 | import {afterClosingBrackets} from './core/roam' 6 | import {NodeSelection, RoamNode, saveToCurrentBlock, SM2Node} from './srs/SM2Node' 7 | 8 | export const config = { 9 | id: 'fuzzy-date', 10 | name: 'Fuzzy Date', 11 | enabledByDefault: true, 12 | settings: [{type: 'string', id: 'guard', initValue: ';', label: 'Guard symbol'}], 13 | } 14 | 15 | const defaultGuard = ';' 16 | 17 | 18 | const getCursor = (node: RoamNode, newText: string, searchStart: number = 0) => 19 | node.text === newText ? node.selection.start : afterClosingBrackets(newText, searchStart) 20 | 21 | export async function replaceFuzzyDate(guard: string) { 22 | const dateContainerExpr = new RegExp(`${guard}\(\.\{3,\}\?\)${guard}`, 'gm') 23 | 24 | // Must use the html element value, because the block text is not updated yet 25 | const editElement = getActiveEditElement() 26 | if (!editElement) return 27 | 28 | const node = new SM2Node(editElement.value, getSelectionInFocusedBlock() as NodeSelection) 29 | 30 | const match = node.text.match(dateContainerExpr) 31 | if (!match) return node 32 | 33 | const dateStr = match[0] 34 | const date = chrono.parseDate(dateStr, new Date(), { 35 | forwardDate: true, 36 | }) 37 | if (!date) return node 38 | 39 | const replaceMode = dateStr.startsWith(';:') 40 | 41 | const replaceWith = replaceMode ? '' : RoamDate.toDatePage(date) 42 | const newText = node.text.replace(dateContainerExpr, replaceWith) 43 | 44 | const cursor = getCursor(node, newText, replaceMode ? 0 : node.selection.start) 45 | const newNode = new SM2Node(newText, new NodeSelection(cursor, cursor)) 46 | 47 | return saveToCurrentBlock(replaceMode ? newNode.withDate(date) : newNode) 48 | } 49 | 50 | /** 51 | * We use `keypress`, since `keyup` is sometimes firing for individual keys instead of the pressed key 52 | * when the guard character is requiring a multi-key stroke. 53 | * 54 | * `setTimeout` is used to put the callback to the end of the event queue, 55 | * since the input is not yet changed when keypress is firing. 56 | */ 57 | export const setup = () => { 58 | document.addEventListener('keypress', keypressListener) 59 | } 60 | 61 | export const disable = () => { 62 | document.removeEventListener('keypress', keypressListener) 63 | } 64 | 65 | const keypressListener = (ev: KeyboardEvent) => { 66 | if (ev.key === defaultGuard) { 67 | setTimeout(() => replaceFuzzyDate(defaultGuard), 10) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/date-panel.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | 3 | import {Classes, Dialog} from '@blueprintjs/core' 4 | 5 | import {createModifier, modifyDateInBlock} from './core/date' 6 | import {SRSSignal, SRSSignals} from './srs/scheduler' 7 | import {Block} from 'roam-api-wrappers/dist/data' 8 | import {SM2Node} from './srs/SM2Node' 9 | 10 | import './date-panel.css' 11 | import {delay} from './core/async' 12 | import {createOverlayRender} from 'roamjs-components/util' 13 | import {rescheduleBlock} from './srs' 14 | 15 | export type DatePanelProps = { 16 | blockUid: string 17 | } 18 | 19 | export interface MoveDateButtonProps { 20 | shift: number 21 | label: string 22 | } 23 | 24 | function getFirstDate(blockUid: string) { 25 | const date = new SM2Node(Block.fromUid(blockUid).text).listDates()[0] 26 | if (!date) return "No date" 27 | 28 | return date.toLocaleDateString('en-US', 29 | {weekday: 'short', year: 'numeric', month: 'long', day: 'numeric'}) 30 | 31 | } 32 | 33 | export const DatePanel = ({blockUid, onClose}: { onClose: () => void; } & DatePanelProps) => { 34 | const [date, setDate] = useState(getFirstDate(blockUid)) 35 | 36 | async function updateDate() { 37 | await delay(0) 38 | setDate(getFirstDate(blockUid)) 39 | } 40 | 41 | const MoveDateButton = ({shift, label}: MoveDateButtonProps) => 42 | 50 | 51 | 52 | return 59 |
60 |

{date}

61 | 62 |
63 |
64 | 65 | 66 |
67 | 68 |
69 | 70 | 71 |
72 | 73 |

SRS

74 |
75 | {SRSSignals.map(it => )} 85 |
86 |
87 |
88 |
89 | } 90 | 91 | export const DatePanelOverlay = createOverlayRender("date-overlay", DatePanel) 92 | -------------------------------------------------------------------------------- /src/srs/SM2Node.ts: -------------------------------------------------------------------------------- 1 | import {RoamDate} from 'roam-api-wrappers/dist/date' 2 | import {Block, Roam} from 'roam-api-wrappers/dist/data' 3 | 4 | export class RoamNode { 5 | constructor(readonly text: string, readonly selection: NodeSelection = new NodeSelection()) { 6 | } 7 | 8 | getInlineProperty(name: string) { 9 | return RoamNode.getInlinePropertyMatcher(name).exec(this.text)?.[1] 10 | } 11 | 12 | withInlineProperty(name: string, value: string) { 13 | const updateProperty = (node: RoamNode) => { 14 | const currentValue = node.getInlineProperty(name) 15 | const property = RoamNode.createInlineProperty(name, value) 16 | return currentValue 17 | ? node.text.replace(RoamNode.getInlinePropertyMatcher(name), property) 18 | : node.text + ' ' + property 19 | } 20 | 21 | return this.withTextTransformation(updateProperty) 22 | } 23 | 24 | withTextTransformation(update: (node: RoamNode) => string) { 25 | // @ts-ignore 26 | return new this.constructor(update(this), this.selection) 27 | } 28 | 29 | static createInlineProperty(name: string, value: string) { 30 | return `[[[[${name}]]:${value}]]` 31 | } 32 | 33 | static getInlinePropertyMatcher(name: string) { 34 | /** 35 | * This has a bunch of things for backward compatibility: 36 | * - Potentially allowing double colon `::` between name and value 37 | * - Accepting both `{}` and `[[]]` wrapped properties 38 | */ 39 | return new RegExp(`(?:\\[\\[|{)\\[\\[${name}]]::?(.*?)(?:]]|})`, 'g') 40 | } 41 | } 42 | 43 | export class SM2Node extends RoamNode { 44 | constructor(text: string, selection: NodeSelection = new NodeSelection()) { 45 | super(text, selection) 46 | } 47 | 48 | private readonly intervalProperty = 'interval' 49 | private readonly factorProperty = 'factor' 50 | 51 | get interval(): number | undefined { 52 | return parseFloat(this.getInlineProperty(this.intervalProperty)!) 53 | } 54 | 55 | withInterval(interval: number): SM2Node { 56 | // Discarding the fractional part for display purposes/and so we don't get infinite number of intervals 57 | // Should potentially reconsider this later 58 | return this.withInlineProperty(this.intervalProperty, Number(interval).toFixed(1)) 59 | } 60 | 61 | get factor(): number | undefined { 62 | return parseFloat(this.getInlineProperty(this.factorProperty)!) 63 | } 64 | 65 | withFactor(factor: number): SM2Node { 66 | return this.withInlineProperty(this.factorProperty, Number(factor).toFixed(2)) 67 | } 68 | 69 | listDatePages() { 70 | return this.text.match(RoamDate.referenceRegex) || [] 71 | } 72 | 73 | listDates() { 74 | return this.listDatePages().map(ref => RoamDate.parseFromReference(ref)) 75 | } 76 | 77 | /** If has 1 date - replace it, if more than 1 date - append it */ 78 | withDate(date: Date) { 79 | const update = (node: SM2Node) => { 80 | const currentDates = node.listDatePages() 81 | console.log(currentDates) 82 | const newDate = RoamDate.toDatePage(date) 83 | console.log(newDate) 84 | return currentDates.length === 1 ? node.text.replace(currentDates[0], newDate) : node.text + ' ' + newDate 85 | } 86 | 87 | return this.withTextTransformation(update) 88 | } 89 | } 90 | 91 | export class NodeSelection { 92 | constructor(readonly start: number = 0, readonly end: number = 0) { 93 | } 94 | } 95 | 96 | export const saveToCurrentBlock = async (node: RoamNode) => { 97 | const block = Block.current 98 | if (!block) return 99 | 100 | block.text = node.text 101 | 102 | return window.roamAlphaAPI.ui.setBlockFocusAndSelection({ 103 | location: { 104 | 'block-uid': block.uid, 105 | 'window-id': Roam.focusedBlockInfo()?.['window-id']!, 106 | }, 107 | selection: { 108 | start: node.selection.start, 109 | end: node.selection.end, 110 | }, 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {RoamDate} from 'roam-api-wrappers/dist/date' 2 | 3 | import runExtension from 'roamjs-components/util/runExtension' 4 | import 'roamjs-components/types' 5 | import createBlockObserver from 'roamjs-components/dom/createBlockObserver' 6 | import createIconButton from 'roamjs-components/dom/createIconButton' 7 | import getUids from 'roamjs-components/dom/getUids' 8 | 9 | import {DatePanelOverlay} from './date-panel' 10 | 11 | import './index.css' 12 | import {disableNavigation, setupNavigation} from './navigation' 13 | import {setup as setupFuzzies, disable as disableFuzzies} from './fuzzy-date' 14 | import {setup as setupReferenceGroups} from './linked-reference-groups' 15 | import {setup as setupHighlightPriority} from './highlight-priority' 16 | import {setup as setupSRS} from './srs' 17 | import {setup as setupIncDec} from './inc-dec-value' 18 | import {createConfigPage} from './config' 19 | import {panelConfig} from './linked-reference-groups/config' 20 | import {addSwipeListeners} from './core/swipe' 21 | import {Block} from 'roam-api-wrappers/dist/data' 22 | 23 | const ID = 'attention-manager' 24 | 25 | //todo this matches things that have a sub-node with date 26 | const hasDateReferenced = (element: HTMLDivElement) => 27 | RoamDate.regex.test(element.innerText) 28 | 29 | const iconClass = 'roam-date-icon' 30 | 31 | const iconAlreadyExists = (refElement: HTMLElement) => 32 | refElement.parentElement?.querySelector(`.${iconClass}`) 33 | 34 | function findDateRef(b: HTMLDivElement) { 35 | const refs = b.querySelectorAll('.rm-page-ref') 36 | const dateElement = [...refs].find(hasDateReferenced) 37 | return dateElement?.parentElement 38 | } 39 | 40 | const removeIconButtons = () => 41 | document.querySelectorAll(`.${iconClass}`).forEach((i) => i.remove()) 42 | 43 | 44 | let observersToCleanup: MutationObserver[] 45 | const cleanupBlockObservers = () => { 46 | observersToCleanup?.forEach((o) => o.disconnect()) 47 | } 48 | 49 | const toggleDone = (blockUid: string) => { 50 | const block = Block.fromUid(blockUid) 51 | if (block.text.startsWith('{{[[DONE]]}} ')) { 52 | block.text = block.text.replace('{{[[DONE]]}} ', '') 53 | } else if (block.text.startsWith('{{[[TODO]]}} ')) { 54 | block.text = block.text.replace('{{[[TODO]]}} ', '{{[[DONE]]}} ') 55 | } else { 56 | block.text = '{{[[DONE]]}} ' + block.text 57 | } 58 | } 59 | 60 | export default runExtension({ 61 | extensionId: ID, 62 | run: async ({extensionAPI}) => { 63 | extensionAPI.settings.panel.create(panelConfig(extensionAPI)) 64 | await createConfigPage() 65 | setupNavigation() 66 | setupFuzzies() 67 | setupSRS() 68 | setupIncDec() 69 | 70 | //todo do the thing for a specific date object in a block 71 | observersToCleanup = createBlockObserver((b: HTMLDivElement) => { 72 | const blockUid = getUids(b).blockUid 73 | 74 | addSwipeListeners(b, { 75 | onSwipeLeft: () => { 76 | DatePanelOverlay({blockUid}) 77 | }, 78 | onSwipeRight: () => { 79 | toggleDone(blockUid) 80 | }, 81 | stopPropagation: true, 82 | }) 83 | 84 | if (!hasDateReferenced(b)) return 85 | 86 | const refElement = findDateRef(b) 87 | // no refs don't care. or probably want to have a loop here actually 88 | if (!refElement || iconAlreadyExists(refElement)) return 89 | 90 | const icon = createIconButton('calendar') 91 | icon.className = iconClass 92 | icon.addEventListener('mousedown', (e) => { 93 | e.stopPropagation() 94 | DatePanelOverlay({blockUid}) 95 | }) 96 | refElement.parentNode?.insertBefore(icon, refElement) 97 | }) 98 | 99 | setupReferenceGroups(extensionAPI) 100 | }, 101 | unload: () => { 102 | disableFuzzies() 103 | disableNavigation() 104 | removeIconButtons() 105 | cleanupBlockObservers() 106 | }, 107 | }) 108 | 109 | // top menu button example 110 | // https://github.com/panterarocks49/autotag/blob/b7653297a57baebef64f18888a6c2c1e186fd19f/src/index.js 111 | -------------------------------------------------------------------------------- /src/srs/AnkiScheduler.ts: -------------------------------------------------------------------------------- 1 | import {RoamNode, SM2Node} from './SM2Node' 2 | import {Scheduler, SRSSignal} from './scheduler' 3 | import {randomFromInterval} from '../core/random' 4 | import {addDays} from '../core/date' 5 | 6 | /** 7 | * Again (1) 8 | * The card is placed into relearning mode, the ease is decreased by 20 percentage points 9 | * (that is, 20 is subtracted from the ease value, which is in units of percentage points), and the current interval is 10 | * multiplied by the value of new interval (this interval will be used when the card exits relearning mode). 11 | * 12 | * Hard (2) 13 | * The card’s ease is decreased by 15 percentage points and the current interval is multiplied by 1.2. 14 | * 15 | * Good (3) 16 | * The current interval is multiplied by the current ease. The ease is unchanged. 17 | * 18 | * Easy (4) 19 | * The current interval is multiplied by the current ease times the easy bonus and the ease is 20 | * increased by 15 percentage points. 21 | * For Hard, Good, and Easy, the next interval is additionally multiplied by the interval modifier. 22 | * If the card is being reviewed late, additional days will be added to the current interval, as described here. 23 | * 24 | * There are a few limitations on the scheduling values that cards can take. 25 | * Eases will never be decreased below 130%; SuperMemo’s research has shown that eases below 130% tend to result in 26 | * cards becoming due more often than is useful and annoying users. 27 | * 28 | * Source: https://docs.ankiweb.net/#/faqs?id=what-spaced-repetition-algorithm-does-anki-use 29 | */ 30 | export class AnkiScheduler implements Scheduler { 31 | static defaultFactor = 2.5 32 | static defaultInterval = 2 33 | 34 | static maxInterval = 50 * 365 35 | static minFactor = 1.3 36 | // todo experiment 37 | static hardFactor = 1.3 38 | static soonerFactor = 0.75 39 | static jitterPercentage = 0.05 40 | 41 | schedule(node: SM2Node, signal: SRSSignal): SM2Node { 42 | const newParams = this.getNewParameters(node, signal) 43 | 44 | const currentDate = new Date() 45 | return node 46 | .withInterval(newParams.interval) 47 | .withFactor(newParams.factor) 48 | .withDate(addDays(currentDate, Math.ceil(newParams.interval))) 49 | } 50 | 51 | getNewParameters(node: SM2Node, signal: SRSSignal) { 52 | const factor = node.factor || AnkiScheduler.defaultFactor 53 | const interval = node.interval || AnkiScheduler.defaultInterval 54 | 55 | let newFactor = factor 56 | let newInterval = interval 57 | 58 | const factorModifier = 0.15 59 | switch (signal) { 60 | case SRSSignal.AGAIN: 61 | newFactor = factor - 0.2 62 | newInterval = 1 63 | break 64 | case SRSSignal.HARD: 65 | newFactor = factor - factorModifier 66 | newInterval = interval * AnkiScheduler.hardFactor 67 | break 68 | case SRSSignal.GOOD: 69 | newInterval = interval * factor 70 | break 71 | case SRSSignal.EASY: 72 | newInterval = interval * factor 73 | newFactor = factor + factorModifier 74 | break 75 | case SRSSignal.SOONER: 76 | newInterval = interval * AnkiScheduler.soonerFactor 77 | break 78 | } 79 | 80 | return AnkiScheduler.enforceLimits(AnkiScheduler.addJitter(new SM2Params(newInterval, newFactor))) 81 | } 82 | 83 | private static addJitter(params: SM2Params) { 84 | // I wonder if i can make this "regressive" i.e. start with larger number & 85 | // reduce percentage, as the number grows higher 86 | const jitter = params.interval * AnkiScheduler.jitterPercentage 87 | return new SM2Params(params.interval + randomFromInterval(-jitter, jitter), params.factor) 88 | } 89 | 90 | private static enforceLimits(params: SM2Params) { 91 | return new SM2Params( 92 | Math.min(params.interval, AnkiScheduler.maxInterval), 93 | Math.max(params.factor, AnkiScheduler.minFactor), 94 | ) 95 | } 96 | } 97 | 98 | export class AnkiAttentionScheduler extends AnkiScheduler { 99 | override schedule(node: SM2Node, signal: SRSSignal): SM2Node { 100 | return super.schedule(node, signal) 101 | .withTextTransformation((node: RoamNode) => node.text + ' *') 102 | } 103 | } 104 | 105 | class SM2Params { 106 | constructor(readonly interval: number, readonly factor: number) { 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/linked-reference-groups/config.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactTags, {Tag} from 'react-tag-autocomplete' 3 | import getAllPageNames from 'roamjs-components/queries/getAllPageNames' 4 | import {OnloadArgs} from 'roamjs-components/types' 5 | import {ExtensionConfig} from '../core/config' 6 | 7 | const autoGroupingLimit = 'auto-grouping-limit' 8 | 9 | const getAllPageTags = () => getAllPageNames().map(it => ({name: it, id: it})) 10 | 11 | export const panelConfig = (extensionAPI: OnloadArgs['extensionAPI']) => new Config(extensionAPI).panelConfig 12 | 13 | export class Config { 14 | constructor(private extensionAPI: OnloadArgs['extensionAPI']) { 15 | } 16 | 17 | keys = { 18 | highPriority: 'high-priority-tag-values', 19 | lowPriority: 'low-priority-tag-values', 20 | } 21 | 22 | get panelConfig() { 23 | return { 24 | tabTitle: 'Grouping Configurations', 25 | settings: [ 26 | { 27 | id: 'high-priority-tags', 28 | name: 'High priority tags', 29 | description: 'Things tagged with these pages will be grouped as high priority', 30 | action: { 31 | type: 'reactComponent', 32 | component: () => { 33 | return this.tagConfig(this.keys.highPriority) 34 | }, 35 | // https://github.com/dvargas92495/roamjs-query-builder/blob/71d41f9d0f80dcf740ab5119e4458e91971e5ad2/src/components/QueryPagesPanel.tsx 36 | } as const, 37 | }, 38 | { 39 | id: 'low-priority-tags', 40 | name: 'Low priority tags', 41 | description: 'Things tagged with these pages will be grouped as low priority', 42 | action: { 43 | type: 'reactComponent', 44 | component: () => { 45 | return this.tagConfig(this.keys.lowPriority) 46 | }, 47 | } as const, 48 | }, 49 | { 50 | id: autoGroupingLimit, 51 | name: 'Do not automatically run grouping if the number of backlinks is greater than', 52 | description: 'Do not automatically run grouping if the number of backlinks is greater than', 53 | action: { 54 | type: 'input', 55 | placeholder: '150', 56 | } as const, 57 | }, 58 | ], 59 | } 60 | } 61 | 62 | private tagConfig(writeConfigKey: string) { 63 | const [tags, setTags] = 64 | new ExtensionConfig(this.extensionAPI).useConfigState(writeConfigKey, [{ 65 | id: 0, 66 | name: 'task', 67 | }]) 68 | 69 | return 74 | } 75 | 76 | pagesFromKey(key: string) { 77 | const tags = new ExtensionConfig(this.extensionAPI).get(key) || [] 78 | console.log({tags}) 79 | return tags.map((it: Tag) => new RegExp(`^${it.name}$`)) 80 | } 81 | 82 | get highPriorityPages() { 83 | return this.pagesFromKey(this.keys.highPriority) 84 | } 85 | 86 | get dontGroupThreshold(): number { 87 | const rawValue = this.extensionAPI.settings.get(autoGroupingLimit) 88 | console.log({rawValue}) 89 | return parseInt(rawValue as string || '150') 90 | } 91 | 92 | get lowPriorityPages() { 93 | return this.pagesFromKey(this.keys.lowPriority) 94 | } 95 | } 96 | 97 | interface TagInputProps { 98 | tags: Tag[] 99 | setTags: (tags: Tag[]) => void 100 | minTags?: number 101 | maxTags?: number 102 | disabled?: boolean 103 | suggestions?: Tag[] 104 | placeholderText?: string 105 | } 106 | 107 | 108 | export const TagInput = ({tags, setTags, minTags = 0, maxTags, disabled, ...restProps}: TagInputProps) => ( [setTags([...tags, tag])]} 111 | onDelete={(i: number) => { 112 | if (!disabled) setTags([...tags.slice(0, i), ...tags.slice(i + 1)]) 113 | }} 114 | // classNames={getClassNames(tags, minTags, disabled)} 115 | // inputAttributes={{disabled: disabled || (maxTags && tags.length >= maxTags)}} 116 | addOnBlur 117 | // removeButtonText={disabled ? '' : undefined} 118 | maxSuggestionsLength={99} 119 | minQueryLength={1} 120 | {...restProps} 121 | />) 122 | -------------------------------------------------------------------------------- /src/inc-dec-value.ts: -------------------------------------------------------------------------------- 1 | import {RoamDate} from "roam-api-wrappers/dist/date" 2 | import {Block} from 'roam-api-wrappers/dist/data' 3 | import {NodeSelection, SM2Node} from './srs/SM2Node' 4 | import {getSelectionInFocusedBlock} from 'roam-api-wrappers/dist/ui' 5 | import {setupFeatureShortcuts} from './core/config' 6 | 7 | const createModifier = (change: number) => (num: number) => num + change 8 | 9 | export const config = { 10 | id: 'incDec', 11 | name: 'Increase / Decrease value or date', 12 | settings: [ 13 | { 14 | type: 'shortcut', 15 | id: 'incShortcut', 16 | label: 'Increase date or number by 1', 17 | initValue: 'Ctrl+Alt+up', 18 | onPress: () => modify(createModifier(1)), 19 | }, 20 | { 21 | type: 'shortcut', 22 | id: 'decShortcut', 23 | label: 'Decrease date or number by 1', 24 | initValue: 'Ctrl+Alt+down', 25 | onPress: () => modify(createModifier(-1)), 26 | }, 27 | { 28 | type: 'shortcut', 29 | id: 'incWeekShortcut', 30 | label: 'Increase date or number by 7', 31 | initValue: 'Ctrl+Alt+PageUp', 32 | onPress: () => modify(createModifier(7)), 33 | }, 34 | { 35 | type: 'shortcut', 36 | id: 'decWeekShortcut', 37 | label: 'Decrease date or number by 7', 38 | initValue: 'Ctrl+Alt+PageDown', 39 | onPress: () => modify(createModifier(-7)), 40 | }, 41 | ], 42 | } 43 | 44 | const openBracketsLeftIndex = (text: string, cursor: number): number => text.substring(0, cursor).lastIndexOf('[[') 45 | 46 | const closingBracketsLeftIndex = (text: string, cursor: number): number => text.substring(0, cursor).lastIndexOf(']]') 47 | 48 | const closingBracketsRightIndex = (text: string, cursor: number): number => 49 | cursor + text.substring(cursor).indexOf(']]') 50 | 51 | const cursorPlacedBetweenBrackets = (text: string, cursor: number): boolean => 52 | openBracketsLeftIndex(text, cursor) < closingBracketsRightIndex(text, cursor) && 53 | closingBracketsLeftIndex(text, cursor) < openBracketsLeftIndex(text, cursor) 54 | 55 | const cursorPlacedOnNumber = (text: any, cursor: number): boolean => 56 | text.substring(0, cursor).match(/[0-9]*$/)[0] + text.substring(cursor).match(/^[0-9]*/)[0] !== '' 57 | 58 | const cursorPlacedOnDate = (text: string, cursor: number): boolean => 59 | cursorPlacedBetweenBrackets(text, cursor) && nameIsDate(nameInsideBrackets(text, cursor)) 60 | 61 | const nameInsideBrackets = (text: string, cursor: number): string => 62 | text.substring(text.substring(0, cursor).lastIndexOf('[['), cursor + text.substring(cursor).indexOf(']]') + 2) 63 | 64 | const nameIsDate = (pageName: string): boolean => pageName.match(RoamDate.regex) !== null 65 | 66 | const modifyDate = (date: Date, modifier: (input: number) => number): Date => { 67 | const newDate = new Date(date) 68 | newDate.setDate(modifier(date.getDate())) 69 | return newDate 70 | } 71 | 72 | export const modify = (modifier: (input: number) => number) => { 73 | // const node = Roam.getActiveRoamNode() 74 | const block = Block.current 75 | console.log(block) 76 | if (!block) return 77 | const node = new SM2Node(block.text, getSelectionInFocusedBlock() as NodeSelection) 78 | 79 | console.log(node) 80 | 81 | const cursor = node.selection.start 82 | const datesInContent = node.text.match(RoamDate.referenceRegex) 83 | 84 | let newValue = node.text 85 | 86 | if (cursorPlacedOnDate(node.text, cursor)) { 87 | // e.g. Lorem ipsum [[Janu|ary 3rd, 2020]] 123 88 | newValue = 89 | node.text.substring(0, openBracketsLeftIndex(node.text, cursor)) + 90 | RoamDate.toDatePage( 91 | modifyDate(RoamDate.parseFromReference(nameInsideBrackets(node.text, cursor)), modifier) 92 | ) + 93 | node.text.substring(closingBracketsRightIndex(node.text, cursor) + 2) 94 | } else if (cursorPlacedOnNumber(node.text, cursor)) { 95 | // e.g. Lorem ipsum [[January 3rd, 2020]] 12|3 96 | const left = node.text.substring(0, cursor)?.match(/[0-9]*$/)![0] 97 | const right = node.text.substring(cursor)?.match(/^[0-9]*/)![0] 98 | const numberStr = left + right 99 | const numberStartedAt = node.text.substring(0, cursor)?.match(/[0-9]*$/)?.index! 100 | 101 | let number = modifier(parseInt(numberStr)) 102 | newValue = 103 | node.text.substring(0, numberStartedAt) + number + node.text.substring(numberStartedAt + numberStr.length) 104 | } else if (datesInContent && datesInContent.length === 1) { 105 | // e.g. Lor|em ipsum [[January 3rd, 2020]] 123 106 | newValue = node.text.replace( 107 | datesInContent[0], 108 | RoamDate.toDatePage(modifyDate(RoamDate.parseFromReference(datesInContent[0]), modifier)) 109 | ) 110 | } 111 | block.text = newValue 112 | // todo cursor position is not currently preserved, which is fixeable but also not a big deal 113 | } 114 | 115 | export const setup = () => { 116 | setupFeatureShortcuts(config) 117 | } 118 | -------------------------------------------------------------------------------- /src/linked-reference-groups/reference-groups.tsx: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from 'react' 2 | import {RoamEntity, Page as RoamPage} from 'roam-api-wrappers/dist/data' 3 | import { 4 | CommonReferencesGrouper, 5 | defaultExclusions, 6 | defaultLowPriority, 7 | getGroupsForEntity, 8 | matchesFilter, 9 | mergeGroupsSmallerThan, 10 | } from 'roam-api-wrappers/dist/data/collection' 11 | import {Block} from '../components/block' 12 | import {Button, Collapse} from '@blueprintjs/core' 13 | import {SRSSignal, SRSSignals} from '../srs/scheduler' 14 | import {rescheduleBlock} from '../srs' 15 | import { createModifier, modifyDateInBlock, replaceDateInBlock, daysFromNow } from '../core/date' 16 | import {MoveDateButtonProps} from '../date-panel' 17 | import {delay} from '../core/async' 18 | import {randomFromInterval} from '../core/random' 19 | import {RoamDate} from 'roam-api-wrappers/dist/date' 20 | 21 | interface ReferenceGroupProps { 22 | uid: string 23 | entities: RoamEntity[] 24 | rootPageUid: string 25 | } 26 | 27 | export const useTogglButton = () => { 28 | const [isOpen, setIsOpen] = useState(true) 29 | const ToggleButton = () => 30 | 51 | 52 | const getDateToRescheduleTo = (entity: RoamEntity, limit: number = 14) => { 53 | const groups = getGroupsForEntity(entity, { 54 | dontGroupReferencesTo: [...defaultExclusions, ...defaultLowPriority, /^wcs$/], 55 | }) // plausibly remove the low priority groups too? 56 | //todo maybe an additional setting 57 | const nextDay = new Date() 58 | 59 | const backlinkEntityReferencesGroup = (bl: RoamEntity, group: RoamEntity) => 60 | bl.getLinkedEntities(true).some(it => it.uid === group.uid) 61 | 62 | for (let i = 0; i < limit; i++) { 63 | nextDay.setDate(nextDay.getDate() + 1) 64 | const backlinks = RoamPage.fromName(RoamDate.toRoam(nextDay))?.backlinks 65 | 66 | if (backlinks?.some(bl => groups.some(group => backlinkEntityReferencesGroup(bl, group)))) { 67 | return nextDay 68 | } 69 | } 70 | return nextDay 71 | } 72 | 73 | const NextDayWithThisGroupButton = ({entities}: { entities: () => RoamEntity[] }) => { 74 | // move all items in a group to a next day that has the items referencing this group present 75 | 76 | return 85 | } 86 | 87 | // Refreshing the entities from db to get latest data vs in-memory cache 88 | const refreshEntities = (entities: RoamEntity[]) => 89 | entities.map(it => RoamEntity.fromUid(it.uid)!) 90 | 91 | function ReferenceGroup({uid, entities, rootPageUid}: ReferenceGroupProps) { 92 | const {isOpen, ToggleButton} = useTogglButton() 93 | 94 | const hasReferenceToRootPage = (ent: RoamEntity) => 95 | ent.linkedEntities.some(it => it.uid === rootPageUid) 96 | const matchesFilters = (ent: RoamEntity) => 97 | matchesFilter(ent, RoamEntity.fromUid(rootPageUid)!.referenceFilter) 98 | const shouldBeRescheduled = (ent: RoamEntity) => 99 | hasReferenceToRootPage(ent) && 100 | matchesFilters(ent) 101 | 102 | const entitiesToReschedule = () => refreshEntities(entities).filter(shouldBeRescheduled) 103 | const movableEntities = () => refreshEntities(entities).filter(matchesFilters) 104 | 105 | const MoveDateButton = ({shift, label}: MoveDateButtonProps) => 106 | 114 | 115 | // todo: before doing batch actions - apply reference filters & check if this is still has a link to original page 116 | // to handle rescheduled & "done" items 117 | // also these don't really make sense outside of the daily notes pages 118 | return
124 |
130 | 131 |
{RoamEntity.fromUid(uid)?.text} ({entities.length})
132 |
133 | 134 | 135 |
136 |
137 | 138 | 139 | 140 | {SRSSignals.slice(1).map(sig => )} 150 | 151 | 152 | 153 |
154 |
155 | 156 |
157 | {entities.map(entity => 158 |
159 | 160 |
)} 161 |
162 |
163 |
164 | } 165 | 166 | interface ReferenceGroupsProps { 167 | entityUid: string 168 | smallestGroupSize: number 169 | highPriorityPages: RegExp[] 170 | lowPriorityPages: RegExp[] 171 | dontGroupThreshold?: number 172 | } 173 | 174 | export function ReferenceGroups( 175 | { 176 | entityUid, 177 | smallestGroupSize, 178 | highPriorityPages, 179 | lowPriorityPages, 180 | dontGroupThreshold = 150, 181 | }: ReferenceGroupsProps) { 182 | const {isOpen, ToggleButton} = useTogglButton() 183 | const [renderGroups, setRenderGroups] = useState<[string, RoamEntity[]][]>([]) 184 | // todo remember collapse state in local storage 185 | 186 | // todo also have a shortcut for refresh 187 | function updateRenderGroups(refresh: boolean = false) { 188 | const entity = RoamEntity.fromUid(entityUid) 189 | if (!entity) { 190 | console.error(`${entityUid} entity not found`) 191 | return 192 | } 193 | 194 | const backlinks = entity.backlinks.filter(it => matchesFilter(it, entity.referenceFilter)) 195 | // todo this is ugly? 196 | if (backlinks.length > dontGroupThreshold && !refresh) { 197 | console.warn(`Too many backlinks (${backlinks.length}) for ${entityUid} - skipping initial render. 198 | Click refresh to render anyway.`) 199 | return 200 | } 201 | 202 | const groups = new CommonReferencesGrouper( 203 | entityUid, 204 | [...defaultExclusions, new RegExp(`^${entity.text}$`)], 205 | { 206 | low: lowPriorityPages, 207 | high: highPriorityPages, 208 | }).group(backlinks) 209 | 210 | const mergedGroups = mergeGroupsSmallerThan( 211 | groups, 212 | entityUid, 213 | smallestGroupSize, 214 | uid => highPriorityPages.some(it => it.test(RoamEntity.fromUid(uid)?.text ?? '')), 215 | ) 216 | console.log({mergedGroups}) 217 | setRenderGroups(Array.from(mergedGroups.entries())) 218 | } 219 | 220 | const updateReferenceGroupsShortcutHandler = (event: KeyboardEvent) => { 221 | if (event.altKey && event.ctrlKey && event.keyCode === 82) { 222 | updateRenderGroups(true) 223 | event.preventDefault() 224 | } 225 | } 226 | 227 | useEffect(() => { 228 | (async () => { 229 | await delay(0) 230 | updateRenderGroups() 231 | document.addEventListener('keydown', updateReferenceGroupsShortcutHandler) 232 | })() 233 | 234 | return () => { 235 | document.removeEventListener('keydown', updateReferenceGroupsShortcutHandler) 236 | } 237 | }, [entityUid, smallestGroupSize]) 238 | // todo loading indicator 239 | // todo if no groups are matching the size limit - show special message 240 | return
249 |
255 | 256 |
References grouped by most common pages
257 |
259 | 260 | 268 | {renderGroups.length === 0 &&
Calculating groups. If there are more then {dontGroupThreshold} backlinks - you need to manually press the refresh button.
} 269 | {renderGroups.map(([uid, entities]) => 270 | )} 271 |
272 |
273 | } 274 | 275 | ReferenceGroups.defaultProps = { 276 | smallestGroupSize: 2, 277 | } 278 | --------------------------------------------------------------------------------