最近在使用一款 Markdown 笔记应用: Obsidian,加之先前做了一个可以快捷收藏/跳转地址的工具: efficient-tools,于是这次打算撸个 Obsidian 的插件,借用 Obsidian 给这个小功能提供可视化界面操作的能力(赋。。。。能?)。
开始
在 efficient-tools 中,我们可以通过命令行的方式添加、删除以及查看并跳转链接,既然如此,那在这个插件中也需要实现这三种功能。
准备工作
在开始开发之前,先要做如下准备工作:
- 去 Obsidian 官网 下载 Obsidian 应用;
- 参照 创建你的第一个插件 这篇文档准备好开发环境,并将插件名称改为 obsidian-link-keeper ;
- 由于 Obsidian 的插件在开发过程中只能通过停用/启用插件的方式让最新的代码生效,为了避开这个繁琐的过程,我们可以使用 Hot-Relod 这个工具来让我们的插件可以热重载。
在做完上述准备工作后,我们可以得到如下目录结构:
├── README.md
├── esbuild.config.mjs
├── main.ts // 入口文件
├── manifest.json // 插件的基本信息
├── modals.ts // 用于创建模态框
├── node_modules
├── package.json
├── pnpm-lock.yaml
├── styles.css // 样式文件
├── tsconfig.json
├── version-bump.mjs
└── versions.json
设置filepath
在 efficient-tools 中,新增的地址会默认保存到 ${process.env.HOME}/etl.json
文件中,在 Obsidian 中,我们可以通过给插件添加配置的方式来设置保存地址的文件路径。
Obsidian 中提供了 Setting 类以创建诸如文本框,下拉框,滑动条,按钮等表单控件,以及 PluginSettingTab 类可以创建插件的配置 tab。
接下来,我们先在项目的根目录下创建 settings.ts
文件,用于自定义插件的设置:
// settings.ts
// 导入插件
import LinkKeeper from './main'
import { App, PluginSettingTab, Setting } from 'obsidian'
// 创建插件的自定义配置
export class LinkKeeperSettingTab extends PluginSettingTab {
plugin: LinkKeeper
/**
* 构造函数中接受两个参数
* app: Obsidian 中的App对象
* plugin: 需要自定义设置的插件对象
*/
constructor (app: App, plugin: LinkKeeper) {
super (app, plugin)
this.plugin = plugin
}
display(): void {
// containerEl 是插件设置面板的容器
const { containerEl } = this
// 清空面板
containerEl.empty()
// 添加控件到面板容器中
new Setting(containerEl)
.setName("Link Filepath") // 设置控件名称
.setDesc("The file where saves the links.") // 设置控件描述文案
.addText((text) => // addText 方法用于创建 input 文本框, 回调函数中的参数为文本框dom对象
text
.setPlaceholder("Enter the full filepath") // 设置文本框 placeholder
.setValue(this.plugin.settings.filepath) // 设置文本框内容
.onChange(async (value) => { // 监听文本框的 change 事件
this.plugin.settings.filepath = value
await this.plugin.saveSettings() // 保存设置
})
)
}
}
在自定义设置时需要引入自定义插件,以便将设置绑定到对应插件上。打开插件的入口文件 main.ts
,由于拉取的插件模板中存在其他示例内容,我们先“清理”一下 main.ts
文件,只保留我们需要的内容:
// main.ts
// 引入 Plugin 类
import { Plugin } from "obsidian"
// 创建自定义插件
export default class InsertLinkPlugin extends Plugin {
async onload() {
// onload 方法在插件被启用时调用
}
}
💡 注:
在 Obsidian 中,自定义插件的类继承自插件基类 Plugin。插件存在两个生命周期函数:onload 以及 onunload,分别在组件启用时以及禁用时调用,具体可以参考插件剖析。
"清理"完成后,我们需要在 main.ts
中引入插件设置,并在 onload 阶段将自定义设置注册到插件上:
// main.ts
import { Plugin } from "obsidian"
// 引入自定义设置
import { LinkKeeperSettingTab } from './settings'
// 定义自定义设置的类型
interface LinkKeeperSettings {
filepath: string
}
// 定义自定义设置的默认值
const DEFAULT_SETTINGS: Partial<LinkKeeperSettings> = {
filepath: `${process.env.HOME}/etl.json`
}
export default class LinkKeeperPlugin extends Plugin {
settings: LinkKeeperSettings
async onload() {
// 加载自定义设置
await this.loadSettings()
// 将插件的配置 tab 添加到设置菜单中
this.addSettingTab(new LinkKeeperSettingTab(this.app, this))
}
async loadSettings () {
// 如果修改了配置项,可以覆盖默认配置
this.settings = {
...DEFAULT_SETTINGS,
...await this.loadData()
}
}
// 用于保存设置,暴露给自定义设置类中使用
async saveSettings () {
await this.saveData(this.settings)
}
}
做完上述步骤后,执行 pnpm run dev,之后打开设置界面,启用自定义插件,即可在设置中看到插件的自定义配置了:
到这里,我们已经完成了第一步,接下来我们就可以开始着手实现我们的功能了~
添加链接
要想添加链接,我们需要一个添加链接的界面。我们来设想下这个界面里需要实现哪些功能:
- 首先我们需要添加两个输入框用于设置链接的名称以及地址;
- 之后再添加一个按钮,点击后对输入的内容进行校验并保存;
- 如果遇到异常场景,比如文件不存在,输入内容为空等情况,我们需要给出友好的弹窗提示。
好,功能已经明确了,那我们就来逐步实现它们,冲冲冲!
第一步:添加链接的界面
上文中提到的 界面,我们可以使用 Obsidian 提供的 Modal 来实现。Modal 用于创建一个模态框,在模态框中,我们可以通过 Setting 以及 createEl 的方式来自定义所需要显示的表单控件或者自定义元素内容。
首先,我们在项目根目录下新建 modals.ts
文件,用于创建模态框:
// modal.ts
import { App, Modal } from "obsidian"
// 创建添加地址的模态框
export class AddLink extends Modal {
linkName: string // 用于保存链接名称
linkUrl: string // 用于保存链接地址
constructor(
app: App
) {
super(app)
}
// 在模态框打开时调用
onOpen (): void {
}
// 在模态框关闭时调用
onClose(): void {
// 需要清空模态框的内容
this.contentEl.empty()
}
}
第二步:添加名称 && 地址输入框
通过 Obsidian 提供的 Setting 类,我们可以很方便的创建两个用于输入的文本框。创建文本框需要用到 Setting 中的 addText 方法。
在 onOpen
方法中,我们插入以下代码
// modal.ts
onOpen (): void {
// contentEl 是模态框的 Element 对象
const { contentEl } = this
// 设置模态框标题
contentEl.createEl("h1", { text: "Add Link", cls: "title" })
// 创建用于设置地址名称的 input 框
new Setting(contentEl).setName("Link name").addText(text =>
text.setValue(this.linkName).setPlaceholder('name').onChange((value) => {
this.linkName = value
})
)
// 创建用于设置地址 url 的 input 框
new Setting(contentEl).setName("Link url").addText(text =>
text.setValue(this.linkUrl).setPlaceholder('url').onChange((value) => {
this.linkUrl = value
})
)
}
💡 注:
createEl 方法用于自定义 Html 标签,其中
cls
用于设置标签的class
属性,可以用于设置标签样式。样式统一设置在项目根目录的style.css
文件中。
第三步:添加保存按钮
添加按钮需要用到 Setting 中的 addButton 方法。
在 onOpen
方法中继续添加以下内容:
// modal.ts
onOpen (): void {
// 省略上述代码
// 创建一个按钮
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText('Add') // 设置按钮文案
.setCta()
.onClick(() => {
// 按钮的点击事件
})
)
}
第四步:实现校验 && 保存功能
接下来,在点击按钮时,我们需要对文本框中的内容进行校验,并给出提示。在这里我们仅校验文本框中是否输入内容,如果文本框中的内容为空,则给出 popup 提示,否则就保存输入的内容。
Obsidian 中提供了 Notice 类用于生成提示。由于提示的 popup 可能会在多出使用到,这里我们将其抽取到单独的 utils.ts
文件中:
// utils.ts
import { Notice } from 'obsidian'
export const noticeHandler = (msg: string) => new Notice(msg)
之后,我们在 modals.ts
文件中引入,并添加到检验判断中去:
// modal.ts
import { noticeHandler } from './utils'
// 省略一大波代码
onOpen (): void {
// 省略一小波代码
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText('Add') // 设置按钮文案
.setCta()
.onClick(() => {
// 在上述 input 的 onChange 事件中已经保存了 linkName 以及 linkUrl
// 在这里我们可以直接获取进行校验
const { linkName, linkUrl } = this
if (!(linkName.trim())) {
noticeHandler('Link name is required!')
} else if (!(linkUrl.trim())) {
noticeHandler('Link url is required!')
}
})
)
}
通过校验后,我们需要对填入的值进行保存,所谓的保存其实就是将值写入文件中。为了保证模态框模块的纯净,我们将保存的逻辑放在 main.ts
中完成。所以,在 main.ts
中调用这个 Modal 的时候,需要传入一个回调函数,用于保存链接设置。同时,在 main.ts
中需要通过 addCommand 方法创建自定义指令,用于呼起模态框。
打开 main.ts
,添加保存地址配置的方法,并且在 onload
方法中创建自定义指令的逻辑:
// main.ts
import { Plugin, Editor } from "obsidian"
import { AddLink } from "./modals"
import { LinkKeeperSettingTab } from './settings'
import { readFile, writeFile } from 'fs/promises'
import { noticeHandler } from './utils'
interface LinkKeeperSettings {
filepath: string
}
interface Options {
[key: string]: string
}
const DEFAULT_SETTINGS: Partial<LinkKeeperSettings> = {
filepath: `${process.env.HOME}/etl.json`
}
export default class LinkKeeperPlugin extends Plugin {
settings: LinkKeeperSettings
/**
* get all links
* @param cb
*/
async getLinks (cb: (data: Options) => void) {
try {
const data = await readFile(this.settings.filepath, { encoding: 'utf-8'})
cb(JSON.parse(data || '{}'))
} catch (err) {
noticeHandler(err.message)
}
}
/**
* save link
* @param data
* @param message
*/
async saveLink (data: Options, message: string) {
try {
await writeFile(this.settings.filepath, JSON.stringify(data))
noticeHandler(message)
} catch (err) {
noticeHandler(err.message)
}
}
/**
* add link submission
* @param name
* @param url
*/
onSubmit (name: string, url: string) {
this.getLinks(async (data: Options) => {
if (Object.prototype.toString.call(data) === '[object Object]') {
this.saveLink({...data, [name]: url}, 'Add Link successfully!')
} else {
noticeHandler('Data format error! It must be a json object.')
}
})
}
/**
* init modal
* @param type
* @param options
* @returns
*/
initModal (type: string) {
switch (type) {
case 'addLink':
return new AddLink(this.app, this.onSubmit.bind(this))
default: break
}
}
async onload() {
// 添加呼起模态框的逻辑
this.addCommand({
id: "add-link", // 用于设置模态框 id
name: "Add link", // 用于设置模态框名称
callback: () => { // 执行命令的回调函数
// 打开模态框
this.initModal('addLink').open()
}
})
}
}
这里我们添加了 onSubmit
方法,提供给模态框使用,用于保存地址设置。回到 modal.ts
文件中,我们需要在按钮的回调事件中加入保存地址的方法:
// modal.ts
export class AddLink extends Modal {
linkName: string
linkUrl: string
onSubmit: (linkName: string, linkUrl: string) => void
constructor(
app: App,
onSubmit: (linkName: string, linkUrl: string) => void
) {
// 省略一小波代码
// 绑定传过来的 onSubmit 方法
this.onSubmit = onSubmit
}
onOpen (): void {
// 省略一小波代码
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText('Add')
.setCta()
.onClick(() => {
const { linkName, linkUrl } = this
if (!(linkName.trim())) {
noticeHandler('Link name is required!')
} else if (!(linkUrl.trim())) {
noticeHandler('Link url is required!')
} else {
this.close() // 添加完成后,需要关闭模态框
this.onSubmit(this.linkName, this.linkUrl) // 调用传过来的 onSubmit 方法
}
})
)
}
// 省略一小波代码
}
至此,我们的模态框功能已经完成了。还记得我们在 modal.ts
中添加的用于呼起模态框的自定义指令么?现在我们就来用它呼起添加地址的模态框。
点击 Obsidian 界面左侧的命令行图标,在弹出的界面中搜索 Add link
(之前在 addCommand
中设置的自定义指令名称):
选择蓝框中的命令后,就会弹出添加地址的模态框了:
直接点击添加按钮,也会在右上角看到提示信息。
删除链接
老规矩,我们还是先来设想下用于删除的模态框需要实现哪些功能:
- 首先我们需要添加一个下拉框,用于选择需要删除的地址;
- 之后再添加一个按钮,点击后删除所选的地址;
- 如果遇到异常场景我们需要给出友好的弹窗提示。
有了添加链接的经验,创建删除链接的模态框就容易多了。在删除链接的模态框中,我们通过 Setting 中提供的 addDropdown 方法添加一个下拉框,用于选择需要删除的地址。此处直接上代码:
export class DeleteLink extends Modal {
linkName: string
options: Options
onDelete: (linkName: string) => void
constructor (
app: App,
options: Options, // 用来接收下拉项
onDelete: (linkName: string) => void // 删除事件
) {
super(app)
this.onDelete = onDelete
this.options = options
this.linkName = Object.keys(options)[0] || '' // 默认删除第一个
}
onOpen (): void {
const { contentEl } = this
contentEl.createEl("h1", { text: "Delete Link", cls: "title" })
// 创建下拉框
new Setting(contentEl).setName("Link name").addDropdown(dp =>
dp.addOptions(this.options).onChange(value => {
this.linkName = value
})
)
// 创建删除按钮
new Setting(contentEl).addButton((btn) =>
btn
.setButtonText('Delete')
.setCta()
.onClick(() => {
const { linkName } = this
if (!linkName) {
noticeHandler('Link name is required!')
} else {
this.close()
this.onDelete(this.linkName)
}
})
)
}
onClose(): void {
this.contentEl.empty()
}
}
之后回到 main.ts
文件,我们需要加入 删除模态框的调出指令、获取删除下拉选项的 options 以及添加删除方法:
// main.ts
import { Plugin, Editor } from "obsidian"
import { AddLink, DeleteLink } from "./modals"
import { LinkKeeperSettingTab } from './settings'
import { readFile, writeFile } from 'fs/promises'
import { noticeHandler } from './utils'
interface LinkKeeperSettings {
filepath: string
}
interface Options {
[key: string]: string
}
const DEFAULT_SETTINGS: Partial<LinkKeeperSettings> = {
filepath: `${process.env.HOME}/etl.json`
}
export default class LinkKeeperPlugin extends Plugin {
settings: LinkKeeperSettings
/**
* get all links
* @param cb
*/
async getLinks (cb: (data: Options) => void) {
try {
const data = await readFile(this.settings.filepath, { encoding: 'utf-8'})
cb(JSON.parse(data || '{}'))
} catch (err) {
noticeHandler(err.message)
}
}
/**
* delete link by name
* @param name
*/
async onDelete (name: string) {
await this.getLinks(async (data: Options) => {
delete data[name]
this.saveLink(data, `Link named ${name} has been deleted!`)
})
}
/**
* init modal
* @param type
* @param options
* @returns
*/
initModal (type: string, options?: Options) {
switch (type) {
// 省略一小波代码
// 创建删除地址的模态框
case 'deleteLink':
return new DeleteLink(this.app, options, this.onDelete.bind(this))
default: break
}
}
async onload() {
// 省略一小波代码
// 新增呼出删除模态框的自定义指令
this.addCommand({
id: "delete-link",
name: 'Delete link',
callback: () => {
this.getLinks(async (data: Options) => {
// 创建删除的模态框,并传入 options
this.initModal('deleteLink', Object.keys(data).reduce((obj, key) => ({
...obj,
[key]: key
}), {})).open()
})
}
})
}
// 省略一小波代码
}
之后,点击 Obsidian 界面左侧的命令行图标,在弹出的界面中搜索 Delete link
并点击,就可以看到删除地址的模态弹框啦:
查看 && 跳转链接
在这个模态框中,我们需要提供以下几种功能:
- 提供一个可以按照地址名称进行筛选的搜索框;
- 列出已经保存的地址,显示对应的名称以及跳转地址,并且给地址加上链接,以便直接点击跳转
回到 modals.ts
,我们先创建用于罗列地址的模态框:
// modal.ts
export class ListAllLinks extends Modal {
options: Options
constructor (
app: App,
options: Options // 用于接收地址配置
) {
super(app)
this.options = options
}
onOpen(): void {
const { contentEl } = this
// 创建标题
contentEl.createEl("h1", { text: "All Links", cls: "title" })
}
onClose(): void {
this.contentEl.empty()
}
}
添加搜索框
这里要用到 Setting 中提供的 addSearch 方法来创建一个搜索框:
// modal.ts
// 省略一小波代码
onOpen(): void {
const { contentEl } = this
contentEl.createEl("h1", { text: "All Links", cls: "title" })
// 创建搜索框
new Setting(contentEl).setName('Search').addSearch(el => {
el.setPlaceholder('Input the link name...').onChange(val => {
// 监听搜索框的 change 事件,在搜索框内容发生变化时重新渲染列表
})
})
}
添加地址列表
在上述过程中一直有使用的 Setting 类主要是用来创建表单控件的,而列表内容主要由自定义标签构成,此时我们可以使用 createEl 方法。由于在模态框初始化阶段以及搜索框内容变化阶段都需要渲染列表,因此我们需要将列表渲染的逻辑抽取出来。
// modal.ts
export class ListAllLinks extends Modal {
options: Options
constructor (
app: App,
options: Options
) {
super(app)
this.options = options
}
/**
* create list item
* @param container list container
* @param key link name
* @param value link url
* @param isLink determine whether it is a link
*/
createListItem (container: Element, key: string, value: string, isLink = true) {
// 向 list 容器中添加地址项
const box = container.createEl("div", { cls: `list-item ${!isLink ? 'list-item-header' : ''}`})
// 添加显示地址名称的容器
box.createEl("div", { text: key })
// 添加显示 url 的容器
const linkBox = box.createEl("div")
// 判断是否是链接
if (isLink) {
// 是链接的话添加 a 标签用于跳转
linkBox.createEl('a', { text: value, href: value})
} else {
// 否则直接显示内容
linkBox.createSpan({ text: value })
}
}
// 渲染列表
renderList (key = ''): Element {
// 获取所有地址信息
let options = this.options
// 根据传过来的 key 筛选需要显示的地址内容
if (key) {
options = Object.keys(options).reduce((obj: Options, item) => {
if (item.includes(key)) obj = { ...obj, [item]: options[item] }
return obj
}, {})
}
// 创建显示列表的内容容器
const container = this.contentEl.createEl("div")
// 添加表头信息
this.createListItem(container, 'Name', 'Url', false)
// 创建 list 容器
const listContainer = container.createEl('div', { cls: 'list-container'})
const keys = Object.keys(options)
// 遍历显示列表
if (keys.length) {
keys.forEach(key => {
this.createListItem(listContainer, key, options[key])
})
} else {
// 当没有搜索到相应结果时,显示提示信息
listContainer.createEl('div', { text: 'No results!', cls: 'list-empty' })
}
return container
}
onOpen(): void {
const { contentEl } = this
contentEl.createEl("h1", { text: "All Links", cls: "title" })
let contentBox: Element = null
new Setting(contentEl).setName('Search').addSearch(el => {
el.setPlaceholder('Input the link name...').onChange(val => {
// 当搜索内容变化时,清空列表
contentBox.empty()
// 重新渲染列表
contentBox = this.renderList(val)
})
})
// 首次加载时渲染列表
contentBox = this.renderList()
}
onClose(): void {
this.contentEl.empty()
}
}
之后,我们要将列表的样式写入 style.css
文件中:
// style.css
/* Sets all the text color to red! */
.title {
text-align: center;
font-size: 20px;
}
.list-container {
max-height: 300px;
overflow-y: scroll;
}
.list-item-header {
font-size: 18px;
font-weight: bold;
}
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.list-item div {
flex: 1;
}
.list-item:not(.list-item-header) div:first-child {
color: #222;
font-weight: bold;
}
.list-item div:last-child {
text-align: right;
}
.list-item:not(.list-item-header) {
background-color: #c0c0c0;
padding: 8px;
border-radius: 4px;
}
.list-empty {
text-align: center;
font-size: 16px;
}
最后,我们需要在 main.ts
文件中添加呼起显示列表的自定义指令,并将地址信息传给模态框:
// main.ts
import { Plugin, Editor } from "obsidian"
import { AddLink, DeleteLink, ListAllLinks } from "./modals"
import { LinkKeeperSettingTab } from './settings'
import { readFile, writeFile } from 'fs/promises'
import { noticeHandler } from './utils'
interface LinkKeeperSettings {
filepath: string
}
interface Options {
[key: string]: string
}
const DEFAULT_SETTINGS: Partial<LinkKeeperSettings> = {
filepath: `${process.env.HOME}/etl.json`
}
export default class LinkKeeperPlugin extends Plugin {
settings: LinkKeeperSettings
/**
* get all links
* @param cb
*/
async getLinks (cb: (data: Options) => void) {
try {
const data = await readFile(this.settings.filepath, { encoding: 'utf-8'})
cb(JSON.parse(data || '{}'))
} catch (err) {
noticeHandler(err.message)
}
}
// 省略一小波代码
/**
* init modal
* @param type
* @param options
* @returns
*/
initModal (type: string, options?: Options) {
switch (type) {
// 省略一小波代码
case 'listLink':
return new ListAllLinks(this.app, options)
default: break
}
}
async onload() {
// 省略一小波代码
// 添加呼起显示地址列表模态框的指令
this.addCommand({
id: 'list-links',
name: 'List links',
icon: 'link',
callback: () => {
this.getLinks(async (data: Options) => {
this.initModal('listLink', data).open()
})
}
})
}
// 省略一小波代码
}
之后,点击 Obsidian 界面左侧的命令行图标,在弹出的界面中搜索 List link
并点击,就可以看到地址列表的模态弹框啦~在搜索框中输入内容,可以看到相应的搜索结果,如果没有符合搜索内容的地址信息,则会显示提示信息:
到这里,我们的插件就基本开发完成啦~~
插件优化
Hotkeys
在上述过程中,我们都是通过自定义指令的方式来呼起模态框,虽然过程并不复杂,但多少还是有些繁琐。Obsidian 给插件的自定义指令提供了设置热键的功能,我们可以通过设置自定义指令的热键来简化这个过程:
添加选中的地址
如果在使用 Obsidian 的过程中,遇到想要保存文档中某个地址的情况,如果要先复制地址,然后再粘贴到模态框中去保存,感觉多少有些麻烦。那么我们是否可以选中想要添加的地址直接进行添加呢?
答案是肯定的!!
此时,我们可以在 addCommand 方法中的 editorCallback 事件中获取到当前编辑器的 editor
对象,并借此获取到选中的地址,之后自动填入模态框中。美滋滋~
接下来,我们需要对先前添加的 Add link
自定义指令以及添加地址的模态框逻辑做些修改。
首先,我们需要将 callback
方法改为 editorCallback
方法,并将选中的内容传给模态框:
// main.ts
// 省略一大波代码
initModal (type: string, options?: Options) {
switch (type) {
case 'addLink':
return new AddLink(this.app, options.link, this.onSubmit.bind(this))
// 省略一小波代码
default: break
}
}
async onload() {
// 省略一小波代码
this.addCommand({
id: "add-link",
name: "Add link",
editorCallback: (editor: Editor) => {
// 获取选中的地址
const selection = editor.getSelection()
this.initModal('addLink', { link: selection }).open()
}
})
}
之后,我们需要修改添加地址的模态框代码,接受传入的地址,并将其作为默认值设置到链接地址的文本框中:
// modals.ts
export class AddLink extends Modal {
linkName: string
linkUrl: string
onSubmit: (linkName: string, linkUrl: string) => void
constructor(
app: App,
linkUrl: string, // 接受传入的地址信息
onSubmit: (linkName: string, linkUrl: string) => void
) {
super(app)
this.linkName = ''
this.linkUrl = linkUrl // 将传入的地址信息设置为默认值
this.onSubmit = onSubmit
}
// 省略一大波代码
}
改造完成后,此时我们选中想要添加的链接地址,然后通过 热键 的方式呼起模态框,即可看到地址已经被填入其中:
写在最后
行文至此,link-keeper 插件就已经完成啦~有兴趣的小伙伴可以下载使用。如果觉得插件还可的小伙伴们欢迎 star ~
另外,笔者最近在翻译 Obsidian 的插件开发文档,有兴趣的小伙伴可以加入我~ 由于本人水平有限,如果有翻译不到位之处也欢迎大佬们提 issue,以便我及时更正。