Obsidian-Digital-Garden 文件树实现原理

在 Obsidian-Digital-Garden 中,支持左侧以文件树形式展示一个目录。

左侧目录在数字花园中是非常重要的。我曾尝试不断优化文章的内容,希望以文章替代目录。但文章读起来还是太“累”了。

Obsidian-Digital-Garden 的文件树是根据物理路径生成的。只要我们在 0.0 Obsidian 介绍 中,合理规划路径,在数字花园中即可直观展示。这对于书籍形式的写作是非常方便的。

但是,回到我个人的场景中,我却不太喜欢这种物理目录的文件树。以写书场景为例,文章与目录(路径)耦合后,调整目录就会将文章由一个文件夹移动到另一个,这对导致文章的 URL 变化。我喜欢采用 Build in Public 的形式,尽早发布。这会导致我今天发出去的文章,明天 URL 变了大家都找不到,搜索引擎也找不到。

因此,我更加喜欢的是,将左侧边栏目录与文件系统解耦,我通过一个 JavaScript 结构声明目录。Obsidian-Digital-Garden 并不支持这一特性。

在本文中,有两个目标,一是梳理 Obsidian-Digital-Garden 文件树的实现原理,二是探索如何 Hack 出我想要的目录形式。


如何开启

对于普通用户来说,可在 0.0 Obsidian 介绍 的 Digital Garden 的 Global Note Settings 下进行开启,如下图所示:

Pasted image 20240303165555.png

这个开关对应于 Obsidian-Digital-Garden 模版工程根目录的 .env 中的 dgShowFileTree:

dgShowFileTree=false

效果截图

开启后,下次发布笔记时,数字花园的左侧将会出现文件树。

值得称赞的是,文件树的界面采用响应式设计,宽屏时常驻,窄屏时通过菜单按钮开关,并且对移动端适配良好。效果如下:

Screenshot 2024-03-03 at 15-20-15 HomePage.png

Screenshot 2024-03-03 at 15-20-00 HomePage.png

注:图中的目录并非来自于文件系统,而是我修改了模板代码,手动编辑而成。具体做法可见后面小节。


文件树协议

为了支持自定义文件树,脱离文件系统,我分析了文件树的协议如下:

{
  '000.wiki': {
    isFolder: true,
    'HomePage.md': {
      isNote: true,
      permalink: '/',
      name: 'HomePage',
      noteIcon: '',
      hide: false,
      pinned: false
    }
  }
}

从中可以看出,还是非常简单的。这个结果支持嵌套


采用自定义文件树

这一步需要对模板工程的源码进行修改。修改方法并不唯一,这里给我我的改法:

首先,在 src/helpers/userUtils.js 中添加目录:

category = {
  'Obsidian': {
    isFolder: true,
    '使用基础': {
      isFolder: true,
      '.obsidian 目录': {
        isNote: true,
        permalink: '/000.wiki/Obsidian .obsidian 目录/',
        name: '.obsidian 目录',
      },
      '我的快捷键': {
        isNote: true,
        permalink: '/000.wiki/我的 Obsidian 快捷键/',
        name: '我的 Obsidian 快捷键',
      },
    },
    //...
  },
},

exports.category = category;

之后,修改 src/site/_data/eleventyComputed.js

const { getGraph } = require("../../helpers/linkUtils");
const { getFileTree } = require("../../helpers/filetreeUtils");
const { userComputed, category } = require("../../helpers/userUtils");

module.exports = {
  graph: (data) => getGraph(data),
  filetree: (data) => category,
  userComputed: (data) => userComputed(data)
};

其中,跳过了 getFileTree 方法,直接给出我们手动编辑的 category。


在 Obsidian 中维护文件树

使用 JSON 来维护数字花园侧边栏目录的方案,运行了一段时间之后,我发现有两个问题:一是 JSON 手动编辑起来太麻烦,二是文件树是脱离 Obsidian 的,造成了工作流的割裂。

于是,我想到,在 Obsidian 中创建一个 Markdown 页面,用嵌套列表来维护这个文件树。这个 Markdown 页面可以直接拖动到 Obsidian 侧边栏,当作目录使用。

给定如下 Markdown:

- 关于我
- 归档
- 个人成长
	- [[Maeiee的成长周报]]
	- [[读书记录]]
	- Maeiee思考
		- [[Maeiee思考1:我在AI浪潮中的位置]]
		- [[Maeiee思考2:LLM连续微调游戏]]
- Obsidian
	- Obsidian 介绍

目标:编写 Node.js 脚本,将上述 Markdown 格式转换为如下 JSON:

