├── 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 |
--------------------------------------------------------------------------------