mirror of
https://github.com/gosticks/flashcards-obsidian.git
synced 2025-10-16 12:05:33 +00:00
Add implementation for add, update and delete on Anki
This commit is contained in:
parent
ccd700833b
commit
9a0d3590d0
16
.gitignore
vendored
16
.gitignore
vendored
@ -8,4 +8,18 @@ package-lock.json
|
|||||||
|
|
||||||
# build
|
# build
|
||||||
main.js
|
main.js
|
||||||
*.js.map
|
*.js.map
|
||||||
|
|
||||||
|
# scripts
|
||||||
|
move.sh
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|||||||
56
README.md
56
README.md
@ -1,45 +1,21 @@
|
|||||||
## Obsidian Sample Plugin
|
# Cards
|
||||||
|
|
||||||
This is a sample plugin for Obsidian (https://obsidian.md).
|
An Anki plugin for Obsidian.
|
||||||
|
|
||||||
This project uses Typescript to provide type checking and documentation.
|
## How to install
|
||||||
The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
|
|
||||||
|
|
||||||
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
## How to start
|
||||||
|
|
||||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
## Anki-connect configuration
|
||||||
- Changes the default font color to red using `styles.css`.
|
|
||||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
|
||||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
|
||||||
- Adds a plugin setting tab to the settings page.
|
|
||||||
- Registers a global click event and output 'click' to the console.
|
|
||||||
- Registers a global interval which logs 'setInterval' to the console.
|
|
||||||
|
|
||||||
|
{
|
||||||
### Releasing new releases
|
"apiKey": null,
|
||||||
|
"apiLogPath": null,
|
||||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
"webBindAddress": "127.0.0.1",
|
||||||
- Update your `versions.json` file with `"new-plugin-version": "minimum-obsidian-version"` so older versions of Obsidian can download an older version of your plugin that's compatible.
|
"webBindPort": 8765,
|
||||||
- Create new GitHub release using your new version number as the "Tag version". Use the exact version number, don't include a prefix `v`. See here for an example: https://github.com/obsidianmd/obsidian-sample-plugin/releases
|
"webCorsOrigin": "http://localhost",
|
||||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments.
|
"webCorsOriginList": [
|
||||||
- Publish the release.
|
"http://localhost",
|
||||||
|
"app://obsidian.md"
|
||||||
### Adding your plugin to the community plugin list
|
]
|
||||||
|
}
|
||||||
- Publish an initial version.
|
|
||||||
- Make sure you have a `README.md` file in the root of your repo.
|
|
||||||
- Make a pull request at https://github.com/obsidianmd/obsidian-releases to add your plugin.
|
|
||||||
|
|
||||||
### How to use
|
|
||||||
|
|
||||||
- Clone this repo.
|
|
||||||
- `npm i` or `yarn` to install dependencies
|
|
||||||
- `npm run dev` to start compilation in watch mode.
|
|
||||||
|
|
||||||
### Manually installing the plugin
|
|
||||||
|
|
||||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
|
||||||
|
|
||||||
### API Documentation
|
|
||||||
|
|
||||||
See https://github.com/obsidianmd/obsidian-api
|
|
||||||
|
|||||||
106
main.ts
106
main.ts
@ -1,8 +1,36 @@
|
|||||||
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
|
import { App, Modal, Notice, Plugin, PluginSettingTab, Setting, CachedMetadata, MetadataCache, parseFrontMatterTags, parseFrontMatterStringArray, SettingTab, parseFrontMatterEntry, TFile } from 'obsidian';
|
||||||
|
import { Flashcard } from "src/entities/flashcard"
|
||||||
|
import { Parser } from 'src/parser';
|
||||||
|
import { Settings } from 'settings';
|
||||||
|
import { SettingsTab } from 'src/gui/settings-tab';
|
||||||
|
import { Anki } from 'src/anki';
|
||||||
|
import { CardsService } from 'src/cards-service';
|
||||||
|
|
||||||
|
|
||||||
|
export default class ObsidianFlashcard extends Plugin {
|
||||||
|
private settings: Settings
|
||||||
|
private cardsService: CardsService
|
||||||
|
|
||||||
|
// EXTRA
|
||||||
|
// this gives you back the app:// of a resource
|
||||||
|
// this.app.vault.adapter.getResourcePath("name")
|
||||||
|
|
||||||
|
// IMAGES inside the file
|
||||||
|
// let temp = this.app.metadataCache.getFileCache(this.app.workspace.getActiveFile()).embeds[2].link
|
||||||
|
//
|
||||||
|
// this.app.vault.getAbstractFileByPath("resources/"+temp)
|
||||||
|
|
||||||
|
// Path
|
||||||
|
// this.app.vault.adapter.getBasePath()
|
||||||
|
// this.app.vault.adapter.getFullPath(attachmentFolder + attachment)
|
||||||
|
// this.app.vault.config.attachmentFolderPath in my case that ś reso
|
||||||
|
|
||||||
|
|
||||||
export default class MyPlugin extends Plugin {
|
|
||||||
onload() {
|
onload() {
|
||||||
console.log('loading plugin');
|
// TODO test when file did not insert flashcards, but one of them is in Anki already
|
||||||
|
console.log('loading flashcard-plugin');
|
||||||
|
this.settings = new Settings()
|
||||||
|
this.cardsService = new CardsService(this.app, this.settings)
|
||||||
|
|
||||||
this.addRibbonIcon('dice', 'Sample Plugin', () => {
|
this.addRibbonIcon('dice', 'Sample Plugin', () => {
|
||||||
new Notice('This is a notice!');
|
new Notice('This is a notice!');
|
||||||
@ -11,16 +39,17 @@ export default class MyPlugin extends Plugin {
|
|||||||
this.addStatusBarItem().setText('Status Bar Text');
|
this.addStatusBarItem().setText('Status Bar Text');
|
||||||
|
|
||||||
this.addCommand({
|
this.addCommand({
|
||||||
id: 'open-sample-modal',
|
id: 'generate-flashcard-this-file',
|
||||||
name: 'Open Sample Modal',
|
name: 'Generate for this file',
|
||||||
// callback: () => {
|
|
||||||
// console.log('Simple Callback');
|
|
||||||
// },
|
|
||||||
checkCallback: (checking: boolean) => {
|
checkCallback: (checking: boolean) => {
|
||||||
let leaf = this.app.workspace.activeLeaf;
|
let activeFile = this.app.workspace.getActiveFile()
|
||||||
if (leaf) {
|
if (activeFile) {
|
||||||
if (!checking) {
|
if (!checking) {
|
||||||
new SampleModal(this.app).open();
|
this.cardsService.execute(activeFile).then(res => {
|
||||||
|
new Notice(res.join(" "))
|
||||||
|
}).catch(err => {
|
||||||
|
Error(err)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -28,56 +57,51 @@ export default class MyPlugin extends Plugin {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.addSettingTab(new SampleSettingTab(this.app, this));
|
this.addCommand({
|
||||||
|
id: "anki-test", name: "Anki", callback: () => {
|
||||||
|
let anki: Anki = new Anki()
|
||||||
|
anki.getDeck().then(res => {
|
||||||
|
console.log(res)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.addSettingTab(new SettingsTab(this.app, this));
|
||||||
|
|
||||||
this.registerEvent(this.app.on('codemirror', (cm: CodeMirror.Editor) => {
|
this.registerEvent(this.app.on('codemirror', (cm: CodeMirror.Editor) => {
|
||||||
console.log('codemirror', cm);
|
//console.log('codemirror', cm);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||||
console.log('click', evt);
|
//console.log('click', evt);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||||
}
|
}
|
||||||
|
|
||||||
onunload() {
|
async onunload() {
|
||||||
console.log('unloading plugin');
|
console.log('Unloading flashcard-obsidian and saving data.');
|
||||||
|
await this.saveData(this.settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SampleModal extends Modal {
|
class SampleModal extends Modal {
|
||||||
constructor(app: App) {
|
flashcards: Flashcard[];
|
||||||
|
|
||||||
|
constructor(app: App, flashcards: Flashcard[]) {
|
||||||
super(app);
|
super(app);
|
||||||
|
this.flashcards = flashcards;
|
||||||
}
|
}
|
||||||
|
|
||||||
onOpen() {
|
onOpen() {
|
||||||
let {contentEl} = this;
|
let { titleEl } = this;
|
||||||
contentEl.setText('Woah!');
|
let { contentEl } = this;
|
||||||
|
|
||||||
|
titleEl.setText('Generated flashcards!');
|
||||||
|
contentEl.setText(this.flashcards.join("\n\n"));
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose() {
|
onClose() {
|
||||||
let {contentEl} = this;
|
let { contentEl } = this;
|
||||||
contentEl.empty();
|
contentEl.empty();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SampleSettingTab extends PluginSettingTab {
|
|
||||||
display(): void {
|
|
||||||
let {containerEl} = this;
|
|
||||||
|
|
||||||
containerEl.empty();
|
|
||||||
|
|
||||||
containerEl.createEl('h2', {text: 'Settings for my awesome plugin.'});
|
|
||||||
|
|
||||||
new Setting(containerEl)
|
|
||||||
.setName('Setting #1')
|
|
||||||
.setDesc('It\'s a secret')
|
|
||||||
.addText(text => text.setPlaceholder('Enter your secret')
|
|
||||||
.setValue('')
|
|
||||||
.onChange((value) => {
|
|
||||||
console.log('Secret: ' + value);
|
|
||||||
}));
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
{
|
{
|
||||||
"id": "obsidian-sample-plugin",
|
"id": "flashcards-obsidian",
|
||||||
"name": "Sample Plugin",
|
"name": "Flashcards",
|
||||||
"version": "1.0.1",
|
"version": "0.1.0",
|
||||||
"minAppVersion": "0.9.12",
|
"minAppVersion": "0.9.17",
|
||||||
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
|
"description": "An Anki integration.",
|
||||||
"author": "Obsidian",
|
"author": "reuseman",
|
||||||
"authorUrl": "https://obsidian.md/about",
|
"authorUrl": "https://github.com/reuseman",
|
||||||
"isDesktopOnly": false
|
"isDesktopOnly": true
|
||||||
}
|
}
|
||||||
@ -17,7 +17,12 @@
|
|||||||
"@types/node": "^14.14.2",
|
"@types/node": "^14.14.2",
|
||||||
"obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master",
|
"obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master",
|
||||||
"rollup": "^2.32.1",
|
"rollup": "^2.32.1",
|
||||||
|
"ts-node": "^9.0.0",
|
||||||
"tslib": "^2.0.3",
|
"tslib": "^2.0.3",
|
||||||
"typescript": "^4.0.3"
|
"typescript": "^4.0.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@types/showdown": "^1.9.3",
|
||||||
|
"showdown": "^1.9.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
9
settings.ts
Normal file
9
settings.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export class Settings {
|
||||||
|
contextAwareMode: boolean = true
|
||||||
|
contextSeparator: string = ' > '
|
||||||
|
|
||||||
|
defaultDeck: string = "Default"
|
||||||
|
|
||||||
|
ankiProfile: string = "?"
|
||||||
|
ankiPath: string = "?" // TODO can i boot automagically anki?
|
||||||
|
}
|
||||||
89
src/anki.ts
Normal file
89
src/anki.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
import { Card } from 'src/entities/card';
|
||||||
|
|
||||||
|
export class Anki {
|
||||||
|
public async addCards(cards: Card[]): Promise<number[]> {
|
||||||
|
let notes: any = []
|
||||||
|
|
||||||
|
cards.forEach(card => notes.push(card.getCard(false)))
|
||||||
|
|
||||||
|
return this.invoke("addNotes", 6, {
|
||||||
|
"notes": notes
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given the new cards with an optional deck name, it updates all the cards on Anki.
|
||||||
|
*
|
||||||
|
* Be aware of https://github.com/FooSoft/anki-connect/issues/82. If the Browse pane is opened on Anki,
|
||||||
|
* the update does not change all the cards.
|
||||||
|
* @param cards the new cards.
|
||||||
|
* @param deckName the new deck name.
|
||||||
|
*/
|
||||||
|
public async updateCards(cards: Card[]): Promise<any> {
|
||||||
|
let updateActions: any[] = []
|
||||||
|
|
||||||
|
// TODO add possibility to edit even tags
|
||||||
|
// Unfortunately https://github.com/FooSoft/anki-connect/issues/183
|
||||||
|
// This means that the delta from the current tags on Anki and the generated one should be added/removed
|
||||||
|
|
||||||
|
for (let card of cards) {
|
||||||
|
updateActions.push({
|
||||||
|
"action": "updateNoteFields",
|
||||||
|
"params": {
|
||||||
|
"note": card.getCard(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.invoke("multi", 6, { "actions": updateActions })
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCards(ids: number[]) {
|
||||||
|
return await this.invoke("notesInfo", 6, { "notes": ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCards(ids: number[]) {
|
||||||
|
return this.invoke("deleteNotes", 6, { "notes": ids })
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDeck() {
|
||||||
|
await this.invoke('createDeck', 6, { deck: 'test1' });
|
||||||
|
const result = await this.invoke('deckNames', 6);
|
||||||
|
console.log(`got list of decks: ${result}`);
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ping(): Promise<boolean> {
|
||||||
|
return await this.invoke('version', 6) === 6
|
||||||
|
}
|
||||||
|
|
||||||
|
private invoke(action: string, version: number, params = {}): any {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.addEventListener('error', () => reject('failed to issue request'));
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (Object.getOwnPropertyNames(response).length != 2) {
|
||||||
|
throw 'response has an unexpected number of fields';
|
||||||
|
}
|
||||||
|
if (!response.hasOwnProperty('error')) {
|
||||||
|
throw 'response is missing required error field';
|
||||||
|
}
|
||||||
|
if (!response.hasOwnProperty('result')) {
|
||||||
|
throw 'response is missing required result field';
|
||||||
|
}
|
||||||
|
if (response.error) {
|
||||||
|
throw response.error;
|
||||||
|
}
|
||||||
|
resolve(response.result);
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', 'http://127.0.0.1:8765');
|
||||||
|
xhr.send(JSON.stringify({ action, version, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
240
src/cards-service.ts
Normal file
240
src/cards-service.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { Anki } from 'src/anki'
|
||||||
|
import { App, FrontMatterCache, Notice, parseFrontMatterEntry, parseFrontMatterTags, TFile } from 'obsidian'
|
||||||
|
import { Parser } from 'src/parser'
|
||||||
|
import { Settings } from 'settings'
|
||||||
|
import { Card } from 'src/entities/card'
|
||||||
|
import { Flashcard } from 'src/entities/flashcard'
|
||||||
|
|
||||||
|
export class CardsService {
|
||||||
|
// TODO right now you do not check for cards that when inserted/updated gives back null as ID
|
||||||
|
// TODO check the deletion for the reversed notes that have 2 cards bind
|
||||||
|
private app: App
|
||||||
|
private parser: Parser
|
||||||
|
private anki: Anki
|
||||||
|
private settings: Settings
|
||||||
|
|
||||||
|
private updateFile: boolean
|
||||||
|
private totalOffset: number
|
||||||
|
private file: string
|
||||||
|
private notifications: string[]
|
||||||
|
|
||||||
|
constructor(app: App, settings: Settings) {
|
||||||
|
this.app = app
|
||||||
|
this.anki = new Anki()
|
||||||
|
this.parser = new Parser(settings)
|
||||||
|
this.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
public async execute(activeFile: TFile): Promise<string[]> {
|
||||||
|
// TODO add note-type to Anki
|
||||||
|
try {
|
||||||
|
await this.anki.ping()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
return ["Error: Anki must be open with AnkiConnect installed."]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init for the execute phase
|
||||||
|
this.updateFile = false
|
||||||
|
this.totalOffset = 0
|
||||||
|
this.notifications = []
|
||||||
|
let fileCachedMetadata = this.app.metadataCache.getFileCache(activeFile)
|
||||||
|
let globalTags: string[] = undefined
|
||||||
|
|
||||||
|
// Parse frontmatter
|
||||||
|
let frontmatter = fileCachedMetadata.frontmatter
|
||||||
|
let deckName = this.settings.defaultDeck
|
||||||
|
if (frontmatter) {
|
||||||
|
deckName = parseFrontMatterEntry(frontmatter, "cards-deck")
|
||||||
|
globalTags = parseFrontMatterTags(frontmatter).map(tag => tag.substr(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.file = await this.app.vault.read(activeFile)
|
||||||
|
// TODO with empty check that does not call ankiCards line
|
||||||
|
let ankiBlocks = this.parser.getAnkiIDsBlocks(this.file)
|
||||||
|
let ankiCards = ankiBlocks ? await this.anki.getCards(this.getAnkiIDs(ankiBlocks)) : undefined
|
||||||
|
|
||||||
|
let cards: Flashcard[] = this.parser.generateFlashcards(this.file, globalTags, deckName)
|
||||||
|
let [cardsToCreate, cardsToUpdate] = this.filterByUpdate(ankiCards, cards)
|
||||||
|
let cardsToDelete: number[] = this.parser.getCardsToDelete(this.file)
|
||||||
|
|
||||||
|
this.printCards(cardsToCreate, cardsToUpdate, cardsToDelete) // TODO delete
|
||||||
|
await this.deleteCardsOnAnki(cardsToDelete, ankiBlocks)
|
||||||
|
await this.updateCardsOnAnki(cardsToUpdate)
|
||||||
|
await this.insertCardsOnAnki(cardsToCreate, frontmatter, deckName)
|
||||||
|
|
||||||
|
// Update file
|
||||||
|
if (this.updateFile) {
|
||||||
|
try {
|
||||||
|
this.app.vault.modify(activeFile, this.file)
|
||||||
|
} catch (err) {
|
||||||
|
Error("Could not update the file.")
|
||||||
|
return ["Error: Could not update the file."]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.notifications.length) {
|
||||||
|
this.notifications.push("Nothing to do. Everything is up to date")
|
||||||
|
}
|
||||||
|
return this.notifications
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private async insertCardsOnAnki(cardsToCreate: Card[], frontmatter: FrontMatterCache, deckName: string): Promise<number> {
|
||||||
|
if (cardsToCreate.length) {
|
||||||
|
let insertedCards = 0
|
||||||
|
// TODO before adding create deck if not exists
|
||||||
|
// TODO check if cardsToCreate is not empty?
|
||||||
|
try {
|
||||||
|
let ids = await this.anki.addCards(cardsToCreate)
|
||||||
|
// Add IDs from response to Flashcard[]
|
||||||
|
ids.map((id: number, index: number) => {
|
||||||
|
cardsToCreate[index].id = id
|
||||||
|
})
|
||||||
|
|
||||||
|
cardsToCreate.forEach(card => {
|
||||||
|
if (card.id === null) {
|
||||||
|
new Notice(`Error, could not add: '${card.initialContent}'`)
|
||||||
|
} else {
|
||||||
|
insertedCards++
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateFrontmatter(frontmatter, deckName)
|
||||||
|
this.writeAnkiBlocks(cardsToCreate)
|
||||||
|
|
||||||
|
this.notifications.push(`Inserted successfully ${insertedCards}/${cardsToCreate.length} cards.`)
|
||||||
|
return insertedCards
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
Error("Error: Could not write cards on Anki")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateFrontmatter(frontmatter: FrontMatterCache, deckName: string) {
|
||||||
|
// TODO evaluate https://regex101.com/r/bJySNf/1
|
||||||
|
let newFrontmatter: string = ""
|
||||||
|
let cardsDeckLine: string = `cards-deck: Default\n`
|
||||||
|
if (frontmatter) {
|
||||||
|
let oldFrontmatter: string = this.file.substring(frontmatter.position.start.offset, frontmatter.position.end.offset)
|
||||||
|
if (!deckName) {
|
||||||
|
newFrontmatter = oldFrontmatter.substring(0, oldFrontmatter.length - 3) + cardsDeckLine + "---"
|
||||||
|
this.totalOffset += cardsDeckLine.length
|
||||||
|
this.file = newFrontmatter + this.file.substring(frontmatter.position.end.offset, this.file.length + 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newFrontmatter = `---\n${cardsDeckLine}---\n`
|
||||||
|
this.totalOffset += newFrontmatter.length
|
||||||
|
this.file = newFrontmatter + this.file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private writeAnkiBlocks(cardsToCreate: Card[]) {
|
||||||
|
for (let card of cardsToCreate) {
|
||||||
|
// Card.id cannot be null, because if written already previously it has an ID,
|
||||||
|
// if it has been inserted it has an ID too
|
||||||
|
if (card.id !== null && !card.inserted) {
|
||||||
|
let id = "^" + card.id.toString() + "\n"
|
||||||
|
card.endOffset += this.totalOffset
|
||||||
|
let offset = card.endOffset
|
||||||
|
|
||||||
|
this.updateFile = true
|
||||||
|
this.file = this.file.substring(0, offset) + id + this.file.substring(offset, this.file.length + 1)
|
||||||
|
this.totalOffset += id.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public printCards(flashcardsToCreate: Card[], flashcardsToUpdate: Card[], flashcardsToDelete: number[]) {
|
||||||
|
console.info("Cards to create")
|
||||||
|
console.info(flashcardsToCreate)
|
||||||
|
console.info("Cards to update")
|
||||||
|
console.info(flashcardsToUpdate)
|
||||||
|
console.info("IDs of cards to delete")
|
||||||
|
console.info(flashcardsToDelete)
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateCardsOnAnki(cards: Card[]): Promise<number> {
|
||||||
|
if (cards.length) {
|
||||||
|
this.anki.updateCards(cards).then(res => {
|
||||||
|
this.notifications.push(`Updated successfully ${cards.length}/${cards.length} cards.`)
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
Error("Error: Could not update cards on Anki")
|
||||||
|
})
|
||||||
|
|
||||||
|
return cards.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deleteCardsOnAnki(cards: number[], ankiBlocks: RegExpMatchArray[]): Promise<number> {
|
||||||
|
if (cards.length) {
|
||||||
|
let deletedCards = 0
|
||||||
|
for (const block of ankiBlocks) {
|
||||||
|
let id = Number(block[1])
|
||||||
|
|
||||||
|
// Deletion of cards that need to be deleted (i.e. blocks ID that don't have content)
|
||||||
|
if (cards.includes(id)) {
|
||||||
|
try {
|
||||||
|
this.anki.deleteCards(cards)
|
||||||
|
deletedCards++
|
||||||
|
|
||||||
|
this.updateFile = true
|
||||||
|
this.file = this.file.substring(0, block["index"]) + this.file.substring(block["index"] + block[0].length, this.file.length)
|
||||||
|
this.totalOffset -= block[0].length
|
||||||
|
this.notifications.push(`Deleted successfully ${deletedCards}/${cards.length} cards.`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
Error("Error, could not delete the card from Anki")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedCards
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getAnkiIDs(blocks: RegExpMatchArray[]): number[] {
|
||||||
|
let IDs: number[] = []
|
||||||
|
|
||||||
|
for (let b of blocks) {
|
||||||
|
IDs.push(Number(b[1]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return IDs
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public filterByUpdate(ankiCards: any, generatedCards: Card[]) {
|
||||||
|
let cardsToCreate: Card[] = []
|
||||||
|
let cardsToUpdate: Card[] = []
|
||||||
|
|
||||||
|
if (ankiCards) {
|
||||||
|
for (let flashcard of generatedCards) {
|
||||||
|
// Inserted means that anki blocks are available, that means that the card should
|
||||||
|
// (the user can always delete it) be in Anki
|
||||||
|
let ankiCard = undefined
|
||||||
|
if (flashcard.inserted) {
|
||||||
|
ankiCard = ankiCards.filter((card: any) => Number(card.noteId) === flashcard.id)[0]
|
||||||
|
if (!flashcard.match(ankiCard)) {
|
||||||
|
cardsToUpdate.push(flashcard)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
cardsToCreate.push(flashcard)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("No cards in Anki, I am going to create all of them")
|
||||||
|
cardsToCreate = [...generatedCards]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [cardsToCreate, cardsToUpdate]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
38
src/entities/card.ts
Normal file
38
src/entities/card.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export abstract class Card {
|
||||||
|
id: number
|
||||||
|
deckName: string
|
||||||
|
initialContent: string
|
||||||
|
fields: Record<string, string>
|
||||||
|
reversed: boolean
|
||||||
|
endOffset: number
|
||||||
|
tags: string[]
|
||||||
|
inserted: boolean
|
||||||
|
|
||||||
|
// TODO set "obsidian as optional in the settings", this means that the tag should be outside
|
||||||
|
constructor(id: number, deckName: string, initialContent: string, fields: Record<string, string>, reversed: boolean, endOffset: number, tags: string[], inserted: boolean) {
|
||||||
|
this.id = id
|
||||||
|
this.deckName = deckName
|
||||||
|
this.initialContent = initialContent
|
||||||
|
this.fields = fields
|
||||||
|
this.reversed = reversed
|
||||||
|
this.endOffset = endOffset
|
||||||
|
this.tags = tags
|
||||||
|
this.tags.unshift("obsidian")
|
||||||
|
this.inserted = inserted
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract toString(): string
|
||||||
|
abstract getCard(update: boolean): object
|
||||||
|
|
||||||
|
match(card: any): boolean {
|
||||||
|
let fields = Object.entries(card.fields)
|
||||||
|
for (let field of fields) {
|
||||||
|
let fieldName = field[0]
|
||||||
|
if (field[1].value !== this.fields[fieldName]) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/entities/flashcard.ts
Normal file
31
src/entities/flashcard.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { Card } from "src/entities/card";
|
||||||
|
|
||||||
|
export class Flashcard extends Card {
|
||||||
|
constructor(id: number = -1, deckName: string, initialContent: string, fields: Record<string, string>, reversed: boolean, endOffset: number, tags: string[] = [], inserted: boolean = false) {
|
||||||
|
super(id, deckName, initialContent, fields, reversed, endOffset, tags, inserted)
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCard(update: boolean = false): object {
|
||||||
|
let modelName = this.reversed ? "Obsidian-basic-reversed" : "Obsidian-basic"
|
||||||
|
let card: any = {
|
||||||
|
"deckName": this.deckName,
|
||||||
|
"modelName": modelName,
|
||||||
|
"fields": {
|
||||||
|
"Front": this.fields["Front"],
|
||||||
|
"Back": this.fields["Back"]
|
||||||
|
},
|
||||||
|
"tags": this.tags,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (update) {
|
||||||
|
card["id"] = this.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public toString = (): string => {
|
||||||
|
return `Q: ${this.fields[0]}\nA: ${this.fields[1]}`
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/gui/settings-tab.ts
Normal file
24
src/gui/settings-tab.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { PluginSettingTab, Setting } from "obsidian"
|
||||||
|
|
||||||
|
export class SettingsTab extends PluginSettingTab {
|
||||||
|
display(): void {
|
||||||
|
let { containerEl } = this
|
||||||
|
const plugin = (this as any).plugin
|
||||||
|
|
||||||
|
containerEl.empty()
|
||||||
|
containerEl.createEl("h1", { text: "Flashcards" })
|
||||||
|
containerEl.createEl("h2", { text: "General Settings" })
|
||||||
|
|
||||||
|
new Setting(containerEl)
|
||||||
|
.setName("Context-aware mode")
|
||||||
|
.setDesc("Add the ancestor headings to the question of the flashcard.")
|
||||||
|
.addToggle((toggle) =>
|
||||||
|
toggle
|
||||||
|
.setValue(plugin.settings.contextAwareMode)
|
||||||
|
.onChange((value) => {
|
||||||
|
plugin.settings.contextAwareMode = value
|
||||||
|
plugin.saveData(plugin.settings)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src/parser.ts
Normal file
162
src/parser.ts
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { Flashcard } from './entities/flashcard';
|
||||||
|
import { Spaced } from "./entities/spaced"
|
||||||
|
import { Cloze } from './entities/cloze';
|
||||||
|
import { Settings } from 'settings';
|
||||||
|
import * as showdown from 'showdown';
|
||||||
|
|
||||||
|
|
||||||
|
export class Parser {
|
||||||
|
private settings: Settings
|
||||||
|
|
||||||
|
constructor(settings: Settings) {
|
||||||
|
this.settings = settings
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives back the ancestor headings of a line.
|
||||||
|
* @param headings The list of all the headings available in a file.
|
||||||
|
* @param line The line whose ancestors need to be calculated.
|
||||||
|
* @param headingLevel The level of the first ancestor heading, i.e. the number of #.
|
||||||
|
*/
|
||||||
|
private getContext(headings: any, index: number, headingLevel: number): string[] {
|
||||||
|
let context: string[] = []
|
||||||
|
let currentIndex: number = index
|
||||||
|
let goalLevel: number = 6
|
||||||
|
|
||||||
|
let i = headings.length - 1
|
||||||
|
// Get the level of the first heading upon the line
|
||||||
|
if (headingLevel !== -1) {
|
||||||
|
// This is the case of a #flashcard in a heading
|
||||||
|
goalLevel = headingLevel - 1
|
||||||
|
} else {
|
||||||
|
// Find first heading and its level
|
||||||
|
// This is the case of a #flashcard in a paragraph
|
||||||
|
for (i; i >= 0; i--) {
|
||||||
|
if (headings[i].index < currentIndex) {
|
||||||
|
currentIndex = headings[i].index
|
||||||
|
goalLevel = headings[i][1].length - 1
|
||||||
|
|
||||||
|
context.unshift(headings[i][2].trim())
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search for the other headings
|
||||||
|
for (i; i >= 0; i--) {
|
||||||
|
let currentLevel = headings[i][1].length
|
||||||
|
if (currentLevel == goalLevel && headings[i].index < currentIndex) {
|
||||||
|
currentIndex = headings[i].index
|
||||||
|
goalLevel = currentLevel - 1
|
||||||
|
|
||||||
|
context.unshift(headings[i][2].trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public getCardsToDelete(file: string): number[] {
|
||||||
|
// Find block IDs with no content above it
|
||||||
|
let regex: RegExp = /^\s*(?:\n)(?:\^(\d{13}))(?:\n\s*?)?/gm
|
||||||
|
return [...file.matchAll(regex)].map((match) => { return Number(match[1]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
public generateFlashcards(file: string, globalTags: string[] = [], deckName: string): Flashcard[] {
|
||||||
|
let htmlConverter = new showdown.Converter()
|
||||||
|
htmlConverter.setOption("simplifiedAutoLink", true)
|
||||||
|
htmlConverter.setOption("tables", true)
|
||||||
|
htmlConverter.setOption("tasks", true)
|
||||||
|
|
||||||
|
let contextAware = this.settings.contextAwareMode
|
||||||
|
let flashcards: Flashcard[] = []
|
||||||
|
|
||||||
|
let regex: RegExp = /( {0,3}[#]*)((?:[^\n]\n?)+?)(#flashcard(?:-reverse)?)((?: *#\w+)*) *?\n+((?:[^\n]\n?)*?(?=\^\d{13}|$))(?:\^(\d{13}))?/gim
|
||||||
|
let matches = [...file.matchAll(regex)]
|
||||||
|
let headings: any = []
|
||||||
|
|
||||||
|
if (contextAware) {
|
||||||
|
// https://regex101.com/r/agSp9X/4
|
||||||
|
headings = [...file.matchAll(/^ {0,3}(#{1,6}) +([^\n]+?) ?((?: *#\S+)*) *$/gim)]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let match of matches) {
|
||||||
|
let reversed: boolean = match[3].trim().toLowerCase() === "#flashcard-reverse"
|
||||||
|
let headingLevel = match[1].trim().length !== 0 ? match[1].length : -1
|
||||||
|
// Match.index - 1 because otherwise in the context there will be even match[1], i.e. the question
|
||||||
|
let context = contextAware ? this.getContext(headings, match.index - 1, headingLevel) : ""
|
||||||
|
|
||||||
|
// todo image
|
||||||
|
// todo code
|
||||||
|
let originalQuestion = match[2].trim()
|
||||||
|
let question = contextAware ? [...context, match[2].trim()].join(`${this.settings.contextSeparator}`) : match[2].trim()
|
||||||
|
question = this.mathToAnki(htmlConverter.makeHtml(question))
|
||||||
|
let answer = this.mathToAnki(htmlConverter.makeHtml(match[5].trim()))
|
||||||
|
let endingLine = match.index + match[0].length
|
||||||
|
let tags: string[] = this.parseTags(match[4], globalTags)
|
||||||
|
let id: number = match[6] ? Number(match[6]) : -1
|
||||||
|
let inserted: boolean = match[6] ? true : false
|
||||||
|
let fields = { "Front": question, "Back": answer }
|
||||||
|
|
||||||
|
let flashcard = new Flashcard(id, deckName, originalQuestion, fields, reversed, endingLine, tags, inserted)
|
||||||
|
flashcards.push(flashcard)
|
||||||
|
}
|
||||||
|
|
||||||
|
return flashcards
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSpacedCards(file: string): Spaced[] {
|
||||||
|
let spaced: Spaced[] = []
|
||||||
|
|
||||||
|
let regex: RegExp = new RegExp("[# ]*((?:[^\n]\n?)+) *(#spaced) ?", "gim")
|
||||||
|
let matches = file.matchAll(regex)
|
||||||
|
|
||||||
|
for (let match of matches) {
|
||||||
|
let spacedCard = new Spaced(match[1].trim(), match.index + match.length)
|
||||||
|
spaced.push(spacedCard)
|
||||||
|
}
|
||||||
|
|
||||||
|
return spaced
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateClozeCards(): Cloze[] {
|
||||||
|
let clozeCards: Cloze[]
|
||||||
|
return clozeCards
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO maybe move it in anki
|
||||||
|
private mathToAnki(str: string) {
|
||||||
|
let mathBlockRegex = /(\$\$)(.*)(\$\$)/gi
|
||||||
|
str = str.replace(mathBlockRegex, '\\($2\\)')
|
||||||
|
|
||||||
|
let mathInlineRegex = /(\$)(.*)(\$)/gi
|
||||||
|
str = str.replace(mathInlineRegex, '\\($2\\)')
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseTags(str: string, globalTags: string[]): string[] {
|
||||||
|
let tags: string[] = [...globalTags]
|
||||||
|
|
||||||
|
if (str) {
|
||||||
|
for (let tag of str.split("#")) {
|
||||||
|
tag = tag.trim()
|
||||||
|
if (tag) {
|
||||||
|
tags.push(tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags
|
||||||
|
}
|
||||||
|
|
||||||
|
public getAnkiIDsBlocks(file: string): RegExpMatchArray[] {
|
||||||
|
return Array.from(file.matchAll(/\^(\d{13})\s*/gm))
|
||||||
|
}
|
||||||
|
|
||||||
|
private convertImagesToHtml(str: string) {
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
30
styles.css
30
styles.css
@ -1,4 +1,32 @@
|
|||||||
/* Sets all the text color to red! */
|
/* Sets all the text color to red! */
|
||||||
body {
|
body {
|
||||||
color: red;
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--text-accent);
|
||||||
|
border: none;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
text-align: center;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0px 0px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 14px;
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
color: var(--text-normal);
|
||||||
|
background-color: var(--text-accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag[href="#flashcard"] {
|
||||||
|
background-color: #821515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag[href="#flashcard-reverse"] {
|
||||||
|
background-color: #821515;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"inlineSourceMap": true,
|
"inlineSourceMap": true,
|
||||||
@ -9,14 +9,17 @@
|
|||||||
"noImplicitAny": true,
|
"noImplicitAny": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "node",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
|
"downlevelIteration": true,
|
||||||
"lib": [
|
"lib": [
|
||||||
"dom",
|
"dom",
|
||||||
"es5",
|
"es5",
|
||||||
"scripthost",
|
"scripthost",
|
||||||
"es2015"
|
"es2015",
|
||||||
]
|
"ES2020.String"
|
||||||
|
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"**/*.ts"
|
"**/*.ts",
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user