category = {
  '关于我': {
    isNote: true,
    permalink: '/000.wiki/Maeiee的自我介绍/',
    name: '关于我',
  },
  '归档': {
    isNote: true,
    permalink: '/000.wiki/数字花园归档页/',
    name: '归档',
  },
  '个人成长': {
    isFolder: true,
    'Maeiee的成长周报': {
      isNote: true,
      permalink: '/000.wiki/Maeiee的成长周报/',
      name: 'Maeiee的成长周报',
    },
    '读书记录': {
      isNote: true,
      permalink: '/006.电子书/读书记录/',
      name: '读书记录',
    },
    'Maeiee思考': {
      isFolder: true,
      'Maeiee思考1:我在AI浪潮中的位置': {
        isNote: true,
        permalink: '/000.wiki/Maeiee思考1:我在AI浪潮中的位置/',
        name: 'Maeiee思考1:我在AI浪潮中的位置',
      },
      'Maeiee思考2:LLM连续微调游戏': {
        isNote: true,
        permalink: '/000.wiki/Maeiee思考2:LLM连续微调游戏/',
        name: 'Maeiee思考2:LLM连续微调游戏',
      },
    },
  },
  'Obsidian': {
    isFolder: true,
    'Obsidian 介绍': {
      isNote: true,
      permalink: '/000.wiki/Obsidian/',
      name: 'Obsidian 介绍',
    },
}

对比 Markdown 与 JSON,有如下细节需要注意:

有一个变量 const cat = data.collections.note;,是一个列表,需要通过遍历用笔记名称找出对应的地址:

let title = cat[n].data.title || cat[n].fileSlug;
let url = cat[n].url;

数字花园站点生成器是一个基于 eleventy 的静态站点,我该在哪一步加入上述脚本的?我理解是在有了所有笔记数据后,但是在具体执行静态生成器前,因为我这里生成的 JSON,需要用于页面的生成。

src/site/_data 下有一个 eleventyComputed.js:

const { getGraph } = require("../../helpers/linkUtils");
const { getFileTree } = require("../../helpers/filetreeUtils");
const { userComputed, category } = require("../../helpers/userUtils");

module.exports = {
  graph: (data) => getGraph(data),
  filetree: (data) => category,
  userComputed: (data) => userComputed(data)
};

其中 category 是我修改之前人为编辑 JSON 的实例,data 是站点全量数据。我想我找到地方了。

在 GPT 的帮助下,最终实现代码如下:

const { getGraph } = require("../../helpers/linkUtils");
const { getFileTree } = require("../../helpers/filetreeUtils");
const { userComputed, category } = require("../../helpers/userUtils");
const fs = require('fs'); // 导入fs模块
let cachedCategory = null;

module.exports = {
  graph: (data) => getGraph(data),
  filetree: (data) => {
    if (cachedCategory) {
      return cachedCategory;
    }
    const cat = data.collections.note || [];
    if (cat.length > 0) {
      const parseMarkdownToJSON = (md) => {
        const lines = md.split('\n')
          .filter(line => !line.startsWith('---')) // 移除元数据
          .filter(line => !line.includes('dg-publish')) // 移除未发布的笔记
          .filter(line => line.trim() !== ''); // 移除空行
        const stack = [{ level: -1, obj: {} }];
        lines.forEach(line => {
          const level = line.lastIndexOf('\t') + 1; // 使用缩进级别来确定当前行的层级
          
          const text = line.trim().replace(/^[\s\-]+/g, '').replace(/[\[\]]/g, ''); // 移除Markdown语法字符
          // 如果是 Obsidian 内链,表示是笔记
          const isNote = (line.indexOf("[[") !== -1 && line.indexOf("]]") !== -1);
          let name, alias;
          if (isNote) {
              [permalink, name] = text.split('\|');
              name = name.trimLeft();
              permalink = '/' + permalink.replace('\\', '/').trimLeft();
          } else {
              name = alias = text.trimLeft();
          }  
          // 创建新的层级/笔记对象
          const newItem = isNote ? {
              isNote: true,
              permalink: `${permalink}`,
              name: name,
          } : {
              isFolder: true,
          };

          // 处理层级逻辑
          while (stack[stack.length - 1].level >= level) {
            stack.pop();
          }
          stack[stack.length - 1].obj[name] = newItem; // 将新项目添加到当前层级
          stack.push({ level, obj: newItem }); // 将新项目压入堆栈      
        });
    
        return stack[0].obj; // 返回最顶层对象
      };
      const mdContent = fs.readFileSync('src/site/notes/000.wiki/数字花园目录.md', 'utf8');
      const fileTree = parseMarkdownToJSON(mdContent);
      cachedCategory = fileTree;
      console.log(cachedCategory);
      return fileTree;
    }
  },
  userComputed: (data) => userComputed(data)
};

本文作者:Maeiee

本文链接:Obsidian-Digital-Garden 文件树实现原理

版权声明:如无特别声明,本文即为原创文章,版权归 Maeiee 所有,未经允许不得转载!


喜欢我文章的朋友请随缘打赏,鼓励我创作更多更好的作品!