├── src ├── types │ ├── Card.ts │ └── Settings.ts ├── util │ ├── Constants.ts │ ├── HideTagFromPreview.ts │ └── MochiExporter.ts └── ui │ ├── ProgressModal.ts │ ├── icons.ts │ └── SettingTab.ts ├── versions.json ├── .gitignore ├── manifest.json ├── rollup.config.js ├── tsconfig.json ├── package.json ├── README.md └── main.ts /src/types/Card.ts: -------------------------------------------------------------------------------- 1 | interface Card { 2 | term: string; 3 | content: string; 4 | } 5 | 6 | export default Card; 7 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.2.0": "0.9.12", 3 | "0.2.1": "0.9.12", 4 | "0.2.2": "0.9.12", 5 | "0.2.3": "0.9.12" 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # npm 2 | node_modules 3 | package-lock.json 4 | 5 | # build 6 | main.js 7 | *.js.map 8 | *.log 9 | 10 | #plugin 11 | data.json -------------------------------------------------------------------------------- /src/types/Settings.ts: -------------------------------------------------------------------------------- 1 | interface Settings { 2 | useDefaultSaveLocation: boolean; 3 | cardTag: string; 4 | deckNamingOption: string; 5 | defaultSaveLocation: string; 6 | hideTagInPreview: boolean; 7 | } 8 | 9 | export default Settings; 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "mochi-cards-exporter", 3 | "name": "Mochi Cards Exporter", 4 | "version": "0.2.3", 5 | "minAppVersion": "0.9.12", 6 | "description": "Export Markdown notes to Mochi cards from within obsidian", 7 | "author": "Kalkidan Betre", 8 | "authorUrl": "https://github.com/kalbetredev", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /src/util/Constants.ts: -------------------------------------------------------------------------------- 1 | import Settings from "../types/Settings"; 2 | 3 | export const DECK_FROM_ACTIVE_FILE_NAME = "Use Active File Name"; 4 | export const DECK_FROM_FRONTMATTER = "Get From Front Matter"; 5 | 6 | export const DEFAULT_SETTINGS: Settings = { 7 | useDefaultSaveLocation: false, 8 | cardTag: "card", 9 | deckNamingOption: DECK_FROM_ACTIVE_FILE_NAME, 10 | defaultSaveLocation: "", 11 | hideTagInPreview: false, 12 | }; 13 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from "@rollup/plugin-typescript"; 2 | import { nodeResolve } from "@rollup/plugin-node-resolve"; 3 | import commonjs from "@rollup/plugin-commonjs"; 4 | 5 | export default { 6 | input: "main.ts", 7 | output: { 8 | dir: ".", 9 | sourcemap: "inline", 10 | format: "cjs", 11 | exports: "default", 12 | }, 13 | external: ["obsidian"], 14 | plugins: [typescript(), nodeResolve({ browser: true }), commonjs()], 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "inlineSourceMap": true, 5 | "inlineSources": true, 6 | "module": "ESNext", 7 | "target": "es6", 8 | "allowJs": true, 9 | "noImplicitAny": true, 10 | "moduleResolution": "node", 11 | "importHelpers": true, 12 | "lib": [ 13 | "dom", 14 | "es5", 15 | "scripthost", 16 | "es2015" 17 | ] 18 | }, 19 | "include": [ 20 | "**/*.ts" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/ProgressModal.ts: -------------------------------------------------------------------------------- 1 | import { App, Modal } from "obsidian"; 2 | 3 | class ProgressModal extends Modal { 4 | constructor(app: App) { 5 | super(app); 6 | } 7 | 8 | onOpen() { 9 | let { contentEl } = this; 10 | contentEl.setText( 11 | "(You can close this window. The exporter continues in the background.)" 12 | ); 13 | this.titleEl.setText("Exporting your cards …"); 14 | } 15 | 16 | onClose() { 17 | let { contentEl } = this; 18 | contentEl.empty(); 19 | } 20 | } 21 | 22 | export default ProgressModal; 23 | -------------------------------------------------------------------------------- /src/util/HideTagFromPreview.ts: -------------------------------------------------------------------------------- 1 | export const hideTagFromPreview = (state: boolean, tagName: string) => { 2 | if(state && document.getElementById("mochi-card-style") == null){ 3 | const style = document.createElement("style"); 4 | style.id = "mochi-card-style"; 5 | 6 | style.innerHTML = ` 7 | .tag[href="#${tagName}"]{ 8 | display: none; 9 | } 10 | `; 11 | 12 | document.head.appendChild(style); 13 | } 14 | else if (!state) { 15 | document.getElementById("mochi-card-style")?.remove(); 16 | } 17 | } -------------------------------------------------------------------------------- /src/ui/icons.ts: -------------------------------------------------------------------------------- 1 | export const icons = { 2 | stackedCards: { 3 | key: "stackedCards", 4 | svgContent: 5 | '', 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mochi-cards-exporter", 3 | "version": "0.2.3", 4 | "description": "Export Markdown-based notes from Obsidian to Mochi cards.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "rollup --config rollup.config.js -w", 8 | "build": "rollup --config rollup.config.js" 9 | }, 10 | "keywords": [ 11 | "Mochi", 12 | "Mochi Cards", 13 | "flashcards", 14 | "spaced repetition", 15 | "Markdown" 16 | ], 17 | "author": "", 18 | "license": "MIT", 19 | "devDependencies": { 20 | "@rollup/plugin-commonjs": "^15.1.0", 21 | "@rollup/plugin-node-resolve": "^9.0.0", 22 | "@rollup/plugin-typescript": "^6.0.0", 23 | "@types/node": "^14.14.2", 24 | "obsidian": "https://github.com/obsidianmd/obsidian-api/tarball/master", 25 | "rollup": "^2.32.1", 26 | "tslib": "^2.0.3", 27 | "typescript": "^4.0.3" 28 | }, 29 | "dependencies": { 30 | "fflate": "^0.6.3", 31 | "nanoid": "^3.1.20" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mochi Cards Exporter Plugin 2 | 3 | This is an [Obsidian](https://obsidian.md/) plugin that exports markdown notes to [Mochi](https://mochi.cards) flashcards, which is an awesome flashcard app that is based on markdown. I highly recommend it, if you use flashcards and spaced repetition to study or remember anything. 4 | 5 | ## Features 6 | 7 | - Create cards using tags in obsidian and export them to Mochi, simple as that. 8 | - Supports images, videos, sounds, or any other attachments that Mochi supports. 9 | 10 | ## Usage 11 | 12 | To use this plugin : 13 | - Mark a line in your notes with the default `#card` tag. You also have the ability to customize this in the plugin's settings. This line will be the front face of your card 14 | - To mark the end of a card, use the section dividers `---` or `***`. 15 | - Any text between the first line marked with the card tag and a section divider will be the back face of your card. 16 | 17 | **NOTE** The plugin exports your cards to a `.mochi` file. This file is just a zip file with your exported cards and your attachments. You can import this file using your Mochi app. 18 | 19 | ## Sample format 20 | 21 | ```md 22 | ## Term to be front face of your flash card #card 23 | 24 | Any Text, image, or other attachment types supported by Mochi 25 | 26 | --- 27 | 28 | ``` 29 | 30 | ## Installation 31 | 32 | To install and use this plugin, search for *Mochi Card Exporter* in the Obsidian Community Plugins from within Obsidian. 33 | 34 | ## Open Source 35 | 36 | This is an open source project, so feel free to contribute !!! 37 | 38 | Check out the repo at github [Mochi-Card-Exporter](https://github.com/kalbetredev/mochi-cards-exporter) -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { addIcon, Notice, Plugin, TFile } from "obsidian"; 2 | import { DEFAULT_SETTINGS } from "src/util/Constants"; 3 | import { icons } from "src/ui/icons"; 4 | import MochiExporter from "src/util/MochiExporter"; 5 | import Settings from "src/types/Settings"; 6 | import SettingTab from "src/ui/SettingTab"; 7 | import { hideTagFromPreview } from "src/util/HideTagFromPreview"; 8 | 9 | const homedir = require("os").homedir(); 10 | 11 | export default class MyPlugin extends Plugin { 12 | settings: Settings; 13 | 14 | async onload() { 15 | console.log("loading obsidian to mochi converter plugin"); 16 | 17 | await this.loadSettings(); 18 | 19 | if (this.settings.defaultSaveLocation === "") { 20 | this.settings.defaultSaveLocation = homedir; 21 | await this.saveSettings(); 22 | } 23 | 24 | hideTagFromPreview(this.settings.hideTagInPreview, this.settings.cardTag); 25 | 26 | this.addIcons(); 27 | 28 | this.addRibbonIcon(icons.stackedCards.key, "Mochi Cards Exporter", () => { 29 | let activeFile = this.app.workspace.getActiveFile(); 30 | this.exportMochiCards(activeFile); 31 | }); 32 | 33 | this.addCommand({ 34 | id: "export-cards-to-mochi", 35 | name: "Export Cards to Mochi", 36 | checkCallback: (checking: boolean) => { 37 | let activeFile = this.app.workspace.getActiveFile(); 38 | if (activeFile) { 39 | if (!checking) { 40 | this.exportMochiCards(activeFile); 41 | } 42 | return true; 43 | } 44 | return false; 45 | }, 46 | }); 47 | 48 | this.addSettingTab(new SettingTab(this.app, this)); 49 | } 50 | 51 | onunload() { 52 | console.log("unloading obsidian to mochi converter plugin"); 53 | } 54 | 55 | addIcons() { 56 | addIcon(icons.stackedCards.key, icons.stackedCards.svgContent); 57 | } 58 | 59 | async loadSettings() { 60 | this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); 61 | } 62 | 63 | async saveSettings() { 64 | await this.saveData(this.settings); 65 | } 66 | 67 | exportMochiCards = async (activeFile: TFile) => { 68 | if (activeFile.extension === "md") { 69 | const mochiExporter = new MochiExporter( 70 | this.app, 71 | activeFile, 72 | this.settings 73 | ); 74 | await mochiExporter.exportMochiCards(); 75 | } else { 76 | new Notice("Open a Markdown File to Generate Mochi Cards"); 77 | } 78 | }; 79 | } 80 | -------------------------------------------------------------------------------- /src/ui/SettingTab.ts: -------------------------------------------------------------------------------- 1 | import MyPlugin from "main"; 2 | import { settings } from "node:cluster"; 3 | import { PluginSettingTab, App, Setting, Notice } from "obsidian"; 4 | import { 5 | DECK_FROM_ACTIVE_FILE_NAME, 6 | DECK_FROM_FRONTMATTER, 7 | } from "src/util/Constants"; 8 | import { hideTagFromPreview } from "src/util/HideTagFromPreview"; 9 | const dialog = require("electron").remote.dialog; 10 | 11 | class SettingTab extends PluginSettingTab { 12 | plugin: MyPlugin; 13 | 14 | constructor(app: App, plugin: MyPlugin) { 15 | super(app, plugin); 16 | this.plugin = plugin; 17 | } 18 | 19 | display(): void { 20 | let { containerEl } = this; 21 | containerEl.empty(); 22 | containerEl.createEl("h3", { text: "Mochi Cards Exporter" }); 23 | 24 | const toggleSettingsEl = () => { 25 | this.plugin.settings.useDefaultSaveLocation 26 | ? defaultSaveLocSettingEl.show() 27 | : defaultSaveLocSettingEl.hide(); 28 | }; 29 | 30 | new Setting(containerEl) 31 | .setName("Deck Naming Option") 32 | .setDesc( 33 | "If you select to get from Frontmatter, use the key 'deck' to specify the deck name. (If no Frontmatter is found, the active file name will be used)" 34 | ) 35 | .addDropdown((dropdown) => { 36 | dropdown 37 | .addOption(DECK_FROM_ACTIVE_FILE_NAME, DECK_FROM_ACTIVE_FILE_NAME) 38 | .addOption(DECK_FROM_FRONTMATTER, DECK_FROM_FRONTMATTER) 39 | .onChange((value) => { 40 | this.plugin.settings.deckNamingOption = value; 41 | }) 42 | .setValue(this.plugin.settings.deckNamingOption); 43 | }); 44 | 45 | new Setting(containerEl) 46 | .setName("Card Tag") 47 | .setDesc("Tag to identify Mochi cards (case-insensitive)") 48 | .addText((text) => 49 | text.setValue(this.plugin.settings.cardTag).onChange(async (value) => { 50 | if (value.length === 0) { 51 | new Notice("Tag should not be empty"); 52 | } else { 53 | this.plugin.settings.cardTag = value; 54 | await this.plugin.saveSettings(); 55 | //hideTagFromPreview(this.plugin.settings.hideTagInPreview, value); 56 | } 57 | }) 58 | ); 59 | 60 | new Setting(containerEl) 61 | .setName("Hide Tag") 62 | .setDesc("Enable to hide the above specified tag in Preview Mode") 63 | .addToggle((toggle) => toggle 64 | .onChange(async (value) => { 65 | this.plugin.settings.hideTagInPreview = value; 66 | await this.plugin.saveSettings(); 67 | hideTagFromPreview(value, this.plugin.settings.cardTag); 68 | }) 69 | .setValue(this.plugin.settings.hideTagInPreview)); 70 | 71 | new Setting(containerEl) 72 | .setName("Use Default Save Location") 73 | .setDesc( 74 | "If you turn this off, you will be prompted to choose a folder for exports." 75 | ) 76 | .addToggle((toggle) => 77 | toggle 78 | .onChange(async (value) => { 79 | this.plugin.settings.useDefaultSaveLocation = value; 80 | await this.plugin.saveSettings(); 81 | toggleSettingsEl(); 82 | }) 83 | .setValue(this.plugin.settings.useDefaultSaveLocation) 84 | ); 85 | 86 | const defaultSaveLocation = new Setting(containerEl); 87 | const defaultSaveLocSettingEl = defaultSaveLocation.settingEl; 88 | const defaultSaveLocationTextEl = defaultSaveLocation.descEl; 89 | 90 | defaultSaveLocationTextEl.innerText = this.plugin.settings.defaultSaveLocation; 91 | 92 | defaultSaveLocation.setName("Default Save Location").addButton((button) => { 93 | button.setButtonText("Select Folder").onClick(async () => { 94 | const options = { 95 | title: "Select a Folder", 96 | properties: ["openDirectory"], 97 | defaultPath: this.plugin.settings.defaultSaveLocation, 98 | }; 99 | const response = await dialog.showOpenDialog(null, options); 100 | if (!response.canceled) { 101 | this.plugin.settings.defaultSaveLocation = response.filePaths[0]; 102 | await this.plugin.saveSettings(); 103 | defaultSaveLocationTextEl.innerText = this.plugin.settings.defaultSaveLocation; 104 | } 105 | }); 106 | }).settingEl; 107 | 108 | toggleSettingsEl(); 109 | } 110 | } 111 | 112 | export default SettingTab; 113 | -------------------------------------------------------------------------------- /src/util/MochiExporter.ts: -------------------------------------------------------------------------------- 1 | import { DECK_FROM_ACTIVE_FILE_NAME } from "./Constants"; 2 | import { AsyncZippable, strToU8, zip } from "fflate"; 3 | import { 4 | TFile, 5 | CachedMetadata, 6 | parseFrontMatterEntry, 7 | Notice, 8 | App, 9 | } from "obsidian"; 10 | import Settings from "../types/Settings"; 11 | import Card from "../types/Card"; 12 | import { nanoid } from "nanoid"; 13 | import ProgressModal from "src/ui/ProgressModal"; 14 | 15 | const path = require("path"); 16 | const dialog = require("electron").remote.dialog; 17 | const fs = require("fs"); 18 | 19 | type FileNameUidPair = { fileName: string; uid: string }; 20 | 21 | class MochiExporter { 22 | app: App; 23 | settings: Settings; 24 | activeFile: TFile; 25 | metaData: CachedMetadata; 26 | mediaFiles: FileNameUidPair[] = []; 27 | progressModal: ProgressModal; 28 | 29 | mediaLinkRegExp = /\[\[(.+?)(?:\|(.+))?\]\]/gim; 30 | errorMessage = "An error occurred while exporting your cards."; 31 | 32 | constructor(app: App, activeFile: TFile, settings: Settings) { 33 | this.app = app; 34 | this.activeFile = activeFile; 35 | this.metaData = app.metadataCache.getFileCache(this.activeFile); 36 | this.settings = settings; 37 | this.progressModal = new ProgressModal(this.app); 38 | } 39 | 40 | async getLines(): Promise { 41 | let fileContent = await this.app.vault.read(this.activeFile); 42 | return fileContent.split("\n"); 43 | } 44 | 45 | getDeckName(): string { 46 | if (this.settings.deckNamingOption == DECK_FROM_ACTIVE_FILE_NAME) { 47 | return this.activeFile.basename; 48 | } else { 49 | let deckName = parseFrontMatterEntry(this.metaData.frontmatter, "deck"); 50 | if (!deckName) deckName = this.activeFile.basename; 51 | return deckName; 52 | } 53 | } 54 | 55 | async readCards(): Promise { 56 | let lines = await this.getLines(); 57 | let cardTag = "#" + this.settings.cardTag.toLowerCase(); 58 | 59 | let cards: Card[] = []; 60 | for (let i = 0; i < lines.length; i++) { 61 | if (lines[i].contains(cardTag)) { 62 | let card: Card = { 63 | term: lines[i].replace(cardTag, "").trim(), 64 | content: "", 65 | }; 66 | i++; 67 | let cardContent = ""; 68 | while ( 69 | i < lines.length && 70 | lines[i].trim() !== "---" && 71 | lines[i].trim() !== "***" 72 | ) { 73 | lines[i] = lines[i].replace(this.mediaLinkRegExp, (match) => { 74 | let fileName = this.removeSpaces( 75 | match.replace("[[", "").replace("]]", "") 76 | ); 77 | let fileUid = 78 | this.getUid() + 79 | "." + 80 | fileName.substring(fileName.lastIndexOf(".") + 1); 81 | let path = `[${this.removeNonAlphaNum( 82 | fileName 83 | )}](@media/${fileUid})`; 84 | this.mediaFiles.push({ 85 | fileName: fileName, 86 | uid: fileUid, 87 | }); 88 | return path; 89 | }); 90 | 91 | cardContent += (cardContent.length === 0 ? "" : "\n") + lines[i]; 92 | i++; 93 | } 94 | 95 | card.content = cardContent; 96 | cards.push(card); 97 | } 98 | } 99 | 100 | return cards; 101 | } 102 | 103 | getUid = (): string => { 104 | let uid = nanoid(6); 105 | while (this.mediaFiles.filter((value) => value.uid === uid).length > 0) 106 | uid = nanoid(6); 107 | return uid; 108 | }; 109 | 110 | removeSpaces = (text: string): string => text.replace(/\s+/g, "_"); 111 | 112 | removeNonAlphaNum = (text: string): string => 113 | text.replace(/[^a-zA-Z0-9+]+/gi, "_"); 114 | 115 | async getMochiCardsEdn(cards: Card[]): Promise { 116 | let deckName = this.getDeckName(); 117 | let mochiCard = "{:decks [{"; 118 | mochiCard += ":name " + JSON.stringify(deckName) + ","; 119 | mochiCard += ":cards ("; 120 | 121 | for (let i = 0; i < cards.length; i++) { 122 | mochiCard += 123 | "{:name " + 124 | JSON.stringify(cards[i].term) + 125 | "," + 126 | ":content " + 127 | JSON.stringify(cards[i].term + "\n---\n" + cards[i].content) + 128 | "}"; 129 | } 130 | 131 | mochiCard += ")"; 132 | mochiCard += "}]"; 133 | mochiCard += ", :version 2}"; 134 | 135 | return mochiCard; 136 | } 137 | 138 | async exportMochiCards() { 139 | this.progressModal.open(); 140 | const cards: Card[] = await this.readCards(); 141 | const count = cards.length; 142 | if (count == 0) { 143 | new Notice("No cards found."); 144 | this.progressModal.close(); 145 | } else { 146 | try { 147 | if (this.settings.useDefaultSaveLocation) { 148 | let savePath = path.join( 149 | this.settings.defaultSaveLocation, 150 | `${this.getDeckName()}.mochi` 151 | ); 152 | await this.zipFiles(savePath, cards); 153 | } else { 154 | const options = { 155 | title: "Select a folder", 156 | properties: ["openDirectory"], 157 | defaultPath: this.settings.defaultSaveLocation, 158 | }; 159 | 160 | const saveResponse = await dialog.showOpenDialog(null, options); 161 | if (!saveResponse.canceled) { 162 | let savePath = path.join( 163 | saveResponse.filePaths[0], 164 | `${this.getDeckName()}.mochi` 165 | ); 166 | await this.zipFiles(savePath, cards); 167 | } else { 168 | new Notice("Export canceled."); 169 | this.progressModal.close(); 170 | } 171 | } 172 | } catch (error) { 173 | new Notice(this.errorMessage); 174 | this.progressModal.close(); 175 | } 176 | } 177 | } 178 | 179 | async zipFiles(savePath: string, cards: Card[]) { 180 | const count = cards.length; 181 | 182 | const successMessage = `${count} Card${ 183 | count > 1 ? "s" : "" 184 | } Exported Successfully`; 185 | 186 | const mochiCardsEdn = await this.getMochiCardsEdn(cards); 187 | const buffer = strToU8(mochiCardsEdn); 188 | const fileBuffers: Map = new Map(); 189 | fileBuffers.set("data.edn", buffer); 190 | 191 | for (let i = 0; i < this.mediaFiles.length; i++) { 192 | let fileName = this.mediaFiles[i]; 193 | let tFile = this.app.vault 194 | .getFiles() 195 | .filter((tFile) => this.removeSpaces(tFile.name) === fileName.fileName) 196 | .first(); 197 | 198 | if (tFile) { 199 | let buffer = await this.app.vault.readBinary(tFile); 200 | fileBuffers.set(fileName.uid, new Uint8Array(buffer)); 201 | } 202 | } 203 | 204 | const files: AsyncZippable = {}; 205 | fileBuffers.forEach((buffer, fileName) => (files[fileName] = buffer)); 206 | await zip(files, async (err, data) => { 207 | if (err) { 208 | console.log(err); 209 | throw err; 210 | } else await this.saveFile(savePath, data, successMessage, this.errorMessage); 211 | }); 212 | } 213 | 214 | saveFile( 215 | path: string, 216 | file: Uint8Array, 217 | successMessage: string, 218 | errorMessage: string 219 | ) { 220 | fs.writeFile(path, file, (error: any) => { 221 | if (error) { 222 | console.log(error); 223 | new Notice(errorMessage); 224 | this.progressModal.close(); 225 | } else { 226 | new Notice(successMessage); 227 | this.progressModal.close(); 228 | } 229 | }); 230 | } 231 | } 232 | 233 | export default MochiExporter; 234 | --------------------------------------------------------------------------------