├── .npmrc ├── .eslintignore ├── media ├── prompt-1.png ├── prompt-2.png ├── status-bar-running.png ├── command-palette-hotkey.png └── command-palette-new-commands.png ├── versions.json ├── src ├── utils │ ├── cusom-types.ts │ ├── validations.ts │ ├── enc.ts │ ├── anki.ts │ └── gpt.ts ├── logger.ts ├── settings.ts ├── main.ts └── modal.ts ├── .editorconfig ├── manifest.json ├── .gitignore ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── package.json ├── LICENSE ├── esbuild.config.mjs ├── sample └── sample_card_information.ts ├── styles.css ├── .github └── workflows │ └── release.yml └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | 3 | main.js 4 | -------------------------------------------------------------------------------- /media/prompt-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadrianxyz/obsidian-auto-anki/HEAD/media/prompt-1.png -------------------------------------------------------------------------------- /media/prompt-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadrianxyz/obsidian-auto-anki/HEAD/media/prompt-2.png -------------------------------------------------------------------------------- /media/status-bar-running.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadrianxyz/obsidian-auto-anki/HEAD/media/status-bar-running.png -------------------------------------------------------------------------------- /media/command-palette-hotkey.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadrianxyz/obsidian-auto-anki/HEAD/media/command-palette-hotkey.png -------------------------------------------------------------------------------- /media/command-palette-new-commands.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cadrianxyz/obsidian-auto-anki/HEAD/media/command-palette-new-commands.png -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.1.1": "0.15.0", 3 | "0.1.2": "0.15.0", 4 | "0.1.3": "0.15.0", 5 | "0.1.4": "0.15.0", 6 | "0.1.5": "0.15.0", 7 | "0.2.1": "0.15.0" 8 | } -------------------------------------------------------------------------------- /src/utils/cusom-types.ts: -------------------------------------------------------------------------------- 1 | export interface StatusBarElement extends HTMLElement { 2 | doReset?: () => void; 3 | doDisplayError?: () => void; 4 | doDisplayRunning?: () => void; 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | insert_final_newline = true 8 | indent_style = tab 9 | indent_size = 4 10 | tab_width = 4 11 | -------------------------------------------------------------------------------- /src/utils/validations.ts: -------------------------------------------------------------------------------- 1 | export function isNumeric(str: string) { 2 | if (typeof str != "string") return false // only process 3 | return !isNaN(+str) && 4 | !isNaN(parseFloat(str)) // ensure strings of whitespace or \n fail 5 | } 6 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "auto-anki", 3 | "name": "Auto Anki", 4 | "version": "0.2.1", 5 | "minAppVersion": "0.15.0", 6 | "description": "Using GPT to automate card creation for Spaced Repetiton in Anki", 7 | "author": "ad2969", 8 | "authorUrl": "https://github.com/ad2969/", 9 | "isDesktopOnly": true 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "ES6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "isolatedModules": true, 13 | "strictNullChecks": true, 14 | "lib": [ 15 | "DOM", 16 | "ES5", 17 | "ES6", 18 | "ES7" 19 | ], 20 | "esModuleInterop": true, 21 | }, 22 | "include": [ 23 | "**/*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from "fs"; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync("manifest.json", "utf8")); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync("manifest.json", JSON.stringify(manifest, null, "\t")); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync("versions.json", "utf8")); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync("versions.json", JSON.stringify(versions, null, "\t")); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { "node": true }, 5 | "plugins": [ 6 | "@typescript-eslint" 7 | ], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "no-unused-vars": "off", 18 | "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], 19 | "@typescript-eslint/ban-ts-comment": "off", 20 | "no-prototype-builtins": "off", 21 | "@typescript-eslint/no-empty-function": "off" 22 | } 23 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-auto-anki", 3 | "version": "0.2.1", 4 | "description": "This is a plugin for Obsidian (https://obsidian.md). The plugin allows use of GPT to automate card creation for Spaced Repetiton in Anki", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 9 | "version": "node version-bump.mjs && git add manifest.json versions.json", 10 | "release": "git push origin --tags" 11 | }, 12 | "keywords": [], 13 | "author": "", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/node": "^16.11.6", 17 | "@typescript-eslint/eslint-plugin": "5.29.0", 18 | "@typescript-eslint/parser": "5.29.0", 19 | "builtin-modules": "3.3.0", 20 | "electron": "^24.3.0", 21 | "esbuild": "0.17.3", 22 | "obsidian": "latest", 23 | "tslib": "2.4.0", 24 | "typescript": "4.7.4" 25 | }, 26 | "dependencies": { 27 | "openai": "^4.24.6", 28 | "winston": "^3.11.0", 29 | "winston-transport": "^4.6.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Clarence Adrian 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/utils/enc.ts: -------------------------------------------------------------------------------- 1 | import { safeStorage } from 'electron'; 2 | 3 | export function electronEncrypt(text: string) { 4 | if (safeStorage == undefined || safeStorage == null) { 5 | console.error('Could not encrypt string: safeStorage not available!') 6 | return text; 7 | } 8 | 9 | if (safeStorage.isEncryptionAvailable()) { 10 | return safeStorage.encryptString(text); 11 | } 12 | else { 13 | console.error('Could not encrypt string: encryption not available!') 14 | throw Error('Could not encrypt string: encryption not available!'); 15 | } 16 | } 17 | 18 | export function electronDecrypt(buf: Buffer) { 19 | if (safeStorage == undefined || safeStorage == null) { 20 | console.error('Could not decrypt string: safeStorage not available!') 21 | return buf; 22 | } 23 | 24 | if (safeStorage.isEncryptionAvailable()) { 25 | return safeStorage.decryptString(Buffer.from(buf)); 26 | } 27 | else { 28 | console.error('Could not decrypt string: encryption not available!') 29 | throw Error('Could not decrypt string: encryption not available!'); 30 | } 31 | } -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from "esbuild"; 2 | import process from "process"; 3 | import builtins from "builtin-modules"; 4 | 5 | const banner = 6 | `/* 7 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 8 | if you want to view the source, please visit the github repository of this plugin 9 | */ 10 | `; 11 | 12 | const prod = (process.argv[2] === "production"); 13 | 14 | const context = await esbuild.context({ 15 | banner: { 16 | js: banner, 17 | }, 18 | entryPoints: ["src/main.ts"], 19 | bundle: true, 20 | external: [ 21 | "obsidian", 22 | "electron", 23 | "@codemirror/autocomplete", 24 | "@codemirror/collab", 25 | "@codemirror/commands", 26 | "@codemirror/language", 27 | "@codemirror/lint", 28 | "@codemirror/search", 29 | "@codemirror/state", 30 | "@codemirror/view", 31 | "@lezer/common", 32 | "@lezer/highlight", 33 | "@lezer/lr", 34 | ...builtins], 35 | format: "cjs", 36 | target: "es2018", 37 | logLevel: "info", 38 | sourcemap: prod ? false : "inline", 39 | treeShaking: true, 40 | outfile: "main.js", 41 | }); 42 | 43 | if (prod) { 44 | await context.rebuild(); 45 | process.exit(0); 46 | } else { 47 | await context.watch(); 48 | } -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | // import fs from 'fs'; 2 | import { Notice } from 'obsidian'; 3 | import { createLogger, format, transports } from 'winston'; 4 | import Transport from 'winston-transport'; 5 | 6 | class ObsidianNoticeTransport extends Transport { 7 | constructor(opts?: Transport.TransportStreamOptions) { 8 | super(opts); 9 | } 10 | 11 | log(info: any, callback: () => void) { 12 | setImmediate(() => { 13 | this.emit('logged', info); 14 | }); 15 | 16 | // Perform the writing to the remote service 17 | new Notice(`ERROR ("auto-anki" plugin)\n${info.message}`, 0) 18 | callback(); 19 | } 20 | } 21 | 22 | const logger = createLogger({ 23 | level: 'info', 24 | format: format.json(), 25 | transports: [ 26 | // new transports.File({ filename: `${logDir}/error.log`, level: 'error' }), 27 | // new transports.File({ filename: `${logDir}/combined.log` }) 28 | ] 29 | }); 30 | 31 | logger.add(new ObsidianNoticeTransport({ level: 'error' })); 32 | 33 | if (process.env.NODE_ENV !== 'production') { 34 | logger.add(new transports.Console({ 35 | format: format.simple(), 36 | })); 37 | } 38 | 39 | export default logger; 40 | -------------------------------------------------------------------------------- /src/settings.ts: -------------------------------------------------------------------------------- 1 | import { ChatCompletionCreateParamsBase } from 'openai/resources/chat/completions'; 2 | import { ANKI_CONNECT_DEFAULT_PORT } from './utils/anki'; 3 | 4 | export interface GptAdvancedOptions extends Partial { 5 | temperature: number; 6 | top_p: number; 7 | frequency_penalty: number; 8 | presence_penalty: number; 9 | max_tokens_per_question: number; 10 | } 11 | 12 | export interface QuestionGenerationDefaults { 13 | textSelection: { 14 | numQuestions: number, 15 | numAlternatives: number, 16 | }, 17 | file: { 18 | numQuestions: number, 19 | numAlternatives: number, 20 | }, 21 | } 22 | 23 | export interface PluginSettings { 24 | ankiConnectPort: number; 25 | ankiDestinationDeck: string; 26 | openAiApiKey: string | null; 27 | openAiApiKeyIdentifier: string; 28 | gptAdvancedOptions: GptAdvancedOptions; 29 | questionGenerationDefaults: QuestionGenerationDefaults; 30 | } 31 | 32 | export const DEFAULT_SETTINGS: PluginSettings = { 33 | ankiConnectPort: ANKI_CONNECT_DEFAULT_PORT, 34 | ankiDestinationDeck: '', 35 | openAiApiKey: null, 36 | openAiApiKeyIdentifier: '', 37 | gptAdvancedOptions: { 38 | temperature: 1, 39 | top_p: 1.0, 40 | frequency_penalty: 0.0, 41 | presence_penalty: 0.0, 42 | max_tokens_per_question: 100, 43 | }, 44 | questionGenerationDefaults: { 45 | textSelection: { 46 | numQuestions: 1, 47 | numAlternatives: 0, 48 | }, 49 | file: { 50 | numQuestions: 5, 51 | numAlternatives: 0, 52 | }, 53 | }, 54 | } 55 | -------------------------------------------------------------------------------- /sample/sample_card_information.ts: -------------------------------------------------------------------------------- 1 | export const SAMPLE_CARD_INFORMATION = [ 2 | [ 3 | { 4 | "question": "What is a confusion matrix?", 5 | "answer": "A confusion matrix is a specific table layout that allows visualization of the performance of an algorithm, typically a supervised learning algorithm.", 6 | }, 7 | { 8 | "question": "What are false positives?", 9 | "answer": "False positives are also called type 1 errors.", 10 | }, 11 | { 12 | "question": "What are false negatives?", 13 | "answer": "False negatives are also called type 2 errors.", 14 | }, 15 | ], 16 | [ 17 | { 18 | "question": "What is a confusion matrix?", 19 | "answer": "A confusion matrix is a table layout that allows visualization of the performance of a supervised learning algorithm.", 20 | }, 21 | { 22 | "question": "What are false positives?", 23 | "answer": "False positives also referred to as **type 1 error**.", 24 | }, 25 | { 26 | "question": "What are false negatives?", 27 | "answer": "False negatives also referred to as **type 2 error**.", 28 | }, 29 | ], 30 | [ 31 | { 32 | "question": "What is a confusion matrix?", 33 | "answer": "Table layout that allows visualization of the performance of a supervised learning algorithm.", 34 | }, 35 | { 36 | "question": "What are false positives?", 37 | "answer": "Type 1 errors.", 38 | }, 39 | { 40 | "question": "What are false negatives?", 41 | "answer": "Type 2 errors.", 42 | }, 43 | ], 44 | [ 45 | { 46 | "question": "What is a confusion matrix?", 47 | "answer": "OPTION FOUR BAAYBEEEE", 48 | }, 49 | { 50 | "question": "What are false positives?", 51 | "answer": "OPTION FOUR BAAYBEEEE.", 52 | }, 53 | { 54 | "question": "What are false negatives?", 55 | "answer": "OPTION FOUR BAAYBEEEE.", 56 | }, 57 | ], 58 | ]; 59 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | This CSS file will be included with your plugin, and 4 | available in the app when your plugin is enabled. 5 | 6 | If your plugin does not need CSS, delete this file. 7 | 8 | */ 9 | 10 | .enabled { 11 | opacity: 1; 12 | } 13 | .disabled { 14 | opacity: 0.5; 15 | } 16 | 17 | .error-notice { 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | justify-content: center; 22 | text-align: center; 23 | } 24 | 25 | .status-bar-auto-anki { 26 | display: flex; 27 | flex-direction: row; 28 | align-items: center; 29 | justify-content: center; 30 | padding: 0 0.3em; 31 | border: black 1px solid; 32 | } 33 | 34 | .status-bar-icon { 35 | display: flex; 36 | flex-direction: row; 37 | align-items: center; 38 | justify-content: center; 39 | } 40 | 41 | .status-bar-auto-anki > div { 42 | margin-left: 0.3em; 43 | } 44 | 45 | .status-bar-auto-anki.--error > .status-bar-icon { 46 | color: red; 47 | } 48 | 49 | .status-bar-auto-anki.--running > .status-bar-icon { 50 | -webkit-animation-name: spin; 51 | -webkit-animation-duration: 1000ms; 52 | -webkit-animation-iteration-count: infinite; 53 | -webkit-animation-timing-function: linear; 54 | 55 | animation-name: spin; 56 | animation-duration: 1000ms; 57 | animation-iteration-count: infinite; 58 | animation-timing-function: linear; 59 | } 60 | @-webkit-keyframes spin { 61 | from { 62 | -webkit-transform: rotate(0deg); 63 | } to { 64 | -webkit-transform: rotate(360deg); 65 | } 66 | } 67 | @keyframes spin { 68 | from { 69 | transform: rotate(0deg); 70 | } to { 71 | transform: rotate(360deg); 72 | } 73 | } 74 | 75 | .modal-buttons { 76 | display:flex; 77 | flex-direction: row; 78 | justify-content: space-between; 79 | } 80 | 81 | .modal-buttons > div > *:first-child { 82 | /* overwrite setting-item style */ 83 | margin: 0; 84 | } 85 | 86 | .slider-val { 87 | margin-left: 0.5rem; 88 | width: 3.5rem; 89 | text-align: end; 90 | } 91 | 92 | .question-options-container, 93 | .deck-options-container { 94 | list-style-type: none; 95 | padding: 0; 96 | margin: 0; 97 | } 98 | 99 | .question-options__buttons > button, 100 | .deck-options__buttons > button { 101 | margin-right: 1em; 102 | } 103 | 104 | .question-option, 105 | .deck-option { 106 | padding: 0.5rem 1rem; 107 | margin: 1rem 0rem; 108 | background: var(--background-secondary); 109 | color: var(--text-normal); 110 | border: 1px solid rgb(0, 0, 0, 0.1); 111 | border-radius: 1rem; 112 | } 113 | 114 | .question-option.--selected, 115 | .deck-option.--selected { 116 | background: var(--color-accent); 117 | color: var(--text-on-accent); 118 | } -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # obtained from https://github.com/argenos/nldates-obsidian/blob/master/.github/workflows/release.yml 2 | name: Build obsidian plugin 3 | 4 | on: 5 | push: 6 | # Sequence of patterns matched against refs/tags 7 | tags: 8 | - "*" # Push events to matching any tag format, i.e. 1.0, 20.15.10 9 | 10 | env: 11 | PLUGIN_NAME: auto-anki 12 | 13 | jobs: 14 | build: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js 20 | uses: actions/setup-node@v1 21 | with: 22 | node-version: "14.x" 23 | - name: Build 24 | id: build 25 | run: | 26 | yarn 27 | yarn run build --if-present 28 | mkdir ${{ env.PLUGIN_NAME }} 29 | cp main.js manifest.json styles.css ${{ env.PLUGIN_NAME }} 30 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 31 | ls 32 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 33 | - name: Create Release 34 | id: create_release 35 | uses: actions/create-release@v1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | VERSION: ${{ github.ref }} 39 | with: 40 | tag_name: ${{ github.ref }} 41 | release_name: ${{ github.ref }} 42 | draft: false 43 | prerelease: false 44 | - name: Upload zip file 45 | id: upload-zip 46 | uses: actions/upload-release-asset@v1 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | upload_url: ${{ steps.create_release.outputs.upload_url }} 51 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 52 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 53 | asset_content_type: application/zip 54 | - name: Upload main.js 55 | id: upload-main 56 | uses: actions/upload-release-asset@v1 57 | env: 58 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 59 | with: 60 | upload_url: ${{ steps.create_release.outputs.upload_url }} 61 | asset_path: ./main.js 62 | asset_name: main.js 63 | asset_content_type: text/javascript 64 | - name: Upload manifest.json 65 | id: upload-manifest 66 | uses: actions/upload-release-asset@v1 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 69 | with: 70 | upload_url: ${{ steps.create_release.outputs.upload_url }} 71 | asset_path: ./manifest.json 72 | asset_name: manifest.json 73 | asset_content_type: application/json 74 | - name: Upload styles.css 75 | id: upload-css 76 | uses: actions/upload-release-asset@v1 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | with: 80 | upload_url: ${{ steps.create_release.outputs.upload_url }} 81 | asset_path: ./styles.css 82 | asset_name: styles.css 83 | asset_content_type: text/css -------------------------------------------------------------------------------- /src/utils/anki.ts: -------------------------------------------------------------------------------- 1 | import { Notice, requestUrl } from 'obsidian'; 2 | 3 | import { CardInformation } from './gpt'; 4 | 5 | const ANKI_VERSION = 6; 6 | // const ANKI_DEFAULT_DECK = 'Default'; 7 | export const ANKI_CONNECT_DEFAULT_PORT = 8765; 8 | 9 | export async function checkAnkiAvailability(ankiPort: number) { 10 | try { 11 | await requestUrl({ 12 | method: 'POST', 13 | url: `http://127.0.0.1:${ankiPort}`, 14 | body: JSON.stringify({ 15 | action: 'deckNames', 16 | version: ANKI_VERSION, 17 | params: {}, 18 | }), 19 | throw: true, 20 | }); 21 | return true; 22 | } 23 | catch (err) { 24 | new Notice(`ERR: Could not connect to Anki! Please ensure you have Anki Connect running on port ${ankiPort}.`); 25 | return false; 26 | } 27 | } 28 | 29 | async function addCardsToAnki(ankiPort: number, deck: string, data: CardInformation[]) { 30 | // for anki connect, the request format is (https://foosoft.net/projects/anki-connect/) 31 | const ankiRequestData = data.map((card) => ({ 32 | 'deckName': deck, 33 | 'modelName': 'Basic', 34 | 'fields': { 35 | 'Front': card.q, 36 | 'Back': card.a, 37 | }, 38 | 'tags': [ 39 | 'auto-gpt-imported' 40 | ], 41 | })); 42 | try { 43 | const res = await requestUrl({ 44 | method: 'POST', 45 | url: `http://127.0.0.1:${ankiPort}`, 46 | body: JSON.stringify({ 47 | action: 'addNotes', 48 | version: ANKI_VERSION, 49 | params: { 50 | 'notes': ankiRequestData, 51 | } 52 | }), 53 | throw: true, 54 | }); 55 | return res.json.result ?? []; 56 | } 57 | catch (err) { 58 | new Notice(`ERR: Could not connect to Anki! Please ensure you have Anki Connect running on port ${ankiPort}.`); 59 | return []; 60 | } 61 | } 62 | 63 | export async function getAnkiDecks(ankiPort: number) { 64 | let res; 65 | try { 66 | res = await requestUrl({ 67 | method: 'POST', 68 | url: `http://127.0.0.1:${ankiPort}`, 69 | body: JSON.stringify({ 70 | action: 'deckNames', 71 | version: ANKI_VERSION, 72 | params: {}, 73 | }), 74 | throw: true, 75 | }); 76 | } 77 | catch (err) { 78 | new Notice(`ERR: Could not connect to Anki! Please ensure you have Anki Connect running on port ${ankiPort}.`); 79 | return []; 80 | } 81 | return res.json.result as string[]; 82 | } 83 | 84 | export async function checkAnkiDecksExist(ankiPort: number) { 85 | const decks = await getAnkiDecks(ankiPort); 86 | if (decks.length == 0) { 87 | new Notice('Your anki account has no decks. Create a new one before using!') 88 | return false; 89 | } 90 | 91 | return true; 92 | } 93 | 94 | export async function exportToAnki(cards: CardInformation[], port: number, deck: string) { 95 | // turn note into Q&A format using GPT 96 | const ankiRes: number[] = await addCardsToAnki(port, deck, cards); 97 | if (ankiRes.length > 0) new Notice(`Successfully exported ${ankiRes.length} card(s) to Anki!`) 98 | return true; 99 | } 100 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Auto Anki 2 | 3 | Plugin for [Obsidian.md](https://obsidian.md/) that uses OpenAI's GPT LLM to automatically generate Flashcards for Anki. 4 | 5 | > The plugin **only works for desktop**. 6 | 7 | The plugin introduces two new "commands" into obsidian: 8 | - _Export Current File to Anki_ 9 | - _Export Highlighted Text to Anki_ (only available in an active `Editable` window open - i.e. you need to have a document open, and it needs to be in `edit` mode) 10 | 11 | The command palette can be accessed on Obsidian through the following hotkey (default): `CMD` + `SHIFT` + `P` 12 | 13 | ![command-palette-hotkey](media/command-palette-hotkey.png). 14 | 15 | If desired, you can set your own hotkeys for the new commands. 16 | 17 | The two new commands look like the following: 18 | 19 | ![command-palette-new-commands](media/command-palette-new-commands.png) 20 | 21 | ## Plugin Requirements 22 | 23 | The following are required for the Plugin to work: 24 | - An [OpenAI](https://openai.com/) Account and an [OpenAI API Key](https://platform.openai.com/account/api-keys) 25 | - The [Anki](https://apps.ankiweb.net/) program, installed locally 26 | - [Anki Connect](https://github.com/FooSoft/anki-connect), to expose an Anki API for Obsidian to make calls to 27 | 28 | ## Plugin Setup 29 | 30 | 1. Download and install the plugin (Options > Community Plugins) 31 | 2. Ensure that you have all the requirements in the [Plugin Requirements](#plugin-requirements) 32 | 3. Go to the Plugin Settings (Settings > Community Plugins > Auto Anki) and make sure to set the following fields appropriately: 33 | - Anki Port (by default, this is `8765`) 34 | - OpenAI API Key 35 | 4. Enjoy! 36 | 37 | ## Feature Details 38 | 39 | #### Exporting an Entire File to Anki (Command: _Export Current File to Anki_) 40 | This command allows you to use the contents of the currently-opened file to send to GPT and generate a list of questions and answers. 41 | 42 | ![prompt-1](media/prompt-1.png) 43 | 44 | Alternatively, you can also specify the _number of alternatives_ to generate for each question. This allows you more variety in the "questions and answers" generated by GPT and it allows you to choose among a larger number of alternative "questions and answers". Choosing a number of alternatives work best with smaller notes. 45 | 46 | ![prompt-2](media/prompt-2.png) 47 | 48 | From the generated list of "questions and answers", you have the option to pick and choose the ones you want. 49 | 50 | After picking and choosing, your selected "questions and answers" automatically imports the chosen questions to Anki, based on the details in your Plugin settings. 51 | 52 | > It may take a while if you are generating a large number of questions, or a large number of question alternatives. 53 | An indicator will show whether `auto-anki` is currently generating your flash cards. This is shown in the status bar at the bottom of the screen, like below: 54 | 55 | ![status-bar-running](media/status-bar-running.png) 56 | 57 | #### Exporting Highlighted Text to Anki (Command: _Export Highlighted Text to Anki_) 58 | This command is similar to "Exporting an Entire File to Anki", but this allows you to use the currently-highlighted text (instead of the whole file) to send to GPT and generate a list of questions and questions. (Important Note: file needs to be in `edit` mode for the command to be available). 59 | 60 | ## Motivation 61 | 62 | With the kajillion things I read and watch on a daily basis, I've recently found myself struggling to retain knowledge of the things I've consumed. Hence, I've found myself trying to find new ways to enhance my self-education. I came upon [Spaced Repetition](https://en.wikipedia.org/wiki/Spaced_repetition), and wanted to try to use [Anki](https://apps.ankiweb.net/) to supplement my daily learnings. Being a long-time user and lover of [Obsidian.md](https://obsidian.md/) as my PKM (Personal Knowledge Management), I wanted to see if there was a way to automate my learning using spaced repetition with my current Obsidian vaults. 63 | 64 | I looked at other [similar plugins](https://github.com/Pseudonium/Obsidian_to_Anki) that attempt to connect Obsidian to Anki, but a lot of require you to change how you write your notes in Obsidian, or just don't seem automated enough. What this plugin does is automate the creation of ["flashcard-style" questions and answers](https://en.wikipedia.org/wiki/Leitner_system) but without needing to format your notes for this purpose. 65 | 66 | I consider myself a complete beginner when it comes to Spaced Repetition, Anki, or the general world of learning techniques, so I am always very open to suggestions, discussions, or any comments about the topic! 67 | 68 | ## Issues, Discussion, etc 69 | 70 | I keep track of all things related to this plugin mostly in [issues](https://github.com/ad2969/obsidian-auto-anki/issues). Feel free to report bugs and/or requests there! 71 | -------------------------------------------------------------------------------- /src/utils/gpt.ts: -------------------------------------------------------------------------------- 1 | import OpenAI from 'openai'; 2 | import type { 3 | ChatCompletion, 4 | ChatCompletionCreateParams, 5 | ChatCompletionUserMessageParam, 6 | ChatCompletionAssistantMessageParam, 7 | ChatCompletionSystemMessageParam, 8 | } from 'openai/resources'; 9 | import logger from 'src/logger'; 10 | import { GptAdvancedOptions } from 'src/settings'; 11 | 12 | export interface CardInformation { 13 | q: string; 14 | a: string; 15 | } 16 | 17 | export function checkGpt(openAiKey: string) { 18 | // check gpt api key 19 | if (openAiKey === '') { 20 | logger.log({ 21 | level: 'error', 22 | message: "OpenAI API key not provided! Please go to your 'Auto Anki' settings to set an API key." 23 | }); 24 | return false; 25 | } 26 | return true; 27 | } 28 | 29 | const SAMPLE_NOTE = `A numeral system is a writing system for expressing numbers. It is a mathematical notation for representing numbers of a given set, using digits or other symbols in a consistent manner. 30 | 31 | Ideally, a numeral system will: 32 | - represent a useful set of numbers (eg: integers, rational numbers) 33 | - give every number represented a unique representation 34 | - reflect the algebraic and arithmetic structure of the numbers 35 | 36 | #### Positional Notation 37 | > also known as the "place-value notation" 38 | 39 | Uses a **radix**/**base** (eg: base 10) to indicate the number of unique *digits* that are used to represent numbers in a position until the position of the digit is used to signify a power of the *base* number. 40 | 41 | - Positional Systems with Base 2: [[Binary Numeral System]] 42 | - Positional Systems with Base 8: Octal Numeral System 43 | - Positional Systems with Base 10: Decimal Numeral System 44 | - Positional Systems with Base 12: Duodecimal (dozenal) Numeral System 45 | - Positional Systems with Base 16: Hexadecimal Numeral System 46 | - Positional Systems with Base 20: Vigesimal Numeral System 47 | - Positional Systems with Base 60: Sexagesimal Numeral System 48 | ` 49 | 50 | const SAMPLE_OUTPUT = [ 51 | { q: 'What is a numeral system?', a: 'A numeral system is a writing system for expressing numbers, using digits or other symbols in a consistent manner.' }, 52 | { q: 'What is the goal of a numeral system?', a: 'The goal of a numeral system is to represent a useful set of numbers (eg: integers, rational numbers), give every number represented a unique representation, and reflect the algebraic and arithmetic structure of the numbers.' }, 53 | { q: 'What is a positional notation also known as?', a: 'Place-value Notation' }, 54 | { q: 'What is a radix/base used for in the context of positional notation?', a: 'To indicate the number of unique digits that are used to represent numbers in a position until the position of the digit is used to signify a power of the base number' }, 55 | { q: 'What numeral system uses a base of 2?', a: 'Binary Numeral System' }, 56 | { q: 'What numeral system uses a base of 8?', a: 'Octal Numeral System' }, 57 | { q: 'What numeral system uses a base of 10?', a: 'Decimal Numeral System' }, 58 | { q: 'What numeral system uses a base of 12?', a: 'Duodecimal Numeral System' }, 59 | { q: 'What numeral system uses a base of 16?', a: 'Hexadecimal Numeral System' }, 60 | { q: 'What numeral system uses a base of 20?', a: 'Vigesimal Numeral System' }, 61 | { q: 'What numeral system uses a base of 60?', a: 'Sexagesimal Numeral System' }, 62 | { q: 'What is binary number representation?', a: 'Binary number representation is a number expressed in the base-2 numeral system or binary numeral system.' }, 63 | // { q: '', a: '' }, 64 | ] 65 | 66 | function generateRepeatedSampleOutput(num: number) { 67 | if (num < SAMPLE_OUTPUT.length) return SAMPLE_OUTPUT.slice(0, num); 68 | 69 | let count = 0; 70 | const output = []; 71 | while (count < num) { 72 | output.push(...SAMPLE_OUTPUT) 73 | count += SAMPLE_OUTPUT.length; 74 | } 75 | return output.slice(0, num); 76 | } 77 | 78 | function createMessages(notes: string, num: number) { 79 | const messages = []; 80 | messages.push({ 81 | role: 'system', 82 | content: `You will be provided notes on a specific topic. The notes are formatted in markdown. Based on the given notes, make a list of ${num} questions and short answers that can be used for reviewing said notes by spaced repetition. Use the following guidelines: 83 | - output the questions and answers in the following JSON format { "questions_answers": [{ "q": "", "a": "" }] } 84 | - ensure that the questions cover the entire portion of the given notes, do not come up with similar questions or repeat the same questions 85 | `} as ChatCompletionSystemMessageParam) 86 | 87 | // Insert sample user prompt 88 | messages.push({ 89 | role: 'user', 90 | content: SAMPLE_NOTE, 91 | } as ChatCompletionUserMessageParam); 92 | 93 | 94 | // Insert sample assistant output (JSON format) 95 | messages.push({ 96 | role: 'assistant', 97 | content: JSON.stringify({ 98 | "questions_answers": generateRepeatedSampleOutput(num) 99 | }), 100 | } as ChatCompletionAssistantMessageParam); 101 | 102 | console.log({ 103 | "questions_answers": generateRepeatedSampleOutput(num) 104 | }) 105 | 106 | // Insert notes 107 | messages.push({ 108 | role: 'user', 109 | content: `\n${notes.trim()}\n`, 110 | } as ChatCompletionUserMessageParam); 111 | 112 | return messages; 113 | } 114 | 115 | export async function convertNotesToFlashcards( 116 | apiKey: string, 117 | notes: string, 118 | num_q: number, 119 | num: number, 120 | options: GptAdvancedOptions, 121 | ) { 122 | let response: ChatCompletion; 123 | try { 124 | const client = new OpenAI({ apiKey, dangerouslyAllowBrowser: true }); 125 | 126 | const { 127 | max_tokens_per_question: tokensPerQuestion, 128 | ...completionOptions 129 | } = options; 130 | 131 | // for anki connect, the output 132 | response = await client.chat.completions.create({ 133 | ...completionOptions, 134 | model: 'gpt-3.5-turbo-1106', 135 | messages: createMessages(notes, num_q), 136 | max_tokens: tokensPerQuestion * num_q, 137 | n: num, 138 | stream: false, 139 | response_format: { type: "json_object" }, 140 | } as ChatCompletionCreateParams) as ChatCompletion; 141 | } 142 | catch (err) { 143 | if (!err.response) { 144 | logger.log({ 145 | level: 'error', 146 | message: `Could not connect to OpenAI! ${err.message}` 147 | }); 148 | } 149 | else { 150 | const errStatus = err.response.status; 151 | const errBody = err.response.data; 152 | let supportingMessage = `(${errBody.error.code}) ${errBody.error.message}`; 153 | if (errStatus === 401) { 154 | supportingMessage = 'Check that your API Key is correct/valid!'; 155 | } 156 | logger.log({ 157 | level: 'error', 158 | message: `ERR ${errStatus}: Could not connect to OpenAI! ${supportingMessage}` 159 | }); 160 | } 161 | return []; 162 | } 163 | 164 | try { 165 | const card_choices: Array = []; 166 | logger.log({ 167 | level: 'info', 168 | message: `Generated ${response.choices.length} choices!`, 169 | }); 170 | 171 | response.choices.forEach((set, idx) => { 172 | const content = set.message.content ?? ''; 173 | logger.log({ 174 | level: 'info', 175 | message: content, 176 | }); 177 | const parsedContent = JSON.parse(content); 178 | if(parsedContent["questions_answers"] === undefined) throw ""; 179 | logger.log({ 180 | level: 'info', 181 | message: `Choice ${idx}: generated ${parsedContent["questions_answers"].length} questions and answers`, 182 | }); 183 | card_choices.push(parsedContent['questions_answers'] as CardInformation[]) 184 | }) 185 | return card_choices; 186 | } 187 | catch (err) { 188 | logger.log({ 189 | level: 'error', 190 | message: `Something happened while parsing OpenAI output! Please contact a developer for more support.`, 191 | }); 192 | return []; 193 | } 194 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { 2 | App, 3 | Editor, 4 | MarkdownView, 5 | Plugin, 6 | PluginSettingTab, 7 | Setting, 8 | Notice, 9 | setIcon, 10 | } from 'obsidian'; 11 | 12 | import { 13 | PluginSettings, 14 | DEFAULT_SETTINGS, 15 | } from './settings'; 16 | 17 | import { ExportModal } from './modal'; 18 | // import { 19 | // electronEncrypt, 20 | // electronDecrypt, 21 | // } from './utils/enc'; 22 | import { ANKI_CONNECT_DEFAULT_PORT } from './utils/anki'; 23 | import { isNumeric } from './utils/validations'; 24 | import { StatusBarElement } from './utils/cusom-types'; 25 | 26 | export default class AutoAnkiPlugin extends Plugin { 27 | settings: PluginSettings; 28 | statusBar: StatusBarElement; 29 | statusBarIcon: HTMLElement; 30 | 31 | async onload() { 32 | await this.loadSettings(); 33 | 34 | this.addSettingTab(new AutoAnkiSettingTab(this.app, this)); 35 | const defaults = this.settings.questionGenerationDefaults; 36 | const { textSelection: defaultsTextSelection, file: defaultsFile } = defaults; 37 | 38 | this.statusBar = this.addStatusBarItem(); 39 | this.statusBar.className = 'status-bar-auto-anki' 40 | this.statusBarIcon = this.statusBar.createEl('span', { cls: 'status-bar-icon' }); 41 | this.statusBar.createEl('div', { text: 'auto-anki' }); 42 | this.statusBar.doReset = () => { 43 | setIcon(this.statusBarIcon, 'check-circle-2'); 44 | this.statusBar.classList.remove('--running'); 45 | this.statusBar.classList.remove('--error'); 46 | }; 47 | this.statusBar.doDisplayError = () => { 48 | setIcon(this.statusBarIcon, 'alert-circle'); 49 | this.statusBar.classList.remove('--running'); 50 | this.statusBar.classList.add('--error'); 51 | 52 | } 53 | this.statusBar.doDisplayRunning = () => { 54 | setIcon(this.statusBarIcon, 'refresh-cw'); 55 | this.statusBar.classList.remove('--error'); 56 | this.statusBar.classList.add('--running'); 57 | }; 58 | this.statusBar.doReset(); 59 | 60 | this.addCommand({ 61 | id: 'export-current-file-to-anki', 62 | name: 'Export Current File to Anki', 63 | checkCallback: (checking: boolean) => { 64 | if (this.settings.openAiApiKey == null) { 65 | return false; 66 | } 67 | 68 | const view = this.app.workspace.getActiveViewOfType(MarkdownView); 69 | if (view == null) { 70 | return false; 71 | } 72 | 73 | if (!checking) { 74 | if (view.data.length <= 0) { 75 | new Notice('There is nothing in the file!'); 76 | return; 77 | } 78 | 79 | // const apiKey = electronDecrypt(this.settings.openAiApiKey); 80 | const apiKey = this.settings.openAiApiKey; 81 | const port = this.settings.ankiConnectPort || ANKI_CONNECT_DEFAULT_PORT; 82 | new ExportModal( 83 | this.app, 84 | this.statusBar, 85 | view.data, 86 | apiKey, 87 | port, 88 | this.settings.ankiDestinationDeck, 89 | this.settings.gptAdvancedOptions, 90 | defaultsFile.numQuestions, 91 | defaultsFile.numAlternatives, 92 | ).open(); 93 | } 94 | 95 | return true; 96 | }, 97 | }); 98 | 99 | this.addCommand({ 100 | id: 'export-text-selection-to-anki', 101 | name: 'Export Highlighted Text to Anki', 102 | editorCheckCallback: (checking: boolean, editor: Editor, view: MarkdownView) => { 103 | const currTextSelection = editor.getSelection(); 104 | 105 | if (this.settings.openAiApiKey == null) { 106 | return false; 107 | } 108 | 109 | if (!checking) { 110 | if (currTextSelection.length == 0) { 111 | new Notice('No text was selected!'); 112 | return; 113 | } 114 | 115 | // const apiKey = electronDecrypt(this.settings.openAiApiKey); 116 | const apiKey = this.settings.openAiApiKey; 117 | const port = this.settings.ankiConnectPort || ANKI_CONNECT_DEFAULT_PORT; 118 | new ExportModal( 119 | this.app, 120 | this.statusBar, 121 | currTextSelection, 122 | apiKey, 123 | port, 124 | this.settings.ankiDestinationDeck, 125 | this.settings.gptAdvancedOptions, 126 | defaultsTextSelection.numQuestions, 127 | defaultsTextSelection.numAlternatives, 128 | ).open(); 129 | } 130 | 131 | return true; 132 | }, 133 | }); 134 | } 135 | 136 | onunload() { 137 | } 138 | 139 | async loadSettings() { 140 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 141 | } 142 | 143 | async saveSettings() { 144 | await this.saveData(this.settings); 145 | } 146 | } 147 | 148 | class AutoAnkiSettingTab extends PluginSettingTab { 149 | plugin: AutoAnkiPlugin; 150 | maxFilesSetting: Setting; 151 | 152 | constructor(app: App, plugin: AutoAnkiPlugin) { 153 | super(app, plugin); 154 | this.plugin = plugin; 155 | } 156 | 157 | display(): void { 158 | const { containerEl } = this; 159 | 160 | containerEl.empty(); 161 | 162 | const ankiDescription = document.createElement('div'); 163 | // use innerHTML for harcoded description 164 | ankiDescription.innerHTML = '

