├── .npmrc ├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── .editorconfig ├── jest.config.js ├── versions.json ├── .gitignore ├── manifest.json ├── tsconfig.json ├── version-bump.mjs ├── .eslintrc ├── test └── format.test.ts ├── src ├── media.ts ├── format.ts ├── setting.ts ├── lang.ts ├── anki.ts ├── note.ts └── state.ts ├── package.json ├── esbuild.config.mjs ├── .github └── workflows │ └── release.yml ├── README.zh.md ├── README.md └── main.ts /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | npm node_modules 2 | build -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | main.js 2 | data.json 3 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "none", 4 | "arrowParens": "avoid", 5 | "printWidth": 100 6 | } 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node' 5 | }; 6 | -------------------------------------------------------------------------------- /versions.json: -------------------------------------------------------------------------------- 1 | { 2 | "0.0.1": "0.14.0", 3 | "0.0.2": "0.14.0", 4 | "0.0.3": "0.14.0", 5 | "0.0.4": "0.14.0", 6 | "0.0.5": "0.14.0", 7 | "0.0.6": "0.14.0", 8 | "0.1.0": "0.14.0", 9 | "0.1.1": "0.14.0", 10 | "0.1.2": "0.14.0", 11 | "0.1.3": "1.0.0", 12 | "0.1.4": "1.0.0", 13 | "0.1.5": "1.0.0" 14 | } 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # vscode 2 | .vscode 3 | 4 | # Intellij 5 | *.iml 6 | .idea 7 | 8 | # npm 9 | node_modules 10 | 11 | # Don't include the compiled main.js file in the repo. 12 | # They should be uploaded to GitHub releases instead. 13 | main.js 14 | 15 | # Exclude sourcemaps 16 | *.map 17 | 18 | # obsidian 19 | data.json 20 | 21 | # Exclude macOS Finder (System Explorer) View States 22 | .DS_Store 23 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "note-synchronizer", 3 | "name": "Note Synchronizer", 4 | "version": "0.1.5", 5 | "minAppVersion": "1.0.0", 6 | "description": "This is a plugin for synchornizing Obsidian notes to other note-based softwares like Anki, following more strictly the principles of Zettelkasten and treating each Obsidian file as a note.", 7 | "author": "Songchen Tan", 8 | "authorUrl": "https://tansongchen.com", 9 | "isDesktopOnly": true 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "baseUrl": ".", 5 | "inlineSourceMap": true, 6 | "inlineSources": true, 7 | "module": "ESNext", 8 | "target": "ES6", 9 | "allowJs": true, 10 | "noImplicitAny": true, 11 | "moduleResolution": "node", 12 | "importHelpers": true, 13 | "isolatedModules": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "lib": ["DOM", "ES5", "ES6", "ES7"] 17 | }, 18 | "include": ["**/*.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /version-bump.mjs: -------------------------------------------------------------------------------- 1 | import { readFileSync, writeFileSync } from 'fs'; 2 | 3 | const targetVersion = process.env.npm_package_version; 4 | 5 | // read minAppVersion from manifest.json and bump version to target version 6 | let manifest = JSON.parse(readFileSync('manifest.json', 'utf8')); 7 | const { minAppVersion } = manifest; 8 | manifest.version = targetVersion; 9 | writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t')); 10 | 11 | // update versions.json with target version and minAppVersion from manifest.json 12 | let versions = JSON.parse(readFileSync('versions.json', 'utf8')); 13 | versions[targetVersion] = minAppVersion; 14 | writeFileSync('versions.json', JSON.stringify(versions, null, '\t')); 15 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "env": { 5 | "node": true 6 | }, 7 | "plugins": ["@typescript-eslint"], 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/eslint-recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "prettier" 13 | ], 14 | "parserOptions": { 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "no-unused-vars": "off", 19 | "@typescript-eslint/no-unused-vars": [ 20 | "error", 21 | { 22 | "args": "none" 23 | } 24 | ], 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "@typescript-eslint/ban-ts-comment": "off", 27 | "no-prototype-builtins": "off", 28 | "@typescript-eslint/no-empty-function": "off" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/format.test.ts: -------------------------------------------------------------------------------- 1 | import Note from 'src/note'; 2 | import Formatter from '../src/format'; 3 | 4 | const markdownMode = new Formatter('卡片盒', { 5 | headingLevel: 1, 6 | render: false, 7 | linkify: true, 8 | highlightAsCloze: false 9 | }); 10 | 11 | test('Render back link', () => { 12 | expect(markdownMode.convertWikilink('[[笔记]]')).toBe( 13 | '[笔记](obsidian://open?vault=%E5%8D%A1%E7%89%87%E7%9B%92&file=%E7%AC%94%E8%AE%B0)' 14 | ); 15 | }); 16 | 17 | test('e2e', () => { 18 | const fields = { 正面: '笔记', 背面: '[[另一条笔记]]' }; 19 | const result = markdownMode.format(new Note('', '', '', { nid: 0, mid: 0, tags: [] }, fields)); 20 | expect(result['正面']).toBe( 21 | '[笔记](obsidian://open?vault=%E5%8D%A1%E7%89%87%E7%9B%92&file=%E7%AC%94%E8%AE%B0)' 22 | ); 23 | expect(result['背面']).toBe( 24 | '[另一条笔记](obsidian://open?vault=%E5%8D%A1%E7%89%87%E7%9B%92&file=%E5%8F%A6%E4%B8%80%E6%9D%A1%E7%AC%94%E8%AE%B0)' 25 | ); 26 | }); 27 | -------------------------------------------------------------------------------- /src/media.ts: -------------------------------------------------------------------------------- 1 | import { EmbedCache, MetadataCache, Vault, parseLinktext } from 'obsidian'; 2 | import path from 'path'; 3 | 4 | 5 | export default class Media { 6 | filename: string; 7 | path: string; 8 | deleteExisting: boolean; 9 | 10 | constructor(filename: string, path: string, deleteExisting = false) { 11 | this.filename = filename; 12 | this.path = path; 13 | this.deleteExisting = deleteExisting; 14 | } 15 | } 16 | 17 | export class MediaManager { 18 | parseMedia(item: EmbedCache, vault: Vault, metadataCache: MetadataCache) { 19 | const file_path = parseLinktext(item.link.replace(/(\.\/)|(\.\.\/)+/g, '')).path; 20 | let mediaFile = vault.getAbstractFileByPath( 21 | parseLinktext(item.link.replace(/(\.\/)|(\.\.\/)+/g, '')).path 22 | ); 23 | if (!mediaFile) mediaFile = metadataCache.getFirstLinkpathDest(file_path, file_path); 24 | 25 | // @ts-ignore 26 | const mediaAbsPath = vault.adapter.basePath + path.sep + mediaFile?.path.replace('/', path.sep); 27 | const mediaName = item.link.split('/').pop() as string; 28 | 29 | return new Media(mediaName, mediaAbsPath); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "obsidian-note-synchronizer", 3 | "version": "0.1.5", 4 | "description": "This is a plugin for synchornizing Obsidian notes to other note-based softwares like Anki, following more strictly the principles of Zettelkasten and treating each Obsidian file as a note.", 5 | "main": "main.js", 6 | "scripts": { 7 | "dev": "node esbuild.config.mjs", 8 | "format": "npx prettier --write .", 9 | "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", 10 | "version": "node version-bump.mjs && git add manifest.json versions.json", 11 | "test": "jest" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "MIT", 16 | "dependencies": { 17 | "markdown-it": "^13.0.1", 18 | "markdown-it-highlightjs": "^4.0.1", 19 | "object-hash": "^3.0.0" 20 | }, 21 | "devDependencies": { 22 | "@types/jest": "^29.2.3", 23 | "@types/markdown-it": "^12.2.3", 24 | "@types/node": "^18.11.9", 25 | "@types/object-hash": "^2.2.1", 26 | "@typescript-eslint/eslint-plugin": "^5.44.0", 27 | "@typescript-eslint/parser": "^5.44.0", 28 | "builtin-modules": "^3.3.0", 29 | "esbuild": "^0.15.15", 30 | "eslint-config-prettier": "^8.6.0", 31 | "jest": "^29.3.1", 32 | "obsidian": "latest", 33 | "prettier": "2.8.4", 34 | "ts-jest": "^29.0.3", 35 | "typescript": "^4.9.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /esbuild.config.mjs: -------------------------------------------------------------------------------- 1 | import esbuild from 'esbuild'; 2 | import process from 'process'; 3 | import builtins from 'builtin-modules'; 4 | 5 | const banner = `/* 6 | THIS IS A GENERATED/BUNDLED FILE BY ESBUILD 7 | if you want to view the source, please visit the github repository of this plugin 8 | */ 9 | `; 10 | 11 | const prod = process.argv[2] === 'production'; 12 | 13 | esbuild 14 | .build({ 15 | banner: { 16 | js: banner 17 | }, 18 | entryPoints: ['main.ts'], 19 | bundle: true, 20 | external: [ 21 | 'obsidian', 22 | 'electron', 23 | '@codemirror/autocomplete', 24 | '@codemirror/closebrackets', 25 | '@codemirror/collab', 26 | '@codemirror/commands', 27 | '@codemirror/comment', 28 | '@codemirror/fold', 29 | '@codemirror/gutter', 30 | '@codemirror/highlight', 31 | '@codemirror/history', 32 | '@codemirror/language', 33 | '@codemirror/lint', 34 | '@codemirror/matchbrackets', 35 | '@codemirror/panel', 36 | '@codemirror/rangeset', 37 | '@codemirror/rectangular-selection', 38 | '@codemirror/search', 39 | '@codemirror/state', 40 | '@codemirror/stream-parser', 41 | '@codemirror/text', 42 | '@codemirror/tooltip', 43 | '@codemirror/view', 44 | ...builtins 45 | ], 46 | format: 'cjs', 47 | watch: !prod, 48 | target: 'es2016', 49 | logLevel: 'info', 50 | sourcemap: prod ? false : 'inline', 51 | treeShaking: true, 52 | outfile: 'main.js' 53 | }) 54 | .catch(() => process.exit(1)); 55 | -------------------------------------------------------------------------------- /src/format.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from './setting'; 2 | import MarkdownIt from 'markdown-it'; 3 | import highlightjs from 'markdown-it-highlightjs'; 4 | import Note from './note'; 5 | 6 | export default class Formatter { 7 | private settings: Settings; 8 | private mdit = new MarkdownIt({ 9 | html: true, 10 | linkify: true 11 | }).use(highlightjs); 12 | private vaultName: string; 13 | 14 | constructor(vaultName: string, settings: Settings) { 15 | this.vaultName = vaultName; 16 | this.settings = settings; 17 | } 18 | 19 | convertWikilink(markup: string) { 20 | return markup.replace(/!?\[\[(.+?)\]\]/g, (match, basename) => { 21 | const url = `obsidian://open?vault=${encodeURIComponent( 22 | this.vaultName 23 | )}&file=${encodeURIComponent(basename)}`; 24 | return `[${basename}](${url})`; 25 | }); 26 | } 27 | 28 | convertHighlightToCloze(markup: string) { 29 | let index = 0; 30 | while (markup.match(/==(.+?)==/) !== null) { 31 | index += 1; 32 | markup = markup.replace(/==(.+?)==/, (match, content) => { 33 | return `{{c${index}::${content}}}`; 34 | }); 35 | } 36 | return markup; 37 | } 38 | 39 | markdown(markup: string) { 40 | markup = this.convertWikilink(markup); 41 | if (this.settings.highlightAsCloze) { 42 | markup = this.convertHighlightToCloze(markup); 43 | } 44 | return markup; 45 | } 46 | 47 | convertMathDelimiter(markdown: string) { 48 | markdown = markdown.replace(/\$(.+?)\$/g, '$1'); 49 | markdown = markdown.replace(/\$\$(.+?)\$\$/gs, '$1'); 50 | return markdown; 51 | } 52 | 53 | html(markdown: string, index: number) { 54 | markdown = this.convertMathDelimiter(markdown); 55 | return index == 0 ? this.mdit.renderInline(markdown) : this.mdit.render(markdown); 56 | } 57 | 58 | format(note: Note) { 59 | const fields = note.fields; 60 | const keys = Object.keys(fields); 61 | const result: Record = {}; 62 | keys.map((key, index) => { 63 | const linkify = index == 0 && this.settings.linkify && !note.isCloze(); 64 | const field = linkify ? `[[${fields[key]}]]` : fields[key]; 65 | const markdown = this.markdown(field); 66 | result[key] = this.settings.render ? this.html(markdown, index) : markdown; 67 | }); 68 | return result; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/setting.ts: -------------------------------------------------------------------------------- 1 | import { App, PluginSettingTab, Setting } from 'obsidian'; 2 | import locale from 'src/lang'; 3 | import AnkiSynchronizer from 'main'; 4 | 5 | // Plugin Settings 6 | export interface Settings { 7 | render: boolean; 8 | linkify: boolean; 9 | headingLevel: number; 10 | highlightAsCloze: boolean; 11 | } 12 | 13 | export const DEFAULT_SETTINGS: Settings = { 14 | render: true, 15 | linkify: true, 16 | headingLevel: 1, 17 | highlightAsCloze: false 18 | }; 19 | 20 | export default class AnkiSynchronizerSettingTab extends PluginSettingTab { 21 | plugin: AnkiSynchronizer; 22 | 23 | constructor(app: App, plugin: AnkiSynchronizer) { 24 | super(app, plugin); 25 | this.plugin = plugin; 26 | } 27 | 28 | display(): void { 29 | this.containerEl.empty(); 30 | this.containerEl.createEl('h2', { text: locale.settingTabHeader }); 31 | 32 | new Setting(this.containerEl) 33 | .setName(locale.settingRenderName) 34 | .setDesc(locale.settingRenderDescription) 35 | .addToggle(v => 36 | v.setValue(this.plugin.settings.render).onChange(async value => { 37 | this.plugin.settings.render = value; 38 | }) 39 | ); 40 | 41 | new Setting(this.containerEl) 42 | .setName(locale.settingLinkifyName) 43 | .setDesc(locale.settingLinkifyDescription) 44 | .addToggle(v => 45 | v.setValue(this.plugin.settings.linkify).onChange(async value => { 46 | this.plugin.settings.linkify = value; 47 | }) 48 | ); 49 | 50 | new Setting(this.containerEl) 51 | .setName(locale.settingHighlightAsClozeName) 52 | .setDesc(locale.settingHighlightAsClozeDescription) 53 | .addToggle(v => 54 | v.setValue(this.plugin.settings.highlightAsCloze).onChange(async value => { 55 | this.plugin.settings.highlightAsCloze = value; 56 | }) 57 | ); 58 | 59 | new Setting(this.containerEl) 60 | .setName(locale.settingHeadingLevelName) 61 | .setDesc(locale.settingHeadingLevelDescription) 62 | .addDropdown(v => 63 | v 64 | .addOptions({ 65 | '1': 'h1', 66 | '2': 'h2', 67 | '3': 'h3', 68 | '4': 'h4', 69 | '5': 'h5', 70 | '6': 'h6' 71 | }) 72 | .setValue(this.plugin.settings.headingLevel.toString()) 73 | .onChange(async value => { 74 | this.plugin.settings.headingLevel = parseInt(value); 75 | }) 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release Obsidian plugin 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | env: 9 | PLUGIN_NAME: obsidian-anki-synchronizer # Change this to match the id of your plugin. 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Setup Node 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: 16 22 | - name: Build 23 | id: build 24 | run: | 25 | npm install 26 | npm run build 27 | mkdir ${{ env.PLUGIN_NAME }} 28 | cp main.js manifest.json ${{ env.PLUGIN_NAME }} 29 | zip -r ${{ env.PLUGIN_NAME }}.zip ${{ env.PLUGIN_NAME }} 30 | ls 31 | echo "::set-output name=tag_name::$(git tag --sort version:refname | tail -n 1)" 32 | - name: Create Release 33 | id: create_release 34 | uses: actions/create-release@v1 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | VERSION: ${{ github.ref }} 38 | with: 39 | tag_name: ${{ github.ref }} 40 | release_name: ${{ github.ref }} 41 | draft: false 42 | prerelease: false 43 | - name: Upload zip file 44 | id: upload-zip 45 | uses: actions/upload-release-asset@v1 46 | env: 47 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 48 | with: 49 | upload_url: ${{ steps.create_release.outputs.upload_url }} 50 | asset_path: ./${{ env.PLUGIN_NAME }}.zip 51 | asset_name: ${{ env.PLUGIN_NAME }}-${{ steps.build.outputs.tag_name }}.zip 52 | asset_content_type: application/zip 53 | - name: Upload main.js 54 | id: upload-main 55 | uses: actions/upload-release-asset@v1 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | with: 59 | upload_url: ${{ steps.create_release.outputs.upload_url }} 60 | asset_path: ./main.js 61 | asset_name: main.js 62 | asset_content_type: text/javascript 63 | - name: Upload manifest.json 64 | id: upload-manifest 65 | uses: actions/upload-release-asset@v1 66 | env: 67 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 68 | with: 69 | upload_url: ${{ steps.create_release.outputs.upload_url }} 70 | asset_path: ./manifest.json 71 | asset_name: manifest.json 72 | asset_content_type: application/json 73 | -------------------------------------------------------------------------------- /README.zh.md: -------------------------------------------------------------------------------- 1 | # Obsidian Anki 同步 2 | 3 | ## 特性 4 | 5 | - 支持任意 Anki 笔记类型,可将 Anki 的笔记类型导入为 Obsidian 笔记模板 6 | - Anki 笔记与 Obsidian 笔记一一对应,Anki 牌组与 Obsidian 文件夹一一对应,Anki 标签与 Obsidian 标签一一对应 7 | - 导入 Anki 时将 Obsidian 链接转换为 Markdown 链接,方便卡片学习时跳转 8 | 9 | 本插件有两种工作模式: 10 | 11 | - Markdown 模式:将 Markdown 文本原封不动导入 Anki,需要使用 [Markdown and KaTeX Support](https://ankiweb.net/shared/info/1087328706) 或类似插件自行配置 Anki 中的实时 Markdown 渲染; 12 | - HTML 模式:将 Markdown 渲染成 HTML 之后导入 Anki。 13 | 14 | ## 安装 15 | 16 | 在 Obsidian 插件市场中搜索「Note Synchronizer」并根据提示安装即可。 17 | 18 | ## 配置 19 | 20 | 在运行本插件之前,您需要确定您的环境满足以下要求: 21 | 22 | ### 启用 Obsidian 核心插件「模板」 23 | 24 | 本插件依赖于核心插件「模板」来确定应该将 Anki 笔记模板生成到哪个文件夹中。您需要在 Obsidian 设置页面的「核心插件」选项中启用「模板」。 25 | 26 | ### 安装并配置 Anki Connect 27 | 28 | 像其他 Anki 插件一样安装 [Anki Connect](https://ankiweb.net/shared/info/2055492159)。安装完成后,在「工具 - 插件 - Anki Connect - 配置」中粘贴以下文本: 29 | 30 | ```json 31 | { 32 | "apiKey": null, 33 | "apiLogPath": null, 34 | "webBindAddress": "127.0.0.1", 35 | "webBindPort": 8765, 36 | "webCorsOrigin": "http://localhost", 37 | "webCorsOriginList": ["http://localhost", "app://obsidian.md"] 38 | } 39 | ``` 40 | 41 | ### Anki 处于打开状态并切换到需要同步的 Anki 用户 42 | 43 | 重启 Anki,选取您希望与 Obsidian 同步的用户并进入。目前您只能选择将 Obsidian 笔记同步到一个 Anki 用户的资料中,请确保您每次使用本插件时 Anki 打开的都是同一用户。 44 | 45 | ## 使用 46 | 47 | ### 提取模板 48 | 49 | 首次安装后运行命令「导入笔记类型」,会提取 Anki 中所有的笔记类型到当前知识库的模板目录下,对每个笔记类型生成一个模板文件。所有模板文件都有这样的 YAML 前言: 50 | 51 | ```yaml 52 | mid: 16xxxxxxxxxxx 53 | nid: 0 54 | tags: [] 55 | date: {{date}} {{time}} 56 | ``` 57 | 58 | 其中 `mid` 是一个以 16 开头的数字表示 Anki 笔记类型的 ID。如果这个笔记类型有三个或更多的字段,那么第三个及以后的字段名称会以一级标题的形式出现在正文。例如,如果笔记类型「费曼笔记」的字段是「概念、定义、实例、类比、备注」,则生成的模板文件形如: 59 | 60 | ```markdown 61 | --- 62 | mid: 1654893531468 63 | nid: 0 64 | tags: [] 65 | date: {{date}} {{time}} 66 | --- 67 | 68 | # 实例 69 | 70 | # 类比、比较与对比 71 | 72 | # 备注 73 | ``` 74 | 75 | ### 编辑笔记 76 | 77 | 使用本插件生成的模板文件创建一个新笔记时,请将笔记的第一个字段写在文件名中(第一个字段一般是概念名称),然后第二个字段写在 YAML 前言的后面,第三个及之后的字段写在相应的一级标题下。 78 | 79 | 笔记所在的文件夹默认是你希望卡片所在的牌组,例如 `/学习/笔记.md` 将会被同步到 Anki 的 `学习` 牌组中,`/学习/项目 1/笔记.md` 将会被同步到 Anki 的 `学习::项目 1` 牌组中。 80 | 81 | ### 填空题的特殊处理 82 | 83 | 插件版本 v0.1.2 以上可以用 Obsidian 的高亮语法 `==content==` 来标注笔记中需要填空的内容,使其可以用于制作 Anki 的填空题笔记。要使用这个功能,首先要在设置中打开「将高亮用作 Anki 填空题」这个选项。此外,在 Anki 中已有的填空题笔记类型的名字必须为「填空题」或者「Cloze」,否则无法识别出来作特殊处理。 84 | 85 | 由于填空题一般主要内容都填写在第一个字段中,没有类似于「概念」、「标题」、「主题」等可以作为索引(即文件名)的字段,所以需要对填空题作特殊处理。导入笔记模板时,第二个及以后(而非第三个及以后)的字段会以一级标题的形式出现在正文。而在编辑笔记时,请将第一个字段写在 YAML 前言的后面,第二个及以后的字段写在相应的一级标题下。例如,以下笔记内容 86 | 87 | ```markdown 88 | --- 89 | mid: 1670708523483 90 | nid: 1673705987889 91 | tags: [] 92 | date: 2023-01-14 09:15 93 | --- 94 | 95 | 这是==需要记忆==的内容。 96 | 97 | # 背面额外 98 | ``` 99 | 100 | 会被制作为第一字段为「这是{{c1::需要记忆}}的内容。」、第二字段为空的 Anki 填空题笔记。 101 | 102 | ### 同步笔记 103 | 104 | 运行命令「同步」。如果没有按照预想的生成 Anki 中的笔记,请打开调试控制台并向作者报告控制台中的输出。 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Obsidian Anki Synchronizer 2 | 3 | See Chinese README [here](README.zh.md). 4 | 5 | ## Features 6 | 7 | - Support arbitrary Anki note type by importing Anki note types as Obsidian note templates 8 | - 1:1 correspondence between Anki notes and Obsidian notes, Anki decks and Obsidian folders, Anki tags and Obsidian tags 9 | - Converting Obsidian wikilinks to Markdown links (Obsidian URL) 10 | 11 | The plugin works in two modes: 12 | 13 | - Markdown mode: importing markdown content into Anki as-is, without rendering into HTML. In order to view rendered content in Anki, you need [Markdown and KaTeX Support](https://ankiweb.net/shared/info/1087328706) or similar Anki plugins to get real-time rendering. 14 | - HTML mode: importing rendered HTML into Anki. 15 | 16 | ## Installation 17 | 18 | Install via the Obsidian community plugin marketplace by searching "Note Synchronizer". 19 | 20 | ## Setup 21 | 22 | Before running this plugin, make sure that the following requirements are met: 23 | 24 | ### Enable Obsidian plugin "Templates" 25 | 26 | This plugin depends on the core plugin "Templates". You need to enable it for this plugin to work. Go to the Obsidian settings "Core Plugins" tab and enable "Templates". 27 | 28 | ### Install and configure Anki Connect 29 | 30 | Install [Anki Connect](https://ankiweb.net/shared/info/2055492159) in the same way as other Anki plugins. After installation, navigate to `Tools -> Addons -> AnkiConnect -> Config`, paste the following text: 31 | 32 | ```json 33 | { 34 | "apiKey": null, 35 | "apiLogPath": null, 36 | "webBindAddress": "127.0.0.1", 37 | "webBindPort": 8765, 38 | "webCorsOrigin": "http://localhost", 39 | "webCorsOriginList": ["http://localhost", "app://obsidian.md"] 40 | } 41 | ``` 42 | 43 | ### Restart Anki and navigate to the desired profile 44 | 45 | Restart Anki and select the profile you want to sync with Obsidian. Right now, you can only select one Anki profile to sync with obsidian. 46 | 47 | ## Usage 48 | 49 | ### Import Note Types 50 | 51 | Run command `Import Note Types` to import all available note types in Anki to the template folder in the current vault, generating a template markdown file for each of the note types. All template markdown files generated have some YAML front matter like this: 52 | 53 | ```yaml 54 | mid: 16xxxxxxxxxxx 55 | nid: 0 56 | tags: [] 57 | date: {{date}} {{time}} 58 | ``` 59 | 60 | Where `mid` is a number representing the note type ID in Anki. If this note type happen to have 3 or more fields, the third field and all other fields after that will appear as `h1` title in the markdown file. 61 | 62 | ### Edit Notes 63 | 64 | When creating notes with generated template files, please write the content of the first field into the filename, and the second field right after the YAML front matter, and other fields below their corresponding `h1` title. 65 | 66 | The way that notes are organized in Obsidian will be mirrored in Anki using decks. For example, the file `/learning/note.md` will be synced to the `learning` deck in Anki, and the file `/learning/project 1/note.md` will be synced to `learning::project 1` deck in Anki. Toplevel files will be synced to a special deck `Obsidian`. If the supposed deck doesn't exist in Anki, it will be created. 67 | 68 | ### Synchonize notes 69 | 70 | Run command `Synchronize`. If unexpected behavior happens, please toggle the developer console and report the output there. 71 | -------------------------------------------------------------------------------- /src/lang.ts: -------------------------------------------------------------------------------- 1 | import { moment } from 'obsidian'; 2 | 3 | interface Locale { 4 | onLoad: string; 5 | onUnload: string; 6 | synchronizeCommandName: string; 7 | templatesNotEnabledNotice: string; 8 | templatesFolderUndefinedNotice: string; 9 | importCommandName: string; 10 | importStartNotice: string; 11 | importSuccessNotice: string; 12 | importFailureNotice: string; 13 | synchronizeStartNotice: string; 14 | synchronizeSuccessNotice: string; 15 | synchronizeBadAnkiConnectNotice: string; 16 | synchronizeAnkiConnectUnavailableNotice: string; 17 | synchronizeAddNoteFailureNotice: (title: string) => string; 18 | synchronizeChangeDeckFailureNotice: (title: string) => string; 19 | synchronizeUpdateFieldsFailureNotice: (title: string) => string; 20 | synchronizeUpdateTagsFailureNotice: (title: string) => string; 21 | settingTabHeader: string; 22 | settingRenderName: string; 23 | settingRenderDescription: string; 24 | settingLinkifyName: string; 25 | settingLinkifyDescription: string; 26 | settingHighlightAsClozeName: string; 27 | settingHighlightAsClozeDescription: string; 28 | settingHeadingLevelName: string; 29 | settingHeadingLevelDescription: string; 30 | } 31 | 32 | const en: Locale = { 33 | onLoad: 'Note Synchronizer is successfully loaded!', 34 | onUnload: 'Note Synchronizer is successfully unloaded!', 35 | synchronizeCommandName: 'Synchronize', 36 | templatesNotEnabledNotice: 'Core plugin Templates is not enabled!', 37 | templatesFolderUndefinedNotice: 'Templates folder is undefined!', 38 | importCommandName: 'Import Note Types', 39 | importStartNotice: 'Importing note types from Anki...', 40 | importSuccessNotice: 'Successfully imported note types from Anki!', 41 | importFailureNotice: 'Cannot import note types from Anki!', 42 | synchronizeStartNotice: 'Synchronizing to Anki...', 43 | synchronizeSuccessNotice: 'Successfully synchronized to Anki!', 44 | synchronizeBadAnkiConnectNotice: `Bad version of AnkiConnect`, 45 | synchronizeAnkiConnectUnavailableNotice: `Anki is not opened or AnkiConnect is not installed!`, 46 | synchronizeAddNoteFailureNotice: (title: string) => `Cannot add note for ${title}`, 47 | synchronizeChangeDeckFailureNotice: (title: string) => `Cannot change deck for ${title}`, 48 | synchronizeUpdateFieldsFailureNotice: (title: string) => `Cannot update fields for ${title}`, 49 | synchronizeUpdateTagsFailureNotice: (title: string) => `Cannot update tags for ${title}`, 50 | settingTabHeader: 'Note Synchronizer Settings', 51 | settingRenderName: 'Render', 52 | settingRenderDescription: 'Whether to render markdown before importing to Anki or not.', 53 | settingLinkifyName: 'Linkify', 54 | settingLinkifyDescription: 'Whether to linkify the Obsidian title', 55 | settingHighlightAsClozeName: 'Highlight as Cloze', 56 | settingHighlightAsClozeDescription: 'Enable using Obsidian highlights (==...==) for Anki clozes', 57 | settingHeadingLevelName: 'Field name heading level', 58 | settingHeadingLevelDescription: 59 | 'Which level (h1, h2, h3, ...) to use for field names when generating the note template' 60 | }; 61 | 62 | const zh_cn: Locale = { 63 | onLoad: '笔记同步插件已成功启用!', 64 | onUnload: '笔记同步插件已成功禁用!', 65 | synchronizeCommandName: '同步', 66 | templatesNotEnabledNotice: '核心插件「模板」未启用,操作无法执行!', 67 | templatesFolderUndefinedNotice: '核心插件「模板」尚未配置模板文件夹位置,操作无法执行!', 68 | importCommandName: '导入笔记类型', 69 | importStartNotice: '正在从 Anki 导入笔记类型……', 70 | importSuccessNotice: '已成功为 Anki 导入笔记类型!', 71 | importFailureNotice: '无法从 Anki 导入笔记类型!', 72 | synchronizeStartNotice: '正在与 Anki 同步笔记……', 73 | synchronizeSuccessNotice: '已成功与 Anki 同步笔记!', 74 | synchronizeBadAnkiConnectNotice: 'Anki Connect 版本不匹配!', 75 | synchronizeAnkiConnectUnavailableNotice: 'Anki 未打开或 Anki Connect 未安装!', 76 | synchronizeAddNoteFailureNotice: (title: string) => `无法添加笔记「${title}」`, 77 | synchronizeChangeDeckFailureNotice: (title: string) => `无法改变笔记「${title}」的牌组`, 78 | synchronizeUpdateFieldsFailureNotice: (title: string) => `无法更新笔记「${title}」的字段`, 79 | synchronizeUpdateTagsFailureNotice: (title: string) => `无法更新笔记「${title}」的标签`, 80 | settingTabHeader: '笔记同步设置', 81 | settingRenderName: '渲染', 82 | settingRenderDescription: '是否在导入时将 Markdown 渲染为 HTML', 83 | settingLinkifyName: '回链', 84 | settingLinkifyDescription: '是否将标题字段加上返回 Obsidian 的链接', 85 | settingHighlightAsClozeName: '将高亮用作 Anki 填空题', 86 | settingHighlightAsClozeDescription: '启用将 Obsidian 高亮的文本转换为 Anki 填空', 87 | settingHeadingLevelName: '字段名称标题层级', 88 | settingHeadingLevelDescription: 89 | '从 Anki 笔记类型生成模板时,将 Anki 的字段名称表示为几级标题(一级、二级、三级等)' 90 | }; 91 | 92 | const locales: { [k: string]: Partial } = { 93 | en, 94 | 'zh-cn': zh_cn 95 | }; 96 | 97 | const locale: Locale = Object.assign({}, en, locales[moment.locale()]); 98 | 99 | export default locale; 100 | -------------------------------------------------------------------------------- /src/anki.ts: -------------------------------------------------------------------------------- 1 | import { Notice, requestUrl } from 'obsidian'; 2 | import locale from './lang'; 3 | import Media from './media'; 4 | 5 | interface Request

