关于 sense-bases
sense-bases 是一份参考技能,说明如何将 Obsidian Bases 的筛选器、公式和视图转换为 sense 的 SQL 查询。它面向需要将 Bases 数据选择迁移到 sense 查询接口的用户,并涵盖等价表达式与边缘情况。
主要功能
- 01将 Bases 属性映射为 SQL
- 02转换筛选条件与谓词
- 03将公式转换为 SQLite 表达式
- 04用排序和窗口函数表达视图
- 05说明转换注意事项
适用场景
- 01将 Bases 视图重建为 sense 查询
- 02转换标签、链接或列表筛选
- 03计算公式与分组汇总
- 04通过绑定路径处理当前笔记
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ kmalakoff/ sensemaking --skill sense-bases安装后在 Claude Code 中输入/sense-bases确认已加载。
附带文件EXAMPLES.md
详细介绍
sense-bases
.base 文件采用 YAML 格式:筛选器用于选择笔记,公式用于计算值,视图用于排序和分组。Obsidian 会根据自身的元数据缓存进行计算;sense 将相同的数据存储在 SQL 表中。所有用于选择或计算行的 Bases 结构都有对应的 SQL 写法。没有对应写法的是呈现方式(columnSize、卡片布局),它们只会改变像素,而不会改变行,因此翻译时忽略它们不会损失任何信息。
将一个视图转换为一条查询:将基础级别的 filters 与视图的 filters 进行 AND 运算,并将视图的 order/sort/limit 分别用作 SELECT 列表、ORDER BY 和 LIMIT。包含四个视图的基础会转换为四条查询;将它们以基础的名称保存在 sense.config.json 的 queries 块中,就可以通过 sense <name> 运行。通用查询接口请参阅 sense 技能;本文仅介绍如何将 Bases 结构映射到该接口。
属性
| Bases | sense | 说明 |
|---|---|---|
note.field / 裸字段 field | f.<field> | Frontmatter 列,名称相同 |
file.name | basename(f.path, '.md') | Name 列:不包含目录和扩展名 |
| 带扩展名的名称 | basename(f.path) | 后缀参数与 Unix basename 相同:会保留并剥除该后缀 |
file.folder | f.path LIKE 'Folder/%' | 用于 inFolder("Folder") |
file.ext | 'md' | sense 仅索引 Markdown |
file.size | f._size | 字节数 |
file.mtime | datetime(f._mtime / 1000, 'unixepoch') | 存储单位为毫秒 |
file.ctime | datetime(f._ctime / 1000, 'unixepoch') | 参见下文的注意事项 |
file.tags | tags 表 | Frontmatter 标签和行内 #tags,已去重,粒度与 Obsidian 相同 |
file.links | links WHERE embed = 0 | 每个不同的书写目标对应一行 |
file.embeds | links WHERE embed = 1 | |
file.backlinks | links WHERE dst = f.path | |
this | 绑定的 ? | 参见下文 |
file.ctime 是文件系统的创建时间。git clone 或复制操作会重置该时间,因此,对于通过检出获得的目录树,其中所有文件的 ctime 都会是检出日期;按作者信息计算的年龄公式会出错,但按文件系统信息计算则没有问题。如果修改时间呈现这种情况,map 会发出警告;创建时间也会有同样的问题。
谓词
| Bases | 含义 |
|---|---|
== != > < >= <= | 相同 |
&& || ! | AND OR NOT |
and: / or: / not: filter blocks | 带括号的 AND / OR / NOT |
field.isEmpty() | (f.field IS NULL OR f.field IN ('', '[]', '[null]')) |
file.hasTag("book") | EXISTS (SELECT 1 FROM tags WHERE tags.path = f.path AND (tag = 'book' OR tag LIKE 'book/%')) |
file.hasLink("Note") | EXISTS (SELECT 1 FROM links WHERE src = f.path AND dst = 'Note.md') |
list.contains(x) | EXISTS (SELECT 1 FROM json_each(f.list) WHERE value = x) |
string.contains(x) | instr(f.string, x) > 0 |
list.containsAny(...) | 使用 value IN (...) 的 json_each EXISTS |
/regex/.matches(x) | SQLite 不支持正则表达式;LIKE/GLOB 可处理锚定和通配符模式 |
value.isType("object") | json_each 自带的 type 列:... FROM json_each(f.field) j WHERE j.type = 'object' |
树中没有笔记声明的字段根本没有对应列,因此筛选器引用它会报错,提示
no such column,而不是将每一行都视为空值。Obsidian 的求值器会将未知属性返回为空;SQL 则不会。
删除该子句与错误所表达的意思相同,而 SELECT name FROM pragma_table_info('frontmatter') 会列出已有的字段。
isEmpty() 有四种为真的情况,因为空值有三种存储方式:NULL(键缺失)、''
(空字符串值)、'[]'(写作 [] 的列表)和 '[null]'(列表键上只有一个裸 -)。
Obsidian 的 isEmpty() 对这些情况都会返回真;单独使用 IS NULL 只能找到第一种。
IN 列表包含了完整的判断条件。json_array_length() 则不行:它会将 '[null]' 读作长度
1,并且在普通字符串上报错。列表内部也有同样的陷阱:json_each 会将字符串成员作为纯文本交给
value,因此对它调用 json_type(value) 会报 malformed JSON;扫描结果自身的 type 列才是判别依据。
contains(link("Movies")) 会与链接值比较。在 frontmatter 中,链接列表存储的是写入的文本,因此
json_each 比较的值是字面量 [[Movies]]。
hasLink 比较的是解析后的路径:links.dst 是解析后的目标(失效链接为 NULL),因此
比较值应为目标的路径,而不是其显示名称。
sense 自带的 has(field, x) 比两种 contains 都宽松:对 JSON 数组执行精确成员匹配,对字符串执行子串匹配,因此 has(f.status, 'active') 也会匹配 inactive。已知字段是列表时,这种写法更简短;json_each 形式则是精确的转换方式。
公式
公式是 SELECT 表达式。Bases 函数对应的 SQLite 写法如下:
| Bases | 含义 |
|---|---|
if(c, a, b) | iif(c, a, b) 或 CASE WHEN |
now() / today() | datetime('now') / date('now') |
date(x) | datetime(x) |
(date1 - date2).days | julianday(date1) - julianday(date2) |
(now() - acquired).months | (julianday('now') - julianday(f.acquired)) / 30.44 |
x.round(n) / x.toFixed(n) | round(x, n) / printf('%.2f', x) |
d.format("YYYY-MM-DD") | strftime('%Y-%m-%d', d) |
x.toString() | CAST(x AS TEXT) |
list.length | 对 JSON 列使用 json_array_length(f.list),对表使用 COUNT 子查询 |
list(a).filter(list(b).containsAny(value)).unique() | 使用 EXISTS 连接的子查询;示例见 EXAMPLES.md |
Bases 的时长具有类型;SQL 日期运算使用儒略日的小数。将时长字段串联起来的公式(.days.round())会简化为对儒略日差值进行运算。
引用其他公式的公式会转换为 CTE 层:SQL 无法在同一个 SELECT 列表中读取 SELECT 别名,因此每个依赖层级会将其公式计算为列,下一层再读取这些列(WITH t AS (SELECT ..., <level-1 formulas> FROM frontmatter) SELECT ..., <level-2 formulas> FROM t)。五个公式串联起来时,CTE 的数量取决于它们的层级,而不是五个。只读取基础列的公式会共用一层。
视图
-
sort:(支持多个键,每个键都有排序方向)->ORDER BY a DESC, b ASC。limit:->LIMIT。 -
groupBy不会合并行。Obsidian 会将每一行按组归入标题下,并显示每组摘要。生成相同行和数值的 SQL 使用排序和窗口函数,而不是 GROUP BY:SELECT f.path, f.status, f.days, AVG(f.days) OVER (PARTITION BY f.status) AS group_avg, COUNT(*) OVER (PARTITION BY f.status) AS group_n FROM frontmatter f WHERE f.days IS NOT NULL ORDER BY f.status, f.days DESC折叠后每组只显示一行的报表使用普通的
GROUP BY。这与 Bases 视图显示的结果不同。 -
视图的
summaries(Sum、Average、通过排序实现的 Median、Unique、Filled、Checked)对应相应的聚合函数,并像上文一样使用窗口函数保留行,或者使用单独的聚合查询。
this
this 是评估基底时所依据的笔记:嵌入该基底的笔记,或 Obsidian 的活动窗格。sense 没有窗格,因此由调用方将路径作为绑定参数传入:sense sql "..." <path>,或运行已保存的查询:sense <name> <path>。使用 CTE 可以只绑定一次:
WITH me AS (SELECT ? AS p)
每个 this.file.* 表达式都会关联 me。EXAMPLES.md 中完整的 Related.base 转换示例就是一个实际案例:通过一个绑定路径,实现链接重叠、互相链接筛选,以及使用 GROUP_CONCAT 获取共享目标列表。
无法转换的部分及其原因
columnSize、卡片/表格/列表/地图界面元素:只是对相同行进行像素布局。- Obsidian 根据活动窗格选择
this:这里没有窗格;路径参数即可确定目标。 - 文件变更时实时重新评估:默认的 CLI 查询会增量准备所需功能;
--no-build会读取上一次已完成的代次,并要求其具备查询所需的就绪状态,而不扫描实时文件。任何内容都不会自行重新渲染。