├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── greetings.yml │ ├── releases.yml │ └── stale.yml ├── .gitignore ├── LICENSE ├── README.md ├── docs ├── CONTRIBUTING.md ├── demo.gif ├── demo.md └── test-vault │ ├── .obsidian │ ├── app.json │ ├── appearance.json │ ├── community-plugins.json │ ├── core-plugins.json │ ├── hotkeys.json │ └── plugins │ │ ├── flashcards │ │ ├── .hotreload │ │ └── data.json │ │ └── hot-reload │ │ └── manifest.json │ ├── 000 Main.md │ ├── Cloze.md │ └── Inline.md ├── jest.config.ts ├── logo.png ├── main.ts ├── manifest.json ├── package.json ├── rollup.config.js ├── src ├── conf │ ├── constants.ts │ ├── regex.ts │ └── settings.ts ├── entities │ ├── card.ts │ ├── clozecard.ts │ ├── flashcard.ts │ ├── inlinecard.ts │ └── spacedcard.ts ├── gui │ └── settings-tab.ts ├── services │ ├── anki.ts │ ├── cards.ts │ └── parser.ts └── utils.ts ├── tests ├── obsidian_vault │ ├── test_flashcard_1.md │ ├── test_flashcard_heading_1.md │ ├── test_flashcard_heading_2.md │ ├── test_flashcard_heading_3.md │ ├── test_flashcard_heading_4.md │ ├── test_flashcard_heading_5.md │ ├── test_flashcard_heading_6.md │ ├── test_flashcard_tag_on_question_line_1.md │ ├── test_flashcard_tag_on_question_line_heading_1.md │ ├── test_flashcard_tag_on_question_line_heading_2.md │ ├── test_flashcard_tag_on_question_line_heading_3.md │ ├── test_flashcard_tag_on_question_line_heading_4.md │ ├── test_flashcard_tag_on_question_line_heading_5.md │ └── test_flashcard_tag_on_question_line_heading_6.md └── sum.test.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "parser": "@typescript-eslint/parser", 11 | "parserOptions": { 12 | "ecmaVersion": 13, 13 | "sourceType": "module" 14 | }, 15 | "plugins": [ 16 | "@typescript-eslint" 17 | ], 18 | "rules": { 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [reuseman] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: reuseman 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **NOTE: Do you have the last version of the plugin? If not that's probably the problem** 11 | 12 | **Describe the bug** 13 | A clear and concise description of what the bug is. 14 | 15 | **To Reproduce** 16 | - Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | **Expected behavior** 23 | A clear and concise description of what you expected to happen. 24 | 25 | **Screenshots** 26 | If applicable, add screenshots to help explain your problem. 27 | 28 | **Markdown used** 29 | ```md 30 | # Question #card 31 | Answer 32 | ``` 33 | 34 | **Additional context** 35 | Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this plugin 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 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 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | issue-message: 'Thank you for taking the time to report the issue and help me to make the project better! 🙂' 13 | pr-message: 'Thank you for helping me to make the project better! 🙂' 14 | -------------------------------------------------------------------------------- /.github/workflows/releases.yml: -------------------------------------------------------------------------------- 1 | name: Build obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | env: 9 | PLUGIN_NAME: flashcards 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Use Node.js 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: "14.x" 20 | - name: Build 21 | id: build 22 | run: | 23 | npm install 24 | npm run build --if-present 25 | mkdir ${{ env.PLUGIN_NAME }} 26 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 27 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 28 | ls 29 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 30 | - name: Create Release 31 | id: create_release 32 | uses: actions/create-release@v1 33 | env: 34 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 35 | VERSION: ${{ github.ref }} 36 | with: 37 | tag_name: ${{ github.ref }} 38 | release_name: ${{ github.ref }} 39 | draft: false 40 | prerelease: false 41 | - name: Upload zip file 42 | id: upload-zip 43 | uses: actions/upload-release-asset@v1 44 | env: 45 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 46 | with: 47 | upload_url: ${{ steps.create_release.outputs.upload_url }} 48 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 49 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 50 | asset_content_type: application/zip 51 | - name: Upload main.js 52 | id: upload-main 53 | uses: actions/upload-release-asset@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 56 | with: 57 | upload_url: ${{ steps.create_release.outputs.upload_url }} 58 | asset_path: ./main.js 59 | asset_name: main.js 60 | asset_content_type: text/javascript 61 | - name: Upload manifest.json 62 | id: upload-manifest 63 | uses: actions/upload-release-asset@v1 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | with: 67 | upload_url: ${{ steps.create_release.outputs.upload_url }} 68 | asset_path: ./manifest.json 69 | asset_name: manifest.json 70 | asset_content_type: application/json -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Mark stale issues and pull requests 2 | 3 | on: 4 | schedule: 5 | - cron: "0 13 * * *" 6 | 7 | jobs: 8 | stale: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/stale@v3 14 | with: 15 | any-of-labels: 'awaiting feedback' 16 | repo-token: ${{ secrets.GITHUB_TOKEN }} 17 | stale-issue-label: 'stale' 18 | stale-pr-label: 'stale' 19 | stale-issue-message: 'This issue is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.' 20 | stale-pr-message: 'This PR is stale because it has been open 60 days with no activity. Remove stale label or comment or this will be closed in 7 days.' 21 | close-issue-message: 'This issue was closed because it has been stalled for 7 days with no activity.' 22 | close-pr-message: 'This PR was closed because it has been stalled for 7 days with no activity.' 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Intellij 2 | *.iml 3 | .idea 4 | 5 | # npm 6 | node_modules 7 | package-lock.json 8 | 9 | # build 10 | main.js 11 | *.js.map 12 | docs/test-vault/.obsidian/plugins/flashcards/main.js 13 | docs/test-vault/.obsidian/plugins/flashcards/manifest.json 14 | 15 | # scripts 16 | move.sh 17 | 18 | # Visual Studio Code 19 | .vscode/* 20 | !.vscode/settings.json 21 | !.vscode/tasks.json 22 | !.vscode/launch.json 23 | !.vscode/extensions.json 24 | *.code-workspace 25 | 26 | # Local History for Visual Studio Code 27 | .history/ 28 | 29 | # Github 30 | .DS_Store 31 | 32 | # Test-vault 33 | docs/test-vault/.obsidian/workspace 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Alex Colucci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > :warning: **I am currently looking out for a co-maintainer.** Look at [#125](https://github.com/reuseman/flashcards-obsidian/issues/125), and if you are interested let me know :) 2 | --- 3 | 4 | # Flashcards 5 | 6 | [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/reuseman/flashcards-obsidian?style=for-the-badge&sort=semver)](https://github.com/reuseman/flashcards-obsidian/releases/latest) 7 | ![GitHub All Releases](https://img.shields.io/github/downloads/reuseman/flashcards-obsidian/total?style=for-the-badge) 8 | 9 | ![logo](logo.png) 10 | Anki integration for [Obsidian](https://obsidian.md/). 11 | 12 | ## Features 13 | 14 | 🗃️ Simple flashcards with **#card** 15 | 🎴 Reversed flashcards with **#card-reverse** or **#card/reverse** 16 | 📅 Spaced-only cards with **#card-spaced** or **#card/spaced** 17 | ✍️ Inline style with **Question::Answer** 18 | ✍️ Inline style reversed with **Question:::Answer** 19 | 📃 Cloze with **==Highlight==** or **{Curly brackets}** or **{2:Cloze}** 20 | 🧠 **Context-aware** mode 21 | 🏷️ Global and local **tags** 22 | 23 | 🔢 Support for **LaTeX** 24 | 🖼️ Support for **images** 25 | 🎤 Support for **audios** 26 | 🔗 Support for **Obsidian URI** 27 | ⚓ Support for **reference to note** 28 | 📟 Support for **code syntax highlight** 29 | 30 | For other features check the [wiki](https://github.com/reuseman/flashcards-obsidian/wiki). 31 | 32 | ## How it works? 33 | 34 | The following is a demo where the three main operations are shown: 35 | 36 | 1. **Insertion** of cards; 37 | 2. **Update** of cards; 38 | 3. **Deletion** of cards. 39 | 40 | ![Demo image](docs/demo.gif) 41 | 42 | ## How to use it? 43 | 44 | The wiki explains in detail [how to use it](https://github.com/reuseman/flashcards-obsidian/wiki). 45 | 46 | ## How to install 47 | 48 | 1. [Install](obsidian://show-plugin?id=flashcards-obsidian) this plugin on Obsidian: 49 | 50 | - Open Settings > Community plugins 51 | - Make sure Safe mode is off 52 | - Click Browse community plugins 53 | - Search for "**Flashcards**" 54 | - Click Install 55 | - Once installed, close the community plugins window and activate the newly installed plugin 56 | 57 | 2. Install [AnkiConnect](https://ankiweb.net/shared/info/2055492159) on Anki 58 | - Tools > Add-ons -> Get Add-ons... 59 | - Paste the code **2055492159** > Ok 60 | 61 | 3. Open the settings of the plugin, and while Anki is opened press "**Grant Permission**" 62 | 63 | ## Contributing 64 | Contributions via bug reports, bug fixes, are welcome. If you have ideas about features to be implemented, please open an issue so we can discuss the best way to implement it. For more details check [Contributing.md](docs/CONTRIBUTING.md) 65 | 66 | ## Support 67 | If flashcards plugin is useful to you and you want to support me, you can thank me with a coffee :) 68 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/V7V0ABKAF) 69 | -------------------------------------------------------------------------------- /docs/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Contributions via bug reports, bug fixes, are welcome. If you have ideas about features to be implemented, please open an issue so we can discuss the best way to implement it. 3 | 4 | ## How to build? 5 | You need to pull the repository, install the dependencies with `node` and then build with the command `npm run dev`. It will automatically move the files into the `docs/test-vault` and hot reload the plugin. 6 | 7 | $ git clone git@github.com:reuseman/flashcards-obsidian.git 8 | $ cd flashcards-obsidian 9 | ~/flashcards-obsidian$ npm install 10 | ~/flashcards-obsidian$ npm run dev -------------------------------------------------------------------------------- /docs/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reuseman/flashcards-obsidian/de737751e69b66ab67caaffe41d504a804a3c39d/docs/demo.gif -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | --- 2 | tags: demo, car 3 | cards-deck: My Knowledge::Demo 4 | --- 5 | 6 | # Car #card 7 | 8 | A car (or automobile) is a wheeled motor vehicle used for transportation. Most definitions of cars say that they run primarily on roads, seat one to eight people, have four tires, and mainly transport people rather than goods. 9 | ![[car example.png]] 10 | 11 | ## Controls #card 12 | 13 | Car controls are the components in automobiles and other powered road vehicles, such as trucks and buses, used for driving and parking. 14 | 15 | - Steering 16 | - Braking 17 | - Transmission 18 | - Signals and lighting 19 | 20 | ### Braking #card 21 | 22 | In modern cars the four-wheel braking system is controlled by a pedal to the left of the accelerator pedal. There is usually also a parking brake which operates the front or rear brakes only. 23 | 24 | ### Transmission #card 25 | 26 | Vehicles that generate power with an internal combustion engine (ICE) are generally equipped with a transmission or gearbox to change the speed-torque ratio and the direction of travel. 27 | 28 | _What about electric vehicles?_ #card 29 | This does not usually apply to electric vehicles because their motors can drive the vehicle both forward and backward from zero speed 30 | 31 | #### Manual #card 32 | 33 | Manual transmission is also known as a manual gearbox, stick shift, standard, and stick. Most automobile manual transmissions have several gear ratios that are chosen by locking selected gear pairs to the output shaft inside the transmission. Manual transmissions feature a driver-operated **clutch pedal** and **gear stick**. 34 | 35 | #### Automatic #card 36 | 37 | Automatic transmissions generally have a straight pattern, beginning at the most forward position with park, and running through reverse, neutral, drive, and then to the lower gears. 38 | 39 | --- 40 | 41 | ## Emerging technologies 42 | 43 | ### Autonomous car #card 44 | 45 | Fully autonomous vehicles, also known as driverless cars, already exist in prototype (such as the Google driverless car), but have **a long way to go** before they are in general use. 46 | 47 | ### Open source development #card #open-source 48 | 49 | There have been several projects aiming to develop a car on the principles of open design, an approach to designing in which the plans for the machinery and systems are publicly shared, often without monetary compensation. 50 | 51 | #### Examples #card-reverse 52 | 53 | OScar, Riversimple (through 40fires.org) and c,mm,n. 54 | 55 | Were they successfull? #card 56 | None of the projects have reached significant success in terms of developing a car as a whole both from hardware and software perspective and no mass production ready open-source based design have been introduced as of late 2009. 57 | 58 | ### Car sharing #card 59 | 60 | Car-share arrangements and carpooling are also increasingly popular, in the US and Europe. For example, in the US, some car-sharing services have experienced double-digit growth in revenue and membership growth between 2006 and 2007. 61 | 62 | --- 63 | 64 | ## Related 65 | 66 | - [[Electric vehicles]] 67 | -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "legacyEditor": false, 3 | "livePreview": true 4 | } -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/appearance.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseFontSize": 16 3 | } -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/community-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "flashcards-obsidian", 3 | "hot-reload" 4 | ] -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/core-plugins.json: -------------------------------------------------------------------------------- 1 | [ 2 | "file-explorer", 3 | "global-search", 4 | "switcher", 5 | "graph", 6 | "backlink", 7 | "page-preview", 8 | "note-composer", 9 | "command-palette", 10 | "editor-status", 11 | "markdown-importer", 12 | "word-count", 13 | "open-with-default-app", 14 | "file-recovery" 15 | ] -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/hotkeys.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/plugins/flashcards/.hotreload: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reuseman/flashcards-obsidian/de737751e69b66ab67caaffe41d504a804a3c39d/docs/test-vault/.obsidian/plugins/flashcards/.hotreload -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/plugins/flashcards/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "contextAwareMode": true, 3 | "sourceSupport": false, 4 | "codeHighlightSupport": false, 5 | "inlineID": false, 6 | "contextSeparator": " > ", 7 | "deck": "Default", 8 | "flashcardsTag": "card", 9 | "inlineSeparator": "::", 10 | "inlineSeparatorReverse": ":::", 11 | "defaultAnkiTag": "obsidian", 12 | "ankiConnectPermission": false 13 | } -------------------------------------------------------------------------------- /docs/test-vault/.obsidian/plugins/hot-reload/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "hot-reload", 3 | "name": "Hot Reload", 4 | "version": "0.1.8", 5 | "minAppVersion": "0.11.13", 6 | "description": "Automatically reload in-development plugins when their files are changed", 7 | "isDesktopOnly": true 8 | } 9 | -------------------------------------------------------------------------------- /docs/test-vault/000 Main.md: -------------------------------------------------------------------------------- 1 | This is the test vault. 2 | 3 | WORK IN PROGRESS 4 | 5 | # My heading 6 | #card 7 | My content -------------------------------------------------------------------------------- /docs/test-vault/Cloze.md: -------------------------------------------------------------------------------- 1 | --- 2 | cards-deck: Cloze 3 | --- 4 | 5 | # Cloze 6 | The **Cloze deck** must contain **4 cards** defined in the section [[#Must work ✔️]]. 7 | 8 | ## Must work ✔️ 9 | 10 | This is a valid ==cloze== that exploits the ==Obsidian Highlight== syntax. 11 | 12 | The other mechanism is the {following one} with these parenthesis. 13 | 14 | Moreover, the {2:order} of the {1:cloze} support can vary. 15 | 16 | And of course {multiple syntax} can be ==mixed== together. 17 | 18 | ## Must NOT work ❌ 19 | 20 | ### Code blocks 21 | ```java 22 | System.out.println("Hey this is not a {valid} one"); 23 | ``` 24 | 25 | ## Delimiters inside inline code blocks 26 | Suppose to have the following situation `==` where you talk about the equal operator of Haskell and then you talk again about it `==`. Then this must not be considered as a cloze card. 27 | 28 | ### Math blocks 29 | $$-\frac{1}{12}$$ 30 | 31 | ## Math inline 32 | This is not a good cloze $\frac{3}{2}$ 33 | -------------------------------------------------------------------------------- /docs/test-vault/Inline.md: -------------------------------------------------------------------------------- 1 | --- 2 | cards-deck: Inline 3 | --- 4 | 5 | # Inline 6 | #todo The **Inline deck** must contain **5 cards** defined in the section [[#Must work ✔️]]. 7 | 8 | ## Must work ✔️ 9 | 10 | This is a valid::Inline card 11 | 12 | This is a valid :: inline card 13 | 14 | This is a valid:: inline card 15 | 16 | This is a valid ::inline card 17 | 18 | This is a valid:::inline reversed card 19 | 20 | ## Must NOT work ❌ 21 | 22 | ### Code blocks 23 | ```java 24 | System.out.println("Hey this is not :: a valid inline card"); 25 | ``` 26 | 27 | ### Inline code blocks 28 | This is not `a valid :: inline card` 29 | 30 | ### Math blocks 31 | This is not a good inline card. $$3::4$$ 32 | 33 | ## Math inline blocks 34 | This is not a good inline $3::4$ card. -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * For a detailed explanation regarding each configuration property and type check, visit: 3 | * https://jestjs.io/docs/configuration 4 | */ 5 | 6 | export default { 7 | // All imported modules in your tests should be mocked automatically 8 | // automock: false, 9 | 10 | // Stop running tests after `n` failures 11 | // bail: 0, 12 | 13 | // The directory where Jest should store its cached dependency information 14 | // cacheDirectory: "/tmp/jest_rs", 15 | 16 | // Automatically clear mock calls, instances, contexts and results before every test 17 | clearMocks: true, 18 | 19 | // Indicates whether the coverage information should be collected while executing the test 20 | collectCoverage: true, 21 | 22 | // An array of glob patterns indicating a set of files for which coverage information should be collected 23 | // collectCoverageFrom: undefined, 24 | 25 | // The directory where Jest should output its coverage files 26 | coverageDirectory: "coverage", 27 | 28 | // An array of regexp pattern strings used to skip coverage collection 29 | // coveragePathIgnorePatterns: [ 30 | // "/node_modules/" 31 | // ], 32 | 33 | // Indicates which provider should be used to instrument code for coverage 34 | coverageProvider: "v8", 35 | 36 | // A list of reporter names that Jest uses when writing coverage reports 37 | // coverageReporters: [ 38 | // "json", 39 | // "text", 40 | // "lcov", 41 | // "clover" 42 | // ], 43 | 44 | // An object that configures minimum threshold enforcement for coverage results 45 | // coverageThreshold: undefined, 46 | 47 | // A path to a custom dependency extractor 48 | // dependencyExtractor: undefined, 49 | 50 | // Make calling deprecated APIs throw helpful error messages 51 | // errorOnDeprecated: false, 52 | 53 | // The default configuration for fake timers 54 | // fakeTimers: { 55 | // "enableGlobally": false 56 | // }, 57 | 58 | // Force coverage collection from ignored files using an array of glob patterns 59 | // forceCoverageMatch: [], 60 | 61 | // A path to a module which exports an async function that is triggered once before all test suites 62 | // globalSetup: undefined, 63 | 64 | // A path to a module which exports an async function that is triggered once after all test suites 65 | // globalTeardown: undefined, 66 | 67 | // A set of global variables that need to be available in all test environments 68 | // globals: {}, 69 | 70 | // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers. 71 | // maxWorkers: "50%", 72 | 73 | // An array of directory names to be searched recursively up from the requiring module's location 74 | // moduleDirectories: [ 75 | // "node_modules" 76 | // ], 77 | 78 | // An array of file extensions your modules use 79 | // moduleFileExtensions: [ 80 | // "js", 81 | // "mjs", 82 | // "cjs", 83 | // "jsx", 84 | // "ts", 85 | // "tsx", 86 | // "json", 87 | // "node" 88 | // ], 89 | 90 | // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module 91 | // moduleNameMapper: {}, 92 | 93 | // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader 94 | // modulePathIgnorePatterns: [], 95 | 96 | // Activates notifications for test results 97 | // notify: false, 98 | 99 | // An enum that specifies notification mode. Requires { notify: true } 100 | // notifyMode: "failure-change", 101 | 102 | // A preset that is used as a base for Jest's configuration 103 | preset: 'ts-jest', 104 | 105 | // Run tests from one or more projects 106 | // projects: undefined, 107 | 108 | // Use this configuration option to add custom reporters to Jest 109 | // reporters: undefined, 110 | 111 | // Automatically reset mock state before every test 112 | // resetMocks: false, 113 | 114 | // Reset the module registry before running each individual test 115 | // resetModules: false, 116 | 117 | // A path to a custom resolver 118 | // resolver: undefined, 119 | 120 | // Automatically restore mock state and implementation before every test 121 | // restoreMocks: false, 122 | 123 | // The root directory that Jest should scan for tests and modules within 124 | // rootDir: undefined, 125 | 126 | // A list of paths to directories that Jest should use to search for files in 127 | // roots: [ 128 | // "" 129 | // ], 130 | 131 | // Allows you to use a custom runner instead of Jest's default test runner 132 | // runner: "jest-runner", 133 | 134 | // The paths to modules that run some code to configure or set up the testing environment before each test 135 | // setupFiles: [], 136 | 137 | // A list of paths to modules that run some code to configure or set up the testing framework before each test 138 | // setupFilesAfterEnv: [], 139 | 140 | // The number of seconds after which a test is considered as slow and reported as such in the results. 141 | // slowTestThreshold: 5, 142 | 143 | // A list of paths to snapshot serializer modules Jest should use for snapshot testing 144 | // snapshotSerializers: [], 145 | 146 | // The test environment that will be used for testing 147 | testEnvironment: "node", 148 | 149 | // Options that will be passed to the testEnvironment 150 | // testEnvironmentOptions: {}, 151 | 152 | // Adds a location field to test results 153 | // testLocationInResults: false, 154 | 155 | // The glob patterns Jest uses to detect test files 156 | // testMatch: [ 157 | // "**/__tests__/**/*.[jt]s?(x)", 158 | // "**/?(*.)+(spec|test).[tj]s?(x)" 159 | // ], 160 | 161 | // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped 162 | // testPathIgnorePatterns: [ 163 | // "/node_modules/" 164 | // ], 165 | 166 | // The regexp pattern or array of patterns that Jest uses to detect test files 167 | // testRegex: [], 168 | 169 | // This option allows the use of a custom results processor 170 | // testResultsProcessor: undefined, 171 | 172 | // This option allows use of a custom test runner 173 | // testRunner: "jest-circus/runner", 174 | 175 | // A map from regular expressions to paths to transformers 176 | transform: { 177 | '^.+\\.js?$': 'ts-jest', 178 | }, 179 | 180 | // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation 181 | transformIgnorePatterns: [ 182 | "/node_modules/", 183 | "\\.pnp\\.[^\\/]+$" 184 | ], 185 | 186 | // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them 187 | // unmockedModulePathPatterns: undefined, 188 | 189 | // Indicates whether each individual test should be reported during the run 190 | // verbose: undefined, 191 | 192 | // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode 193 | // watchPathIgnorePatterns: [], 194 | 195 | // Whether to use watchman for file crawling 196 | // watchman: true, 197 | }; 198 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reuseman/flashcards-obsidian/de737751e69b66ab67caaffe41d504a804a3c39d/logo.png -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { addIcon, Notice, Plugin, TFile } from 'obsidian'; 2 | import { ISettings } from 'src/conf/settings'; 3 | import { SettingsTab } from 'src/gui/settings-tab'; 4 | import { CardsService } from 'src/services/cards'; 5 | import { Anki } from 'src/services/anki'; 6 | import { noticeTimeout, flashcardsIcon } from 'src/conf/constants'; 7 | 8 | export default class ObsidianFlashcard extends Plugin { 9 | private settings: ISettings 10 | private cardsService: CardsService 11 | 12 | async onload() { 13 | addIcon("flashcards", flashcardsIcon) 14 | 15 | // TODO test when file did not insert flashcards, but one of them is in Anki already 16 | const anki = new Anki() 17 | this.settings = await this.loadData() || this.getDefaultSettings() 18 | this.cardsService = new CardsService(this.app, this.settings) 19 | 20 | const statusBar = this.addStatusBarItem() 21 | 22 | this.addCommand({ 23 | id: 'generate-flashcard-current-file', 24 | name: 'Generate for the current file', 25 | checkCallback: (checking: boolean) => { 26 | const activeFile = this.app.workspace.getActiveFile() 27 | if (activeFile) { 28 | if (!checking) { 29 | this.generateCards(activeFile) 30 | } 31 | return true; 32 | } 33 | return false; 34 | } 35 | }); 36 | 37 | this.addRibbonIcon('flashcards', 'Generate flashcards', () => { 38 | const activeFile = this.app.workspace.getActiveFile() 39 | if (activeFile) { 40 | this.generateCards(activeFile) 41 | } else { 42 | new Notice("Open a file before") 43 | } 44 | }); 45 | 46 | this.addSettingTab(new SettingsTab(this.app, this)); 47 | 48 | this.registerInterval(window.setInterval(() => 49 | anki.ping().then(() => statusBar.setText('Anki ⚡️')).catch(() => statusBar.setText('')), 15 * 1000 50 | )); 51 | } 52 | 53 | async onunload() { 54 | await this.saveData(this.settings); 55 | } 56 | 57 | private getDefaultSettings(): ISettings { 58 | return { contextAwareMode: true, sourceSupport: false, codeHighlightSupport: false, inlineID: false, contextSeparator: " > ", deck: "Default", folderBasedDeck: true, flashcardsTag: "card", inlineSeparator: "::", inlineSeparatorReverse: ":::", defaultAnkiTag: "obsidian", ankiConnectPermission: false } 59 | } 60 | 61 | private generateCards(activeFile: TFile) { 62 | this.cardsService.execute(activeFile).then(res => { 63 | for (const r of res) { 64 | new Notice(r, noticeTimeout) 65 | } 66 | console.log(res) 67 | }).catch(err => { 68 | Error(err) 69 | }) 70 | } 71 | } -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "flashcards-obsidian", 3 | "name": "Flashcards", 4 | "version": "1.6.5", 5 | "minAppVersion": "0.9.17", 6 | "description": "Anki integration", 7 | "author": "Alex Colucci", 8 | "authorUrl": "https://github.com/reuseman", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flashcards", 3 | "version": "0.2.0", 4 | "description": "An Anki integration.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w --environment BUILD:dev", 8 | "build": "rollup --config rollup.config.js --environment BUILD:production", 9 | "test": "jest" 10 | }, 11 | "keywords": [ 12 | "obsidian", 13 | "anki", 14 | "flashcards" 15 | ], 16 | "author": "", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "@rollup/plugin-commonjs": "^15.1.0", 20 | "@rollup/plugin-node-resolve": "^9.0.0", 21 | "@rollup/plugin-typescript": "^6.0.0", 22 | "@types/jest": "^29.2.0", 23 | "@types/node": "^14.17.4", 24 | "@typescript-eslint/eslint-plugin": "^5.7.0", 25 | "@typescript-eslint/parser": "^5.7.0", 26 | "eslint": "^8.4.1", 27 | "jest": "^29.2.2", 28 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 29 | "rollup": "^2.52.2", 30 | "rollup-plugin-copy": "^3.4.0", 31 | "ts-jest": "^29.0.3", 32 | "ts-node": "^10.9.1", 33 | "tslib": "^2.3.0", 34 | "typescript": "^4.3.4" 35 | }, 36 | "dependencies": { 37 | "@types/showdown": "^1.9.3", 38 | "showdown": "^1.9.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from '@rollup/plugin-typescript'; 2 | import {nodeResolve} from '@rollup/plugin-node-resolve'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | 5 | const PRODUCTION_PLUGIN_CONFIG = { 6 | input: 'main.ts', 7 | output: { 8 | dir: '.', 9 | sourcemap: 'inline', 10 | sourcemapExcludeSources: true, 11 | format: 'cjs', 12 | exports: 'default' 13 | }, 14 | external: ['obsidian'], 15 | plugins: [ 16 | typescript(), 17 | nodeResolve({browser: true}), 18 | commonjs(), 19 | ] 20 | }; 21 | 22 | const DEV_PLUGIN_CONFIG = { 23 | input: 'main.ts', 24 | output: { 25 | dir: 'docs/test-vault/.obsidian/plugins/flashcards-obsidian/', 26 | sourcemap: 'inline', 27 | format: 'cjs', 28 | exports: 'default' 29 | }, 30 | external: ['obsidian'], 31 | plugins: [ 32 | typescript(), 33 | nodeResolve({browser: true}), 34 | commonjs(), 35 | ] 36 | }; 37 | 38 | let configs = [] 39 | 40 | if (process.env.BUILD === "dev") { 41 | configs.push(DEV_PLUGIN_CONFIG); 42 | } else if (process.env.BUILD === "production" ) { 43 | configs.push(PRODUCTION_PLUGIN_CONFIG); 44 | } else { 45 | configs.push(DEV_PLUGIN_CONFIG); 46 | } 47 | 48 | export default configs; -------------------------------------------------------------------------------- /src/conf/regex.ts: -------------------------------------------------------------------------------- 1 | import { ISettings } from "src/conf/settings"; 2 | 3 | export class Regex { 4 | headingsRegex: RegExp; 5 | wikiImageLinks: RegExp; 6 | markdownImageLinks: RegExp; 7 | wikiAudioLinks: RegExp; 8 | obsidianCodeBlock: RegExp; // ```code block`` 9 | codeBlock: RegExp; 10 | mathBlock: RegExp; // $$ latex $$ 11 | mathInline: RegExp; // $ latex $ 12 | cardsDeckLine: RegExp; 13 | cardsToDelete: RegExp; 14 | globalTagsSplitter: RegExp; 15 | tagHierarchy: RegExp; 16 | 17 | flashscardsWithTag: RegExp; 18 | cardsInlineStyle: RegExp; 19 | cardsSpacedStyle: RegExp; 20 | cardsClozeWholeLine: RegExp; 21 | singleClozeCurly: RegExp; 22 | singleClozeHighlight: RegExp; 23 | clozeHighlight: RegExp; 24 | 25 | embedBlock: RegExp; 26 | 27 | constructor(settings: ISettings) { 28 | this.update(settings); 29 | } 30 | 31 | public update(settings: ISettings) { 32 | // https://regex101.com/r/BOieWh/1 33 | this.headingsRegex = /^ {0,3}(#{1,6}) +([^\n]+?) ?((?: *#\S+)*) *$/gim; 34 | 35 | // Supported images https://publish.obsidian.md/help/How+to/Embed+files 36 | this.wikiImageLinks = 37 | /!\[\[(.*\.(?:png|jpg|jpeg|gif|bmp|svg|tiff)).*?\]\]/gim; 38 | this.markdownImageLinks = 39 | /!\[\]\((.*\.(?:png|jpg|jpeg|gif|bmp|svg|tiff)).*?\)/gim; 40 | 41 | this.wikiAudioLinks = 42 | /!\[\[(.*\.(?:mp3|webm|wav|m4a|ogg|3gp|flac)).*?\]\]/gim; 43 | 44 | // https://regex101.com/r/eqnJeW/1 45 | this.obsidianCodeBlock = /(?:```(?:.*?\n?)+?```)(?:\n|$)/gim; 46 | 47 | this.codeBlock = /]*>(.*?)<\/code>/gims; 48 | 49 | this.mathBlock = /(\$\$)(.*?)(\$\$)/gis; 50 | this.mathInline = /(\$)(.*?)(\$)/gi; 51 | 52 | this.cardsDeckLine = /cards-deck: [\p{L}]+/giu; 53 | this.cardsToDelete = /^\s*(?:\n)(?:\^(\d{13}))(?:\n\s*?)?/gm; 54 | 55 | // https://regex101.com/r/WxuFI2/1 56 | this.globalTagsSplitter = 57 | /\[\[(.*?)\]\]|#([\p{L}\d:\-_/]+)|([\p{L}\d:\-_/]+)/gimu; 58 | this.tagHierarchy = /\//gm; 59 | 60 | // Cards 61 | const flags = "gimu"; 62 | // https://regex101.com/r/p3yQwY/2 63 | let str = 64 | "( {0,3}[#]*)((?:[^\\n]\\n?)+?)(#" + 65 | settings.flashcardsTag + 66 | "(?:[/-]reverse)?)((?: *#[\\p{Number}\\p{Letter}\\-\\/_]+)*) *?\\n+((?:[^\\n]\\n?)*?(?=\\^\\d{13}|$))(?:\\^(\\d{13}))?"; 67 | this.flashscardsWithTag = new RegExp(str, flags); 68 | 69 | // https://regex101.com/r/8wmOo8/1 70 | const sepLongest = settings.inlineSeparator.length >= settings.inlineSeparatorReverse.length ? settings.inlineSeparator : settings.inlineSeparatorReverse; 71 | const sepShortest = settings.inlineSeparator.length < settings.inlineSeparatorReverse.length ? settings.inlineSeparator : settings.inlineSeparatorReverse; 72 | // sepLongest is the longest between the inlineSeparator and the inlineSeparatorReverse because if the order is ::|::: then always the first will be matched 73 | // sepShortest is the shortest 74 | if (settings.inlineID) { 75 | str = 76 | "( {0,3}[#]{0,6})?(?:(?:[\\t ]*)(?:\\d.|[-+*]|#{1,6}))?(.+?) ?(" + sepLongest + "|" + sepShortest + ") ?(.+?)((?: *#[\\p{Letter}\\-\\/_]+)+)?(?:\\s+\\^(\\d{13})|$)"; 77 | } else { 78 | str = 79 | "( {0,3}[#]{0,6})?(?:(?:[\\t ]*)(?:\\d.|[-+*]|#{1,6}))?(.+?) ?(" + sepLongest + "|" + sepShortest + ") ?(.+?)((?: *#[\\p{Letter}\\-\\/_]+)+|$)(?:\\n\\^(\\d{13}))?"; 80 | } 81 | this.cardsInlineStyle = new RegExp(str, flags); 82 | 83 | // https://regex101.com/r/HOXF5E/1 84 | str = 85 | "( {0,3}[#]*)((?:[^\\n]\\n?)+?)(#" + 86 | settings.flashcardsTag + 87 | "[/-]spaced)((?: *#[\\p{Letter}-]+)*) *\\n?(?:\\^(\\d{13}))?"; 88 | this.cardsSpacedStyle = new RegExp(str, flags); 89 | 90 | // https://regex101.com/r/cgtnLf/1 91 | 92 | str = "( {0,3}[#]{0,6})?(?:(?:[\\t ]*)(?:\\d.|[-+*]|#{1,6}))?(.*?(==.+?==|\\{.+?\\}).*?)((?: *#[\\w\\-\\/_]+)+|$)(?:\n\\^(\\d{13}))?" 93 | this.cardsClozeWholeLine = new RegExp(str, flags); 94 | 95 | this.singleClozeCurly = /((?:{)(?:(\d):?)?(.+?)(?:}))/g; 96 | this.singleClozeHighlight = /((?:==)(.+?)(?:==))/g; 97 | 98 | // Matches any embedded block but the one with an used extension from the wikilinks 99 | this.embedBlock = /!\[\[(.*?)(?; 9 | reversed: boolean; 10 | initialOffset: number; 11 | endOffset: number; 12 | tags: string[]; 13 | inserted: boolean; 14 | mediaNames: string[]; 15 | mediaBase64Encoded: string[]; 16 | oldTags: string[]; 17 | containsCode: boolean; 18 | modelName: string; 19 | 20 | constructor( 21 | id: number, 22 | deckName: string, 23 | initialContent: string, 24 | fields: Record, 25 | reversed: boolean, 26 | initialOffset: number, 27 | endOffset: number, 28 | tags: string[], 29 | inserted: boolean, 30 | mediaNames: string[], 31 | containsCode = false 32 | ) { 33 | this.id = id; 34 | this.deckName = deckName; 35 | this.initialContent = initialContent; 36 | this.fields = fields; 37 | this.reversed = reversed; 38 | this.initialOffset = initialOffset 39 | this.endOffset = endOffset; 40 | this.tags = tags; 41 | this.inserted = inserted; 42 | this.mediaNames = mediaNames; 43 | this.mediaBase64Encoded = []; 44 | this.oldTags = []; 45 | this.containsCode = containsCode; 46 | this.modelName = ""; 47 | } 48 | 49 | abstract toString(): string; 50 | abstract getCard(update: boolean): object; 51 | abstract getMedias(): object[]; 52 | abstract getIdFormat(): string; 53 | 54 | match(card: any): boolean { 55 | // TODO not supported currently 56 | // if (this.modelName !== card.modelName) { 57 | // return false 58 | // } 59 | 60 | const fields : any = Object.entries(card.fields); 61 | // This is the case of a switch from a model to another one. It cannot be handeled 62 | if (fields.length !== Object.entries(this.fields).length) { 63 | return true; 64 | } 65 | 66 | for (const field of fields) { 67 | const fieldName = field[0]; 68 | if (field[1].value !== this.fields[fieldName]) { 69 | return false; 70 | } 71 | } 72 | 73 | return arraysEqual(card.tags, this.tags); 74 | } 75 | 76 | getCodeDeckNameExtension() { 77 | return this.containsCode ? codeDeckExtension : ""; 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/entities/clozecard.ts: -------------------------------------------------------------------------------- 1 | import { codeDeckExtension, sourceDeckExtension } from "src/conf/constants"; 2 | import { Card } from "src/entities/card"; 3 | 4 | export class Clozecard extends Card { 5 | constructor( 6 | id = -1, 7 | deckName: string, 8 | initialContent: string, 9 | fields: Record, 10 | reversed: boolean, 11 | initialOffset: number, 12 | endOffset: number, 13 | tags: string[] = [], 14 | inserted = false, 15 | mediaNames: string[], 16 | containsCode: boolean 17 | ) { 18 | super( 19 | id, 20 | deckName, 21 | initialContent, 22 | fields, 23 | reversed, 24 | initialOffset, 25 | endOffset, 26 | tags, 27 | inserted, 28 | mediaNames, 29 | containsCode 30 | ); 31 | this.modelName = `Obsidian-cloze`; 32 | if (fields["Source"]) { 33 | this.modelName += sourceDeckExtension; 34 | } 35 | if (containsCode) { 36 | this.modelName += codeDeckExtension; 37 | } 38 | } 39 | 40 | public getCard(update = false): object { 41 | const card: any = { 42 | deckName: this.deckName, 43 | modelName: this.modelName, 44 | fields: this.fields, 45 | tags: this.tags, 46 | }; 47 | 48 | if (update) { 49 | card["id"] = this.id; 50 | } 51 | 52 | return card; 53 | } 54 | 55 | public getMedias(): object[] { 56 | const medias: object[] = []; 57 | this.mediaBase64Encoded.forEach((data, index) => { 58 | medias.push({ 59 | filename: this.mediaNames[index], 60 | data: data, 61 | }); 62 | }); 63 | 64 | return medias; 65 | } 66 | 67 | public toString = (): string => { 68 | return `Cloze: ${this.fields[0]}`; 69 | }; 70 | 71 | public getIdFormat(): string { 72 | return "\n^" + this.id.toString(); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/entities/flashcard.ts: -------------------------------------------------------------------------------- 1 | import { codeDeckExtension, sourceDeckExtension } from "src/conf/constants"; 2 | import { Card } from "src/entities/card"; 3 | 4 | export class Flashcard extends Card { 5 | constructor( 6 | id = -1, 7 | deckName: string, 8 | initialContent: string, 9 | fields: Record, 10 | reversed: boolean, 11 | initialOffset: number, 12 | endOffset: number, 13 | tags: string[] = [], 14 | inserted = false, 15 | mediaNames: string[], 16 | containsCode: boolean 17 | ) { 18 | super( 19 | id, 20 | deckName, 21 | initialContent, 22 | fields, 23 | reversed, 24 | initialOffset, 25 | endOffset, 26 | tags, 27 | inserted, 28 | mediaNames, 29 | containsCode 30 | ); 31 | this.modelName = this.reversed 32 | ? `Obsidian-basic-reversed` 33 | : `Obsidian-basic`; 34 | if (fields["Source"]) { 35 | this.modelName += sourceDeckExtension; 36 | } 37 | if (containsCode) { 38 | this.modelName += codeDeckExtension; 39 | } 40 | } 41 | 42 | public getCard(update = false): object { 43 | const card: any = { 44 | deckName: this.deckName, 45 | modelName: this.modelName, 46 | fields: this.fields, 47 | tags: this.tags, 48 | }; 49 | 50 | if (update) { 51 | card["id"] = this.id; 52 | } 53 | 54 | return card; 55 | } 56 | 57 | public getMedias(): object[] { 58 | const medias: object[] = []; 59 | this.mediaBase64Encoded.forEach((data, index) => { 60 | medias.push({ 61 | filename: this.mediaNames[index], 62 | data: data, 63 | }); 64 | }); 65 | 66 | return medias; 67 | } 68 | 69 | public toString = (): string => { 70 | return `Q: ${this.fields[0]}\nA: ${this.fields[1]}`; 71 | }; 72 | 73 | public getIdFormat(): string { 74 | return "^" + this.id.toString() + "\n"; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/entities/inlinecard.ts: -------------------------------------------------------------------------------- 1 | import { codeDeckExtension, sourceDeckExtension } from "src/conf/constants"; 2 | import { Card } from "src/entities/card"; 3 | 4 | export class Inlinecard extends Card { 5 | constructor( 6 | id = -1, 7 | deckName: string, 8 | initialContent: string, 9 | fields: Record, 10 | reversed: boolean, 11 | initialOffset: number, 12 | endOffset: number, 13 | tags: string[] = [], 14 | inserted = false, 15 | mediaNames: string[], 16 | containsCode: boolean 17 | ) { 18 | super( 19 | id, 20 | deckName, 21 | initialContent, 22 | fields, 23 | reversed, 24 | initialOffset, 25 | endOffset, 26 | tags, 27 | inserted, 28 | mediaNames, 29 | containsCode 30 | ); // ! CHANGE [] 31 | 32 | this.modelName = this.reversed 33 | ? `Obsidian-basic-reversed` 34 | : `Obsidian-basic`; 35 | if (fields["Source"]) { 36 | this.modelName += sourceDeckExtension; 37 | } 38 | if (containsCode) { 39 | this.modelName += codeDeckExtension; 40 | } 41 | } 42 | 43 | public getCard(update = false): object { 44 | const card: any = { 45 | deckName: this.deckName, 46 | modelName: this.modelName, 47 | fields: this.fields, 48 | tags: this.tags, 49 | }; 50 | 51 | if (update) { 52 | card["id"] = this.id; 53 | } 54 | 55 | return card; 56 | } 57 | 58 | public getMedias(): object[] { 59 | const medias: object[] = []; 60 | this.mediaBase64Encoded.forEach((data, index) => { 61 | medias.push({ 62 | filename: this.mediaNames[index], 63 | data: data, 64 | }); 65 | }); 66 | 67 | return medias; 68 | } 69 | 70 | public toString = (): string => { 71 | return `Q: ${this.fields[0]} \nA: ${this.fields[1]} `; 72 | }; 73 | 74 | public getIdFormat(): string { 75 | return "^" + this.id.toString(); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/entities/spacedcard.ts: -------------------------------------------------------------------------------- 1 | import { codeDeckExtension, sourceDeckExtension } from "src/conf/constants"; 2 | import { Card } from "src/entities/card"; 3 | 4 | export class Spacedcard extends Card { 5 | constructor( 6 | id = -1, 7 | deckName: string, 8 | initialContent: string, 9 | fields: Record, 10 | reversed: boolean, 11 | initialOffset: number, 12 | endOffset: number, 13 | tags: string[] = [], 14 | inserted = false, 15 | mediaNames: string[], 16 | containsCode: boolean 17 | ) { 18 | super( 19 | id, 20 | deckName, 21 | initialContent, 22 | fields, 23 | reversed, 24 | initialOffset, 25 | endOffset, 26 | tags, 27 | inserted, 28 | mediaNames, 29 | containsCode 30 | ); 31 | this.modelName = `Obsidian-spaced`; 32 | if (fields["Source"]) { 33 | this.modelName += sourceDeckExtension; 34 | } 35 | if (containsCode) { 36 | this.modelName += codeDeckExtension; 37 | } 38 | } 39 | 40 | public getCard(update = false): object { 41 | const card: any = { 42 | deckName: this.deckName, 43 | modelName: this.modelName, 44 | fields: this.fields, 45 | tags: this.tags, 46 | }; 47 | 48 | if (update) { 49 | card["id"] = this.id; 50 | } 51 | 52 | return card; 53 | } 54 | 55 | public getMedias(): object[] { 56 | const medias: object[] = []; 57 | this.mediaBase64Encoded.forEach((data, index) => { 58 | medias.push({ 59 | filename: this.mediaNames[index], 60 | data: data, 61 | }); 62 | }); 63 | 64 | return medias; 65 | } 66 | 67 | public toString = (): string => { 68 | return `Prompt: ${this.fields[0]}`; 69 | }; 70 | 71 | public getIdFormat(): string { 72 | return "^" + this.id.toString() + "\n"; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/gui/settings-tab.ts: -------------------------------------------------------------------------------- 1 | import { Notice, PluginSettingTab, Setting } from "obsidian"; 2 | import { Anki } from "src/services/anki"; 3 | import { escapeRegExp } from "src/utils"; 4 | 5 | export class SettingsTab extends PluginSettingTab { 6 | display(): void { 7 | const { containerEl } = this; 8 | const plugin = (this as any).plugin; 9 | 10 | containerEl.empty(); 11 | containerEl.createEl("h1", { text: "Flashcards - Settings" }); 12 | 13 | const description = createFragment() 14 | description.append( 15 | "This needs to be done only one time. Open Anki and click the button to grant permission.", 16 | createEl('br'), 17 | 'Be aware that AnkiConnect must be installed.', 18 | ) 19 | 20 | new Setting(containerEl) 21 | .setName("Give Permission") 22 | .setDesc(description) 23 | .addButton((button) => { 24 | button.setButtonText("Grant Permission").onClick(() => { 25 | 26 | new Anki().requestPermission().then((result) => { 27 | if (result.permission === "granted") { 28 | plugin.settings.ankiConnectPermission = true; 29 | plugin.saveData(plugin.settings); 30 | new Notice("Anki Connect permission granted"); 31 | } else { 32 | new Notice("AnkiConnect permission not granted"); 33 | } 34 | }).catch((error) => { 35 | new Notice("Something went wrong, is Anki open?"); 36 | console.error(error); 37 | }); 38 | }); 39 | }); 40 | 41 | 42 | new Setting(containerEl) 43 | .setName("Test Anki") 44 | .setDesc("Test that connection between Anki and Obsidian actually works.") 45 | .addButton((text) => { 46 | text.setButtonText("Test").onClick(() => { 47 | new Anki() 48 | .ping() 49 | .then(() => new Notice("Anki works")) 50 | .catch(() => new Notice("Anki is not connected")); 51 | }); 52 | }); 53 | 54 | containerEl.createEl("h2", { text: "General" }); 55 | 56 | new Setting(containerEl) 57 | .setName("Context-aware mode") 58 | .setDesc("Add the ancestor headings to the question of the flashcard.") 59 | .addToggle((toggle) => 60 | toggle.setValue(plugin.settings.contextAwareMode).onChange((value) => { 61 | plugin.settings.contextAwareMode = value; 62 | plugin.saveData(plugin.settings); 63 | }) 64 | ); 65 | 66 | new Setting(containerEl) 67 | .setName("Source support") 68 | .setDesc( 69 | "Add to every card the source, i.e. the link to the original card. NOTE: Old cards made without source support cannot be updated." 70 | ) 71 | .addToggle((toggle) => 72 | toggle.setValue(plugin.settings.sourceSupport).onChange((value) => { 73 | plugin.settings.sourceSupport = value; 74 | plugin.saveData(plugin.settings); 75 | }) 76 | ); 77 | 78 | new Setting(containerEl) 79 | .setName("Code highlight support") 80 | .setDesc("Add highlight of the code in Anki.") 81 | .addToggle((toggle) => 82 | toggle 83 | .setValue(plugin.settings.codeHighlightSupport) 84 | .onChange((value) => { 85 | plugin.settings.codeHighlightSupport = value; 86 | plugin.saveData(plugin.settings); 87 | }) 88 | ); 89 | new Setting(containerEl) 90 | .setName("Inline ID support") 91 | .setDesc("Add ID to end of line for inline cards.") 92 | .addToggle((toggle) => 93 | toggle.setValue(plugin.settings.inlineID).onChange((value) => { 94 | plugin.settings.inlineID = value; 95 | plugin.saveData(plugin.settings); 96 | }) 97 | ); 98 | 99 | new Setting(containerEl) 100 | .setName("Folder-based deck name") 101 | .setDesc("Add ID to end of line for inline cards.") 102 | .addToggle((toggle) => 103 | toggle.setValue(plugin.settings.folderBasedDeck).onChange((value) => { 104 | plugin.settings.folderBasedDeck = value; 105 | plugin.saveData(plugin.settings); 106 | }) 107 | ); 108 | 109 | 110 | new Setting(containerEl) 111 | .setName("Default deck name") 112 | .setDesc( 113 | "The name of the default deck where the cards will be added when not specified." 114 | ) 115 | .addText((text) => { 116 | text 117 | .setValue(plugin.settings.deck) 118 | .setPlaceholder("Deck::sub-deck") 119 | .onChange((value) => { 120 | if (value.length) { 121 | plugin.settings.deck = value; 122 | plugin.saveData(plugin.settings); 123 | } else { 124 | new Notice("The deck name must be at least 1 character long"); 125 | } 126 | }); 127 | }); 128 | 129 | new Setting(containerEl) 130 | .setName("Default Anki tag") 131 | .setDesc("This tag will be added to each generated card on Anki") 132 | .addText((text) => { 133 | text 134 | .setValue(plugin.settings.defaultAnkiTag) 135 | .setPlaceholder("Anki tag") 136 | .onChange((value) => { 137 | if (!value) new Notice("No default tags will be added"); 138 | plugin.settings.defaultAnkiTag = value.toLowerCase(); 139 | plugin.saveData(plugin.settings); 140 | }); 141 | }); 142 | 143 | containerEl.createEl("h2", { text: "Cards Identification" }); 144 | 145 | new Setting(containerEl) 146 | .setName("Flashcards #tag") 147 | .setDesc( 148 | "The tag to identify the flashcards in the notes (case-insensitive)." 149 | ) 150 | .addText((text) => { 151 | text 152 | .setValue(plugin.settings.flashcardsTag) 153 | .setPlaceholder("Card") 154 | .onChange((value) => { 155 | if (value) { 156 | plugin.settings.flashcardsTag = value.toLowerCase(); 157 | plugin.saveData(plugin.settings); 158 | } else { 159 | new Notice("The tag must be at least 1 character long"); 160 | } 161 | }); 162 | }); 163 | 164 | new Setting(containerEl) 165 | .setName("Inline card separator") 166 | .setDesc( 167 | "The separator to identifty the inline cards in the notes." 168 | ) 169 | .addText((text) => { 170 | text 171 | .setValue(plugin.settings.inlineSeparator) 172 | .setPlaceholder("::") 173 | .onChange((value) => { 174 | // if the value is empty or is the same like the inlineseparatorreverse then set it to the default, otherwise save it 175 | if (value.trim().length === 0 || value === plugin.settings.inlineSeparatorReverse) { 176 | plugin.settings.inlineSeparator = "::"; 177 | if (value.trim().length === 0) { 178 | new Notice("The separator must be at least 1 character long"); 179 | } else if (value === plugin.settings.inlineSeparatorReverse) { 180 | new Notice("The separator must be different from the inline reverse separator"); 181 | } 182 | } else { 183 | plugin.settings.inlineSeparator = escapeRegExp(value.trim()); 184 | new Notice("The separator has been changed"); 185 | } 186 | plugin.saveData(plugin.settings); 187 | }); 188 | }); 189 | 190 | 191 | new Setting(containerEl) 192 | .setName("Inline reverse card separator") 193 | .setDesc( 194 | "The separator to identifty the inline revese cards in the notes." 195 | ) 196 | .addText((text) => { 197 | text 198 | .setValue(plugin.settings.inlineSeparatorReverse) 199 | .setPlaceholder(":::") 200 | .onChange((value) => { 201 | // if the value is empty or is the same like the inlineseparatorreverse then set it to the default, otherwise save it 202 | if (value.trim().length === 0 || value === plugin.settings.inlineSeparator) { 203 | plugin.settings.inlineSeparatorReverse = ":::"; 204 | if (value.trim().length === 0) { 205 | new Notice("The separator must be at least 1 character long"); 206 | } else if (value === plugin.settings.inlineSeparator) { 207 | new Notice("The separator must be different from the inline separator"); 208 | } 209 | } else { 210 | plugin.settings.inlineSeparatorReverse = escapeRegExp(value.trim()); 211 | new Notice("The separator has been changed"); 212 | } 213 | plugin.saveData(plugin.settings); 214 | }); 215 | }); 216 | 217 | 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/services/anki.ts: -------------------------------------------------------------------------------- 1 | import { Card } from "src/entities/card"; 2 | import { 3 | sourceField, 4 | codeScript, 5 | highlightjsBase64, 6 | hihglightjsInitBase64, 7 | highlightCssBase64, 8 | codeDeckExtension, 9 | sourceDeckExtension, 10 | } from "src/conf/constants"; 11 | 12 | export class Anki { 13 | public async createModels( 14 | sourceSupport: boolean, 15 | codeHighlightSupport: boolean 16 | ) { 17 | let models = this.getModels(sourceSupport, false); 18 | if (codeHighlightSupport) { 19 | models = models.concat(this.getModels(sourceSupport, true)); 20 | } 21 | 22 | return this.invoke("multi", 6, { actions: models }); 23 | } 24 | 25 | public async createDeck(deckName: string): Promise { 26 | return this.invoke("createDeck", 6, { deck: deckName }); 27 | } 28 | 29 | public async storeMediaFiles(cards: Card[]) { 30 | const actions: any[] = []; 31 | 32 | for (const card of cards) { 33 | for (const media of card.getMedias()) { 34 | actions.push({ 35 | action: "storeMediaFile", 36 | params: media, 37 | }); 38 | } 39 | } 40 | 41 | if (actions) { 42 | return this.invoke("multi", 6, { actions: actions }); 43 | } else { 44 | return {}; 45 | } 46 | } 47 | 48 | public async storeCodeHighlightMedias() { 49 | const fileExists = await this.invoke("retrieveMediaFile", 6, { 50 | filename: "_highlightInit.js", 51 | }); 52 | 53 | if (!fileExists) { 54 | const highlightjs = { 55 | action: "storeMediaFile", 56 | params: { 57 | filename: "_highlight.js", 58 | data: highlightjsBase64, 59 | }, 60 | }; 61 | const highlightjsInit = { 62 | action: "storeMediaFile", 63 | params: { 64 | filename: "_highlightInit.js", 65 | data: hihglightjsInitBase64, 66 | }, 67 | }; 68 | const highlightjcss = { 69 | action: "storeMediaFile", 70 | params: { 71 | filename: "_highlight.css", 72 | data: highlightCssBase64, 73 | }, 74 | }; 75 | return this.invoke("multi", 6, { 76 | actions: [highlightjs, highlightjsInit, highlightjcss], 77 | }); 78 | } 79 | } 80 | 81 | public async addCards(cards: Card[]): Promise { 82 | const notes: any = []; 83 | 84 | cards.forEach((card) => notes.push(card.getCard(false))); 85 | 86 | return this.invoke("addNotes", 6, { 87 | notes: notes, 88 | }); 89 | } 90 | 91 | /** 92 | * Given the new cards with an optional deck name, it updates all the cards on Anki. 93 | * 94 | * Be aware of https://github.com/FooSoft/anki-connect/issues/82. If the Browse pane is opened on Anki, 95 | * the update does not change all the cards. 96 | * @param cards the new cards. 97 | * @param deckName the new deck name. 98 | */ 99 | public async updateCards(cards: Card[]): Promise { 100 | let updateActions: any[] = []; 101 | 102 | // Unfortunately https://github.com/FooSoft/anki-connect/issues/183 103 | // This means that the delta from the current tags on Anki and the generated one should be added/removed 104 | // That's what the current approach does, but in the future if the API it is made more consistent 105 | // then mergeTags(...) is not needed anymore 106 | const ids: number[] = []; 107 | 108 | for (const card of cards) { 109 | updateActions.push({ 110 | action: "updateNoteFields", 111 | params: { 112 | note: card.getCard(true), 113 | }, 114 | }); 115 | 116 | updateActions = updateActions.concat( 117 | this.mergeTags(card.oldTags, card.tags, card.id) 118 | ); 119 | ids.push(card.id); 120 | } 121 | 122 | // Update deck 123 | updateActions.push({ 124 | action: "changeDeck", 125 | params: { 126 | cards: ids, 127 | deck: cards[0].deckName, 128 | }, 129 | }); 130 | 131 | return this.invoke("multi", 6, { actions: updateActions }); 132 | } 133 | 134 | public async changeDeck(ids: number[], deckName: string) { 135 | return await this.invoke("changeDeck", 6, { cards: ids, deck: deckName }); 136 | } 137 | 138 | public async cardsInfo(ids: number[]) { 139 | return await this.invoke("cardsInfo", 6, { cards: ids }); 140 | } 141 | 142 | public async getCards(ids: number[]) { 143 | return await this.invoke("notesInfo", 6, { notes: ids }); 144 | } 145 | 146 | public async deleteCards(ids: number[]) { 147 | return this.invoke("deleteNotes", 6, { notes: ids }); 148 | } 149 | 150 | public async ping(): Promise { 151 | return (await this.invoke("version", 6)) === 6; 152 | } 153 | 154 | private mergeTags(oldTags: string[], newTags: string[], cardId: number) { 155 | const actions = []; 156 | 157 | // Find tags to Add 158 | for (const tag of newTags) { 159 | const index = oldTags.indexOf(tag); 160 | if (index > -1) { 161 | oldTags.splice(index, 1); 162 | } else { 163 | actions.push({ 164 | action: "addTags", 165 | params: { 166 | notes: [cardId], 167 | tags: tag, 168 | }, 169 | }); 170 | } 171 | } 172 | 173 | // All Tags to delete 174 | for (const tag of oldTags) { 175 | actions.push({ 176 | action: "removeTags", 177 | params: { 178 | notes: [cardId], 179 | tags: tag, 180 | }, 181 | }); 182 | } 183 | 184 | return actions; 185 | } 186 | 187 | private invoke(action: string, version = 6, params = {}): any { 188 | return new Promise((resolve, reject) => { 189 | const xhr = new XMLHttpRequest(); 190 | xhr.addEventListener("error", () => reject("failed to issue request")); 191 | xhr.addEventListener("load", () => { 192 | try { 193 | const response = JSON.parse(xhr.responseText); 194 | if (Object.getOwnPropertyNames(response).length != 2) { 195 | throw "response has an unexpected number of fields"; 196 | } 197 | if (!Object.prototype.hasOwnProperty.call(response, "error")) { 198 | throw "response is missing required error field"; 199 | } 200 | if (!Object.prototype.hasOwnProperty.call(response, "result")) { 201 | throw "response is missing required result field"; 202 | } 203 | if (response.error) { 204 | throw response.error; 205 | } 206 | resolve(response.result); 207 | } catch (e) { 208 | reject(e); 209 | } 210 | }); 211 | 212 | xhr.open("POST", "http://127.0.0.1:8765"); 213 | xhr.send(JSON.stringify({ action, version, params })); 214 | }); 215 | } 216 | 217 | private getModels( 218 | sourceSupport: boolean, 219 | codeHighlightSupport: boolean 220 | ): object[] { 221 | let sourceFieldContent = ""; 222 | let codeScriptContent = ""; 223 | let sourceExtension = ""; 224 | let codeExtension = ""; 225 | if (sourceSupport) { 226 | sourceFieldContent = "\r\n" + sourceField; 227 | sourceExtension = sourceDeckExtension; 228 | } 229 | 230 | if (codeHighlightSupport) { 231 | codeScriptContent = "\r\n" + codeScript + "\r\n"; 232 | codeExtension = codeDeckExtension; 233 | } 234 | 235 | const css = 236 | '.card {\r\n font-family: arial;\r\n font-size: 20px;\r\n text-align: center;\r\n color: black;\r\n background-color: white;\r\n}\r\n\r\n.tag::before {\r\n\tcontent: "#";\r\n}\r\n\r\n.tag {\r\n color: white;\r\n background-color: #9F2BFF;\r\n border: none;\r\n font-size: 11px;\r\n font-weight: bold;\r\n padding: 1px 8px;\r\n margin: 0px 3px;\r\n text-align: center;\r\n text-decoration: none;\r\n cursor: pointer;\r\n border-radius: 14px;\r\n display: inline;\r\n vertical-align: middle;\r\n}\r\n .cloze { font-weight: bold; color: blue;}.nightMode .cloze { color: lightblue;}'; 237 | const front = `{{Front}}\r\n

{{Tags}}<\/p>\r\n\r\n