Add implementation for add, update and delete on Anki

This commit is contained in:
reuseman 2020-12-04 23:34:45 +01:00
parent ccd700833b
commit 9a0d3590d0
14 changed files with 740 additions and 97 deletions

16
.gitignore vendored
View File

@ -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/

View File

@ -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
View File

@ -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);
}));
}
}

View File

@ -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
}

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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) {
}
}

View File

@ -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;
}

View File

@ -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",
]
}
}