三股水
Obsidian Memos 插件开发全记录
本文从 Obsidian 撰写发布

Obsidian Memos 插件开发全记录

从零开始,在 Obsidian 中实现一个完整的 Memos 客户端

📖 目录

  1. 项目背景与目标
  2. 最终功能展示
  3. 开发环境搭建
  4. 技术架构设计
  5. 完整代码实现
  6. 构建与安装
  7. 开发过程中的经验教训
  8. 常见问题排查
  9. 后续扩展方向

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 -y

3.3 安装依赖

npm install obsidian @types/node typescript esbuild --save-dev

3.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
└── .gitignore

4. 技术架构设计

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
发布 MemoPOST/api/v1/memo{ content, visibility }
获取列表GET/api/v1/memo?limit=100limit
更新 MemoPATCH/api/v1/memo/{id}{ content }
删除 MemoDELETE/api/v1/memo/{id}

4.3 数据流

用户操作 → 插件界面 → API 调用 → Memos 服务器 → 返回结果 → 更新 UI

5. 完整代码实现

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 `![${alt}](${src})`;
            });

            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 build

6.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-plugin

6.3 在 Obsidian 中启用

  1. 打开 Obsidian 设置(Ctrl/Cmd + ,
  2. 进入「社区插件」
  3. 找到「Memos Manager」
  4. 点击开关启用

6.4 配置插件

  1. 在设置中找到「Memos 插件设置」
  2. 填写「服务器地址」(如 https://me.sangushui.com
  3. 填写「API Token」(在 Memos 网页端获取)
  4. 点击「测试连接」验证配置
  5. 调整「加载数量」(默认 100)

6.5 获取 API Token

  1. 登录 Memos 网页端
  2. 点击右上角头像 →「设置」
  3. 选择「我的账户」→「生成 API Token」
  4. 复制生成的 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 `![${alt}](${src})`;
});

教训:不同平台支持的 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 /resources API 上传附件
  • 集成七牛云、阿里云 OSS 等图床

9.2 标签筛选

  • 在边栏添加标签过滤功能
  • 点击标签筛选相关 Memos
  • 显示标签云

9.3 搜索功能

  • 添加搜索框,实时搜索 Memo 内容
  • 支持全文搜索

9.4 更多操作

  • 收藏/置顶 Memo
  • 批量删除
  • 导出到 Obsidian 笔记

9.5 性能优化

  • 虚拟滚动(大量 Memo 时)
  • 增量加载
  • 本地缓存

📚 参考资源


🎉 恭喜你!现在你已经拥有一个完整的 Obsidian Memos 插件了!

😊
提交