Logo

flomo to obsidian importer 2.0 开发记录

Published on
...
Authors

背景

作为一个长期使用 Flomo 和 Obsidian 的用户,我一直在用 jia6y/flomo-to-obsidian 这个插件来同步我的 Flomo 笔记到 Obsidian。之前虽然插件基本功能可用,但是随着 flomo的更新,导出逻辑也变了,插件不能用了,需要debug 之后才能使用。 原作者已经很久没有更新了,所以我决定 fork 这个项目,自己动手解决这些问题。

项目地址:geekhuashan/flomo-to-obsidian

关于 AI 辅助开发

坦白说,我并不是一个专业的软件开发者。 这个插件的开发过程,完全是在 AI 的帮助下完成的 —— 一种我称之为 "Vibe Coding" 的方式。

什么是 Vibe Coding?就是:

  • 🤔 我有明确的需求和想法("我想要这个功能")
  • 💬 我用自然语言描述给 AI(Claude、Cursor 等)
  • 🤖 AI 帮我写代码、调试、优化
  • ✅ 我测试、验证,提出改进意见
  • 🔄 循环往复,直到功能完善

这是一个非常美好的时代。有了 AI 之后:

  • 想法可以快速变成现实 - 不需要花几年时间学习 TypeScript、Playwright、Obsidian API
  • 🚀 专注于"我要什么"而不是"怎么实现" - 把精力放在产品思考和用户体验上
  • 🎯 迭代速度极快 - 发现问题立即优化,而不是被技术细节卡住
  • 📚 在实践中学习 - AI 会解释代码,帮你理解技术原理

我的开发流程

  1. 明确痛点 → "每次同步浏览器都弹出来,太烦了"
  2. 问 AI → "怎么让 Playwright 后台运行?"
  3. AI 给方案 → "设置 headless: true"
  4. 我来测试 → 运行插件,验证效果
  5. 发现新问题 → "附件路径有 4 层,太复杂了"
  6. 继续问 AI → 循环上述流程

为什么要分享这个

我希望通过这个项目告诉大家:

  • 🌟 不要被技术门槛吓退 - 你不需要成为专家才能开发有用的工具
  • 🤝 AI 是最好的学习伙伴 - 它既是老师,又是搭档
  • 🎨 创意比技能更重要 - 知道"要什么"比知道"怎么做"更有价值
  • 🔧 Fork 和改造是最好的实践 - 基于现有项目,加入自己的需求

给你的建议

如果你也在用 Flomo 和 Obsidian,欢迎 fork 这个项目,根据你自己的需求来定制:

  • 想要不同的文件夹结构?改!
  • 想要特殊的标签处理?加!
  • 想要定时自动同步?写!

有 AI 帮忙,这些都不是问题。这是一个普通人也能开发软件的时代。

开发过程

第一步:静默同步 - 消除打扰

问题:每次点击同步,可能会有浏览器窗口干扰工作流程。

解决方案

  • 修改 lib/flomo/exporter.ts,确保 Playwright 使用 headless: true
  • 保留认证时显示浏览器(因为需要处理验证码)
// 确保后台运行
browser = await playwright.chromium.launch(** headless: true **);

效果:自动同步完全在后台进行,不会打断工作流。

第二步:简化附件结构 - 从 4 层到 2 层

问题:Flomo 导出的附件路径是这样的:

flomo picture/file/2025-11-03/[USER_ID]/filename.m4a

这个路径有 4 层目录:

  • flomo picture/ - 附件根目录
  • file/ - 没必要的中间层
  • 2025-11-03/ - 日期目录
  • [USER_ID]/ - 用户 ID(每个人不同)

我想要的是:

flomo attachment/2025-11-03/filename.m4a

解决方案

  1. 修改目录名称:从 flomo picture 改为 flomo attachment(更准确,因为包含音频、视频等)

  2. 创建专门的复制方法 lib/flomo/importer.ts:146-197

private async copyAttachmentsSkipUserIdDir(sourceDir: string, targetDir: string) **
    // 第一层:遍历日期目录 (2025-11-03)
    // 第二层:跳过用户ID目录 ([USER_ID])
    // 第三层:直接复制文件到日期目录下
**
  1. 更新文件引用的正则表达式 lib/flomo/core.ts:72
// 支持 ![xxx](file/...) 和 ![](file/...) 两种格式
.replace(/!\[([^\]]*)\]\(file\/([^\/]+)\/[^\/]+\/([^)]+)\)/gi,
         `![$1](<$**attachmentPath**$2/$3&gt;)`)

遇到的问题

  • 第一次写正则时只匹配了 ![]() 空括号,导致带 alt 文字的附件引用没有被更新
  • 通过 ([^\]]*) 捕获 alt 文字,用 $1 保留原文

第三步:动态路径配置 - 尊重用户设置

问题:我的 Flomo 主目录设置的是 "10 flomo",但附件路径写死在代码里是 "flomo"。

