├── .nvmrc ├── src ├── lib │ ├── customData.ts │ ├── consts.ts │ ├── validation.ts │ ├── scoreConversion.ts │ ├── createRevlog.ts │ └── parameters.ts ├── widgets │ ├── sidebar.tsx │ └── index.tsx ├── App.css └── style.css ├── .gitignore ├── public ├── logo.png └── manifest.json ├── .prettierrc ├── tailwind.config.js ├── .github ├── pull_request_template.md └── ISSUE_TEMPLATE │ └── feature_request.md ├── postcss.config.js ├── tsconfig.json ├── LICENSE ├── package.json ├── webpack.config.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.15.1 -------------------------------------------------------------------------------- /src/lib/customData.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/widgets/sidebar.tsx: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | /* Add your plugin styles here. */ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | *.zip 7 | *.csv 8 | *.tsv -------------------------------------------------------------------------------- /public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/open-spaced-repetition/fsrs4remnote/HEAD/public/logo.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{vue,js,ts,jsx,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Summary 🎯 2 | 3 | 4 | 5 | ### Changes 🔁 6 | 7 | - 8 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | return { 3 | plugins: [require('postcss-import'), require('tailwindcss'), require('autoprefixer')], 4 | }; 5 | }; 6 | -------------------------------------------------------------------------------- /src/lib/consts.ts: -------------------------------------------------------------------------------- 1 | export enum SchedulerParam { 2 | Weights = "Weights", 3 | RequestRetention = "Request Retention", 4 | EnableFuzz = "Enable Fuzz", 5 | MaximumInterval = "Maximum Interval", 6 | EasyBonus = "Easy Bonus", 7 | HardInterval = "Hard Interval", 8 | } 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest a feature 4 | title: "[FEATURE]" 5 | labels: '' 6 | assignees: bjsi 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "jsx": "react-jsx", 6 | "moduleResolution": "node", 7 | "strict": true, 8 | "strictNullChecks": true, 9 | "noEmit": true, 10 | "allowJs": true, 11 | "skipLibCheck": true, 12 | "isolatedModules": true, 13 | "esModuleInterop": true 14 | }, 15 | "ts-node": { 16 | "compilerOptions": { 17 | "module": "CommonJS" 18 | } 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifestVersion": 1, 3 | "id": "fsrs4remnote", 4 | "name": "FSRS4RemNote", 5 | "author": "Jarrett Ye", 6 | "repoUrl": "https://github.com/open-spaced-repetition/fsrs4remnote", 7 | "version": { 8 | "major": 0, 9 | "minor": 2, 10 | "patch": 0 11 | }, 12 | "theme": [], 13 | "enableOnMobile": true, 14 | "description": "A custom scheduler plugin implementing the Free Spaced Repetition Scheduler algorithm based on a variant of the DSR (Difficulty, Stability, Retrievability) model.", 15 | "requestNative": false, 16 | "requiredScopes": [ 17 | { 18 | "type": "All", 19 | "level": "Read" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/validation.ts: -------------------------------------------------------------------------------- 1 | import { RepetitionStatus } from '@remnote/plugin-sdk'; 2 | 3 | export const validateCustomData = (data: Record | undefined): data is CustomData => { 4 | return ( 5 | !!data && 6 | (data as CustomData).stage != null && 7 | (data as CustomData).stability != null && 8 | (data as CustomData).lastReview != null && 9 | (data as CustomData).difficulty != null 10 | ); 11 | }; 12 | 13 | export enum Stage { 14 | New = 0, 15 | Learning, 16 | Review, 17 | Relearning, 18 | } 19 | 20 | export interface CustomData { 21 | difficulty: number; 22 | stability: number; 23 | stage: Stage; 24 | lastReview: number | Date; 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Yoshihide Shiono 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This file contains the tailwind directives and equivalent styles 3 | * to replicate the behavior inside the Shadow DOM of native plugins. 4 | */ 5 | 6 | @tailwind base; 7 | @tailwind components; 8 | @tailwind utilities; 9 | 10 | /* 11 | 1. Use a consistent sensible line-height in all browsers. 12 | 2. Prevent adjustments of font size after orientation changes in iOS. 13 | 3. Use a more readable tab size. 14 | */ 15 | 16 | html, 17 | :host-context(div) { 18 | line-height: 1.5; /* 1 */ 19 | -webkit-text-size-adjust: 100%; /* 2 */ 20 | -moz-tab-size: 4; /* 3 */ 21 | tab-size: 4; /* 3 */ 22 | } 23 | 24 | /* 25 | 1. Remove the margin in all browsers. 26 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 27 | */ 28 | 29 | body, 30 | :host { 31 | margin: 0; /* 1 */ 32 | line-height: inherit; /* 2 */ 33 | } 34 | 35 | /** 36 | */ 37 | 38 | body, 39 | :host { 40 | margin: 0; 41 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 42 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 43 | -webkit-font-smoothing: antialiased; 44 | -moz-osx-font-smoothing: grayscale; 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "remnote-plugin-template-react", 4 | "version": "0.0.1", 5 | "license": "MIT", 6 | "scripts": { 7 | "check-types": "tsc", 8 | "dev": "cross-env NODE_ENV=development webpack-dev-server --color --progress --no-open", 9 | "build": "npx remnote-plugin validate && shx rm -rf dist && cross-env NODE_ENV=production webpack --color --progress && shx cp README.md dist && cd dist && bestzip ../PluginZip.zip ./*" 10 | }, 11 | "dependencies": { 12 | "@remnote/plugin-sdk": "^0.0.28", 13 | "react": "^17.0.2", 14 | "react-dom": "^17.0.2", 15 | "remeda": "^1.14.0" 16 | }, 17 | "devDependencies": { 18 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 19 | "@types/node": "^18.0.3", 20 | "@types/react": "^17.0.2", 21 | "@types/react-dom": "^17.0.2", 22 | "@types/seedrandom": "^3.0.4", 23 | "seedrandom": "^3.0.4", 24 | "autoprefixer": "^10.4.7", 25 | "bestzip": "^2.2.1", 26 | "concurrently": "^7.2.2", 27 | "copy-webpack-plugin": "^11.0.0", 28 | "cross-env": "^7.0.3", 29 | "css-loader": "^6.7.1", 30 | "esbuild-loader": "^2.19.0", 31 | "html-webpack-plugin": "^5.5.0", 32 | "mini-css-extract-plugin": "^2.6.1", 33 | "postcss": "^8.4.14", 34 | "postcss-import": "^14.1.0", 35 | "postcss-loader": "^7.0.0", 36 | "react-refresh": "^0.14.0", 37 | "shx": "^0.3.4", 38 | "tailwindcss": "^3.1.5", 39 | "ts-node": "^10.9.1", 40 | "typescript": "^4.7.4", 41 | "webpack": "^5.73.0", 42 | "webpack-cli": "^4.10.0", 43 | "webpack-dev-server": "^4.9.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/scoreConversion.ts: -------------------------------------------------------------------------------- 1 | import { QueueInteractionScore, RepetitionStatus } from '@remnote/plugin-sdk'; 2 | 3 | export enum Rating { 4 | Again = 1, 5 | Hard, 6 | Good, 7 | Easy, 8 | } 9 | 10 | export function filterUnusedQueueInteractionScores(scores: T[]): T[] { 11 | return scores.filter( 12 | (status) => 13 | status.score !== QueueInteractionScore.TOO_EARLY && 14 | status.score !== QueueInteractionScore.VIEWED_AS_LEECH && 15 | status.score !== QueueInteractionScore.RESET 16 | ); 17 | } 18 | 19 | export function convertRemNoteScoreToAnkiRating(score: QueueInteractionScore): Rating { 20 | return score === QueueInteractionScore.AGAIN 21 | ? Rating.Again 22 | : score === QueueInteractionScore.HARD 23 | ? Rating.Hard 24 | : score === QueueInteractionScore.GOOD 25 | ? Rating.Good 26 | : score === QueueInteractionScore.EASY 27 | ? Rating.Easy 28 | : null!; 29 | } 30 | 31 | export function getRepetitionHistoryWithoutResets(rawHistory: RepetitionStatus[] | undefined) { 32 | return getRepetitionHistoryWithoutItems(rawHistory, [QueueInteractionScore.RESET]); 33 | } 34 | 35 | function takeRightWhile(xs: T[], p: (x: T) => boolean) { 36 | const arr = xs.slice().reverse(); 37 | const ret: T[] = []; 38 | for (const x of arr) { 39 | if (p(x)) { 40 | ret.push(x); 41 | } else { 42 | break; 43 | } 44 | } 45 | return ret; 46 | } 47 | 48 | export function getRepetitionHistoryWithoutItems( 49 | rawHistory: RepetitionStatus[] | undefined, 50 | scoresThatMakeThisNew: QueueInteractionScore[] 51 | ) { 52 | return takeRightWhile( 53 | rawHistory || [], 54 | (x) => !scoresThatMakeThisNew.includes(x.score) 55 | ).reverse(); 56 | } 57 | -------------------------------------------------------------------------------- /src/lib/createRevlog.ts: -------------------------------------------------------------------------------- 1 | import { Card, RepetitionStatus, RNPlugin } from '@remnote/plugin-sdk'; 2 | import * as R from 'remeda'; 3 | import { create_init_custom_data } from '../widgets'; 4 | import { 5 | convertRemNoteScoreToAnkiRating, 6 | filterUnusedQueueInteractionScores, 7 | } from './scoreConversion'; 8 | import { validateCustomData } from './validation'; 9 | 10 | interface RemNoteRepetition { 11 | card: Card; 12 | // the repetition this object represents is the last rep in this array 13 | revlog: RepetitionStatus[]; 14 | } 15 | 16 | interface AnkiRevlogRow { 17 | id: number; 18 | cid: number; 19 | r: number; 20 | time: number; 21 | type: number; 22 | } 23 | 24 | function convertRepetitionStatusToRevlogRow(data: RemNoteRepetition): AnkiRevlogRow { 25 | const currentRep = data.revlog[data.revlog.length - 1]; 26 | const card = data.card; 27 | const cid = card.createdAt; 28 | const id = ( 29 | currentRep.date instanceof Date ? currentRep.date : new Date(currentRep.date) 30 | ).valueOf(); 31 | const r = convertRemNoteScoreToAnkiRating(currentRep.score); 32 | const time = currentRep.responseTime || 0; 33 | // type -- 0=learn, 1=review, 2=relearn, 3=cram 34 | const pluginData = currentRep.pluginData; 35 | let type = 0; 36 | if (validateCustomData(pluginData)) { 37 | type = pluginData.stage; 38 | } else { 39 | type = create_init_custom_data(currentRep, data.revlog).stage; 40 | } 41 | 42 | return { id, cid, r, time, type }; 43 | } 44 | 45 | export async function createRevlog(plugin: RNPlugin): Promise { 46 | const repetitionStatusArray: RemNoteRepetition[] = ((await plugin.card.getAll()) || []).flatMap( 47 | (c) => 48 | filterUnusedQueueInteractionScores(c.repetitionHistory || []).map((_, i, arr) => ({ 49 | revlog: arr.slice(0, i + 1), 50 | card: c, 51 | })) 52 | ); 53 | 54 | const revlogArray: AnkiRevlogRow[] = repetitionStatusArray 55 | .filter((x) => x.card.nextRepetitionTime != null) 56 | .map(convertRepetitionStatusToRevlogRow); 57 | 58 | const csvData = R.sortBy( 59 | revlogArray, 60 | (revlog) => revlog.cid, 61 | (revlog) => revlog.id 62 | ).map((revlog) => { 63 | return [ 64 | revlog.id, 65 | revlog.cid, 66 | revlog.r, 67 | revlog.time, 68 | revlog.type, 69 | ]; 70 | }); 71 | const header = ['id', 'cid', 'r', 'time', 'type']; 72 | return [header, ...csvData]; 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/parameters.ts: -------------------------------------------------------------------------------- 1 | import { SchedulerParam } from './consts'; 2 | 3 | export const defaultParameters = { 4 | [SchedulerParam.Weights]: { 5 | id: SchedulerParam.Weights, 6 | title: SchedulerParam.Weights, 7 | defaultValue: "1, 1, 5, -0.5, -0.5, 0.2, 1.4, -0.12, 0.8, 2, -0.2, 0.2, 1", 8 | description: "Weights created by running the FSRS optimizer. By default these are weights computed from a sample dataset. Coming soon: You will be able to create weights tuned to your own knowledge base by running your repetition history through the FSRS optimizer.", 9 | type: 'string' as const, 10 | validators: [ 11 | { 12 | type: "regex" as const, 13 | arg: '^-?\\d+\\.?\\d*(?:, -?\\d+\\.?\\d*){12}$', 14 | }, 15 | ] 16 | }, 17 | [SchedulerParam.RequestRetention]: { 18 | id: SchedulerParam.RequestRetention, 19 | title: SchedulerParam.RequestRetention, 20 | defaultValue: 0.9, 21 | description: "Represents the probability of recall you want to target. Note that there is a tradeoff between higher retention and higher number of repetitions. It is recommended that you set this value somewhere between 0.8 and 0.9.", 22 | type: 'number' as const, 23 | validators: [ 24 | { 25 | type: "gte" as const, 26 | arg: 0, 27 | }, 28 | { 29 | type: "lte" as const, 30 | arg: 1, 31 | } 32 | ] 33 | }, 34 | [SchedulerParam.EnableFuzz]: { 35 | id: SchedulerParam.EnableFuzz, 36 | title: SchedulerParam.EnableFuzz, 37 | defaultValue: true, 38 | description: "When enabled this adds a small random delay to new intervals to prevent cards from sticking together and always coming up for review on the same day.", 39 | type: 'boolean' as const, 40 | }, 41 | [SchedulerParam.MaximumInterval]: { 42 | id: SchedulerParam.MaximumInterval, 43 | title: SchedulerParam.MaximumInterval, 44 | defaultValue: 36500, 45 | description: "The maximum number of days between repetitions.", 46 | type: 'number' as const, 47 | validators: [ 48 | { 49 | type: "int" as const 50 | }, 51 | { 52 | type: "gte" as const, 53 | arg: 0, 54 | }, 55 | ] 56 | }, 57 | [SchedulerParam.EasyBonus]: { 58 | id: SchedulerParam.EasyBonus, 59 | title: SchedulerParam.EasyBonus, 60 | defaultValue: 1.3, 61 | description: "An extra multiplier applied to the interval when a review card is answered Easy.", 62 | type: 'number' as const, 63 | validators: [ 64 | { 65 | type: "gte" as const, 66 | arg: 0, 67 | }, 68 | ] 69 | }, 70 | [SchedulerParam.HardInterval]: { 71 | id: SchedulerParam.HardInterval, 72 | title: SchedulerParam.HardInterval, 73 | defaultValue: 1.2, 74 | description: "", 75 | type: 'number' as const, 76 | validators: [ 77 | { 78 | type: "gte" as const, 79 | arg: 0, 80 | }, 81 | ] 82 | } 83 | }; 84 | 85 | type DefaultParameterRecord = typeof defaultParameters 86 | 87 | export type SchedulerParameterTypes = { 88 | [Param in keyof typeof defaultParameters]: DefaultParameterRecord[Param]["defaultValue"] 89 | } 90 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require('path'); 2 | var glob = require('glob'); 3 | var path = require('path'); 4 | 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | const { ESBuildMinifyPlugin } = require('esbuild-loader'); 7 | const { ProvidePlugin, BannerPlugin } = require('webpack'); 8 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 9 | const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin'); 10 | 11 | const CopyPlugin = require('copy-webpack-plugin'); 12 | 13 | const isProd = process.env.NODE_ENV === 'production'; 14 | const isDevelopment = !isProd; 15 | 16 | const fastRefresh = isDevelopment ? new ReactRefreshWebpackPlugin() : null; 17 | 18 | const SANDBOX_SUFFIX = '-sandbox'; 19 | 20 | const config = { 21 | mode: isProd ? 'production' : 'development', 22 | entry: glob.sync('./src/widgets/**.tsx').reduce(function (obj, el) { 23 | obj[path.parse(el).name] = el; 24 | obj[path.parse(el).name + SANDBOX_SUFFIX] = el; 25 | return obj; 26 | }, {}), 27 | 28 | output: { 29 | path: resolve(__dirname, 'dist'), 30 | filename: `[name].js`, 31 | publicPath: '', 32 | }, 33 | resolve: { 34 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 35 | }, 36 | module: { 37 | rules: [ 38 | { 39 | test: /\.(ts|tsx|jsx|js)?$/, 40 | loader: 'esbuild-loader', 41 | options: { 42 | loader: 'tsx', 43 | target: 'es2020', 44 | minify: false, 45 | }, 46 | }, 47 | { 48 | test: /\.css$/i, 49 | use: [ 50 | MiniCssExtractPlugin.loader, 51 | { loader: 'css-loader', options: { url: false } }, 52 | 'postcss-loader', 53 | ], 54 | }, 55 | ], 56 | }, 57 | plugins: [ 58 | new MiniCssExtractPlugin({ 59 | filename: 'App.css', 60 | }), 61 | new HtmlWebpackPlugin({ 62 | templateContent: ` 63 | 64 | 75 | `, 76 | filename: 'index.html', 77 | inject: false, 78 | }), 79 | new ProvidePlugin({ 80 | React: 'react', 81 | reactDOM: 'react-dom', 82 | }), 83 | new BannerPlugin({ 84 | banner: (file) => { 85 | return !file.chunk.name.includes(SANDBOX_SUFFIX) ? 'const IMPORT_META=import.meta;' : ''; 86 | }, 87 | raw: true, 88 | }), 89 | new CopyPlugin({ 90 | patterns: [ 91 | {from: 'public', to: ''}, 92 | {from: 'README.md', to: ''} 93 | ] 94 | }), 95 | fastRefresh, 96 | ].filter(Boolean), 97 | }; 98 | 99 | if (isProd) { 100 | config.optimization = { 101 | minimize: isProd, 102 | minimizer: [new ESBuildMinifyPlugin()], 103 | }; 104 | } else { 105 | // for more information, see https://webpack.js.org/configuration/dev-server 106 | config.devServer = { 107 | port: 8080, 108 | open: true, 109 | hot: true, 110 | compress: true, 111 | watchFiles: ['src/*'], 112 | headers: { 113 | 'Access-Control-Allow-Origin': '*', 114 | }, 115 | }; 116 | } 117 | 118 | module.exports = config; 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Archived in 2024-04-25 2 | 3 | This repository is archived and will be read-only, because FSRS has been integrated to RemNote 1.16 officially: [🎉 RemNote 1.16 – Ultimate Spaced Repetition! 🎉 | Changelog | RemNote](https://feedback.remnote.com/changelog/remnote-1-16-ultimate-spaced-repetition) 4 | 5 | # FSRS4RemNote 6 | 7 | FSRS4RemNote is a custom scheduler plugin for RemNote implementing the Free Spaced Repetition Scheduler. FSRS is based on the [DSR](https://supermemo.guru/wiki/Two_components_of_memory) (Difficulty, Stability, Retrievability) model proposed by [Piotr Wozniak](https://supermemo.guru/wiki/Piotr_Wozniak), the author of SuperMemo. FSRS is improved with the DHP (Difficulty, Half-life, Probability of recall) model introduced in the paper: [A Stochastic Shortest Path Algorithm for Optimizing Spaced Repetition Scheduling](https://www.maimemo.com/paper/). 8 | 9 | FSRS4RemNote consists of two main parts: scheduler and optimizer. 10 | 11 | The scheduler is based on a variant of the DSR model, which is used to predict memory states. The scheduler aims to achieve the requested retention for each card and each review. 12 | 13 | The optimizer applies *Maximum Likelihood Estimation* and *Backpropagation Through Time* to estimate the stability of memory and learn the laws of memory from time-series review logs. 14 | 15 | ## Usage 16 | 17 | - Install the plugin from the RemNote plugin marketplace. 18 | - Open the settings page and click on [Custom Schedulers](https://www.youtube.com/watch?v=IwaoV-C9az8). 19 | - You can choose to use FSRS4RemNote as your Global Default Scheduler or any other scheduler by clicking on the "Scheduler Type" dropdown menu and selecting "FSRS4RemNote". 20 | - There are a number of scheduler parameters which you can modify if you wish. 21 | 22 | ## FAQ 23 | 24 | ### What does the 'Free' mean in the name? 25 | 26 | - The algorithm (FSRS) supports reviewing in advance or delay. It's free for users to decide the time of review. And it will adapt to the user's memory. 27 | - Meanwhile, spaced repetition is one essential technology to achieve free learning. 28 | - FSRS runs entirely locally and has no risk under others' control. 29 | 30 | ### How does FSRS Calculate the next review date? 31 | 32 | - The FSRS4Anki scheduler will calculate memory states based on your rating and the DSR model of memory (Difficulty, Stability, Retrievability). The scheduled date is based on memory states which get updated with each repetition and the custom parameters you set in the Custom Scheduler settings. 33 | - The DSR model uses thirteen parameters and six equations to describe memory dynamics during spaced repetition practice. For more details, see [Free Spaced Repetition Scheduler](https://github.com/open-spaced-repetition/fsrs4anki/wiki/Free-Spaced-Repetition-Scheduler). 34 | - The model considers three variables that affect memory: difficulty, stability, and retrievability. 35 | - Difficulty refers to how hard it is to maintain a memory of something; the higher the difficulty, the harder it is to increase its stability and maintain it long term. 36 | - Stability refers to the storage strength of memory; the higher it is, the slower it is forgotten. 37 | - Retrievability refers to memory's retrieval strength; the lower it is, the higher the probability that the memory will be forgotten. 38 | - In the model, the following memory laws are considered: 39 | - The more complex the memorized material, the lower the stability increase. 40 | - The higher the stability, the lower the stability increase (also known as [stabilization decay](https://supermemo.guru/wiki/Stabilization_decay)) 41 | - The lower the retrievability, the higher the stability increase (also known as [stabilization curve](https://supermemo.guru/wiki/Stabilization_curve)) 42 | 43 | ### What about cards with long repetition histories using a different scheduler? Do I have to start from scratch? 44 | 45 | - No! The FSRS4RemNote scheduler will automatically convert other schedulers' repetition information to FSRS memory states. 46 | 47 | ### What happens if the plugin isn't running when I do my repetitions? 48 | 49 | - RemNote will automatically fallback to either the Global Default Scheduler or the RemNote default scheduler algorithm. 50 | -------------------------------------------------------------------------------- /src/widgets/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | declareIndexPlugin, 3 | QueueInteractionScore, 4 | ReactRNPlugin, 5 | RepetitionStatus, 6 | SpecialPluginCallback, 7 | } from '@remnote/plugin-sdk'; 8 | import '../style.css'; 9 | import '../App.css'; 10 | import { defaultParameters, SchedulerParameterTypes } from '../lib/parameters'; 11 | import 'seedrandom'; 12 | import seedrandom from 'seedrandom'; 13 | import { SchedulerParam } from '../lib/consts'; 14 | import { 15 | convertRemNoteScoreToAnkiRating, 16 | filterUnusedQueueInteractionScores, 17 | Rating, 18 | } from '../lib/scoreConversion'; 19 | import { createRevlog } from '../lib/createRevlog'; 20 | import { CustomData, Stage, validateCustomData } from '../lib/validation'; 21 | 22 | export function create_init_custom_data( 23 | currentRep: RepetitionStatus, 24 | revlogs: RepetitionStatus[] 25 | ): CustomData { 26 | if ( 27 | revlogs.length == 1 || 28 | (new Date(currentRep.scheduled!).getTime() - 29 | new Date(revlogs[revlogs.length - 2].date).getTime()) / 30 | (1000 * 60 * 60 * 24) <= 31 | 1 32 | ) { 33 | return { 34 | difficulty: 0, 35 | stability: 0, 36 | stage: Stage.New, 37 | lastReview: currentRep.date, 38 | }; 39 | } 40 | return { 41 | difficulty: 5, 42 | stability: 43 | (new Date(currentRep.scheduled!).getTime() - 44 | new Date(revlogs[revlogs.length - 2].date).getTime()) / 45 | (1000 * 60 * 60 * 24), 46 | stage: Stage.Review, 47 | lastReview: revlogs[revlogs.length - 2].date, 48 | }; 49 | } 50 | 51 | async function onActivate(plugin: ReactRNPlugin) { 52 | await plugin.scheduler.registerCustomScheduler('FSRS4RemNote', Object.values(defaultParameters)); 53 | 54 | await plugin.app.registerCommand({ 55 | name: 'Create Revlog CSV', 56 | id: 'create-revlog-csv', 57 | action: async () => { 58 | const revlog = await createRevlog(plugin); 59 | console.log(revlog); 60 | const blob = new Blob([revlog.join('\n')], { type: 'text/csv' }); 61 | const url = URL.createObjectURL(blob); 62 | const downloadLink = document.createElement('a'); 63 | downloadLink.href = url; 64 | downloadLink.download = 'remnote-revlog.csv'; 65 | document.body.appendChild(downloadLink); 66 | downloadLink.click(); 67 | URL.revokeObjectURL(url); 68 | }, 69 | }); 70 | 71 | await plugin.app.registerCallback( 72 | SpecialPluginCallback.SRSScheduleCard, 73 | getNextSpacingDate 74 | ); 75 | 76 | async function getNextSpacingDate(args: { 77 | history: RepetitionStatus[]; 78 | schedulerParameters: Record; 79 | cardId: string | undefined; 80 | }) { 81 | const { history, schedulerParameters, cardId } = args; 82 | const currentRep = history[history.length - 1]; 83 | const seed = cardId ? cardId + String(history.length) : String(history.length); 84 | 85 | if ( 86 | currentRep.score === QueueInteractionScore.TOO_EARLY || 87 | currentRep.score === QueueInteractionScore.VIEWED_AS_LEECH 88 | ) { 89 | return { nextDate: new Date(Date.now() + 60 * 60 * 1000).getTime() }; 90 | } 91 | 92 | const revlogs = filterUnusedQueueInteractionScores(history); 93 | const { 94 | [SchedulerParam.Weights]: weightsStr, 95 | [SchedulerParam.RequestRetention]: requestRetention, 96 | [SchedulerParam.EnableFuzz]: enableFuzz, 97 | [SchedulerParam.MaximumInterval]: maximumInterval, 98 | [SchedulerParam.EasyBonus]: easyBonus, 99 | [SchedulerParam.HardInterval]: hardInterval, 100 | } = schedulerParameters as SchedulerParameterTypes; 101 | 102 | const w = weightsStr.split(', ').map((x) => Number(x)); 103 | const intervalModifier = Math.log(requestRetention) / Math.log(0.9); 104 | 105 | const customData: CustomData = { 106 | ...(revlogs.length > 1 && validateCustomData(revlogs[revlogs.length - 2].pluginData) 107 | ? (revlogs[revlogs.length - 2].pluginData as CustomData) 108 | : create_init_custom_data(currentRep, revlogs)), 109 | }; 110 | 111 | const convertedScore = convertRemNoteScoreToAnkiRating(currentRep.score); 112 | let newCustomData = customData; 113 | let scheduleDays = 0; 114 | if (customData.stage == Stage.New) { 115 | newCustomData = init_states(convertedScore); 116 | scheduleDays = 117 | convertedScore == Rating.Again 118 | ? 1 / 1440 119 | : convertedScore == Rating.Hard 120 | ? 5 / 1440 121 | : convertedScore == Rating.Good 122 | ? 10 / 1440 123 | : convertedScore == Rating.Easy 124 | ? next_interval(newCustomData.stability * easyBonus) 125 | : null!; 126 | } else if (customData.stage == Stage.Review) { 127 | const elapsedDays = 128 | (new Date(currentRep.date).getTime() - new Date(customData.lastReview).getTime()) / 129 | (1000 * 60 * 60 * 24); 130 | newCustomData = next_states(convertedScore, customData, elapsedDays); 131 | let hardIvl = next_interval(customData.stability * hardInterval); 132 | let goodIvl = Math.max(next_interval(newCustomData.stability), hardIvl + 1); 133 | let easyIvl = Math.max(next_interval(newCustomData.stability * easyBonus), goodIvl + 1); 134 | scheduleDays = 135 | convertedScore == Rating.Again 136 | ? 1 / 1440 137 | : convertedScore == Rating.Hard 138 | ? hardIvl 139 | : convertedScore == Rating.Good 140 | ? goodIvl 141 | : convertedScore == Rating.Easy 142 | ? easyIvl 143 | : null!; 144 | } else if (customData.stage == Stage.Learning || customData.stage == Stage.Relearning) { 145 | let goodIvl = next_interval(newCustomData.stability); 146 | let easyIvl = Math.max(next_interval(newCustomData.stability * easyBonus), goodIvl + 1); 147 | scheduleDays = 148 | convertedScore == Rating.Again 149 | ? 5 / 1440 150 | : convertedScore == Rating.Hard 151 | ? 10 / 1440 152 | : convertedScore == Rating.Good 153 | ? goodIvl 154 | : convertedScore == Rating.Easy 155 | ? easyIvl 156 | : null!; 157 | } 158 | newCustomData.stage = next_stage(newCustomData.stage, convertedScore); 159 | 160 | const day = new Date(currentRep.date); 161 | day.setMinutes(day.getMinutes() + scheduleDays * 1440); 162 | const time = day.getTime(); 163 | console.log( 164 | convertedScore, 165 | history, 166 | customData, 167 | newCustomData, 168 | scheduleDays, 169 | currentRep.scheduled 170 | ); 171 | return { nextDate: time, pluginData: newCustomData ? newCustomData : customData }; 172 | 173 | function constrain_difficulty(difficulty: number) { 174 | return Math.min(Math.max(+difficulty.toFixed(2), 1), 10); 175 | } 176 | 177 | function next_interval(stability: number) { 178 | const new_interval = apply_fuzz(stability * intervalModifier); 179 | return Math.min(Math.max(Math.round(new_interval), 1), maximumInterval); 180 | } 181 | 182 | function apply_fuzz(ivl: number) { 183 | const generator = seedrandom(seed); 184 | const fuzz_factor = generator(); 185 | if (!enableFuzz || ivl < 2.5) return ivl; 186 | ivl = Math.round(ivl); 187 | const min_ivl = Math.max(2, Math.round(ivl * 0.95 - 1)); 188 | const max_ivl = Math.round(ivl * 1.05 + 1); 189 | return Math.floor(fuzz_factor * (max_ivl - min_ivl + 1) + min_ivl); 190 | } 191 | 192 | function next_difficulty(d: number, rating: Rating) { 193 | let next_d = d + w[4] * (rating - 3); 194 | return constrain_difficulty(mean_reversion(w[2], next_d)); 195 | } 196 | 197 | function mean_reversion(init: number, current: number) { 198 | return w[5] * init + (1 - w[5]) * current; 199 | } 200 | 201 | function next_recall_stability(d: number, s: number, r: number) { 202 | return +( 203 | s * 204 | (1 + Math.exp(w[6]) * (11 - d) * Math.pow(s, w[7]) * (Math.exp((1 - r) * w[8]) - 1)) 205 | ).toFixed(2); 206 | } 207 | 208 | function next_forget_stability(d: number, s: number, r: number) { 209 | return +(w[9] * Math.pow(d, w[10]) * Math.pow(s, w[11]) * Math.exp((1 - r) * w[12])).toFixed( 210 | 2 211 | ); 212 | } 213 | 214 | function init_states(rating: Rating): CustomData { 215 | return { 216 | difficulty: init_difficulty(rating), 217 | stability: init_stability(rating), 218 | stage: Stage.New, 219 | lastReview: currentRep.date, 220 | }; 221 | } 222 | 223 | function next_states(rating: Rating, last_states: CustomData, interval: number): CustomData { 224 | const next_d = next_difficulty(last_states.difficulty, rating); 225 | const retrievability = Math.exp((Math.log(0.9) * interval) / last_states.stability); 226 | let next_s; 227 | if (rating == Rating.Again) { 228 | next_s = next_forget_stability(next_d, last_states.stability, retrievability); 229 | } else { 230 | next_s = next_recall_stability(next_d, last_states.stability, retrievability); 231 | } 232 | return { 233 | difficulty: next_d, 234 | stability: next_s, 235 | stage: last_states.stage, 236 | lastReview: currentRep.date, 237 | }; 238 | } 239 | 240 | function init_difficulty(rating: Rating) { 241 | return +constrain_difficulty(w[2] + w[3] * (rating - 3)).toFixed(2); 242 | } 243 | 244 | function init_stability(rating: Rating) { 245 | return +Math.max(w[0] + w[1] * (rating - 1), 0.1).toFixed(2); 246 | } 247 | 248 | function next_stage(current_stage: Stage, rating: Rating): Stage { 249 | if (current_stage == Stage.New) { 250 | if (rating == Rating.Again || rating == Rating.Hard || rating == Rating.Good) { 251 | return Stage.Learning; 252 | } else { 253 | return Stage.Review; 254 | } 255 | } else if (current_stage == Stage.Learning || current_stage == Stage.Relearning) { 256 | if (rating == Rating.Again || rating == Rating.Hard) { 257 | return current_stage; 258 | } else { 259 | return Stage.Review; 260 | } 261 | } else if (current_stage == Stage.Review) { 262 | if (rating == Rating.Again) { 263 | return Stage.Relearning; 264 | } else { 265 | return Stage.Review; 266 | } 267 | } 268 | return current_stage; 269 | } 270 | } 271 | } 272 | 273 | async function onDeactivate(_: ReactRNPlugin) {} 274 | 275 | declareIndexPlugin(onActivate, onDeactivate); 276 | --------------------------------------------------------------------------------