{ 6 | action: string; 7 | version: number; 8 | params: P; 9 | } 10 | 11 | interface Response { 12 | error: string | null; 13 | result: R; 14 | } 15 | 16 | export class AnkiError extends Error {} 17 | 18 | export interface Note { 19 | deckName: string; 20 | modelName: string; 21 | fields: Record; 22 | options?: { 23 | allowDuplicate: boolean; 24 | duplicateScope: string; 25 | }; 26 | tags: Array; 27 | } 28 | 29 | class Anki { 30 | private port = 8765; 31 | 32 | async invoke(action: string, params: Params): Promise { 33 | type requestType = Request; 34 | type responseType = Response; 35 | const request: requestType = { 36 | action: action, 37 | version: 6, 38 | params: params 39 | }; 40 | try { 41 | const { json } = await requestUrl({ 42 | url: `http://127.0.0.1:${this.port}`, 43 | method: `POST`, 44 | contentType: `application/json`, 45 | body: JSON.stringify(request) 46 | }); 47 | const data = json as responseType; 48 | if (data.error !== null) { 49 | return new AnkiError(data.error); 50 | } 51 | return data.result; 52 | } catch (error) { 53 | new Notice(locale.synchronizeAnkiConnectUnavailableNotice); 54 | throw error; 55 | } 56 | } 57 | 58 | async multi(actionName: string, actionList: P[]) { 59 | return this.invoke, 'version'>[] }>('multi', { 60 | actions: actionList.map(params => ({ 61 | action: actionName, 62 | params: params 63 | })) 64 | }); 65 | } 66 | 67 | // read-only 68 | 69 | async version() { 70 | return this.invoke('version', undefined); 71 | } 72 | 73 | async noteTypes() { 74 | return this.invoke('modelNames', undefined); 75 | } 76 | 77 | async noteTypesAndIds() { 78 | return this.invoke>('modelNamesAndIds', undefined); 79 | } 80 | 81 | async fields(noteType: string) { 82 | return this.invoke('modelFieldNames', { 83 | modelName: noteType 84 | }); 85 | } 86 | 87 | async findNotes(query: string) { 88 | return this.invoke('findNotes', { 89 | query: query 90 | }); 91 | } 92 | 93 | 94 | async notesInfo(noteIds: number[]) { 95 | return this.invoke<{ cards: number[], tags: string[], noteId: string }[], { notes: number[] }>('notesInfo', { 96 | notes: noteIds 97 | }); 98 | } 99 | 100 | async notesInfoByDeck(deck: string): Promise { 101 | const notesIds = await this.findNotes(`deck:${deck}`); 102 | if (notesIds instanceof AnkiError) { 103 | return notesIds; 104 | } 105 | return await this.notesInfo(notesIds); 106 | 107 | 108 | } 109 | 110 | // write-only 111 | 112 | async addMedia(media: Media) { 113 | return this.invoke('storeMediaFile', { 114 | filename: media.filename, 115 | path: media.path, 116 | deleteExisting: media.deleteExisting 117 | }); 118 | } 119 | 120 | async addNote(note: Note) { 121 | return this.invoke('addNote', { 122 | note: note 123 | }); 124 | } 125 | 126 | async updateFields(id: number, fields: Record) { 127 | return this.invoke('updateNoteFields', { 128 | note: { 129 | id: id, 130 | fields: fields 131 | } 132 | }); 133 | } 134 | 135 | async updateNoteTags(noteId: number, tags: string[]) { 136 | const tagstring = tags.map(item => item.replace(/\//g, '::')).join(' '); 137 | return this.invoke('updateNoteTags', { 138 | note: noteId, 139 | tags: tagstring 140 | }); 141 | } 142 | 143 | async addTagsToNotes(noteIds: number[], tags: string[]) { 144 | const tagstring = tags.join(' '); 145 | return this.invoke('addTags', { 146 | notes: noteIds, 147 | tags: tagstring 148 | }); 149 | } 150 | 151 | async removeTagsFromNotes(noteIds: number[], tags: string[]) { 152 | const tagstring = tags.join(' '); 153 | return this.invoke('removeTags', { 154 | notes: noteIds, 155 | tags: tagstring 156 | }); 157 | } 158 | 159 | async deleteNotes(noteIds: number[]) { 160 | return this.invoke('deleteNotes', { 161 | notes: noteIds 162 | }); 163 | } 164 | 165 | async changeDeck(cardIds: number[], deck: string) { 166 | return this.invoke('changeDeck', { 167 | cards: cardIds, 168 | deck: deck 169 | }); 170 | } 171 | 172 | async createDeck(deckName: string) { 173 | return this.invoke('createDeck', { 174 | deck: deckName 175 | }); 176 | } 177 | } 178 | 179 | export default Anki; 180 | -------------------------------------------------------------------------------- /src/note.ts: -------------------------------------------------------------------------------- 1 | import { stringifyYaml, FrontMatterCache, TFile, EmbedCache } from 'obsidian'; 2 | import { NoteDigest, NoteTypeDigest } from './state'; 3 | import { MD5 } from 'object-hash'; 4 | import { Settings } from './setting'; 5 | 6 | const PICTURE_EXTENSION = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg']; 7 | const VIDEO_EXTENSION = [ 8 | 'mp3', 9 | 'wav', 10 | 'm4a', 11 | 'ogg', 12 | '3gp', 13 | 'flac', 14 | 'mp4', 15 | 'ogv', 16 | 'mov', 17 | 'mkv', 18 | 'webm' 19 | ]; 20 | 21 | export interface MediaNameMap { 22 | obsidian: string; 23 | anki: string; 24 | } 25 | 26 | export interface FrontMatter { 27 | mid: number; 28 | nid: number; 29 | tags: string[]; 30 | } 31 | 32 | export default class Note { 33 | basename: string; 34 | folder: string; 35 | nid: number; 36 | mid: number; 37 | tags: string[]; 38 | fields: Record; 39 | typeName: string; 40 | extras: object; 41 | 42 | constructor( 43 | basename: string, 44 | folder: string, 45 | typeName: string, 46 | frontMatter: FrontMatter, 47 | fields: Record 48 | ) { 49 | this.basename = basename; 50 | this.folder = folder; 51 | const { mid, nid, tags, ...extras } = frontMatter; 52 | this.typeName = typeName; 53 | this.mid = mid; 54 | this.nid = nid; 55 | this.tags = tags; 56 | this.extras = extras; 57 | this.fields = fields; 58 | } 59 | 60 | digest(): NoteDigest { 61 | return { 62 | deck: this.renderDeckName(), 63 | hash: MD5(this.fields), 64 | tags: this.tags 65 | }; 66 | } 67 | 68 | title() { 69 | return this.basename; 70 | } 71 | 72 | renderDeckName() { 73 | return this.folder.replace(/\//g, '::') || 'Obsidian'; 74 | } 75 | 76 | isCloze() { 77 | return this.typeName === '填空题' || this.typeName === 'Cloze'; 78 | } 79 | } 80 | 81 | export class NoteManager { 82 | private settings: Settings; 83 | 84 | constructor(settings: Settings) { 85 | this.settings = settings; 86 | } 87 | 88 | validateNote( 89 | file: TFile, 90 | frontmatter: FrontMatterCache, 91 | content: string, 92 | media: EmbedCache[] | undefined, 93 | noteTypes: Map 94 | ): [Note | undefined, MediaNameMap[] | undefined] { 95 | if ( 96 | !frontmatter.hasOwnProperty('mid') || 97 | !frontmatter.hasOwnProperty('nid') || 98 | !frontmatter.hasOwnProperty('tags') 99 | ) 100 | return [undefined, undefined]; 101 | const frontMatter = Object.assign({}, frontmatter, { position: undefined }) as FrontMatter; 102 | const lines = content.split('\n'); 103 | const yamlEndIndex = lines.indexOf('---', 1); 104 | const body = lines.slice(yamlEndIndex + 1); 105 | const noteType = noteTypes.get(frontMatter.mid); 106 | if (!noteType) return [undefined, undefined]; 107 | const [fields, mediaNameMap] = this.parseFields(file.basename, noteType, body, media); 108 | if (!fields) return [undefined, undefined]; 109 | // now it is a valid Note 110 | const basename = file.basename; 111 | const folder = file.parent.path == '/' ? '' : file.parent.path; 112 | return [new Note(basename, folder, noteType.name, frontMatter, fields), mediaNameMap]; 113 | } 114 | 115 | parseFields( 116 | title: string, 117 | noteType: NoteTypeDigest, 118 | body: string[], 119 | media: EmbedCache[] | undefined 120 | ): [Record | undefined, MediaNameMap[] | undefined] { 121 | const fieldNames = noteType.fieldNames; 122 | const headingLevel = this.settings.headingLevel; 123 | const isCloze = noteType.name === '填空题' || noteType.name === 'Cloze'; 124 | const fieldContents: string[] = isCloze ? [] : [title]; 125 | const mediaNameMap: MediaNameMap[] = []; 126 | let buffer: string[] = []; 127 | let mediaCount = 0; 128 | for (const line of body) { 129 | if (line.slice(0, headingLevel + 1) === '#'.repeat(headingLevel) + ' ') { 130 | fieldContents.push(buffer.join('\n')); 131 | buffer = []; 132 | } else { 133 | if ( 134 | media && 135 | mediaCount < media.length && 136 | line.includes(media[mediaCount].original) && 137 | this.validateMedia(media[mediaCount].link) 138 | ) { 139 | let mediaName = line.replace( 140 | media[mediaCount].original, 141 | media[mediaCount].link.split('/').pop() as string 142 | ); 143 | if (this.isPicture(mediaName)) mediaName = ''; 144 | else mediaName = '[sound:' + mediaName + ']'; 145 | if (!mediaNameMap.map(d => d.obsidian).includes(media[mediaCount].original)) { 146 | mediaNameMap.push({ obsidian: media[mediaCount].original, anki: mediaName }); 147 | mediaCount++; 148 | buffer.push(mediaName); 149 | } 150 | } else { 151 | buffer.push(line); 152 | } 153 | } 154 | } 155 | fieldContents.push(buffer.join('\n')); 156 | if (fieldNames.length !== fieldContents.length) return [undefined, undefined]; 157 | const fields: Record = {}; 158 | fieldNames.map((v, i) => (fields[v] = fieldContents[i])); 159 | return [fields, mediaNameMap]; 160 | } 161 | 162 | validateMedia(mediaName: string) { 163 | return [...PICTURE_EXTENSION, ...VIDEO_EXTENSION].includes( 164 | mediaName.split('.').pop() as string 165 | ); 166 | } 167 | 168 | isPicture(mediaName: string) { 169 | return PICTURE_EXTENSION.includes(mediaName.split('.').pop() as string); 170 | } 171 | 172 | dump(note: Note, mediaNameMap: MediaNameMap[] | undefined = undefined) { 173 | const frontMatter = stringifyYaml( 174 | Object.assign( 175 | { 176 | mid: note.mid, 177 | nid: note.nid, 178 | tags: note.tags 179 | }, 180 | note.extras 181 | ) 182 | ) 183 | .trim() 184 | .replace(/"/g, ``); 185 | const fieldNames = Object.keys(note.fields); 186 | const lines = [`---`, frontMatter, `---`]; 187 | if (note.isCloze()) { 188 | lines.push(note.fields[fieldNames[0]]); 189 | fieldNames.slice(1).map(s => { 190 | lines.push(`${'#'.repeat(this.settings.headingLevel)} ${s}`, note.fields[s]); 191 | }); 192 | } else { 193 | lines.push(note.fields[fieldNames[1]]); 194 | fieldNames.slice(2).map(s => { 195 | lines.push(`${'#'.repeat(this.settings.headingLevel)} ${s}`, note.fields[s]); 196 | }); 197 | } 198 | 199 | if (mediaNameMap) 200 | for (const i in lines) 201 | for (const mediaName of mediaNameMap) 202 | if (lines[i].includes(mediaName.anki)) 203 | lines[i] = lines[i].replace(mediaName.anki, mediaName.obsidian); 204 | 205 | return lines.join('\n'); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import AnkiSynchronizer from 'main'; 2 | import { Notice, TFile } from 'obsidian'; 3 | import Note, { FrontMatter } from 'src/note'; 4 | import Media from './media'; 5 | import Anki from './anki'; 6 | import Formatter from './format'; 7 | import locale from './lang'; 8 | 9 | abstract class State extends Map { 10 | protected plugin: AnkiSynchronizer; 11 | protected anki: Anki; 12 | 13 | constructor(plugin: AnkiSynchronizer) { 14 | super(); 15 | this.plugin = plugin; 16 | this.anki = plugin.anki; 17 | } 18 | 19 | async change(newState: Map) { 20 | const existingKeys = [...this.keys()]; 21 | const newKeys = [...newState.keys()]; 22 | 23 | // Iterate over the new state 24 | for (const [key, value] of newState.entries()) { 25 | if (Array.isArray(value)) { 26 | const [digest, info] = value; 27 | await this.update(key, digest, info); 28 | this.set(key, digest); 29 | } else { 30 | await this.update(key, value); 31 | this.set(key, value); 32 | } 33 | } 34 | // for (const key of existingKeys.filter(x => !newKeys.includes(x))) { 35 | // this.delete(key); 36 | // } 37 | } 38 | 39 | abstract update(key: K, value: V, info?: I): Promise; 40 | } 41 | 42 | export type NoteTypeDigest = { name: string; fieldNames: string[] }; 43 | 44 | export class NoteTypeState extends State { 45 | private templateFolderPath: string | undefined = undefined; 46 | 47 | setTemplatePath(templateFolderPath: string) { 48 | this.templateFolderPath = templateFolderPath; 49 | } 50 | 51 | delete(key: number) { 52 | const noteTypeDigest = this.get(key); 53 | if (noteTypeDigest !== undefined) { 54 | const templatePath = `${this.templateFolderPath}/${noteTypeDigest.name}.md`; 55 | const maybeTemplate = this.plugin.app.vault.getAbstractFileByPath(templatePath); 56 | if (maybeTemplate !== null) { 57 | this.plugin.app.vault.delete(maybeTemplate); 58 | } 59 | } 60 | return super.delete(key); 61 | } 62 | 63 | async update(key: number, digest: NoteTypeDigest) { 64 | if (this.has(key)) { 65 | this.delete(key); 66 | } 67 | const pseudoFrontMatter = { 68 | mid: key, 69 | nid: 0, 70 | tags: [], 71 | date: '{{date}} {{time}}' 72 | } as FrontMatter; 73 | const pseudoFields: Record = {}; 74 | digest.fieldNames.map(x => (pseudoFields[x] = '\n\n')); 75 | const templateNote = new Note( 76 | digest.name, 77 | this.templateFolderPath!, 78 | digest.name, 79 | pseudoFrontMatter, 80 | pseudoFields 81 | ); 82 | const templatePath = `${this.templateFolderPath}/${digest.name}.md`; 83 | const maybeTemplate = this.plugin.app.vault.getAbstractFileByPath(templatePath); 84 | if (maybeTemplate !== null) { 85 | await this.plugin.app.vault.modify( 86 | maybeTemplate as TFile, 87 | this.plugin.noteManager.dump(templateNote) 88 | ); 89 | } else { 90 | await this.plugin.app.vault.create(templatePath, this.plugin.noteManager.dump(templateNote)); 91 | } 92 | console.log(`Created template ${templatePath}`); 93 | } 94 | } 95 | 96 | export type NoteDigest = { deck: string; hash: string; tags: string[] }; 97 | 98 | export class NoteState extends State { 99 | private formatter: Formatter; 100 | 101 | constructor(plugin: AnkiSynchronizer) { 102 | super(plugin); 103 | this.formatter = new Formatter(this.plugin.app.vault.getName(), this.plugin.settings); 104 | } 105 | 106 | // Existing notes may have 3 things to update: deck, fields, tags 107 | async update(key: number, digest: NoteDigest, info: Note) { 108 | 109 | const current = this.get(key); 110 | if (!current) return; 111 | if (current.deck !== digest.deck) { 112 | // updating deck 113 | this.updateDeck(info); 114 | } 115 | if (current.hash !== digest.hash) { 116 | // updating fields 117 | this.updateFields(info); 118 | } 119 | // Check for null case 120 | if (current.tags === null) current.tags = []; 121 | if (digest.tags === null) digest.tags = []; 122 | if ( 123 | current.tags.length !== digest.tags.length || 124 | current.tags.some((item, index) => item !== digest.tags[index]) 125 | ) { 126 | // updating tags 127 | this.updateTags(info); 128 | } 129 | } 130 | 131 | async updateDeck(note: Note) { 132 | const deck = note.renderDeckName(); 133 | const notesInfoResponse = await this.anki.notesInfo([note.nid]); 134 | 135 | if (!Array.isArray(notesInfoResponse)) { 136 | return; 137 | } 138 | const { cards } = notesInfoResponse[0]; 139 | console.log(`Changing deck for ${note.title()}`, deck); 140 | let changeDeckResponse = await this.anki.changeDeck(cards, deck); 141 | if (changeDeckResponse === null) return; 142 | 143 | // if the supposed deck does not exist, create it 144 | if (changeDeckResponse.message.contains('deck was not found')) { 145 | console.log(changeDeckResponse.message, ', try creating'); 146 | const createDeckResponse = await this.anki.createDeck(deck); 147 | if (createDeckResponse === null) { 148 | changeDeckResponse = await this.anki.changeDeck(cards, deck); 149 | if (changeDeckResponse === null) return; 150 | } 151 | } 152 | 153 | new Notice(locale.synchronizeChangeDeckFailureNotice(note.title())); 154 | } 155 | 156 | async updateFields(note: Note) { 157 | const fields = this.formatter.format(note); 158 | console.log(`Updating fields for ${note.title()}`, fields); 159 | const updateFieldsResponse = await this.anki.updateFields(note.nid, fields); 160 | if (updateFieldsResponse === null) return; 161 | new Notice(locale.synchronizeUpdateFieldsFailureNotice(note.title())); 162 | } 163 | 164 | async updateTags(note: Note) { 165 | let updateTagsResponse = null; 166 | console.log(`Updating tags for ${note.title()}`, note.tags); 167 | updateTagsResponse = await this.anki.updateNoteTags(note.nid, note.tags); 168 | if (updateTagsResponse) new Notice(locale.synchronizeUpdateTagsFailureNotice(note.title())); 169 | } 170 | 171 | delete(key: number) { 172 | this.plugin.anki.deleteNotes([key]); 173 | return super.delete(key); 174 | } 175 | 176 | async handleAddNote(note: Note) { 177 | const ankiNote = { 178 | deckName: note.renderDeckName(), 179 | modelName: note.typeName, 180 | fields: this.formatter.format(note), 181 | tags: note.tags 182 | }; 183 | console.log(`Adding note for ${note.title()}`, ankiNote); 184 | let idOrError = await this.anki.addNote(ankiNote); 185 | if (typeof idOrError === 'number') { 186 | return idOrError; 187 | } 188 | 189 | // if the supposed deck does not exist, create it 190 | if (idOrError.message.contains('deck was not found')) { 191 | console.log(idOrError.message, ', try creating'); 192 | const didOrError = await this.anki.createDeck(ankiNote.deckName); 193 | if (typeof didOrError === 'number') { 194 | idOrError = await this.anki.addNote(ankiNote); 195 | if (typeof idOrError === 'number') { 196 | return idOrError; 197 | } 198 | } 199 | } else { 200 | console.log(idOrError.message); 201 | } 202 | } 203 | 204 | async handleAddMedia(media: Media) { 205 | console.log(`Adding media ${media.filename}`, media); 206 | await this.anki.addMedia(media); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { normalizePath, Notice, Plugin, TFile, TFolder, Vault } from 'obsidian'; 2 | import Anki, { AnkiError } from 'src/anki'; 3 | import Note, { NoteManager } from 'src/note'; 4 | import { MediaManager } from 'src/media'; 5 | import locale from 'src/lang'; 6 | import { NoteDigest, NoteState, NoteTypeDigest, NoteTypeState } from 'src/state'; 7 | import AnkiSynchronizerSettingTab, { Settings, DEFAULT_SETTINGS } from 'src/setting'; 8 | import { version } from './package.json'; 9 | import { MD5 } from 'object-hash'; 10 | 11 | interface Data { 12 | version: string; 13 | settings: Settings; 14 | noteState: Record; 15 | noteTypeState: Record; 16 | } 17 | 18 | export default class AnkiSynchronizer extends Plugin { 19 | anki = new Anki(); 20 | settings = DEFAULT_SETTINGS; 21 | mediaManager = new MediaManager(); 22 | noteManager = new NoteManager(this.settings); 23 | noteState = new NoteState(this); 24 | noteTypeState = new NoteTypeState(this); 25 | 26 | 27 | async onload() { 28 | // Recover data from local file 29 | const data: Data | null = await this.loadData(); 30 | if (data) { 31 | const { settings, noteState, noteTypeState } = data; 32 | Object.assign(this.settings, settings); 33 | for (const key in noteState) { 34 | this.noteState.set(parseInt(key), noteState[key]); 35 | } 36 | for (const key in noteTypeState) { 37 | this.noteTypeState.set(parseInt(key), noteTypeState[key]); 38 | } 39 | } 40 | this.configureUI(); 41 | console.log(locale.onLoad); 42 | } 43 | 44 | configureUI() { 45 | // Add import note types command 46 | this.addCommand({ 47 | id: 'import', 48 | name: locale.importCommandName, 49 | callback: async () => await this.importNoteTypes() 50 | }); 51 | this.addRibbonIcon('enter', locale.importCommandName, async () => await this.importNoteTypes()); 52 | 53 | // Add synchronize command 54 | this.addCommand({ 55 | id: 'synchronize', 56 | name: locale.synchronizeCommandName, 57 | callback: async () => await this.synchronize() 58 | }); 59 | this.addRibbonIcon( 60 | 'sheets-in-box', 61 | locale.synchronizeCommandName, 62 | async () => await this.synchronize() 63 | ); 64 | 65 | // Add a setting tab to configure settings 66 | this.addSettingTab(new AnkiSynchronizerSettingTab(this.app, this)); 67 | } 68 | 69 | // Save data to local file 70 | save() { 71 | return this.saveData({ 72 | version: version, 73 | settings: this.settings, 74 | noteState: Object.fromEntries(this.noteState), 75 | noteTypeState: Object.fromEntries(this.noteTypeState) 76 | }); 77 | } 78 | 79 | async onunload() { 80 | await this.save(); 81 | console.log(locale.onUnload); 82 | } 83 | 84 | // Retrieve template information from Obsidian core plugin "Templates" 85 | getTemplatePath() { 86 | const templatesPlugin = (this.app as any).internalPlugins?.plugins['templates']; 87 | if (!templatesPlugin?.enabled) { 88 | new Notice(locale.templatesNotEnabledNotice); 89 | return; 90 | } 91 | if (templatesPlugin.instance.options.folder === undefined) { 92 | new Notice(locale.templatesFolderUndefinedNotice); 93 | return; 94 | } 95 | return normalizePath(templatesPlugin.instance.options.folder); 96 | } 97 | 98 | async importNoteTypes() { 99 | new Notice(locale.importStartNotice); 100 | const templatesPath = this.getTemplatePath(); 101 | if (templatesPath === undefined) return; 102 | this.noteTypeState.setTemplatePath(templatesPath); 103 | const noteTypesAndIds = await this.anki.noteTypesAndIds(); 104 | if (noteTypesAndIds instanceof AnkiError) { 105 | new Notice(locale.importFailureNotice); 106 | return; 107 | } 108 | const noteTypes = Object.keys(noteTypesAndIds); 109 | const noteTypeFields = await this.anki.multi<{ modelName: string }, string[]>( 110 | 'modelFieldNames', 111 | noteTypes.map(s => ({ modelName: s })) 112 | ); 113 | if (noteTypeFields instanceof AnkiError) { 114 | new Notice(locale.importFailureNotice); 115 | return; 116 | } 117 | const state = new Map( 118 | noteTypes.map((name, index) => [ 119 | noteTypesAndIds[name], 120 | { 121 | name: name, 122 | fieldNames: noteTypeFields[index] 123 | } 124 | ]) 125 | ); 126 | console.log(`Retrieved note type data from Anki`, state); 127 | await this.noteTypeState.change(state); 128 | await this.save(); 129 | new Notice(locale.importSuccessNotice); 130 | } 131 | 132 | async synchronize() { 133 | const templatesPath = this.getTemplatePath(); 134 | if (templatesPath === undefined) return; 135 | new Notice(locale.synchronizeStartNotice); 136 | const state = new Map(); 137 | 138 | // getActiveViewOfType 139 | const activeFile = this.app.workspace.getActiveFile(); 140 | const folderPath = activeFile?.parent?.path 141 | const deck = folderPath?.replace(/\//g, '::') || 'Obsidian'; 142 | 143 | const folder = this.app.vault.getAbstractFileByPath(folderPath || "/") as any 144 | const files = folder?.children as any 145 | 146 | console.log(`Found ${files.length} files in obsidian folder`, folder); 147 | 148 | const notesInfoResponse = await this.anki.notesInfoByDeck(deck) 149 | 150 | console.log("Found notes in Anki", notesInfoResponse); 151 | 152 | 153 | for (const file of files) { 154 | const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; 155 | 156 | if (!frontmatter) continue; 157 | 158 | const content = await this.app.vault.cachedRead(file); 159 | const media = this.app.metadataCache.getFileCache(file)?.embeds; 160 | 161 | const [obsidianNote, mediaNameMap] = this.noteManager.validateNote( 162 | file, 163 | frontmatter, 164 | content, 165 | media, 166 | this.noteTypeState 167 | ); 168 | 169 | if (!obsidianNote) continue; 170 | 171 | console.log(`Validated note ${obsidianNote.title()}`, obsidianNote); 172 | 173 | if (media) { 174 | for (const item of media) { 175 | this.noteState.handleAddMedia( 176 | this.mediaManager.parseMedia(item, this.app.vault, this.app.metadataCache) 177 | ); 178 | } 179 | } 180 | 181 | const correspondingAnkiNote = notesInfoResponse.find((note: any) => note.noteId === frontmatter.nid); 182 | 183 | // Merge anki tags and obsidian tags 184 | const obsidianTags = frontmatter.tags || [] 185 | const ankiTags = correspondingAnkiNote?.tags || []; 186 | const mergedTags = [...new Set([...obsidianTags, ...ankiTags])]; 187 | 188 | const tagsBeforeHash = MD5(frontmatter.tags); 189 | const tagsAfterHash = MD5(mergedTags); 190 | const shouldUpdateTags = tagsBeforeHash !== tagsAfterHash; 191 | 192 | 193 | 194 | if (obsidianNote.nid === 0) { 195 | // new file 196 | const nid = await this.noteState.handleAddNote(obsidianNote); 197 | if (nid === undefined) { 198 | new Notice(locale.synchronizeAddNoteFailureNotice(file.basename)); 199 | continue; 200 | } 201 | obsidianNote.nid = nid; 202 | this.app.vault.modify(file, this.noteManager.dump(obsidianNote, mediaNameMap)); 203 | } 204 | 205 | if (shouldUpdateTags) { 206 | obsidianNote.tags = mergedTags; 207 | this.app.vault.modify(file, this.noteManager.dump(obsidianNote, mediaNameMap)); 208 | } 209 | 210 | 211 | state.set(obsidianNote.nid, [obsidianNote.digest(), obsidianNote]); 212 | } 213 | 214 | await this.noteState.change(state); 215 | await this.save(); 216 | new Notice(locale.synchronizeSuccessNotice); 217 | } 218 | } 219 | --------------------------------------------------------------------------------