解决方案

  1. FlomoCore 构造函数添加 flomoTarget 参数 lib/flomo/core.ts:14
  2. importer.ts 中从配置读取 flomoTarget 并传递给 FlomoCore
  3. 使用模板字符串动态生成路径:
const attachmentPath = `$**this.flomoTarget**/flomo attachment/`;

收获:学会了在类之间传递配置参数,保持灵活性。

第四步:智能内容更新检测 - 不再重复导入

问题:如果在 Flomo 网页上编辑了某条笔记,同步时会被跳过,无法得到最新内容。

原因分析: 原来的逻辑只检查时间戳是否存在于 syncedMemoIds 中:

// 旧逻辑
if (syncedMemoIds.includes(dateTime)) **
    return; // 跳过
**

但如果内容变了,时间戳不变,就检测不到更新。

解决方案 lib/flomo/core.ts:129-163

改进 memo ID 生成算法:

const memoId = `$**dateTime**_$**contentHash**_$**occurrence**_$**total**`;

更新检测逻辑:

const isAlreadySynced = this.syncedMemoIds.some(syncedId => **
    const parts = syncedId.split('_');
    const syncedDateTime = parts[0];
    const syncedHash = parts[1];
    // 时间戳和哈希都要匹配
    return syncedDateTime === dateTime &&
           syncedHash === Math.abs(contentHash).toString();
**);

// 如果时间戳相同但哈希不同,说明内容更新了
if (existingMemoWithSameTime && differentHash) **
    // 删除旧ID,重新导入
    this.syncedMemoIds.splice(oldIndex, 1);
**

效果:现在可以检测到笔记的编辑,并自动重新导入最新版本。

第五步:重置同步历史 - 给用户更多控制

需求:路径改变后,需要清空同步记录重新导入所有笔记。

实现 lib/ui/main_ui.ts:247-280

  1. 添加 "Reset Sync History" 按钮
  2. 显示同步统计(上次同步时间、已同步备忘录数量)
  3. 清空前给出明确警告,提醒用户先删除旧文件夹
const confirmed = confirm(
    `⚠️  IMPORTANT: Before syncing again, you should:\n` +
    `1. Delete the old memos folder: $**flomoTarget**/$**memoTarget**/\n` +
    `2. Delete the old attachments folder if path changed\n\n` +
    `Otherwise, existing files will be OVERWRITTEN!`
);

设计思考

  • 不自动删除文件,让用户手动确认,避免误操作丢失数据
  • 显示统计信息,让用户了解当前状态

第六步:完善文档 - 让别人也能用

开发完成后,我意识到如果要分享给其他人使用,需要完善的文档。

创建的文档

  1. CHANGELOG.md - 详细的版本更新记录

    • 所有新功能的说明
    • Bug 修复列表
    • 升级指南(Option A / Option B)
    • 技术改进说明
  2. 更新 README.md - 用户使用指南

    • "What's New in Version 2.0" 部分,突出新特性
    • 升级指南,提供两种方案供选择
    • 安装说明(特别强调 Playwright 依赖)
  3. 更新版本号

    • manifest.json: 1.4.02.0.0
    • package.json: 1.1.22.0.0
    • versions.json: 添加 "2.0.0": "1.5.0" 条目

发布流程

1. 构建项目

npm run build

生成 main.js (3.8mb,包含 Playwright)

2. 提交代码

git add .
git commit -m "Release version 2.0.0 - Major improvements"
git push origin main

3. 创建 GitHub Release

brew install gh

gh auth login

gh release create v2.0.0 \
  main.js manifest.json styles.css \
  --title "Version 2.0.0 - Major Feature Release" \
  --notes "详细的 Release Notes..."

Release 地址https://github.com/geekhuashan/flomo-to-obsidian/releases/tag/v2.0.0

技术要点总结

1. 正则表达式的陷阱

最初写的正则:

/!\[\]\(file\/([^\/]+)\/[^\/]+\/([^)]+)\)/gi

只能匹配 ![](file/...),不能匹配 ![文字](file/...)

改进后:

/!\[([^\]]*)\]\(file\/([^\/]+)\/[^\/]+\/([^)]+)\)/gi

使用 ([^\]]*) 捕获方括号内的任意内容(包括空)。

2. 文件系统操作的异步处理

处理 Flomo 的嵌套目录结构时,需要递归遍历:

private async copyAttachmentsSkipUserIdDir(sourceDir: string, targetDir: string) **
    const dateItems = await fs.readdir(sourceDir, ** withFileTypes: true **);

    for (const dateItem of dateItems) **
        if (!dateItem.isDirectory()) continue;

        // 检查目录是否包含文件(递归)
        const hasFiles = await this.directoryHasFiles(dateDirPath);
        if (!hasFiles) continue;

        // 跳过用户ID层,直接复制文件
        // ...
    **
**

