本文从 Obsidian 撰写发布
Obsidian Memos 插件开发全记录
从零开始,在 Obsidian 中实现一个完整的 Memos 客户端
📖 目录
1. 项目背景与目标
1.1 什么是 Memos?
Memos 是一个开源的、轻量级的碎片化知识记录工具,类似于个人微博或便签本。它提供了 RESTful API,可以方便地与其他应用集成。
1.2 为什么要开发这个插件?
Obsidian 是一个非常强大的知识管理工具,但它的核心定位是"双向链接笔记",不适合快速记录碎片化信息。而 Memos 正好擅长这一点。将两者结合,可以在 Obsidian 中直接管理 Memos,实现:
- 📝 快速发布:在边栏中输入内容,一键发布到 Memos
- 📋 浏览列表:在 Obsidian 中查看所有 Memos
- ✏️ 编辑修改:直接修改已发布的 Memos
- 🗑️ 删除管理:删除不需要的 Memos
- 🎨 Markdown 支持:内容和 Memos 网页端显示一致
1.3 技术选型
| 技术 | 用途 |
|---|---|
| TypeScript | 主要开发语言 |
| Obsidian API | 插件开发框架 |
| Memos REST API | 数据交互 |
| esbuild | 代码打包工具 |
2. 最终功能展示
2.1 边栏界面
┌─────────────────────────────┐
│ [输入框 写点什么...] │ ← 支持 Markdown,Ctrl+Enter 快速发布
│ [发布] [刷新] │ ← 发布后自动刷新列表
│ │
│ ─── Memo 列表 ─── │
│ ┌─────────────────────────┐ │
│ │ 这是第一条 Memo 内容 │ │ ← Markdown 渲染显示
│ │ #标签 也会高亮显示 │ │
│ │ 12月25日 14:30 ✏️ 🗑️ 📋 │ │ ← 时间 + 操作按钮
│ └─────────────────────────┘ │
│ ┌─────────────────────────┐ │
│ │ **加粗** 和 *斜体* 支持 │ │
│ │  正常显示 │ │
│ │ 12月24日 10:20 ✏️ 🗑️ 📋 │ │
│ └─────────────────────────┘ │
└─────────────────────────────┘2.2 设置页面
┌─────────────────────────────────────┐
│ Memos 插件设置 │
│ │
│ 服务器地址 │
│ [https://me.sangushui.com] │
│ │
│ API Token │
│ [••••••••••••••••] │
│ │
│ 加载数量 │
│ [100] │
│ │
│ 连接测试 │
│ [测试连接] ✅ 连接成功! │
│ │
│ ─── 使用说明 ─── │
│ 1. 点击左侧边栏的 💬 图标打开 │
│ 2. 支持 Markdown 语法 │
│ ... │
└─────────────────────────────────────┘2.3 编辑弹窗
┌─────────────────────────────────────┐
│ ✏️ 编辑 Memo │
│ [📝 预览] │ ← 点击切换预览模式
│ ┌─────────────────────────────────┐ │
│ │ 编辑内容... │ │
│ │ 支持 Markdown │ │
│ └─────────────────────────────────┘ │
│ [取消] [💾 保存] │
└─────────────────────────────────────┘3. 开发环境搭建
3.1 前置要求
- Node.js 16+ (推荐 18+)
- npm 或 yarn
- Obsidian 桌面版
- 一个可用的 Memos 服务(自建或云端)
3.2 创建项目
# 创建项目目录
mkdir obsidian-memos-plugin
cd obsidian-memos-plugin
# 初始化 npm 项目
npm init -y3.3 安装依赖
npm install obsidian @types/node typescript esbuild --save-dev3.4 创建配置文件
manifest.json - 插件清单
{
"id": "obsidian-memos-plugin",
"name": "Memos Manager",
"version": "1.0.0",
"minAppVersion": "0.15.0",
"description": "在 Obsidian 边栏中管理 Memos,支持发布、浏览、修改、删除",
"author": "Your Name",
"authorUrl": "",
"isDesktopOnly": false
}tsconfig.json - TypeScript 配置
{
"compilerOptions": {
"baseUrl": ".",
"inlineSourceMap": true,
"inlineSources": true,
"module": "ESNext",
"target": "ES6",
"allowJs": true,
"noImplicitAny": true,
"moduleResolution": "node",
"importHelpers": true,
"isolatedModules": true,
"strictNullChecks": true,
"lib": ["DOM", "ES5", "ES6", "ES7"],
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["**/*.ts"]
}esbuild.config.mjs - 构建配置
import esbuild from "esbuild";
import process from "process";
import builtins from "builtin-modules";
const banner = `/*
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
*/
`;
const prod = process.argv[2] === "production";
const context = await esbuild.context({
banner: {
js: banner,
},
entryPoints: ["main.ts"],
bundle: true,
external: [
"obsidian",
"electron",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
...builtins,
],
format: "cjs",
target: "es2018",
logLevel: "info",
sourcemap: prod ? false : "inline",
treeShaking: true,
outfile: "main.js",
});
if (prod) {
await context.rebuild();
process.exit(0);
} else {
await context.watch();
}package.json - npm 脚本
{
"name": "obsidian-memos-plugin",
"version": "1.0.0",
"description": "Memos management in Obsidian sidebar",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production"
},
"devDependencies": {
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0",
"builtin-modules": "3.3.0",
"esbuild": "0.17.3",
"obsidian": "latest",
"tslib": "2.4.0",
"typescript": "4.7.4"
}
}styles.css - 样式文件
/* ===== 边栏主容器 ===== */
.memos-sidebar {
padding: 12px 10px 20px;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.memos-sidebar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.memos-sidebar-header h4 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.memos-sidebar-header .refresh-btn {
padding: 4px 12px;
font-size: 13px;
cursor: pointer;
background: var(--interactive-normal);
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
color: var(--text-normal);
}
.memos-sidebar-header .refresh-btn:hover {
background: var(--interactive-hover);
}
/* ===== 发布区域 ===== */
.memos-publish-area {
margin-bottom: 16px;
padding: 12px;
background: var(--background-secondary);
border-radius: 8px;
border: 1px solid var(--background-modifier-border);
}
.memos-publish-area textarea {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-size: 14px;
resize: vertical;
font-family: inherit;
min-height: 60px;
box-sizing: border-box;
}
.memos-publish-area textarea:focus {
outline: none;
border-color: var(--interactive-accent);
box-shadow: 0 0 0 2px var(--interactive-accent-hover);
}
.memos-publish-actions {
display: flex;
gap: 8px;
margin-top: 8px;
}
.memos-publish-actions button {
flex: 1;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
color: var(--text-normal);
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
}
.memos-publish-actions button:hover {
background: var(--interactive-hover);
}
.memos-publish-actions .publish-btn {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.memos-publish-actions .publish-btn:hover {
background: var(--interactive-accent-hover);
}
/* ===== Memo 列表 ===== */
.memos-list {
flex: 1;
overflow-y: auto;
}
.memos-empty {
text-align: center;
color: var(--text-muted);
padding: 40px 20px;
font-size: 14px;
}
/* ===== 单条 Memo ===== */
.memo-item {
padding: 12px 14px;
margin-bottom: 10px;
border-radius: 8px;
background: var(--background-secondary);
border-left: 3px solid var(--interactive-accent);
transition: background 0.2s;
}
.memo-item:hover {
background: var(--background-secondary-alt);
}
.memo-content {
font-size: 14px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-word;
color: var(--text-normal);
margin-bottom: 6px;
}
.memo-content .memo-tag {
color: var(--interactive-accent);
font-weight: 500;
}
.memo-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--text-muted);
}
.memo-time {
font-size: 12px;
}
.memo-actions {
display: flex;
gap: 4px;
}
.memo-actions button {
padding: 2px 8px;
font-size: 12px;
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
color: var(--text-muted);
transition: all 0.2s;
}
.memo-actions button:hover {
background: var(--background-modifier-hover);
color: var(--text-normal);
}
.memo-actions .delete-btn:hover {
color: var(--text-error);
background: var(--background-modifier-error);
}
.memo-actions .edit-btn:hover {
color: var(--interactive-accent);
}
/* ===== 编辑弹窗(Modal) ===== */
.memos-edit-modal {
padding: 20px;
}
.memos-edit-modal textarea {
width: 100%;
min-height: 120px;
padding: 10px;
border-radius: 6px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-size: 14px;
font-family: inherit;
resize: vertical;
box-sizing: border-box;
}
.memos-edit-modal textarea:focus {
outline: none;
border-color: var(--interactive-accent);
}
.memos-edit-modal .modal-actions {
display: flex;
gap: 10px;
margin-top: 12px;
justify-content: flex-end;
}
.memos-edit-modal .modal-actions button {
padding: 6px 20px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
cursor: pointer;
font-size: 13px;
}
.memos-edit-modal .modal-actions .save-btn {
background: var(--interactive-accent);
color: var(--text-on-accent);
border-color: var(--interactive-accent);
}
.memos-edit-modal .modal-actions .save-btn:hover {
background: var(--interactive-accent-hover);
}
.memos-edit-modal .modal-actions .cancel-btn:hover {
background: var(--background-modifier-hover);
}
/* ===== 设置页 ===== */
.memos-settings .setting-item-info {
flex: 1.5;
}
.memos-settings .setting-item-control {
flex: 2.5;
}
.memos-settings input[type="text"],
.memos-settings input[type="password"] {
width: 100%;
padding: 6px 10px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--background-primary);
color: var(--text-normal);
font-size: 14px;
}
.memos-settings input:focus {
outline: none;
border-color: var(--interactive-accent);
}
.memos-settings .test-connection-btn {
margin-top: 8px;
padding: 4px 16px;
border-radius: 4px;
border: 1px solid var(--background-modifier-border);
background: var(--interactive-normal);
cursor: pointer;
}
.memos-settings .test-connection-btn:hover {
background: var(--interactive-hover);
}
.memos-settings .connection-status {
margin-left: 12px;
font-size: 13px;
font-weight: 500;
}
.memos-settings .status-success {
color: var(--text-success);
}
.memos-settings .status-error {
color: var(--text-error);
}3.5 目录结构
obsidian-memos-plugin/
├── manifest.json
├── tsconfig.json
├── esbuild.config.mjs
├── package.json
├── styles.css
├── main.ts
└── .gitignore4. 技术架构设计
4.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ Obsidian 主程序 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Memos 插件 (main.ts) │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 主插件类 (MemosPlugin) │ │ │
│ │ │ • 加载设置 │ │ │
│ │ │ • 注册边栏视图 │ │ │
│ │ │ • 注册命令 │ │ │
│ │ │ • API 调用封装 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 边栏视图 (MemosSidebarView) │ │ │
│ │ │ • 发布输入框 │ │ │
│ │ │ • Memo 列表渲染 │ │ │
│ │ │ • Markdown 渲染 │ │ │
│ │ │ • 刷新功能 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 编辑弹窗 (EditMemoModal) │ │ │
│ │ │ • 编辑输入框 │ │ │
│ │ │ • 预览模式切换 │ │ │
│ │ │ • 保存功能 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ ┌─────────────────────────────────────────────┐ │ │
│ │ │ 设置页 (MemosSettingTab) │ │ │
│ │ │ • 服务器地址 │ │ │
│ │ │ • API Token │ │ │
│ │ │ • 加载数量 │ │ │
│ │ │ • 连接测试 │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ Memos REST API │ │
│ │ /api/v1/memo │ │
│ │ /api/v1/status │ │
│ │ /api/v1/memo/{id} │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────┘4.2 Memos API 端点(v0.18.2)
| 操作 | 方法 | 端点 | 参数 |
|---|---|---|---|
| 测试连接 | GET | /api/v1/status | 无 |
| 发布 Memo | POST | /api/v1/memo | { content, visibility } |
| 获取列表 | GET | /api/v1/memo?limit=100 | limit |
| 更新 Memo | PATCH | /api/v1/memo/{id} | { content } |
| 删除 Memo | DELETE | /api/v1/memo/{id} | 无 |
4.3 数据流
用户操作 → 插件界面 → API 调用 → Memos 服务器 → 返回结果 → 更新 UI5. 完整代码实现
5.1 核心代码结构
// ============================================================
// 1. 导入 Obsidian API
// ============================================================
import {
App,
Plugin,
PluginSettingTab,
Setting,
ItemView,
WorkspaceLeaf,
Modal,
Notice,
requestUrl,
RequestUrlParam,
MarkdownRenderer,
Component
} from 'obsidian';
// ============================================================
// 2. 类型定义
// ============================================================
interface MemosPluginSettings {
serverUrl: string; // 服务器地址
apiToken: string; // API 令牌
limit: number; // 加载数量
}
interface Memo {
id: string;
content: string;
visibility?: 'PRIVATE' | 'PUBLIC' | 'PROTECTED';
createTime?: string;
createdTs?: number;
displayTime?: string;
}
// ============================================================
// 3. 默认设置
// ============================================================
const DEFAULT_SETTINGS: MemosPluginSettings = {
serverUrl: 'https://me.sangushui.com',
apiToken: '',
limit: 100
};
const VIEW_TYPE_MEMOS = 'memos-sidebar-view';
// ============================================================
// 4. 主插件类
// ============================================================
export default class MemosPlugin extends Plugin {
settings: MemosPluginSettings;
async onload() {
await this.loadSettings();
// 注册设置页
this.addSettingTab(new MemosSettingTab(this.app, this));
// 注册边栏视图
this.registerView(VIEW_TYPE_MEMOS, (leaf) => new MemosSidebarView(leaf, this));
// 添加 Ribbon 图标
this.addRibbonIcon('message-square', 'Memos 管理器', () => {
this.activateView();
});
// 注册命令
this.addCommand({
id: 'open-memos-sidebar',
name: '打开 Memos 边栏',
callback: () => this.activateView()
});
// 注册发布命令
this.addCommand({
id: 'publish-selection-to-memos',
name: '发布选中文本到 Memos',
editorCallback: (editor) => {
const selection = editor.getSelection();
if (!selection) {
new Notice('请先选中要发布的文本');
return;
}
this.createMemo(selection).then(() => {
new Notice('✅ 已发布到 Memos');
}).catch((e) => {
new Notice('❌ 发布失败: ' + e.message);
});
}
});
console.log('Memos Plugin 已加载 (v0.18.2 适配版)');
}
async onunload() {
console.log('Memos Plugin 已卸载');
}
async activateView() {
const { workspace } = this.app;
let leaf = workspace.getLeavesOfType(VIEW_TYPE_MEMOS)[0];
if (!leaf) {
const side = workspace.getRightLeaf(false);
if (!side) {
new Notice('无法创建边栏');
return;
}
await side.setViewState({ type: VIEW_TYPE_MEMOS, active: true });
leaf = side;
}
workspace.revealLeaf(leaf);
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
}
// ============================================================
// 5. API 调用封装
// ============================================================
private async apiCall<T>(endpoint: string, method: string, body?: any): Promise<T> {
if (!this.settings.apiToken) {
throw new Error('请先在设置中配置 API Token');
}
const baseUrl = this.settings.serverUrl.replace(/\/$/, '');
const url = `${baseUrl}/api/v1${endpoint}`;
const headers: Record<string, string> = {
'Authorization': `Bearer ${this.settings.apiToken}`,
'Accept': 'application/json'
};
if (body) {
headers['Content-Type'] = 'application/json';
}
const params: RequestUrlParam = { url, method, headers };
if (body) {
params.body = JSON.stringify(body);
}
try {
const response = await requestUrl(params);
if (response.status >= 400) {
let errorMsg = `API 错误 (${response.status})`;
try {
const errorData = JSON.parse(response.text);
errorMsg = errorData.message || errorData.error || errorMsg;
} catch {
errorMsg = response.text || errorMsg;
}
throw new Error(errorMsg);
}
if (!response.text || response.text.trim() === '') {
return {} as T;
}
return JSON.parse(response.text);
} catch (e) {
if (e instanceof Error) {
if (e.message.includes('fetch') || e.message.includes('network') || e.message.includes('ECONNREFUSED')) {
throw new Error('无法连接到服务器,请检查地址和端口是否正确');
}
throw e;
}
throw e;
}
}
// ============================================================
// 6. 业务 API 方法
// ============================================================
async testConnection(): Promise<boolean> {
try {
await this.apiCall<any>('/status', 'GET');
return true;
} catch {
return false;
}
}
async createMemo(content: string): Promise<Memo> {
return this.apiCall<Memo>('/memo', 'POST', {
content: content.trim(),
visibility: 'PRIVATE'
});
}
async listMemos(limit?: number): Promise<Memo[]> {
const size = limit || this.settings.limit;
const response = await this.apiCall<any>(`/memo?limit=${size}`, 'GET');
let memos: Memo[] = [];
if (response.memos) memos = response.memos;
else if (response.data) memos = response.data;
else if (Array.isArray(response)) memos = response;
return memos || [];
}
async updateMemo(id: string, content: string): Promise<Memo> {
return this.apiCall<Memo>(`/memo/${id}`, 'PATCH', {
content: content.trim()
});
}
async deleteMemo(id: string): Promise<void> {
await this.apiCall<void>(`/memo/${id}`, 'DELETE');
}
}
// ============================================================
// 7. 边栏视图 (MemosSidebarView)
// ============================================================
class MemosSidebarView extends ItemView {
private plugin: MemosPlugin;
private listContainer!: HTMLElement;
private textarea!: HTMLTextAreaElement;
private isLoading: boolean = false;
private allMemos: Memo[] = [];
constructor(leaf: WorkspaceLeaf, plugin: MemosPlugin) {
super(leaf);
this.plugin = plugin;
}
getViewType(): string { return VIEW_TYPE_MEMOS; }
getDisplayText(): string { return 'Memos'; }
getIcon(): string { return 'message-square'; }
async onOpen() {
const container = this.containerEl;
container.empty();
container.addClass('memos-sidebar');
if (!this.plugin.settings.apiToken) {
this.renderConfigWarning(container);
return;
}
this.renderContent(container);
await this.refreshList();
}
private renderConfigWarning(container: HTMLElement) {
container.empty();
const warning = container.createDiv('memos-empty');
warning.createEl('p', { text: '⚠️ 请先配置 Memos 服务器信息' });
const btn = warning.createEl('button', { text: '打开设置' });
btn.onclick = () => {
// @ts-ignore
this.app.setting.open();
};
}
private renderContent(container: HTMLElement) {
container.empty();
this.renderPublishArea(container);
this.listContainer = container.createDiv('memos-list');
}
private renderPublishArea(container: HTMLElement) {
const area = container.createDiv('memos-publish-area');
const inputRow = area.createDiv('memos-input-row');
inputRow.style.display = 'flex';
inputRow.style.gap = '8px';
inputRow.style.marginBottom = '8px';
this.textarea = inputRow.createEl('textarea', {
attr: { placeholder: '写点什么... 支持 #标签 和 Markdown', rows: '2' }
});
this.textarea.style.flex = '1';
this.textarea.style.padding = '8px 10px';
this.textarea.style.borderRadius = '6px';
this.textarea.style.border = '1px solid var(--background-modifier-border)';
this.textarea.style.background = 'var(--background-primary)';
this.textarea.style.color = 'var(--text-normal)';
this.textarea.style.fontSize = '14px';
this.textarea.style.resize = 'vertical';
this.textarea.style.fontFamily = 'inherit';
this.textarea.style.minHeight = '40px';
this.textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
this.publishMemo();
}
});
const btnContainer = area.createDiv('memos-actions-row');
btnContainer.style.display = 'flex';
btnContainer.style.gap = '8px';
const publishBtn = btnContainer.createEl('button', { text: '发布', cls: 'publish-btn' });
publishBtn.style.flex = '1';
publishBtn.style.padding = '6px 12px';
publishBtn.style.borderRadius = '4px';
publishBtn.style.border = '1px solid var(--background-modifier-border)';
publishBtn.style.cursor = 'pointer';
publishBtn.style.background = 'var(--interactive-accent)';
publishBtn.style.color = 'var(--text-on-accent)';
publishBtn.onclick = () => this.publishMemo();
const refreshBtn = btnContainer.createEl('button', { text: '刷新' });
refreshBtn.style.flex = '1';
refreshBtn.style.padding = '6px 12px';
refreshBtn.style.borderRadius = '4px';
refreshBtn.style.border = '1px solid var(--background-modifier-border)';
refreshBtn.style.cursor = 'pointer';
refreshBtn.style.background = 'var(--interactive-normal)';
refreshBtn.style.color = 'var(--text-normal)';
refreshBtn.onclick = () => {
this.refreshList();
};
}
public async refreshList() {
if (this.isLoading) return;
console.log('🔄 刷新列表...');
this.isLoading = true;
if (this.listContainer) {
this.listContainer.empty();
this.listContainer.createDiv('memos-loading').setText('加载中...');
}
try {
const memos = await this.plugin.listMemos();
console.log(`📋 获取到 ${memos.length} 条 Memos`);
this.allMemos = memos;
await this.renderMemos(this.allMemos);
} catch (e) {
console.error('加载失败:', e);
if (this.listContainer) {
this.listContainer.empty();
const error = this.listContainer.createDiv('memos-empty');
error.createEl('p', { text: '❌ 加载失败: ' + (e as Error).message });
const retryBtn = error.createEl('button', { text: '重试' });
retryBtn.style.marginTop = '8px';
retryBtn.onclick = () => this.refreshList();
}
} finally {
this.isLoading = false;
}
}
private async publishMemo() {
const content = this.textarea.value.trim();
if (!content) {
new Notice('请输入内容');
return;
}
if (this.isLoading) return;
console.log('📤 发布 Memo...');
this.isLoading = true;
try {
// 自动转换 <img> 标签为 Markdown 语法
let processedContent = content;
const imgRegex = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
processedContent = processedContent.replace(imgRegex, (match, src) => {
const altMatch = match.match(/alt=["']([^"']*)["']/i);
const alt = altMatch ? altMatch[1] : '图片';
return ``;
});
const result = await this.plugin.createMemo(processedContent);
console.log('✅ 发布成功:', result);
this.textarea.value = '';
new Notice('✅ 发布成功');
await this.refreshList();
} catch (e) {
console.error('发布失败:', e);
new Notice('❌ 发布失败: ' + (e as Error).message);
} finally {
this.isLoading = false;
}
}
private async renderMemos(memos: Memo[]) {
if (!this.listContainer) return;
this.listContainer.empty();
if (!memos || memos.length === 0) {
this.listContainer.createDiv('memos-empty', (el) => {
el.createEl('p', { text: '✨ 还没有 Memos' });
});
return;
}
const sorted = [...memos].sort((a, b) => {
const timeA = a.createTime || a.displayTime || String(a.createdTs || '');
const timeB = b.createTime || b.displayTime || String(b.createdTs || '');
return new Date(timeB).getTime() - new Date(timeA).getTime();
});
for (const memo of sorted) {
const item = this.listContainer.createDiv('memo-item');
item.style.padding = '12px 14px';
item.style.marginBottom = '10px';
item.style.borderRadius = '8px';
item.style.background = 'var(--background-secondary)';
item.style.borderLeft = '3px solid var(--interactive-accent)';
const contentDiv = item.createDiv('memo-content');
contentDiv.style.fontSize = '14px';
contentDiv.style.lineHeight = '1.7';
contentDiv.style.marginBottom = '6px';
contentDiv.style.overflowWrap = 'break-word';
const content = memo.content || '(空内容)';
await MarkdownRenderer.render(
this.app,
content,
contentDiv,
'',
this
);
const meta = item.createDiv('memo-meta');
meta.style.display = 'flex';
meta.style.justifyContent = 'space-between';
meta.style.alignItems = 'center';
meta.style.fontSize = '12px';
meta.style.color = 'var(--text-muted)';
const time = meta.createSpan('memo-time');
let timeStr = memo.createTime || memo.displayTime || '';
if (!timeStr && memo.createdTs) {
timeStr = new Date(memo.createdTs * 1000).toISOString();
}
if (timeStr) {
const date = new Date(timeStr);
time.setText(date.toLocaleString('zh-CN', {
month: 'short', day: 'numeric',
hour: '2-digit', minute: '2-digit'
}));
} else {
time.setText('未知时间');
}
const actions = meta.createDiv('memo-actions');
actions.style.display = 'flex';
actions.style.gap = '4px';
const editBtn = actions.createEl('button', { text: '✏️' });
editBtn.style.padding = '2px 8px';
editBtn.style.fontSize = '12px';
editBtn.style.background = 'transparent';
editBtn.style.border = 'none';
editBtn.style.cursor = 'pointer';
editBtn.style.borderRadius = '4px';
editBtn.style.color = 'var(--text-muted)';
editBtn.onclick = (e) => {
e.stopPropagation();
new EditMemoModal(this.app, this.plugin, memo, () => {
this.refreshList();
}).open();
};
const deleteBtn = actions.createEl('button', { text: '🗑️' });
deleteBtn.style.padding = '2px 8px';
deleteBtn.style.fontSize = '12px';
deleteBtn.style.background = 'transparent';
deleteBtn.style.border = 'none';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.borderRadius = '4px';
deleteBtn.style.color = 'var(--text-muted)';
deleteBtn.onclick = (e) => {
e.stopPropagation();
new ConfirmModal(
this.app,
'确认删除',
'确定要删除这条 Memo 吗?',
async () => {
try {
await this.plugin.deleteMemo(memo.id);
new Notice('🗑️ 已删除');
this.allMemos = this.allMemos.filter(m => m.id !== memo.id);
await this.renderMemos(this.allMemos);
} catch (e) {
new Notice('❌ 删除失败: ' + (e as Error).message);
}
}
).open();
};
const copyBtn = actions.createEl('button', { text: '📋' });
copyBtn.style.padding = '2px 8px';
copyBtn.style.fontSize = '12px';
copyBtn.style.background = 'transparent';
copyBtn.style.border = 'none';
copyBtn.style.cursor = 'pointer';
copyBtn.style.borderRadius = '4px';
copyBtn.style.color = 'var(--text-muted)';
copyBtn.onclick = (e) => {
e.stopPropagation();
navigator.clipboard.writeText(memo.content).then(() => {
new Notice('已复制到剪贴板');
});
};
}
console.log(`✅ 渲染了 ${sorted.length} 条 Memos`);
}
}
// ============================================================
// 8. 编辑弹窗 (EditMemoModal)
// ============================================================
class EditMemoModal extends Modal {
private plugin: MemosPlugin;
private memo: Memo;
private onSave: () => void;
private textarea!: HTMLTextAreaElement;
private previewContainer!: HTMLElement;
private isPreviewMode: boolean = false;
constructor(app: App, plugin: MemosPlugin, memo: Memo, onSave: () => void) {
super(app);
this.plugin = plugin;
this.memo = memo;
this.onSave = onSave;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass('memos-edit-modal');
contentEl.style.minWidth = '500px';
contentEl.createEl('h3', { text: '✏️ 编辑 Memo' });
const toolbar = contentEl.createDiv('memos-edit-toolbar');
toolbar.style.display = 'flex';
toolbar.style.gap = '8px';
toolbar.style.marginBottom = '8px';
const previewToggle = toolbar.createEl('button', { text: '📝 预览' });
previewToggle.style.padding = '4px 12px';
previewToggle.style.borderRadius = '4px';
previewToggle.style.border = '1px solid var(--background-modifier-border)';
previewToggle.style.cursor = 'pointer';
previewToggle.style.background = 'var(--interactive-normal)';
previewToggle.style.color = 'var(--text-normal)';
previewToggle.onclick = () => {
this.isPreviewMode = !this.isPreviewMode;
previewToggle.setText(this.isPreviewMode ? '✏️ 编辑' : '📝 预览');
this.toggleEditPreview();
};
const editorContainer = contentEl.createDiv('memos-edit-container');
editorContainer.style.position = 'relative';
editorContainer.style.minHeight = '150px';
this.textarea = editorContainer.createEl('textarea');
this.textarea.value = this.memo.content;
this.textarea.style.width = '100%';
this.textarea.style.minHeight = '150px';
this.textarea.style.padding = '10px';
this.textarea.style.borderRadius = '6px';
this.textarea.style.border = '1px solid var(--background-modifier-border)';
this.textarea.style.background = 'var(--background-primary)';
this.textarea.style.color = 'var(--text-normal)';
this.textarea.style.fontSize = '14px';
this.textarea.style.boxSizing = 'border-box';
this.textarea.style.fontFamily = 'inherit';
this.textarea.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.key === 'Enter') {
e.preventDefault();
this.save();
}
});
this.previewContainer = editorContainer.createDiv('memos-edit-preview');
this.previewContainer.style.display = 'none';
this.previewContainer.style.width = '100%';
this.previewContainer.style.minHeight = '150px';
this.previewContainer.style.padding = '10px';
this.previewContainer.style.borderRadius = '6px';
this.previewContainer.style.border = '1px solid var(--background-modifier-border)';
this.previewContainer.style.background = 'var(--background-secondary)';
this.previewContainer.style.boxSizing = 'border-box';
this.previewContainer.style.overflowY = 'auto';
setTimeout(() => this.textarea.focus(), 100);
const actions = contentEl.createDiv('modal-actions');
actions.style.display = 'flex';
actions.style.gap = '10px';
actions.style.marginTop = '12px';
actions.style.justifyContent = 'flex-end';
const cancelBtn = actions.createEl('button', { text: '取消' });
cancelBtn.style.padding = '6px 20px';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.border = '1px solid var(--background-modifier-border)';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.background = 'var(--interactive-normal)';
cancelBtn.style.color = 'var(--text-normal)';
cancelBtn.onclick = () => this.close();
const saveBtn = actions.createEl('button', { text: '💾 保存' });
saveBtn.style.padding = '6px 20px';
saveBtn.style.borderRadius = '4px';
saveBtn.style.border = '1px solid var(--interactive-accent)';
saveBtn.style.cursor = 'pointer';
saveBtn.style.background = 'var(--interactive-accent)';
saveBtn.style.color = 'var(--text-on-accent)';
saveBtn.onclick = () => this.save();
this.updatePreview();
}
private toggleEditPreview() {
if (this.isPreviewMode) {
this.textarea.style.display = 'none';
this.previewContainer.style.display = 'block';
this.updatePreview();
} else {
this.textarea.style.display = 'block';
this.previewContainer.style.display = 'none';
}
}
private async updatePreview() {
if (!this.previewContainer) return;
this.previewContainer.empty();
const dummyComponent = {
load: () => {},
onload: () => {},
unload: () => {},
onunload: () => {},
app: this.app,
scope: undefined,
registerEvent: () => {},
registerDomEvent: () => {},
addChild: () => {},
removeChild: () => {},
getChildren: () => [],
loadChild: async () => {},
unloadChild: async () => {}
} as unknown as Component;
await MarkdownRenderer.render(
this.app,
this.textarea.value,
this.previewContainer,
'',
dummyComponent
);
}
private async save() {
const newContent = this.textarea.value.trim();
if (!newContent) {
new Notice('内容不能为空');
return;
}
if (newContent === this.memo.content) {
this.close();
return;
}
try {
await this.plugin.updateMemo(this.memo.id, newContent);
new Notice('✅ 已更新');
this.close();
this.onSave();
} catch (e) {
new Notice('❌ 更新失败: ' + (e as Error).message);
}
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// ============================================================
// 9. 确认弹窗 (ConfirmModal)
// ============================================================
class ConfirmModal extends Modal {
private title: string;
private message: string;
private onConfirm: () => void;
constructor(app: App, title: string, message: string, onConfirm: () => void) {
super(app);
this.title = title;
this.message = message;
this.onConfirm = onConfirm;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h3', { text: this.title });
contentEl.createEl('p', { text: this.message });
const actions = contentEl.createDiv('modal-actions');
actions.style.display = 'flex';
actions.style.gap = '10px';
actions.style.marginTop = '12px';
actions.style.justifyContent = 'flex-end';
const cancelBtn = actions.createEl('button', { text: '取消' });
cancelBtn.style.padding = '6px 20px';
cancelBtn.style.borderRadius = '4px';
cancelBtn.style.border = '1px solid var(--background-modifier-border)';
cancelBtn.style.cursor = 'pointer';
cancelBtn.style.background = 'var(--interactive-normal)';
cancelBtn.style.color = 'var(--text-normal)';
cancelBtn.onclick = () => this.close();
const confirmBtn = actions.createEl('button', { text: '确认删除' });
confirmBtn.style.padding = '6px 20px';
confirmBtn.style.borderRadius = '4px';
confirmBtn.style.border = '1px solid var(--text-error)';
confirmBtn.style.cursor = 'pointer';
confirmBtn.style.background = 'var(--text-error)';
confirmBtn.style.color = 'white';
confirmBtn.onclick = () => {
this.close();
this.onConfirm();
};
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}
// ============================================================
// 10. 设置页 (MemosSettingTab)
// ============================================================
class MemosSettingTab extends PluginSettingTab {
plugin: MemosPlugin;
private statusEl: HTMLElement | null = null;
constructor(app: App, plugin: MemosPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
containerEl.addClass('memos-settings');
const title = containerEl.createEl('h2');
title.setText('Memos 插件设置');
title.style.marginLeft = '0';
title.style.paddingLeft = '0';
new Setting(containerEl)
.setName('服务器地址')
.setDesc('Memos 服务地址,例如 https://me.sangushui.com')
.addText((text) => {
text.setPlaceholder('https://me.sangushui.com')
.setValue(this.plugin.settings.serverUrl)
.onChange(async (value) => {
this.plugin.settings.serverUrl = value;
await this.plugin.saveSettings();
});
});
new Setting(containerEl)
.setName('API Token')
.setDesc('在 Memos 的 设置 → 我的账户 → 生成 API Token 中获取')
.addText((text) => {
text.setPlaceholder('输入你的 API Token')
.setValue(this.plugin.settings.apiToken)
.onChange(async (value) => {
this.plugin.settings.apiToken = value.trim();
await this.plugin.saveSettings();
});
(text.inputEl as HTMLInputElement).type = 'password';
});
new Setting(containerEl)
.setName('加载数量')
.setDesc('每次加载的 Memos 数量(修改后自动刷新生效)')
.addText((text) => {
text.setPlaceholder('100')
.setValue(String(this.plugin.settings.limit))
.onChange(async (value) => {
const num = parseInt(value);
if (num > 0 && num <= 500) {
this.plugin.settings.limit = num;
await this.plugin.saveSettings();
this.refreshSidebar();
}
});
});
const testSetting = new Setting(containerEl)
.setName('连接测试')
.setDesc('测试当前配置是否有效');
const control = testSetting.controlEl.createDiv();
const testBtn = control.createEl('button', { text: '测试连接', cls: 'test-connection-btn' });
testBtn.style.padding = '4px 16px';
testBtn.style.borderRadius = '4px';
testBtn.style.border = '1px solid var(--background-modifier-border)';
testBtn.style.cursor = 'pointer';
testBtn.style.background = 'var(--interactive-normal)';
testBtn.style.color = 'var(--text-normal)';
this.statusEl = control.createSpan('connection-status');
this.statusEl.style.marginLeft = '12px';
this.statusEl.style.fontSize = '13px';
testBtn.onclick = async () => {
if (!this.plugin.settings.apiToken) {
this.setStatus('请先填写 API Token', false);
return;
}
this.setStatus('⏳ 测试中...', null);
try {
const success = await this.plugin.testConnection();
if (success) {
this.setStatus('✅ 连接成功!', true);
} else {
this.setStatus('❌ 连接失败,请检查服务器地址和 Token', false);
}
} catch (e) {
this.setStatus('❌ 错误: ' + (e as Error).message, false);
}
};
containerEl.createEl('hr');
const usageTitle = containerEl.createEl('h3');
usageTitle.setText('使用说明');
usageTitle.style.marginLeft = '0';
usageTitle.style.paddingLeft = '0';
const tips = containerEl.createDiv('memos-settings-tips');
tips.style.color = 'var(--text-muted)';
tips.style.fontSize = '13px';
tips.style.lineHeight = '1.8';
tips.innerHTML = `
<p>1. 点击左侧边栏的 <strong>💬 图标</strong> 打开 Memos 管理器</p>
<p>2. 在输入框中写内容,点击 <strong>发布</strong> 或按 <kbd>Ctrl+Enter</kbd> 发布</p>
<p>3. 内容支持 <strong>Markdown 语法</strong>,边栏和 Memos 显示一致</p>
<p>4. 每条 Memo 支持 <strong>✏️ 编辑</strong>(支持预览)、<strong>🗑️ 删除</strong>、<strong>📋 复制</strong></p>
<p>5. 在编辑器中选中文本,使用命令 <strong>发布选中文本到 Memos</strong></p>
<hr>
<p style="font-size: 12px;">📌 适配 Memos v0.18.2</p>
`;
}
private setStatus(text: string, isSuccess: boolean | null) {
if (!this.statusEl) return;
this.statusEl.setText(text);
this.statusEl.style.color = isSuccess === true ? 'var(--text-success)' :
isSuccess === false ? 'var(--text-error)' :
'var(--text-muted)';
}
private refreshSidebar() {
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_MEMOS);
if (leaves.length > 0) {
const view = leaves[0].view as MemosSidebarView;
if (view) {
view.refreshList();
}
}
}
}6. 构建与安装
6.1 构建插件
# 安装依赖(仅首次)
npm install
# 开发模式(自动编译)
npm run dev
# 生产构建
npm run build6.2 安装到 Obsidian
# 方法一:直接复制(推荐)
cp -r obsidian-memos-plugin ~/Documents/Obsidian/MyVault/.obsidian/plugins/
# 方法二:软链接(开发时方便)
ln -s /path/to/obsidian-memos-plugin ~/Documents/Obsidian/MyVault/.obsidian/plugins/obsidian-memos-plugin6.3 在 Obsidian 中启用
- 打开 Obsidian 设置(
Ctrl/Cmd + ,) - 进入「社区插件」
- 找到「Memos Manager」
- 点击开关启用
6.4 配置插件
- 在设置中找到「Memos 插件设置」
- 填写「服务器地址」(如
https://me.sangushui.com) - 填写「API Token」(在 Memos 网页端获取)
- 点击「测试连接」验证配置
- 调整「加载数量」(默认 100)
6.5 获取 API Token
- 登录 Memos 网页端
- 点击右上角头像 →「设置」
- 选择「我的账户」→「生成 API Token」
- 复制生成的 Token(格式:
memos_pat_xxxxx)
7. 开发过程中的经验教训
7.1 Memos API 版本差异
| 版本 | 列表 API | 分页参数 |
|---|---|---|
| v0.18.2 及以下 | /api/v1/memo | ?limit=100 |
| v0.18.2 以上 | /api/v1/memos | ?pageSize=30 |
教训:开发前先确认 Memos 版本,使用正确的 API 路径。
7.2 TypeScript 类型问题
MarkdownRenderer.render() 需要 Component 类型的参数,但 Modal 不是 Component。解决方案:
// 创建虚拟 Component
const dummyComponent = {
load: () => {},
onload: () => {},
// ... 实现所有必需方法
} as unknown as Component;
await MarkdownRenderer.render(app, content, container, '', dummyComponent);教训:遇到类型错误时,可以用 as unknown as 绕过,但要确保运行时逻辑正确。
7.3 发布后刷新列表
发布后必须调用 refreshList() 才能看到新内容:
private async publishMemo() {
const result = await this.plugin.createMemo(content);
await this.refreshList(); // 关键:发布后刷新
}教训:数据变更后要主动刷新 UI,Obsidian 不会自动感知外部数据变化。
7.4 Markdown 渲染不一致
Obsidian 的 MarkdownRenderer 渲染结果与 Memos 网页端略有差异,但基本一致。使用 Obsidian 内置渲染器是最简单的方式。
7.5 图片标签处理
Memos 不支持 HTML <img> 标签,需要转换为 Markdown 语法:
const imgRegex = /<img\s+[^>]*src=["']([^"']+)["'][^>]*>/gi;
processedContent = processedContent.replace(imgRegex, (match, src) => {
const altMatch = match.match(/alt=["']([^"']*)["']/i);
const alt = altMatch ? altMatch[1] : '图片';
return ``;
});教训:不同平台支持的 Markdown 语法不同,要做好转换。
7.6 分页 vs 一次性加载
Memos v0.18.2 使用 limit 参数,一次性加载指定数量。如果需要分页,需要使用 pageToken 游标分页。
教训:小规模使用(<500 条)用 limit 一次性加载更简单。
8. 常见问题排查
8.1 测试连接失败
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接失败 | 服务器地址错误 | 检查是否包含 http:// 或 https:// |
| 连接失败 | Token 错误 | 重新生成 Token,注意格式 memos_pat_xxx |
| 连接失败 | Memos 服务未启动 | 检查 Memos 服务状态 |
| 连接失败 | CORS 问题 | 检查 Memos 配置,允许跨域请求 |
8.2 边栏列表不显示
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 空列表 | API Token 无效 | 重新配置 Token |
| 空列表 | 加载数量为 0 | 设置加载数量 > 0 |
| 加载中... | 网络问题 | 检查网络连接 |
8.3 发布失败
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 发布失败 | 内容为空 | 输入有效内容 |
| 发布失败 | Token 过期 | 重新生成 Token |
| 发布失败 | 网络超时 | 检查服务器状态 |
8.4 Markdown 不渲染
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 显示纯文本 | 未使用 MarkdownRenderer | 确保使用 MarkdownRenderer.render() |
| 图片不显示 | 使用 <img> 标签 | 转换为 ![]() 语法 |
| 样式不对 | CSS 未加载 | 检查 styles.css 是否正确引用 |
8.5 编译错误
# 常见错误:类型不匹配
error TS2345: Argument of type 'null' is not assignable to parameter of type 'Component'
# 解决方案:使用 @ts-ignore 或创建虚拟 Component
// @ts-ignore
await MarkdownRenderer.render(app, content, container, '', null);9. 后续扩展方向
9.1 图片上传功能
当前插件仅支持粘贴图片链接,未来可扩展:
- 拖拽/粘贴上传图片到 Memos
- 调用 Memos
/resourcesAPI 上传附件 - 集成七牛云、阿里云 OSS 等图床
9.2 标签筛选
- 在边栏添加标签过滤功能
- 点击标签筛选相关 Memos
- 显示标签云
9.3 搜索功能
- 添加搜索框,实时搜索 Memo 内容
- 支持全文搜索
9.4 更多操作
- 收藏/置顶 Memo
- 批量删除
- 导出到 Obsidian 笔记
9.5 性能优化
- 虚拟滚动(大量 Memo 时)
- 增量加载
- 本地缓存
📚 参考资源
🎉 恭喜你!现在你已经拥有一个完整的 Obsidian Memos 插件了!