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
|
||||
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.
|
||||
The repo depends on the latest plugin API (obsidian.d.ts) in Typescript Definition format, which contains TSDoc comments describing what it does.
|
||||
## How to install
|
||||
|
||||
**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.
|
||||
- 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.
|
||||
## Anki-connect configuration
|
||||
|
||||
|
||||
### Releasing new releases
|
||||
|
||||
- Update your `manifest.json` with your new version number, such as `1.0.1`, and the minimum Obsidian version required for your latest release.
|
||||
- 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.
|
||||
- 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
|
||||
- Upload the files `manifest.json`, `main.js`, `styles.css` as binary attachments.
|
||||
- Publish the release.
|
||||
|
||||
### 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
|
||||
{
|
||||
"apiKey": null,
|
||||
"apiLogPath": null,
|
||||
"webBindAddress": "127.0.0.1",
|
||||
"webBindPort": 8765,
|
||||
"webCorsOrigin": "http://localhost",
|
||||
"webCorsOriginList": [
|
||||
"http://localhost",
|
||||
"app://obsidian.md"
|
||||
]
|
||||
}
|
||||
|
||||
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() {
|
||||
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', () => {
|
||||
new Notice('This is a notice!');
|
||||
@ -11,16 +39,17 @@ export default class MyPlugin extends Plugin {
|
||||
this.addStatusBarItem().setText('Status Bar Text');
|
||||
|
||||
this.addCommand({
|
||||
id: 'open-sample-modal',
|
||||
name: 'Open Sample Modal',
|
||||
// callback: () => {
|
||||
// console.log('Simple Callback');
|
||||
// },
|
||||
id: 'generate-flashcard-this-file',
|
||||
name: 'Generate for this file',
|
||||
checkCallback: (checking: boolean) => {
|
||||
let leaf = this.app.workspace.activeLeaf;
|
||||
if (leaf) {
|
||||
let activeFile = this.app.workspace.getActiveFile()
|
||||
if (activeFile) {
|
||||
if (!checking) {
|
||||
new SampleModal(this.app).open();
|
||||
this.cardsService.execute(activeFile).then(res => {
|
||||
new Notice(res.join(" "))
|
||||
}).catch(err => {
|
||||
Error(err)
|
||||
})
|
||||
}
|
||||
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) => {
|
||||
console.log('codemirror', cm);
|
||||
//console.log('codemirror', cm);
|
||||
}));
|
||||
|
||||
this.registerDomEvent(document, 'click', (evt: MouseEvent) => {
|
||||
console.log('click', evt);
|
||||
//console.log('click', evt);
|
||||
});
|
||||
|
||||
this.registerInterval(window.setInterval(() => console.log('setInterval'), 5 * 60 * 1000));
|
||||
}
|
||||
|
||||
onunload() {
|
||||
console.log('unloading plugin');
|
||||
async onunload() {
|
||||
console.log('Unloading flashcard-obsidian and saving data.');
|
||||
await this.saveData(this.settings);
|
||||
}
|
||||
}
|
||||
|
||||
class SampleModal extends Modal {
|
||||
constructor(app: App) {
|
||||
flashcards: Flashcard[];
|
||||
|
||||
constructor(app: App, flashcards: Flashcard[]) {
|
||||
super(app);
|
||||
this.flashcards = flashcards;
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
let {contentEl} = this;
|
||||
contentEl.setText('Woah!');
|
||||
let { titleEl } = this;
|
||||
let { contentEl } = this;
|
||||
|
||||
titleEl.setText('Generated flashcards!');
|
||||
contentEl.setText(this.flashcards.join("\n\n"));
|
||||
}
|
||||
|
||||
onClose() {
|
||||
let {contentEl} = this;
|
||||
let { contentEl } = this;
|
||||
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",
|
||||
"name": "Sample Plugin",
|
||||
"version": "1.0.1",
|
||||
"minAppVersion": "0.9.12",
|
||||
"description": "This is a sample plugin for Obsidian. This plugin demonstrates some of the capabilities of the Obsidian API.",
|
||||
"author": "Obsidian",
|
||||
"authorUrl": "https://obsidian.md/about",
|
||||
"isDesktopOnly": false
|
||||
}
|
||||
"id": "flashcards-obsidian",
|
||||
"name": "Flashcards",
|
||||
"version": "0.1.0",
|
||||
"minAppVersion": "0.9.17",
|
||||
"description": "An Anki integration.",
|
||||
"author": "reuseman",
|
||||
"authorUrl": "https://github.com/reuseman",
|
||||
"isDesktopOnly": true
|
||||
}
|
||||
@ -17,7 +17,12 @@
|
||||
"@types/node": "^14.14.2",
|
||||
"obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master",
|
||||
"rollup": "^2.32.1",
|
||||
"ts-node": "^9.0.0",
|
||||
"tslib": "^2.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! */
|
||||
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": {
|
||||
"baseUrl": ".",
|
||||
"inlineSourceMap": true,
|
||||
@ -9,14 +9,17 @@
|
||||
"noImplicitAny": true,
|
||||
"moduleResolution": "node",
|
||||
"importHelpers": true,
|
||||
"downlevelIteration": true,
|
||||
"lib": [
|
||||
"dom",
|
||||
"es5",
|
||||
"scripthost",
|
||||
"es2015"
|
||||
]
|
||||
"es2015",
|
||||
"ES2020.String"
|
||||
|
||||
],
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
"**/*.ts",
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user