学到的

  • 使用 fs.readdir(..., ** withFileTypes: true **) 获取文件类型
  • 递归检查空目录,避免创建无用文件夹
  • 使用 try-catch 处理文件操作异常

3. 增量同步的 ID 设计

ID 格式:$**timestamp**_$**contentHash**_$**occurrence**_$**total**

  • timestamp: 精确到秒的时间戳
  • contentHash: 标题+正文+附件的哈希值
  • occurrence: 同一时间戳的第 N 条(防止同时创建多条)
  • total: 总序号(最后的保险)

兼容性考虑

// 支持旧格式
const parts = syncedId.split('_');
if (parts.length >= 2) **
    // 新格式:时间_哈希_...
    return syncedDateTime === dateTime &&
           syncedHash === Math.abs(contentHash).toString();
** else **
    // 非常旧的格式:只有时间戳
    return syncedId === dateTime;
**

向后兼容让老用户升级时不会丢失同步记录。

4. Playwright 的 headless 模式

// 认证时显示浏览器(需要人工处理验证码)
await playwright.chromium.launch(** headless: false **);

// 导出时隐藏浏览器(自动化任务)
await playwright.chromium.launch(** headless: true **);

分离交互场景和自动化场景,提升用户体验。

遇到的坑

1. 变量作用域问题

// ❌ 错误
} else if (item.isFile()) {
    try {
        const targetPath = `$**targetDir**${item.name}`;
        // ...
    } catch (copyError) **
        console.warn(`失败: $**targetPath**`); // targetPath 不在作用域内
    **
}

// ✅ 正确
} else if (item.isFile()) {
    const targetPath = `$**targetDir**${item.name}`;
    try **
        // ...
    ** catch (copyError) **
        console.warn(`失败: $**targetPath**`); // OK
    **
}

2. 正则表达式的贪婪匹配

需要明确 [^\/]+ 来匹配"不是斜杠的字符",而不是用 .+ 贪婪匹配。

3. 文件覆盖的风险

重置同步历史后,如果不删除旧文件就同步,会直接覆盖。所以:

  • UI 上给出明确警告
  • 不自动删除,让用户手动操作
  • 在 README 中反复强调

收获与感悟

技术层面

  1. TypeScript 的类型系统很有用,帮我在编译阶段发现了很多潜在问题
  2. 正则表达式需要反复测试,特别是涉及复杂路径时
  3. 异步操作要注意错误处理和资源清理
  4. 向后兼容很重要,升级不能让老用户受影响

产品层面

  1. 用户体验细节决定产品质量(比如静默同步)
  2. 清晰的文档详细的提示能避免很多支持问题
  3. 提供选项比强制行为更友好(升级时的 Option A/B)

开发流程

  1. 先解决自己的痛点,这是最好的动力来源
  2. 渐进式开发,一个功能一个功能地实现和测试
  3. 完善的文档GitHub Release 让分享变得容易
  4. AI 辅助开发让非专业开发者也能实现复杂功能

AI 时代的启示

  1. 技术不再是唯一门槛 - 想法、需求理解和产品思维同样重要
  2. 学会提问比学会写代码更关键 - 如何清晰地向 AI 描述需求是新的核心能力
  3. Fork 文化更有价值 - 站在巨人的肩膀上,用 AI 快速定制
  4. 分享你的改进 - 每个人的需求都可能启发其他人

适用人群

这个插件适合:

  • ✅ 同时使用 Flomo 和 Obsidian 的用户
  • ✅ 需要定期同步 Flomo 笔记的用户
  • ✅ 希望在 Obsidian 中管理所有笔记的用户
  • ✅ 重视笔记系统稳定性的用户(增量同步,不丢失数据)

需要注意:

  • ⚠️ 仅支持桌面版 Obsidian(依赖 Playwright)
  • ⚠️ 需要安装 Playwright:npx [email protected] install
  • ⚠️ 从 1.x 升级需要重置同步历史(见升级指南)

下一步计划

可能的改进方向:

  1. 支持选择性同步(按标签、日期筛选)
  2. 支持双向同步(Obsidian → Flomo)
  3. 优化大量笔记的同步性能
  4. 支持更多自定义模板

但更重要的是:我会根据自己的实际使用情况持续更新和优化。同时,我真心希望你也能 fork 这个项目,根据你自己的需求去开发和定制。

在 AI 的帮助下,这些改进都不是难事。每个人都可以成为自己工具的开发者。

如果你也在用 Flomo 和 Obsidian,欢迎试试这个插件,更欢迎 fork 后改造成你想要的样子!

相关链接


写于 2025-11-03,一个非专业开发者在 AI 帮助下的开发实践记录。

这不是一个完美的项目,但它解决了我的实际问题。希望它也能帮到你,或者启发你去创造自己的工具。

如果这篇文章对你有帮助,欢迎 Star ⭐️ 或 Fork 🍴

flomo to obsidian importer 2.0 开发记录 | 原子比特之间