diff --git a/README.md b/README.md index 9f8019f..9a2369a 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,7 @@ The plugin works in two modes: ## Installation -The plugin is currently in alpha stage and therefore not on the Obsidian marketplace. Therefore, manual installation is required. - -Download the file `dist.zip` from [release page](https://github.com/tansongchen/obsidian-anki-synchronizer/releases), create a new folder `obsidian-anki-synchronizer` under the `.obsidian/plugins` directory in your vault, and put the three files extracted into this folder. Reload obsidian. - -For example, the plugin path on my computer is `/Users/tansongchen/Library/Mobile Documents/iCloud~md~obsidian/Documents/卡片盒/.obsidian/plugins/obsidian-anki-synchronizer`. +Install via the Obsidian community plugin marketplace by searching "Note Synchronizer". ## Setup diff --git a/README.zh.md b/README.zh.md index 8fb2612..3e49018 100644 --- a/README.zh.md +++ b/README.zh.md @@ -13,11 +13,7 @@ ## 安装 -本插件目前处于 Alpha 阶段,所以还没有在 Obsidian 插件市场发布。您需要手动安装此插件。 - -在[发布页](https://github.com/tansongchen/obsidian-anki-synchronizer/releases)下载 `dist.zip` 文件,然后在您的知识库目录下面的 `.obsidian/plugins` 新建一个文件夹 `obsidian-anki-synchronizer`,然后把解压得到的三个文件放进去。重启 Obsidian。 - -例如,我这里的目录是 `/Users/tansongchen/Library/Mobile Documents/iCloud~md~obsidian/Documents/卡片盒/.obsidian/plugins/obsidian-anki-synchronizer`。 +在 Obsidian 插件市场中搜索「Note Synchronizer」并根据提示安装即可。 ## 配置 diff --git a/main.ts b/main.ts index 87cdffa..cf2d39c 100644 --- a/main.ts +++ b/main.ts @@ -1,6 +1,6 @@ import { normalizePath, Notice, Plugin } from 'obsidian'; import Anki, { AnkiError } from 'src/anki'; -import Note from 'src/note'; +import Note, { NoteManager } from 'src/note'; import locale from 'src/lang'; import { NoteDigest, NoteState, NoteTypeDigest, NoteTypeState } from 'src/state'; import AnkiSynchronizerSettingTab, { Settings, DEFAULT_SETTINGS } from 'src/setting'; @@ -16,6 +16,7 @@ interface Data { export default class AnkiSynchronizer extends Plugin { anki = new Anki(); settings = DEFAULT_SETTINGS; + noteManager = new NoteManager(this.settings); noteState = new NoteState(this); noteTypeState = new NoteTypeState(this); @@ -119,7 +120,7 @@ export default class AnkiSynchronizer extends Plugin { const content = await this.app.vault.cachedRead(file); const frontmatter = this.app.metadataCache.getFileCache(file)?.frontmatter; if (!frontmatter) continue; - const note = Note.validateNote(file.path, frontmatter, content, this.noteTypeState); + const note = this.noteManager.validateNote(file.path, frontmatter, content, this.noteTypeState); if (!note) continue; if (note.nid === 0) { // new file const nid = await this.noteState.handleAddNote(note); @@ -128,7 +129,7 @@ export default class AnkiSynchronizer extends Plugin { continue; } note.nid = nid; - this.app.vault.modify(file, note.dump()); + this.app.vault.modify(file, this.noteManager.dump(note)); } state.set(note.nid, [note.digest(), note]); } diff --git a/manifest.json b/manifest.json index 08a1a64..29de72b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,10 @@ { "id": "note-synchronizer", "name": "Note Synchronizer", - "version": "0.1.0", + "version": "0.1.1", "minAppVersion": "0.14.0", "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.", "author": "Songchen Tan", "authorUrl": "https://tansongchen.com", "isDesktopOnly": true -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5643634..1247d39 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "highlight.js": "^11.6.0", "markdown-it": "^13.0.1", "object-hash": "^3.0.0" }, @@ -2903,6 +2904,14 @@ "node": ">=8" } }, + "node_modules/highlight.js": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.6.0.tgz", + "integrity": "sha512-ig1eqDzJaB0pqEvlPVIpSSyMaO92bH1N2rJpLMN/nX396wTpDA4Eq0uK+7I/2XG17pFaaKE0kjV/XPeGt7Evjw==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7189,6 +7198,11 @@ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "highlight.js": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.6.0.tgz", + "integrity": "sha512-ig1eqDzJaB0pqEvlPVIpSSyMaO92bH1N2rJpLMN/nX396wTpDA4Eq0uK+7I/2XG17pFaaKE0kjV/XPeGt7Evjw==" + }, "html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", diff --git a/package.json b/package.json index bdf65c1..17573de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-note-synchronizer", - "version": "0.1.0", + "version": "0.1.1", "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.", "main": "main.js", "scripts": { @@ -13,6 +13,7 @@ "author": "", "license": "MIT", "dependencies": { + "highlight.js": "^11.6.0", "markdown-it": "^13.0.1", "object-hash": "^3.0.0" }, diff --git a/src/format.ts b/src/format.ts index 2316f53..e71644c 100644 --- a/src/format.ts +++ b/src/format.ts @@ -1,9 +1,23 @@ import { Settings } from "./setting"; import MarkdownIt from "markdown-it"; +import hljs from "highlight.js"; export default class Formatter { private settings: Settings; - private mdit = new MarkdownIt(); + private mdit = new MarkdownIt({ + html: true, + linkify: true, + highlight: function (str, lang) { + if (lang && hljs.getLanguage(lang)) { + try { + return hljs.highlight(str, { language: lang }).value; + } catch (__) { + return ''; + } + } + return ''; + } + }); private vaultName: string; constructor(vaultName: string, settings: Settings) { @@ -17,12 +31,19 @@ export default class Formatter { } markdown(markup: string) { - return markup.replace(/\[\[(.+)\]\]/, (match, p) => { + return markup.replace(/!?\[\[(.+?)\]\]/g, (match, p) => { return this.renderBacklink(p); }); } + convertMathDelimiter(markdown: string) { + markdown = markdown.replace(/\$(.+?)\$/g, '\\\\($1\\\\)'); + markdown = markdown.replace(/\$\$(.+?)\$\$/gs, '\\\\[$1\\\\]'); + return markdown; + } + html(markdown: string) { + markdown = this.convertMathDelimiter(markdown); return this.mdit.render(markdown); } diff --git a/src/lang.ts b/src/lang.ts index 7ed100b..e5cd953 100644 --- a/src/lang.ts +++ b/src/lang.ts @@ -20,11 +20,13 @@ interface Locale { settingTabHeader: string, settingRenderName: string, settingRenderDescription: string, + settingHeadingLevelName: string, + settingHeadingLevelDescription: string, } const en: Locale = { - onLoad: 'Anki Synchronizer is successfully loaded!', - onUnload: 'Anki Synchronizer is successfully unloaded!', + onLoad: 'Note Synchronizer is successfully loaded!', + onUnload: 'Note Synchronizer is successfully unloaded!', synchronizeCommandName: 'Synchronize', templatesNotEnabledNotice: 'Core plugin Templates is not enabled!', importCommandName: 'Import Note Types', @@ -39,14 +41,16 @@ const en: Locale = { synchronizeChangeDeckFailureNotice: (filename: string) => `Cannot change deck for ${filename}`, synchronizeUpdateFieldsFailureNotice: (filename: string) => `Cannot update fields for ${filename}`, synchronizeUpdateTagsFailureNotice: (filename: string) => `Cannot update tags for ${filename}`, - settingTabHeader: 'Anki Synchronizer Settings', + settingTabHeader: 'Note Synchronizer Settings', settingRenderName: 'Render', settingRenderDescription: 'Whether to render markdown before importing to Anki or not.', + settingHeadingLevelName: 'Field name heading level', + settingHeadingLevelDescription: 'Which level (h1, h2, h3, ...) to use for field names when generating the note template', } const zh_cn: Locale = { - onLoad: 'Anki 同步插件已成功启用!', - onUnload: 'Anki 同步插件已成功禁用!', + onLoad: '笔记同步插件已成功启用!', + onUnload: '笔记同步插件已成功禁用!', synchronizeCommandName: '同步', templatesNotEnabledNotice: '核心插件「模板」未启用,操作无法执行!', importCommandName: '导入笔记类型', @@ -61,9 +65,11 @@ const zh_cn: Locale = { synchronizeChangeDeckFailureNotice: (filename: string) => `无法改变 ${filename} 的牌组`, synchronizeUpdateFieldsFailureNotice: (filename: string) => `无法更新 ${filename} 的字段`, synchronizeUpdateTagsFailureNotice: (filename: string) => `无法更新 ${filename} 的标签`, - settingTabHeader: 'Anki 同步设置', + settingTabHeader: '笔记同步设置', settingRenderName: '渲染', - settingRenderDescription: '是否在导入时将 Markdown 渲染为 HTML' + settingRenderDescription: '是否在导入时将 Markdown 渲染为 HTML', + settingHeadingLevelName: '字段名称标题层级', + settingHeadingLevelDescription: '从 Anki 笔记类型生成模板时,将 Anki 的字段名称表示为几级标题(一级、二级、三级等)', } const locales: { [k: string]: Partial } = { diff --git a/src/note.ts b/src/note.ts index a8850ef..619dda9 100644 --- a/src/note.ts +++ b/src/note.ts @@ -1,6 +1,7 @@ import { stringifyYaml, FrontMatterCache } from "obsidian"; import { NoteDigest, NoteTypeDigest } from "./state"; import { MD5 } from 'object-hash'; +import { Settings } from "./setting"; export interface FrontMatter { mid: number, @@ -12,12 +13,43 @@ export default class Note { nid: number; tags: string[]; fields: Record; - private path: string; + path: string; typeName: string; - private mid: number; - private extras: object; + mid: number; + extras: object; - static validateNote(path: string, frontmatter: FrontMatterCache, content: string, noteTypes: Map) { + constructor(path: string, typeName: string, frontMatter: FrontMatter, fields: Record) { + this.path = path; + const { mid, nid, tags, ...extras } = frontMatter; + this.typeName = typeName; + this.mid = mid; + this.nid = nid; + this.tags = tags; + this.extras = extras; + this.fields = fields; + } + + digest(): NoteDigest { + return { deck: this.renderDeckName(), hash: MD5(this.fields), tags: this.tags } + } + + title() { + return Object.values(this.fields)[0]; + } + + renderDeckName() { + return this.path.split('/').slice(0, -1).join('::') || 'Obsidian'; + } +} + +export class NoteManager { + private settings: Settings; + + constructor(settings: Settings) { + this.settings = settings; + } + + validateNote(path: string, frontmatter: FrontMatterCache, content: string, noteTypes: Map) { if (!frontmatter.hasOwnProperty('mid') || !frontmatter.hasOwnProperty('nid') || !frontmatter.hasOwnProperty('tags')) return; const frontMatter = Object.assign({}, frontmatter, { position: undefined }) as FrontMatter; const lines = content.split('\n'); @@ -25,18 +57,20 @@ export default class Note { const body = lines.slice(yamlEndIndex + 1); const noteType = noteTypes.get(frontMatter.mid); if (!noteType) return; - const fields = Note.parseFields(path, noteType.fieldNames, body); + const fields = this.parseFields(path, noteType.fieldNames, body); + if (!fields) return; // now it is a valid Note return new Note(path, noteType.name, frontMatter, fields); } - static parseFields(path: string, fieldNames: string[], body: string[]) { + parseFields(path: string, fieldNames: string[], body: string[]) { + const headingLevel = this.settings.headingLevel; const pathList = path.split('/'); const baseName = pathList[pathList.length - 1]; const fieldContents: string[] = [baseName.split('.').slice(0, -1).join('')]; let buffer: string[] = []; for (const line of body) { - if (line.slice(0, 2) === '# ') { + if (line.slice(0, headingLevel + 1) === ('#'.repeat(headingLevel) + ' ')) { fieldContents.push(buffer.join('\n')); buffer = []; } else { @@ -44,45 +78,23 @@ export default class Note { } } fieldContents.push(buffer.join('\n')); + if (fieldNames.length !== fieldContents.length) return; const fields: Record = {}; fieldNames.map((v, i) => fields[v] = fieldContents[i]); return fields; } - constructor(path: string, typeName: string, frontMatter: FrontMatter, fields: Record) { - this.path = path; - const { mid, nid, tags, ...extras } = frontMatter; - this.typeName = typeName; - this.mid = mid; - this.nid = nid; - this.tags = tags; - this.extras = extras; - this.fields = fields; - } - - digest(): NoteDigest { - return { deck: this.renderDeckName(), hash: MD5(this.fields), tags: this.tags } - } - - title() { - return Object.values(this.fields)[0]; - } - - dump() { + dump(note: Note) { const frontMatter = stringifyYaml(Object.assign({ - mid: this.mid, - nid: this.nid, - tags: this.tags - }, this.extras)).trim().replace(/"/g, ``); - const fieldNames = Object.keys(this.fields); - const lines = [`---`, frontMatter, `---`, this.fields[fieldNames[1]]]; + mid: note.mid, + nid: note.nid, + tags: note.tags + }, note.extras)).trim().replace(/"/g, ``); + const fieldNames = Object.keys(note.fields); + const lines = [`---`, frontMatter, `---`, note.fields[fieldNames[1]]]; fieldNames.slice(2).map(s => { - lines.push(`# ${s}`, this.fields[s]); + lines.push(`${'#'.repeat(this.settings.headingLevel)} ${s}`, note.fields[s]); }); return lines.join('\n'); } - - renderDeckName() { - return this.path.split('/').slice(0, -1).join('::') || 'Obsidian'; - } } diff --git a/src/setting.ts b/src/setting.ts index 629c6ad..87a533c 100644 --- a/src/setting.ts +++ b/src/setting.ts @@ -5,10 +5,12 @@ import AnkiSynchronizer from "main"; // Plugin Settings export interface Settings { render: boolean; + headingLevel: number; } export const DEFAULT_SETTINGS: Settings = { render: false, + headingLevel: 1, } export default class AnkiSynchronizerSettingTab extends PluginSettingTab { @@ -30,6 +32,18 @@ export default class AnkiSynchronizerSettingTab extends PluginSettingTab { .setValue(this.plugin.settings.render) .onChange(async (value) => { this.plugin.settings.render = value; - })); + }) + ); + + new Setting(this.containerEl) + .setName(locale.settingHeadingLevelName) + .setDesc(locale.settingHeadingLevelDescription) + .addDropdown(v => v + .addOptions({"1": "h1", "2": "h2", "3": "h3", "4": "h4", "5": "h5", "6": "h6"}) + .setValue(this.plugin.settings.headingLevel.toString()) + .onChange(async (value) => { + this.plugin.settings.headingLevel = parseInt(value); + }) + ); } } diff --git a/src/state.ts b/src/state.ts index 4d602be..fe41971 100644 --- a/src/state.ts +++ b/src/state.ts @@ -71,9 +71,9 @@ export class NoteTypeState extends State { const templateNote = new Note(templatePath, value.name, pseudoFrontMatter, pseudoFields); const maybeTemplate = this.plugin.app.vault.getAbstractFileByPath(templatePath); if (maybeTemplate !== null) { - await this.plugin.app.vault.modify(maybeTemplate as TFile, templateNote.dump()); + await this.plugin.app.vault.modify(maybeTemplate as TFile, this.plugin.noteManager.dump(templateNote)); } else { - await this.plugin.app.vault.create(templatePath, templateNote.dump()); + await this.plugin.app.vault.create(templatePath, this.plugin.noteManager.dump(templateNote)); } console.log(`Created template ${templatePath}`); } diff --git a/test/format.test.ts b/test/format.test.ts index 7930b4b..54a2d1b 100644 --- a/test/format.test.ts +++ b/test/format.test.ts @@ -1,6 +1,6 @@ import Formatter from "../src/format"; -const markdownMode = new Formatter('卡片盒', { render: false }); +const markdownMode = new Formatter('卡片盒', { headingLevel: 1, render: false }); test('Render back link', () => { expect(markdownMode.renderBacklink('笔记')).toBe( @@ -19,5 +19,5 @@ test('e2e', () => { ); }) -const htmlMode = new Formatter('卡片盒', { render: true }); +const htmlMode = new Formatter('卡片盒', { headingLevel: 1, render: true }); diff --git a/versions.json b/versions.json index 08fd8f5..c90f1c9 100644 --- a/versions.json +++ b/versions.json @@ -5,5 +5,6 @@ "0.0.4": "0.14.0", "0.0.5": "0.14.0", "0.0.6": "0.14.0", - "0.1.0": "0.14.0" + "0.1.0": "0.14.0", + "0.1.1": "0.14.0" } \ No newline at end of file