├── .github └── FUNDING.yml ├── .gitignore ├── media ├── screen1.jpg ├── fuzzy_date.gif └── fuzzy_date_replace.gif ├── src ├── core │ ├── async.ts │ ├── roam.ts │ ├── random.ts │ └── date.ts ├── index.css ├── srs │ ├── scheduler.ts │ ├── SM2Node.ts │ └── AnkiScheduler.ts ├── navigation.ts ├── date-panel.css ├── fuzzy-date.ts ├── index.ts └── date-panel.tsx ├── tsconfig.json ├── package.json ├── LICENSE └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Stvad 2 | patreon: stvad 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | dist 4 | out 5 | .env.local 6 | -------------------------------------------------------------------------------- /media/screen1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-date/master/media/screen1.jpg -------------------------------------------------------------------------------- /media/fuzzy_date.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-date/master/media/fuzzy_date.gif -------------------------------------------------------------------------------- /media/fuzzy_date_replace.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Stvad/roam-date/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 | "include": [ 4 | "src" 5 | ], 6 | "exclude": [ 7 | "node_modules" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /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 | } 13 | 14 | export const SRSSignals = [SRSSignal.AGAIN, SRSSignal.HARD, SRSSignal.GOOD, SRSSignal.EASY] 15 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 --depot", 9 | "deploy": "netlify deploy --prod -s roam-date.roam.garden -d build", 10 | "prebuild:roam": "yarn install", 11 | "build:roam": "roamjs-scripts build --depot" 12 | }, 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@types/react": "^17.0.31", 16 | "@types/react-dom": "^17.0.10", 17 | "roamjs-scripts": "^0.21.11", 18 | "typescript": "^4.8.2" 19 | }, 20 | "dependencies": { 21 | "@blueprintjs/popover2": "^0.12.7", 22 | "chrono-node": "^2.4.1", 23 | "date-fns": "^2.29.2", 24 | "hotkeys-js": "^3.9.5", 25 | "react": "^17.0.2", 26 | "react-dom": "^17.0.2", 27 | "roam-api-wrappers": "^0.1.0", 28 | "roamjs-components": "^0.72.9" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | export const createModifier = (change: number) => (num: number) => num + change 10 | 11 | export function modifyDateInBlock(blockUid: string, modifier: (input: number) => number) { 12 | const block = Block.fromUid(blockUid) 13 | 14 | const datesInContent = block.text.match(RoamDate.referenceRegex) 15 | 16 | block.text = block.text.replace( 17 | datesInContent[0], 18 | RoamDate.toDatePage(applyToDate(RoamDate.parseFromReference(datesInContent[0]), modifier)), 19 | ) 20 | } 21 | 22 | export const addDays = (date: Date, days: number) => applyToDate(date, createModifier(days)) 23 | 24 | const days = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] 25 | 26 | export const getDayName = (date: Date) => days[date.getDay()] 27 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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()) 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/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 | 15 | const ID = 'roam-date' 16 | 17 | //todo this matches things that have a sub-node with date 18 | const hasDateReferenced = (element: HTMLDivElement) => 19 | RoamDate.regex.test(element.innerText) 20 | 21 | const iconClass = 'roam-date-icon' 22 | 23 | const iconAlreadyExists = (refElement: HTMLElement) => 24 | refElement.parentElement.querySelector(`.${iconClass}`) 25 | 26 | function findDateRef(b: HTMLDivElement) { 27 | const refs = b.querySelectorAll('.rm-page-ref') 28 | const dateElement = [...refs].find(hasDateReferenced) 29 | return dateElement?.parentElement 30 | } 31 | 32 | const removeIconButtons = () => 33 | document.querySelectorAll(`.${iconClass}`).forEach((i) => i.remove()) 34 | 35 | 36 | let observersToCleanup: MutationObserver[] 37 | const cleanupBlockObservers = () => { 38 | observersToCleanup?.forEach((o) => o.disconnect()) 39 | } 40 | 41 | export default runExtension({ 42 | extensionId: ID, 43 | run: () => { 44 | console.log('run extension is run') 45 | setupNavigation() 46 | setupFuzzies() 47 | 48 | //todo do the thing for a specific date object in a block 49 | observersToCleanup = createBlockObserver((b: HTMLDivElement) => { 50 | if (!hasDateReferenced(b)) return 51 | 52 | const refElement = findDateRef(b) 53 | // no refs don't care. or probably want to have a loop here actually 54 | if (!refElement || iconAlreadyExists(refElement)) return 55 | 56 | const blockUid = getUids(b).blockUid 57 | 58 | const icon = createIconButton('calendar') 59 | icon.className = iconClass 60 | icon.addEventListener('mousedown', (e) => { 61 | e.stopPropagation() 62 | DatePanelOverlay({blockUid}) 63 | }) 64 | refElement.parentNode.insertBefore(icon, refElement) 65 | }) 66 | }, 67 | unload: () => { 68 | disableFuzzies() 69 | disableNavigation() 70 | removeIconButtons() 71 | cleanupBlockObservers() 72 | }, 73 | }) 74 | 75 | // top menu button example 76 | // https://github.com/panterarocks49/autotag/blob/b7653297a57baebef64f18888a6c2c1e186fd19f/src/index.js 77 | -------------------------------------------------------------------------------- /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 {AnkiScheduler} from "./srs/AnkiScheduler" 8 | import {Block} from "roam-api-wrappers/dist/data" 9 | import {SM2Node} from "./srs/SM2Node" 10 | 11 | import "./date-panel.css" 12 | import {delay} from "./core/async" 13 | import {createOverlayRender} from 'roamjs-components/util' 14 | 15 | export type DatePanelProps = { 16 | blockUid: string 17 | } 18 | 19 | interface MoveDateButtonParams { 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 | 27 | return date.toLocaleDateString('en-US', 28 | {weekday: 'short', year: 'numeric', month: 'long', day: 'numeric'}) 29 | 30 | } 31 | 32 | export const DatePanel = ({blockUid, onClose}: { onClose: () => void; } & DatePanelProps) => { 33 | const [date, setDate] = useState(getFirstDate(blockUid)) 34 | 35 | async function updateDate() { 36 | await delay(0) 37 | setDate(getFirstDate(blockUid)) 38 | } 39 | 40 | const MoveDateButton = ({shift, label}: MoveDateButtonParams) => 41 | 49 | 50 | 51 | return 58 |
59 |

