未命名 6
记录
- Obsidian 存在内部 api,例如 FileExplorerView类型等。
- 已有排序项目的修改原理,猴子补丁技术 monkey-around的了解。
- 更多关于内部 api d的信息,在 Obsidian Discard 论坛搜索 “FileExplorerView” 得到相关讨论:https://discord.com/channels/686053708261228577/840286264964022302/1377222124238016532
- Obsidian Typings 项目,用于扩展一些内部的 api 的 typescript 类型提示:Fevol/obsidian-typings:Obsidian API 中未记录部分的 Typescript 类型 — Fevol/obsidian-typings: Typescript typings for undocumented parts of the Obsidian API
- Halo 文档插件的 api 机制分析。由于内容比较多,单条讲述逻辑不清楚,单独开一个标题。
Halo 文档插件的 api 机制
基于浏览器 F12 抓包调试。
每一篇文档有 metadata.name 和 spec.docName 两个 id 标识符。
获取文档
/apis/api.uc.doc.halo.run/v1alpha1/doctrees/{metadata.name} 作为文档树中节点。获取该节点在树上的相关信息,例如父子关系、排序优先级等。(包含 docName,需要某方面的完整信息则通过 docName 和对应 api 继续查询)。
/apis/api.uc.doc.halo.run/v1alpha1/docs/{spec.docName} 作为文档内容实体。获取 doc 相关的属性信息。
/apis/api.uc.doc.halo.run/v1alpha1/docs/{spec.docName}/head-content 获取文档正文信息。
整个流程就是通过 doctrees/{metadata.name} 获取 docName,再通过后两个 api 和 docName 获取完整的属性和内容信息。
创建文档
- /apis/api.uc.doc.halo.run/v1alpha1/projectversions/{project-version-name}/tree
ProjectVersion: Create doc tree under project version
创建文档,本地生成并发送 metadata.name 和 spec.docName,文档类型。在文档树上创建节点 (关联 meadata.name),并创建空文档节点 (关联 spec.docName),仅添加 title、slug、描述属性,无正文类型和内容。
{
  "apiVersion": "doc.halo.run/v1alpha1",
  "kind": "DocTree",
  "metadata": {
    "name": "9ff5a81d-d06f-4b63-afe8-f806f6875627"
  },
  "spec": {
    "priority": 9,
    "projectVersionName": "project-version-o4a8ocii",
    "slug": "新建文档",
    "title": "xinjian",
    "type": "DOC",  // 文件夹此选项为 TREE
    "description": "新建一个测试文档描述",
    "docName": "d99f609a-92cb-49eb-a526-11c932b24aaa"
  },
  "status": {}
}
- /apis/api.uc.doc.halo.run/v1alpha1/docs
Doc: Create or update a doc
通过 metadata.name 和 spec.docTreeName 创建文档类型和内容。其中 Post 数据中的 metadata.name 是上异步请求中的 docName,同时 docTreeName 是上一个请求中的 metadata.name。
// Post 数据
{
  "doc": {
    "spec": {
      "docTreeName": "9ff5a81d-d06f-4b63-afe8-f806f6875627",
      "description": "",
      "owner": "holwell",
      "headSnapshot": "doc-snapshot-omztzkgb",
      "publish": false,
      "updatedAt": "2025-10-28T04:48:51.621807552Z",
      "updatedBy": "holwell"
    },
    "status": {
      "inProgress": false
    },
    "apiVersion": "doc.halo.run/v1alpha1",
    "kind": "Doc",
    "metadata": {
      "name": "d99f609a-92cb-49eb-a526-11c932b24aaa",
      "version": 0,
      "creationTimestamp": "2025-10-28T04:48:51.622347738Z"
    }
  },
  "content": {
    "raw": "",
    "content": "",
    "rawType": "markdown"
  },
  "published": false
}
综上,创建一个文档包含 “创建 DocTree" 和 ”创建 Doc“ 两个过程;同样地,创建一个文件夹仅包含 ”创建 DocTree"
创建文件夹
同上的第一个 api,请求数据如下:
{
  "apiVersion": "doc.halo.run/v1alpha1",
  "kind": "DocTree",
  "metadata": {
    "name": "86283fa9-5576-4b6d-aeed-9a88ea33c955"
  },
  "spec": {
    "priority": 10,
    "projectVersionName": "project-version-o4a8ocii",
    "slug": "wenjianjai",
    "title": "文件夹",
    "type": "TREE",  // 类型为文档则是 DOC
    "description": "文件夹描述"
  },
  "status": {}
}
修改文档
- /apis/api.uc.doc.halo.run/v1alpha1/projects/{name}/markdown
Project: Render markdown content under a project
POST 方法:先将 markdown 渲染成 HTML 格式 。
- /apis/api.uc.doc.halo.run/v1alpha1/docs/{name}/head-content
Doc: Update doc content by doc name.
PUT 方法:再将原始数据和渲染后的数据更新到文档中。
{
  "rawType": "markdown",
  "content": "<p>费水电费</p>\n",
  "raw": "费水电费"
}
metadata 和 spec 的区别——以 metadata.name 和 spec.docName、spec.docTreeName 的关系为例
在文档树 docTree 上,节点与节点间的关系需要维护,例如父子关系、次序关系等,它们在树里会有一个 metadata.name 映射。而每个节点类型可能是文件夹或文档,对应的属性信息和存储信息的节点也不一样,这些就信息就存储在 spec 中。
也就是说,我们会通过 “在 docTree 上通过 metadata 查询节点在树上的信息以及 spec 信息” => “通过 spec 信息查询完整的存储信息” 这样一个查询链条。
换言之,每一个 api 对应的是一个类型的数据管理,其中 metadata.name 表示在这个 api 中的唯一资源符,如果这个资源还附带其他类型的数据,则通过 spec.xxxName 进行记录和对应的 api 查询。
移动文档——调整优先级 priority
移动某篇文档时,实际会对该文档当前位置之后与移动新位置之后的所有文档进行 prioriy 的调整,即使部分文档不受调整排序影响,也发起了相关 patch 请求。
这种复杂的排序更新逻辑,这种批量修改在 Obsidian 中可能比较复杂。
排序
- 文章排序:前置数据“发布时间”
- 文档排序:前置数据“priority”
- 文档文件夹排序:给文件夹添加一个 priority属性,获取该属性进行排序。
- 文章分类排序:为了和 3 统一,也可以添加 priority 属性,获取该属性进行排序。
文件夹属性的读取
存储的数据结构由读取的需求决定。所以,在确定存储结构之前,我们分析一下读取需求:
- 文件夹排序时,需要用到 priority 属性。
- Obsidian 客户端在某文档文件夹下,发布文档时,需要知道这个文档文件夹的唯一 id,也就是 project-version-name属性。
- 基于 2 的文档文件夹,类比文章分类文件夹——Obsidian 客户端发布文章时,如果需要携带分类信息,那么也应该知道分类的唯一 id,也就是 categoryName。
文件夹唯一 id
- 不能是路径,文件夹被移动后,路径发生变化。
- 唯一 id 必须关联文件夹,是文件夹名或在文件夹内,可以跟随文件夹移动并表示文件夹 id 的对象。
- 文件夹名具有表意作用,作为 id 需要不重复,不合适。
- 在文件夹内可能比较合理。json 文件或 folder.md 前置数据。
以上是基于文件夹被移动后,路径变化无法作为文件夹唯一 id 的假设。那么,如果禁止本地文件夹的移动呢?又或是本地文件夹的移动同步修改路径和文件夹 id 映射关系呢?
- 本地文件夹应该被移动吗?不应该,移动文件夹对应Halo 文件树管理关系的变化,而文件树管理关系是 Halo 的职责,本地无权执行这样的操作,应该被禁止。
- 禁止本地文件夹移动,决定了无需考虑移动文件夹同步修改映射 Id 的方案。
- 远程 Halo 文件分类目录树变化后,如何在本地找到未变化之前的文件夹。需要建立唯一 id 和路径的映射,通过 id 寻找路径。
- 那目前就有两个需求。第一,远程 Halo 寻址,通过 id 寻找文件夹路径,存储在各文件夹之外。第二,本地文件夹排序,通过文件夹路径获取其 priority 等相关属性,可储存在文件夹之内;如存储在文件夹之外,则需处理文件夹移动时的路径映射变化。
- 最终数据结构可能是两个:第一,id 作为 key,与文件夹路径、priority 属性等对象作为 value 的 map。第二,文件夹路径作为 key 和 id 的 map 隐射关系。因为Obsidian 在内部处理时,并不知道有 id、属性等关系,它只认路径。
- 数据更新。同样的,如果 Halo 远程文件分类树结构发生变化,需要同时更新这两个 map。
- 这两个 map 始终仅 Halo 端的写入权限,Obsidian 端读取权限。由此决定了,本地端无需进行分类文件夹维护,随时重建更新。
文件夹属性的存储——数据结构设计
第一个 map,分类文件夹、文档文件夹的唯一 id 及其对象属性,例如文件夹路径、priority等属性,也就是 “id => 属性”。
第二个 map,由于各个文件夹每次排序都要用到其属性 priority,而 Obsidian 只能直接获取文件夹路径,如果仅基于第一个 map,则每次都需要遍历判断。由于高频使用 “文件夹路径 => id => 属性”,所以我们决定额外维护一个 map。
同步和冲突
考虑以下几种冲突情况:
- 远程有本地也有。
- 远程删除本地存在。
考虑以下冲突类型和来源:
- 文章分类文件夹 “存在性” 冲突。远程删除或移动了分类结构。
- 文章分类文件夹 “名称” 和 “属性” 冲突。远程修改了名称和属性。
- 文章 “存在性” 冲突。远程删除了某篇文章而本地仍然存在;同标题即同文件名文章。
- 文章 “内容” 和 “属性冲突”
- 文档和文档文件夹冲突同上。
为了减少不必要的冲突,本地操作原则如下:
- 本地不对分类文件夹进行任何移动、修改的操作。第一,因为本地分类文件夹是远程 Halo 分类组织结构的映射,我们无法在本地执行移动、修改操作后同步到 Halo,所以这是一个单向的 “Halo=>本地” 的映射,它应该是只读的。第二,分类文件夹的属性存储和读取是基于路径的。在本地存储的数据读取是基于 “path=>id=>属性” 的,但移动、修改文件夹的操作无法同步更新本地存储数据的映射,所以在本地禁止这些操作更便于维护。
- 禁止本地新建文章分类文件夹和文档分类文件夹。原因同上。
- 本地文章修改后,必须及时同步至 Halo,保证 Halo 版本不落后于本地文章。
- 本地仅有新增文章、新建文档,以及更新文章内容和部分属性(发布时间等需要自定义的属性),和更新文档内容和部分属性(优先级)
针对上述可能存在的冲突情况和冲突类型,设计如下更新原则和冲突检测原则:
- 分类文件夹的组织结构和位置是由远程 Halo 决定,所以每次更新都应该重建文件夹组织结构。换言之,分类文件夹的的冲突处理方式完全是 “Halo=>本地” 的单向覆盖。
- 文档文件夹同上。
- 理论上,文章文档版本均落后于 Halo 版本,也应该遵循单向覆盖和重建原则。
最终设计
基于上述的分析,最终设计如下。
Markdown 文件
markdown 文件作为 Obsidian 主要的内容载体形式,它在我们当前设计中被用来放 ”文章“ 和 ”文档“,除了给它们添加特定属性外,还应添加一些通用属性。具体如下:
Md 文件通用属性
- type:用于区分文件类型,例如 POST/DOC等。
文章
前置数据:
- _name:唯一 id
- title:原始 title,区别于文件名,后者需要做字符替换和冲突检测。(可提交)
- _category:文章所属分类名称。
- _tags。
- publishTime:发布时间,排序属性。(可提交)
- _mtime
- _url:完整的文章链接,方便访问,也包含 slug。Halo 引用链接转 Obsidian 本地引用时得寻找依据。
- commited:布尔值,该属性的设计用于标记是否已提交 Halo。在对本地文章进行修改之前,先将该值设置为 false,在之后的提交成功后该值会自动修改为 true。方便从远程同步文章到本地时,避免覆盖未提交文章,也大大简化了冲突检测的机制和难度。
文件名:
- 用 title 正则替换字符。
- 文件名重复,添加创建日期。
正文转换:
- Halo 站内文章链接转 Obsidian 本地文件引用。url => 前置数据生成 map<url, path> => path。如果在 map 中未查询到 url 对应的文档,则先拉取该 url 对应的文章,并重建 map。
- Obsidian 本地文件引用转 Halo 站内文章链接。path => 前置数据生成 map<path, url> => url
文档
前置数据:
- _name
- title
- priority:优先级,排序用。
- _ctime
- _mtime
- _url
- commited
文件名、正文转换,同上。
文件夹
Obsidian 中的文件夹,对应 Halo 文章中的分类、文档中的项目、项目中的子目录等。
文件夹通用属性
- type:用于区分文件夹的类型,例如 category、project、tree等
分类文件夹
额外属性:
- categoryName:发布分类文章用
- priority:
文档文件夹(project-Version-name)
额外属性:
- projectVersionName:发布文档用
- priority
文档子文件夹 (docTree-Tree)
额外属性:
- docTreeName
- priority
额外的数据存储
在上述分析中,Markdown 文件的属性可以存储在 前置数据中,但是文件夹的属性只能存储在额外的 json 文件中。
在 Obsidian 中,一个文件夹的唯一属性是它的路径。所以,本地查询某个文件夹的额外属性的 key 就应该是路径 path。所以,大致数据结构如下:
{
	folderPath: {
		type: 'catecory/project/docTree',
		name: 'metadata.name',
		priority: '8'
	}
}