Anki is an open-source flashcard program that is popular for spaced repetition. This plugin has only been tested on desktop, and requires Anki Connect to be installed alongside the main Anki program.

Enabling this plugin will add commands to automatically generate Question-Answer-style flashcards into the Anki system using OpenAI\'s AI models.

For information on usage, see the instructions online.

'; 165 | containerEl.appendChild(ankiDescription) 166 | 167 | new Setting(containerEl) 168 | .setName('Anki Port') 169 | .setDesc('The port number used to host Anki Connect') 170 | .addText(textComponent => textComponent 171 | .setPlaceholder('Anki Connect Default: 8765') 172 | .setValue(String(this.plugin.settings.ankiConnectPort)) 173 | .onChange(async (value) => { 174 | this.plugin.settings.ankiConnectPort = Number(value); 175 | await this.plugin.saveSettings(); 176 | }) 177 | ); 178 | 179 | const openAiDescription = new DocumentFragment(); 180 | const openAiDescHtml = document.createElement('p'); 181 | // use innerHTML for harcoded description 182 | openAiDescHtml.innerHTML = 'The API Key associated with your OpenAI account, used for querying GPT. Go here to obtain one.'; 183 | openAiDescription.appendChild(openAiDescHtml); 184 | 185 | new Setting(containerEl) 186 | .setName('OpenAI API Key') 187 | .setDesc(openAiDescription) 188 | .addText(textComponent => textComponent 189 | .setPlaceholder(`key entered: ${this.plugin.settings.openAiApiKeyIdentifier}` ?? 'NO_KEY_ENTERED') 190 | .onChange(async (value) => { 191 | // this.plugin.settings.openAiApiKey = electronEncrypt(value); 192 | this.plugin.settings.openAiApiKey = value; 193 | let identifier = 'xxxx'; 194 | if (value.length >= 7) { 195 | identifier = `${value.slice(0,3)}...${value.slice(-4)}` 196 | } 197 | this.plugin.settings.openAiApiKeyIdentifier = identifier; 198 | await this.plugin.saveSettings(); 199 | }) 200 | ); 201 | 202 | containerEl.createEl('h2', { text: 'Default Options for Exporting' }); 203 | 204 | new Setting(containerEl) 205 | .setName('Anki Default Deck Name') 206 | .setDesc('The name of the deck in Anki you want to export flashcards to, by default') 207 | .addText(textComponent => textComponent 208 | .setPlaceholder('Default') 209 | .setValue(String(this.plugin.settings.ankiDestinationDeck)) 210 | .onChange(async (value) => { 211 | this.plugin.settings.ankiDestinationDeck = value; 212 | await this.plugin.saveSettings(); 213 | }) 214 | ); 215 | 216 | containerEl.createEl('p', { text: '--> For exporting full files' }); 217 | 218 | new Setting(containerEl) 219 | .setName('Number of Questions') 220 | .setDesc('The number of questions to generate from the file.') 221 | .addText(textComponent => textComponent 222 | .setValue(String(this.plugin.settings.questionGenerationDefaults.file.numQuestions)) 223 | .onChange(async (value) => { 224 | if (value == '') { 225 | this.plugin.settings.questionGenerationDefaults.file.numQuestions = 0; 226 | await this.plugin.saveSettings(); 227 | } 228 | else if (!isNumeric(value)) { 229 | new Notice('The value you entered is not a number value'); 230 | } 231 | else { 232 | this.plugin.settings.questionGenerationDefaults.file.numQuestions = Number(value); 233 | await this.plugin.saveSettings(); 234 | } 235 | }) 236 | ); 237 | 238 | new Setting(containerEl) 239 | .setName('Number of Alternatives') 240 | .setDesc('The number of alternatives to generate for each question. Zero means no alteratives.') 241 | .addText(textComponent => textComponent 242 | .setValue(String(this.plugin.settings.questionGenerationDefaults.file.numAlternatives)) 243 | .onChange(async (value) => { 244 | if (value == '') { 245 | this.plugin.settings.questionGenerationDefaults.file.numAlternatives = 0; 246 | await this.plugin.saveSettings(); 247 | } 248 | else if (!isNumeric(value)) { 249 | new Notice('The value you entered is not a number value'); 250 | } 251 | else { 252 | this.plugin.settings.questionGenerationDefaults.file.numAlternatives = Number(value); 253 | await this.plugin.saveSettings(); 254 | } 255 | }) 256 | ); 257 | 258 | containerEl.createEl('p', { text: '--> For exporting selected text' }); 259 | 260 | new Setting(containerEl) 261 | .setName('Number of Questions') 262 | .setDesc('The number of questions to generate from the selected text.') 263 | .addText(textComponent => textComponent 264 | .setValue(String(this.plugin.settings.questionGenerationDefaults.textSelection.numQuestions)) 265 | .onChange(async (value) => { 266 | if (value == '') { 267 | this.plugin.settings.questionGenerationDefaults.textSelection.numQuestions = 0; 268 | await this.plugin.saveSettings(); 269 | } 270 | else if (!isNumeric(value)) { 271 | new Notice('The value you entered is not a number value'); 272 | } 273 | else { 274 | this.plugin.settings.questionGenerationDefaults.textSelection.numQuestions = Number(value); 275 | await this.plugin.saveSettings(); 276 | } 277 | }) 278 | ); 279 | 280 | new Setting(containerEl) 281 | .setName('Number of Alternatives') 282 | .setDesc('The number of alternatives to generate for each question. Zero means no alteratives.') 283 | .addText(textComponent => textComponent 284 | .setValue(String(this.plugin.settings.questionGenerationDefaults.textSelection.numAlternatives)) 285 | .onChange(async (value) => { 286 | if (value == '') { 287 | this.plugin.settings.questionGenerationDefaults.textSelection.numAlternatives = 0; 288 | await this.plugin.saveSettings(); 289 | } 290 | else if (!isNumeric(value)) { 291 | new Notice('The value you entered is not a number value'); 292 | } 293 | else { 294 | this.plugin.settings.questionGenerationDefaults.textSelection.numAlternatives = Number(value); 295 | await this.plugin.saveSettings(); 296 | } 297 | }) 298 | ); 299 | 300 | containerEl.createEl('h2', { text: 'Advanced Options for OpenAI\'s GPT Models' }); 301 | 302 | // See OpenAI docs for more info: 303 | // https://platform.openai.com/docs/api-reference/completions 304 | const tempValComponent = createEl('span', { 305 | text: String(this.plugin.settings.gptAdvancedOptions.temperature), 306 | cls: 'slider-val', // used to make custom slider component with displayed value next to it 307 | }); 308 | const tempComponent = new Setting(containerEl) 309 | .setName('Temperature') 310 | .setDesc('The sampling temperature used. Higher values increases randomness, while lower values makes the output more deterministic. (Default = 1)') 311 | .addSlider(sliderComponent => sliderComponent 312 | .setValue(this.plugin.settings.gptAdvancedOptions.temperature) 313 | .setLimits(0, 2, 0.1) 314 | .onChange(async (value) => { 315 | this.plugin.settings.gptAdvancedOptions.temperature = value; 316 | tempValComponent.textContent = String(value); 317 | await this.plugin.saveSettings(); 318 | }) 319 | ); 320 | tempComponent.settingEl.appendChild(tempValComponent); 321 | 322 | const topPValComponent = createEl('span', { 323 | text: String(this.plugin.settings.gptAdvancedOptions.top_p), 324 | cls: 'slider-val', 325 | }); 326 | const topPComponent = new Setting(containerEl) 327 | .setName('Top P') 328 | .setDesc('Value for nucleus sampling. Lower values mean the output considers the tokens comprising higher probability mass. (Default = 1)') 329 | .addSlider(sliderComponent => sliderComponent 330 | .setValue(this.plugin.settings.gptAdvancedOptions.top_p) 331 | .setLimits(0, 1, 0.05) 332 | .onChange(async (value) => { 333 | this.plugin.settings.gptAdvancedOptions.top_p = value; 334 | topPValComponent.textContent = String(value); 335 | await this.plugin.saveSettings(); 336 | }) 337 | ); 338 | topPComponent.settingEl.appendChild(topPValComponent); 339 | 340 | const fPenaltyValComponent = createEl('span', { 341 | text: String(this.plugin.settings.gptAdvancedOptions.frequency_penalty), 342 | cls: 'slider-val', 343 | }); 344 | const fPenaltyComponent = new Setting(containerEl) 345 | .setName('Frequency Penalty') 346 | .setDesc('Positive values penalize new tokens based on their existing frequency in the text so far. Higher values decrease chance of \'repetition\'. (Default = 0)') 347 | .addSlider(sliderComponent => sliderComponent 348 | .setValue(this.plugin.settings.gptAdvancedOptions.frequency_penalty) 349 | .setLimits(-2, 2, 0.1) 350 | .onChange(async (value) => { 351 | this.plugin.settings.gptAdvancedOptions.frequency_penalty = value; 352 | fPenaltyValComponent.textContent = String(value); 353 | await this.plugin.saveSettings(); 354 | }) 355 | ); 356 | fPenaltyComponent.settingEl.appendChild(fPenaltyValComponent); 357 | 358 | const pPenaltyValComponent = createEl('span', { 359 | text: String(this.plugin.settings.gptAdvancedOptions.presence_penalty), 360 | cls: 'slider-val', 361 | }); 362 | const pPenaltyComponent = new Setting(containerEl) 363 | .setName('Presence Penalty') 364 | .setDesc('Positive values penalize new tokens based on whether they appear in the text so far. Higher values increase chance of \'creativity\'. (Default = 0)') 365 | .addSlider(sliderComponent => sliderComponent 366 | .setValue(this.plugin.settings.gptAdvancedOptions.presence_penalty) 367 | .setLimits(-2, 2, 0.1) 368 | .onChange(async (value) => { 369 | this.plugin.settings.gptAdvancedOptions.presence_penalty = value; 370 | pPenaltyValComponent.textContent = String(value); 371 | await this.plugin.saveSettings(); 372 | }) 373 | ); 374 | pPenaltyComponent.settingEl.appendChild(pPenaltyValComponent); 375 | 376 | 377 | const openAiTokenDescription = new DocumentFragment(); 378 | const openAiTokenDescHtml = document.createElement('p'); 379 | // use innerHTML for harcoded description 380 | openAiTokenDescHtml.innerHTML = 'The maximum number of tokens consumed for each question. See tokens to better understand how tokens are quantized. (Default = 16)'; 381 | openAiTokenDescription.appendChild(openAiTokenDescHtml); 382 | 383 | new Setting(containerEl) 384 | .setName('Maximum Tokens per Question') 385 | .setDesc(openAiTokenDescription) 386 | .addText(textComponent => textComponent 387 | .setValue(String(this.plugin.settings.gptAdvancedOptions.max_tokens_per_question)) 388 | .onChange(async (value) => { 389 | if (value == '') { 390 | this.plugin.settings.gptAdvancedOptions.max_tokens_per_question = DEFAULT_SETTINGS.gptAdvancedOptions.max_tokens_per_question; 391 | await this.plugin.saveSettings(); 392 | } 393 | else if (!isNumeric(value)) { 394 | new Notice('The value you entered is not a number value'); 395 | } 396 | else { 397 | this.plugin.settings.gptAdvancedOptions.max_tokens_per_question = Number(value); 398 | await this.plugin.saveSettings(); 399 | } 400 | }) 401 | ); 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /src/modal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal, Notice, Setting } from 'obsidian'; 2 | import { GptAdvancedOptions } from './settings'; 3 | 4 | import { 5 | checkAnkiAvailability, 6 | checkAnkiDecksExist, 7 | exportToAnki, 8 | getAnkiDecks, 9 | } from './utils/anki'; 10 | import { CardInformation, checkGpt, convertNotesToFlashcards } from './utils/gpt'; 11 | import { StatusBarElement } from './utils/cusom-types'; 12 | 13 | // import { SAMPLE_CARD_INFORMATION } from 'sample/sample_card_information'; 14 | 15 | function checkValidNumGreaterThanZero(text: string|number, inclusiveZero?: boolean) { 16 | if (typeof text == 'string' && text === '') return false; 17 | if (isNaN(+text)) return false; 18 | return inclusiveZero ? +text >= 0 : +text > 0; 19 | } 20 | export class ExportModal extends Modal { 21 | n_q: number; 22 | n_q_valid: boolean; 23 | n_alt: number; 24 | n_alt_valid: boolean; 25 | data: string; 26 | apiKey: string; 27 | port: number; 28 | deck: string; 29 | gptAdvancedOptions: GptAdvancedOptions; 30 | statusBar: StatusBarElement; 31 | 32 | constructor( 33 | app: App, 34 | statusBar: StatusBarElement, 35 | data: string, 36 | openAiApiKey: string, 37 | ankiConnectPort: number, 38 | ankiDestinationDeck: string, 39 | gptAdvancedOptions: GptAdvancedOptions, 40 | dafaultNumQuestions?: number, 41 | defaultNumAlternatives?: number, 42 | ) { 43 | super(app); 44 | this.statusBar = statusBar; 45 | this.data = data; 46 | this.apiKey = openAiApiKey; 47 | this.port = ankiConnectPort; 48 | this.deck = ankiDestinationDeck; 49 | this.gptAdvancedOptions = gptAdvancedOptions; 50 | 51 | this.n_q = dafaultNumQuestions ?? 5; 52 | this.n_q_valid = checkValidNumGreaterThanZero(this.n_q); 53 | this.n_alt = defaultNumAlternatives ?? 3; 54 | this.n_alt_valid = checkValidNumGreaterThanZero(this.n_alt, true); 55 | } 56 | 57 | async onOpen() { 58 | const { contentEl } = this; 59 | 60 | const isAnkiAvailable = await checkAnkiAvailability(this.port); 61 | // update status bar if error/success 62 | if (this.statusBar.doDisplayError && !isAnkiAvailable) this.statusBar.doDisplayError(); 63 | if (this.statusBar.doReset && isAnkiAvailable) this.statusBar.doReset(); 64 | if (!isAnkiAvailable) return this.close(); 65 | 66 | const ankiCheck = await checkAnkiDecksExist(this.port); 67 | if (this.statusBar.doDisplayError && !ankiCheck) this.statusBar.doDisplayError(); 68 | if (this.statusBar.doReset && ankiCheck) this.statusBar.doReset(); 69 | if (!ankiCheck) return this.close(); 70 | 71 | contentEl.createEl('h1', { text: 'How many questions should be generated?' }); 72 | 73 | new Setting(contentEl) 74 | .setName('Number of Questions') 75 | .addText((text) => text 76 | .setValue(String(this.n_q)) 77 | .onChange((value) => { 78 | this.n_q_valid = checkValidNumGreaterThanZero(value); 79 | this.n_q = Number(value) 80 | }) 81 | ); 82 | 83 | new Setting(contentEl) 84 | .setName('Number of Alternatives') 85 | .setDesc('Generate multiple versions of questions and choose your favorite ones!') 86 | .addText((text) => text 87 | .setValue(String(this.n_alt)) 88 | .onChange((value) => { 89 | this.n_alt_valid = checkValidNumGreaterThanZero(value, true); 90 | this.n_alt = Number(value) 91 | }) 92 | ); 93 | 94 | new Setting(contentEl) 95 | .addButton((btn) => 96 | btn 97 | .setButtonText('Generate Cards') 98 | .setCta() 99 | .onClick(async () => { 100 | if (!this.n_q_valid || !this.n_alt_valid) { 101 | new Notice('An invalid number was entered!'); 102 | return; 103 | } 104 | this.close(); 105 | 106 | let isRequestValid = false; 107 | isRequestValid = checkGpt(this.apiKey); 108 | 109 | if (!isRequestValid) return; 110 | if (this.statusBar.doDisplayRunning) this.statusBar.doDisplayRunning(); 111 | const card_sets: Array = await convertNotesToFlashcards( 112 | this.apiKey, 113 | this.data, 114 | this.n_q, 115 | this.n_alt+1, 116 | this.gptAdvancedOptions, 117 | ); 118 | if (this.statusBar.doReset) this.statusBar.doReset(); 119 | 120 | if (card_sets.length === 0) return; 121 | // TODO: add loading indicator somewhere 122 | new ChoiceModal( 123 | this.app, 124 | card_sets, 125 | // SAMPLE_CARD_INFORMATION, 126 | this.port, 127 | this.deck, 128 | this.n_q, 129 | this.n_alt > 0, 130 | ).open(); 131 | }) 132 | ); 133 | } 134 | 135 | onClose() { 136 | const { contentEl } = this; 137 | contentEl.empty(); 138 | } 139 | } 140 | 141 | class QuestionSetWithSelections { 142 | questions: CardInformation[]; 143 | selected: Set; 144 | renderFunc: VoidFunction; 145 | 146 | constructor( 147 | questions: CardInformation[], 148 | onChangeCallback: VoidFunction, 149 | selectAllOnInit?: boolean, 150 | ) { 151 | this.questions = questions; 152 | if (selectAllOnInit) this.selected = new Set([...Array(questions.length).keys()]) 153 | else this.selected = new Set([]); 154 | 155 | this.renderFunc = onChangeCallback; 156 | } 157 | 158 | renderHtmlList() { 159 | const htmlList = createEl('ul', { cls: 'question-options-container' }); 160 | 161 | const convenienceButtons = createEl('div', { cls: 'question-options__buttons' }); 162 | const selectAllButton = convenienceButtons.createEl('button', { text: 'Select All' }); 163 | selectAllButton.onclick = (e: MouseEvent) => { this.selectAll() }; 164 | const deselectAllButton = convenienceButtons.createEl('button', { text: 'Deselect All' }); 165 | deselectAllButton.onclick = (e: MouseEvent) => { this.deselectAll() }; 166 | 167 | htmlList.appendChild(convenienceButtons); 168 | 169 | this.questions.forEach((q: CardInformation, idx: number) => { 170 | const htmlQuestion = createEl('li'); 171 | htmlQuestion.appendChild(createEl('h3', { text: q.q })); 172 | htmlQuestion.appendChild(createEl('p', { text: q.a })); 173 | if (this.selected.has(idx)) { 174 | htmlQuestion.className = 'question-option --selected' 175 | } 176 | else { 177 | htmlQuestion.className = 'question-option' 178 | } 179 | htmlQuestion.onclick = () => { this.toggleSelect(idx) }; 180 | htmlList.appendChild(htmlQuestion); 181 | }) 182 | 183 | return htmlList; 184 | } 185 | 186 | toggleSelect(idx: number) { 187 | if (this.selected.has(idx)) { 188 | this.selected.delete(idx); 189 | } 190 | else { 191 | this.selected.add(idx); 192 | } 193 | this.renderFunc(); 194 | } 195 | 196 | selectAll() { 197 | this.questions.forEach((q: CardInformation, idx: number) => { 198 | if (this.selected.has(idx)) return; 199 | this.selected.add(idx); 200 | }); 201 | this.renderFunc(); 202 | } 203 | 204 | deselectAll() { 205 | this.selected.clear(); 206 | this.renderFunc(); 207 | } 208 | 209 | extractSelectedQuesions() { 210 | return this.questions.filter((val, idx) => this.selected.has(idx)); 211 | } 212 | } 213 | 214 | export class ChoiceModal extends Modal { 215 | card_sets: Array; 216 | question_sets: QuestionSetWithSelections[]; 217 | n_sets: number; 218 | port: number; 219 | deck: string; 220 | 221 | curr_set: number; 222 | 223 | constructor( 224 | app: App, 225 | card_sets: Array, 226 | port: number, 227 | deck: string, 228 | n_q: number, 229 | has_alternatives: boolean, 230 | ) { 231 | super(app); 232 | this.renderContent = this.renderContent.bind(this); 233 | 234 | this.card_sets = card_sets; 235 | this.port = port; 236 | this.deck = deck; 237 | 238 | // create question sets 239 | this.curr_set = 0; 240 | this.question_sets = []; 241 | 242 | if (has_alternatives) { 243 | this.n_sets = n_q; 244 | for (let i = 0; i < n_q; i++) { 245 | const question_choices: CardInformation[] = []; 246 | 247 | card_sets.forEach((set) => { 248 | if (i < set.length) question_choices.push(set[i]); 249 | }) 250 | this.question_sets.push( 251 | new QuestionSetWithSelections(question_choices, this.renderContent) 252 | ); 253 | } 254 | } 255 | else { 256 | this.n_sets = 1; 257 | this.question_sets.push( 258 | new QuestionSetWithSelections(card_sets[0], this.renderContent) 259 | ); 260 | } 261 | } 262 | 263 | renderContent() { 264 | const { contentEl } = this; 265 | contentEl.innerHTML = ''; // use innerHTML to reset content 266 | 267 | // modal title, description 268 | if (this.n_sets === 1) { 269 | contentEl.createEl('h1', { text: 'Questions List' }); 270 | } 271 | else { 272 | contentEl.createEl('h1', { text: `Question Set No. ${this.curr_set+1}` }); 273 | } 274 | contentEl.createEl('p', { text: 'Pick one or more of the questions below you want to export to Anki.' }); 275 | 276 | // get card set to render 277 | const htmlList = this.question_sets[this.curr_set].renderHtmlList(); 278 | contentEl.appendChild(htmlList); 279 | 280 | // create buttons depending on how many sets there are 281 | if (this.n_sets > 1) { 282 | // create buttons in modal footer 283 | const htmlButtons = createEl('div', { cls: 'modal-buttons' }); 284 | // previous button 285 | new Setting(htmlButtons) 286 | .addButton((btn) => 287 | btn 288 | .setButtonText('Previous') 289 | .setCta() 290 | .setClass(this.curr_set === 0 ? 'disabled' : 'enabled') 291 | .setDisabled(this.curr_set === 0) 292 | .onClick(() => { 293 | this.curr_set -= 1; 294 | this.renderContent(); 295 | }) 296 | ); 297 | 298 | // next/confirm button 299 | if (this.curr_set < this.n_sets-1){ 300 | new Setting(htmlButtons) 301 | .addButton((btn) => 302 | btn 303 | .setButtonText('Next') 304 | .setCta() 305 | .onClick(() => { 306 | this.curr_set += 1; 307 | this.renderContent(); 308 | }) 309 | ); 310 | } 311 | else { 312 | new Setting(htmlButtons) 313 | .addButton((btn) => 314 | btn 315 | .setButtonText('Confirm Selection') 316 | .setCta() 317 | .onClick(async () => { 318 | this.close(); 319 | const allSelectedCards: CardInformation[] = []; 320 | this.question_sets.forEach((set) => { 321 | const selectedCards = set.extractSelectedQuesions() 322 | allSelectedCards.push(...selectedCards); 323 | }) 324 | new AnkiDeckModal( 325 | this.app, 326 | this.port, 327 | this.deck, 328 | allSelectedCards, 329 | ).open(); 330 | }) 331 | ); 332 | } 333 | contentEl.appendChild(htmlButtons); 334 | } else { 335 | new Setting(contentEl) 336 | .addButton((btn) => 337 | btn 338 | .setButtonText('Confirm Selection') 339 | .setCta() 340 | .onClick(async () => { 341 | this.close(); 342 | const allSelectedCards: CardInformation[] = []; 343 | this.question_sets.forEach((set) => { 344 | const selectedCards = set.extractSelectedQuesions() 345 | allSelectedCards.push(...selectedCards); 346 | }) 347 | new AnkiDeckModal( 348 | this.app, 349 | this.port, 350 | this.deck, 351 | allSelectedCards, 352 | ).open(); 353 | }) 354 | ); 355 | } 356 | } 357 | 358 | onOpen() { 359 | this.renderContent(); 360 | } 361 | 362 | onClose() { 363 | const { contentEl } = this; 364 | contentEl.empty(); 365 | } 366 | } 367 | 368 | export class AnkiDeckModal extends Modal { 369 | port: number; 370 | cardsToExport: CardInformation[]; 371 | decks: string[]; 372 | selectedDeck: string; 373 | isDataFetched: boolean; 374 | isDataError: boolean; 375 | 376 | constructor( 377 | app: App, 378 | port: number, 379 | defaultDeck: string, 380 | allSelectedCards: CardInformation[], 381 | ) { 382 | super(app); 383 | this.renderContent = this.renderContent.bind(this); 384 | this.port = port; 385 | this.selectedDeck = defaultDeck; 386 | this.cardsToExport = allSelectedCards; 387 | 388 | this.isDataFetched = false; 389 | } 390 | 391 | async fetchData() { 392 | const fetchedDecks = await getAnkiDecks(this.port); 393 | this.decks = fetchedDecks; 394 | this.isDataFetched = true; 395 | 396 | 397 | if (this.selectedDeck === '' && fetchedDecks.length > 0) { 398 | this.selectedDeck = fetchedDecks[0]; 399 | } 400 | } 401 | 402 | renderHtmlList() { 403 | const htmlList = createEl('ul', { cls: 'deck-options-container' }); 404 | const convenienceButtons = createEl('div', { cls: 'deck-options__buttons' }); 405 | 406 | htmlList.appendChild(convenienceButtons); 407 | 408 | this.decks.forEach((d: string) => { 409 | const htmlDeck = createEl('li'); 410 | htmlDeck.appendChild(createEl('h3', { text: d })); 411 | if (this.selectedDeck === d) { 412 | htmlDeck.className = 'deck-option --selected' 413 | } 414 | else { 415 | htmlDeck.className = 'deck-option' 416 | } 417 | htmlDeck.onclick = () => { 418 | this.selectedDeck = d; 419 | this.renderContent(); 420 | }; 421 | htmlList.appendChild(htmlDeck); 422 | }) 423 | 424 | return htmlList; 425 | } 426 | 427 | renderContent() { 428 | const { contentEl } = this; 429 | contentEl.innerHTML = ''; // use innerHTML to reset content 430 | 431 | // modal title 432 | contentEl.createEl('h1', { text: 'Anki Decks' }); 433 | 434 | if (!this.isDataFetched) { 435 | const centerContainer = contentEl.createEl('div', { cls: 'error-notice' }); 436 | centerContainer.createEl('h4', { text: 'loading data...' }); 437 | return; 438 | } 439 | 440 | if (this.decks.length === 0) { 441 | const centerContainer = contentEl.createEl('div', { cls: 'error-notice' }); 442 | centerContainer.createEl('h4', { text: 'Either an error occured or no Anki decks were found' }); 443 | const refreshButton = centerContainer.createEl('button', { text: 'Refresh' }); 444 | refreshButton.onclick = async () => { 445 | this.renderContent(); 446 | await this.fetchData(); 447 | this.renderContent(); 448 | } 449 | return; 450 | } 451 | 452 | // modal description 453 | contentEl.createEl('p', { text: 'Pick one of the following available Anki decks to export to.' }); 454 | 455 | // get deck list to render 456 | const htmlList = this.renderHtmlList(); 457 | contentEl.appendChild(htmlList); 458 | 459 | new Setting(contentEl) 460 | .addButton((btn) => 461 | btn 462 | .setButtonText('Confirm and Export') 463 | .setDisabled(this.selectedDeck === '') 464 | .setCta() 465 | .onClick(async () => { 466 | if (this.selectedDeck === '') return; 467 | 468 | this.close(); 469 | exportToAnki( 470 | this.cardsToExport, 471 | this.port, 472 | this.selectedDeck, 473 | ); 474 | }) 475 | ); 476 | } 477 | 478 | async onOpen() { 479 | this.renderContent(); 480 | await this.fetchData(); 481 | this.renderContent(); 482 | } 483 | 484 | onClose() { 485 | const { contentEl } = this; 486 | contentEl.empty(); 487 | } 488 | } 489 | --------------------------------------------------------------------------------