{date}

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

SRS

73 |
74 | {SRSSignals.map(it => )} 83 |
84 |
85 |
86 |
87 | } 88 | 89 | export const DatePanelOverlay = createOverlayRender("date-overlay", DatePanel) 90 | 91 | 92 | export function rescheduleBlock(blockUid: string, signal: SRSSignal) { 93 | const scheduler = new AnkiScheduler() 94 | const block = Block.fromUid(blockUid) 95 | block.text = scheduler.schedule(new SM2Node(block.text), signal).text 96 | } 97 | -------------------------------------------------------------------------------- /src/srs/SM2Node.ts: -------------------------------------------------------------------------------- 1 | import {RoamDate} from "roam-api-wrappers/dist/date" 2 | import {Block, Roam} from "roam-api-wrappers/dist/data" 3 | import {delay} from '../core/async' 4 | 5 | export class RoamNode { 6 | constructor(readonly text: string, readonly selection: NodeSelection = new NodeSelection()) {} 7 | 8 | getInlineProperty(name: string) { 9 | return RoamNode.getInlinePropertyMatcher(name).exec(this.text)?.[1] 10 | } 11 | 12 | withInlineProperty(name: string, value: string) { 13 | const currentValue = this.getInlineProperty(name) 14 | const property = RoamNode.createInlineProperty(name, value) 15 | const newText = currentValue 16 | ? this.text.replace(RoamNode.getInlinePropertyMatcher(name), property) 17 | : this.text + ' ' + property 18 | // @ts-ignore 19 | return new this.constructor(newText) 20 | } 21 | 22 | static createInlineProperty(name: string, value: string) { 23 | return `[[[[${name}]]:${value}]]` 24 | } 25 | 26 | static getInlinePropertyMatcher(name: string) { 27 | /** 28 | * This has a bunch of things for backward compatibility: 29 | * - Potentially allowing double colon `::` between name and value 30 | * - Accepting both `{}` and `[[]]` wrapped properties 31 | */ 32 | return new RegExp(`(?:\\[\\[|{)\\[\\[${name}]]::?(.*?)(?:]]|})`, 'g') 33 | } 34 | } 35 | 36 | export class SM2Node extends RoamNode { 37 | constructor(text: string, selection: NodeSelection = new NodeSelection()) { 38 | super(text, selection) 39 | } 40 | 41 | private readonly intervalProperty = 'interval' 42 | private readonly factorProperty = 'factor' 43 | 44 | get interval(): number | undefined { 45 | return parseFloat(this.getInlineProperty(this.intervalProperty)!) 46 | } 47 | 48 | withInterval(interval: number): SM2Node { 49 | // Discarding the fractional part for display purposes/and so we don't get infinite number of intervals 50 | // Should potentially reconsider this later 51 | return this.withInlineProperty(this.intervalProperty, Number(interval).toFixed(1)) 52 | } 53 | 54 | get factor(): number | undefined { 55 | return parseFloat(this.getInlineProperty(this.factorProperty)!) 56 | } 57 | 58 | withFactor(factor: number): SM2Node { 59 | return this.withInlineProperty(this.factorProperty, Number(factor).toFixed(2)) 60 | } 61 | 62 | listDatePages() { 63 | return this.text.match(RoamDate.referenceRegex) || [] 64 | } 65 | 66 | listDates() { 67 | return this.listDatePages().map(ref => RoamDate.parseFromReference(ref)) 68 | } 69 | 70 | /** If has 1 date - replace it, if more then 1 date - append it */ 71 | withDate(date: Date) { 72 | const currentDates = this.listDatePages() 73 | console.log(currentDates) 74 | const newDate = RoamDate.toDatePage(date) 75 | console.log(newDate) 76 | const newText = 77 | currentDates.length === 1 ? this.text.replace(currentDates[0], newDate) : this.text + ' ' + newDate 78 | 79 | // @ts-ignore 80 | return new this.constructor(newText) 81 | } 82 | 83 | } 84 | 85 | export class NodeSelection { 86 | constructor(readonly start: number = 0, readonly end: number = 0) {} 87 | } 88 | 89 | export const saveToCurrentBlock = async (node: RoamNode) => { 90 | const block = Block.current 91 | block.text = node.text 92 | 93 | return window.roamAlphaAPI.ui.setBlockFocusAndSelection({ 94 | location: { 95 | 'block-uid': block.uid, 96 | 'window-id': Roam.focusedBlockInfo()['window-id'] 97 | }, 98 | selection: { 99 | start: node.selection.start, 100 | end: node.selection.end, 101 | } 102 | }) 103 | } 104 | -------------------------------------------------------------------------------- /src/srs/AnkiScheduler.ts: -------------------------------------------------------------------------------- 1 | import {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 | static hardFactor = 1.2 37 | static jitterPercentage = 0.05 38 | 39 | schedule(node: SM2Node, signal: SRSSignal): SM2Node { 40 | const newParams = this.getNewParameters(node, signal) 41 | 42 | const currentDate = new Date() 43 | return ( 44 | node 45 | .withInterval(newParams.interval) 46 | .withFactor(newParams.factor) 47 | .withDate(addDays(currentDate, Math.ceil(newParams.interval))) 48 | ) 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 | } 76 | 77 | return AnkiScheduler.enforceLimits(AnkiScheduler.addJitter(new SM2Params(newInterval, newFactor))) 78 | } 79 | 80 | private static addJitter(params: SM2Params) { 81 | // I wonder if i can make this "regressive" i.e. start with larger number & 82 | // reduce percentage, as the number grows higher 83 | const jitter = params.interval * AnkiScheduler.jitterPercentage 84 | return new SM2Params(params.interval + randomFromInterval(-jitter, jitter), params.factor) 85 | } 86 | 87 | private static enforceLimits(params: SM2Params) { 88 | return new SM2Params( 89 | Math.min(params.interval, AnkiScheduler.maxInterval), 90 | Math.max(params.factor, AnkiScheduler.minFactor) 91 | ) 92 | } 93 | } 94 | 95 | class SM2Params { 96 | constructor(readonly interval: number, readonly factor: number) { 97 | } 98 | } 99 | --------------------------------------------------------------------------------