思源模板功能新人指南:模板语法 + 函数 + md 块语法

Sy 源文件

思源模板功能新人指南模板语法函数 md 块语法.sy.zip

什么是模板?模板,简单来说就是在一段文本中定义一些「变量」,这些变量实际渲染的时候会按照一定的规则被替换为实际的值

比如我们最常见的日记路径模板:

/daily note/{{now | date "2006/01"}}/{{now | date "2006-01-02"}}

在每天调用 Alt+5 快捷键创建日记的时候,思源就会渲染以上的模板,把其中由 {{}} 内部定义的变量替换为实际的值(在这里是日期),最后变为 /daily note/2024/01/01-31 这样具有实际意义的路径字符串。

思源的模板在功能上非常强大,然而实际用起来的感受——作为一个笔记软件的模板而言——老实说,使用起来还挺繁杂的。

这篇文章不会帮你完全掌握思源的模板功能,而是尽量提纲挈领地告诉大家思源模板功能的概况。如果你想要深入了解甚至熟练掌握——那你需要去认真看看相关文档,并抽空练习。


从总体上讲,我会把思源的模板功能划分为这么几个重要的版块:

  • 基于 Golang 的模板语法

    • 基本语法

      • {{ }} vs .action{}
    • 常用的模板函数

    • 常用的流程控制

      • 条件控制
      • 循环控制
  • 数据库中的用法

  • Markdown 块语法

    • {: } 块属性声明语法
    • 超级块排版声明语法

在正式讲解之前,有必要先把这几个版块之间的关系厘清楚:

  1. Golang 模板语法和 Sprig

    • 思源的模板的语法是基于 Golang 的模板引擎来实现的

    • Golang 的模板引擎并没有提供多少好用的模板函数,所以思源又内置了 Sprig 库——这个库提供了大量丰富的模板函数,来增强模板的功能性

    • 基本语法

      • 原始的 Golang 模板语法是 {{ xxx }}
      • 但是在思源中 {{ }} 又是嵌入块的声明语法
      • 为了避免冲突,开发者就自定义了 .action{ xxx } 语法来替换原始的 {{ xxx }}
  2. 数据库模板列

    • 思源目前的数据库当中,可以创建一个「模板列」,在这个列当中可以使用 .action{} 语法,这样就可以在模板列中根据对应行的内容动态展示其内容
    • 一个类似的例子:就好比在 excel 中增加一列公式
  3. Markdown 块语法

    • Markdown 本身是没有块结构的;思源实现了一种基于 kramdown 的 markdown 方言;这个方言不属于 Golang 模板语法的一部分,而是属于思源自己的特殊语法
    • 基于这种方言,思源就可以在模板文件中为块预定义自定义属性、通过特殊的语法创建复杂的超级块排版
    • 注意:这个语法和 Golang 模板无关,在数据库 、路径模板的这些地方使用是不会起效果的!

前附:Test-Template 插件

在这篇教程之外,我上架了一个插件“测试模板语法”以方便大家测试 Golang 的模板语法。

image

你可以在阅读的过程中,使用这个插件对模板语法进行测试。插件的详细用法见说明文档。

image

思源 Golang 模板语法 Cheatsheet

基本的模板语法讲起来不够直观,这里我画了一个 cheatsheet 给大家展示,信息密度高一点会更加方便理解。

image

image

image

image

基础语法快速总结

  1. 使用 {{ }} 或者 .action{} 来定义模板,里面一般是函数调用

  2. 函数的基本概念

    1. 函数可以接受零个或者若干个参数,进行计算后返回一个特定的值
    2. 函数的参数和返回值有特定的类型,不同类型之间不能混用
  3. 变量的赋值

    • 如果使用 {{ now }} 这种语法,模板引擎会直接将其渲染为具体的值
    • 但是如果使用 {{ $t := now}} 这种语法,那么 now 的返回值会存储在变量 $t 中而不会渲染内部具体的值,此后就可以使用 $t 来引用这个变量
  4. 使用函数

    • 函数是一个封装好的功能包,输入一些参数,进行特定的计算,返回特定的值

    • 传入函数的参数类型要和函数接受的参数类型匹配

    • | 语法:管道语法,这是一种在计算机领域非常常见的语法,相关参考

image

控制流语法快速介绍

Go 语言模板控制流的用法可以参考官方文档:https://pkg.go.dev/text/template#hdr-Actions

这里介绍最常用的:

  1. if 语句:通过条件判断来有选择性地渲染、执行一部分模板语法

    基本的用法是:如果 condition 中的条件被判定为 true,则渲染 T1 否则渲染 T2

    {{if (condition)}} T1 {{else}} T0 {{end}}
    

    例如:

    {{ if eq (Weekday now) 1 }}
    好好工作
    {{ else }}
    摸鱼!
    {{ end }}
    

    在这里的 condition 中:

    1. 首先计算 Weekday now 获取今天的星期数

    2. 然后通过 eq 和 1 对比,看看是不是周一;

      1. 如果是,就渲染「好好工作」
      2. 如果不是,就渲染「摸鱼!」

    如果你不理解这里用到的 eq 函数,请不要焦虑,在后面的部分我们会详细说明在 if 语句中常常用到的函数。

  2. range 语句

    range 语句被用来在一个序列当中进行迭代,最常见的语法如下

    {{ range $v := <slice> }}
      {{ $v }}
    {{ end }}
    

    这里的含义是:

    • 在一个可迭代的 <slice> (通常是一个列表 List)中迭代
    • 每次取出一个值赋值给 $v
    • 我们就可以在 range 块内部访问这个 $v 变量

    一个简单的案例如下:

    {{ $list := list 1 2 3 4 }}
    {{ range $v := $list }}
      - Index: {{ $v }}
    {{ end }}
    

    这里我们首先通过 list 函数创建一个列表(后面会介绍这个函数),然后在 range 中迭代这个列表,依次访问每个值。

    渲染结果为:

    • Index: 1
    • Index: 2
    • Index: 3
    • Index: 4

这里介绍的是最简单的控制流语法,还有更高级的用法请自行探索。

image

常用函数介绍

函数是想要用好模板功能必定绕不开的一座大山。在上面的 Cheatsheet 中,我们简要介绍过函数的基本概念——函数就是一个封装好的功能包,他可以接收零个或者多个(视函数而定)参数,并计算得到一个输出。

{{ now }} # now 函数,接收 0 个参数,输出当前的时间对象
{{ add 1 2 3 4}} # add 函数,接收任个 int (整数)参数,输出他们的累加结果

思源中支持的函数很多,有 Sprig 包支持的也有思源内置的。而实际在思源中常用的函数,我把他们分类为这几类:

  1. 算术计算

    • 整数算术(int 类型)
    • 浮点数算术(float 类型)
  2. 时间计算

  3. 字符串操作

  4. 列表计算

  5. 类型转换(偶尔)

  6. 逻辑运算(在写条件块的时候要用)

  7. 思源模板片段特殊函数

为了方便,我这里整理了最常用的一些函数,进行简单介绍。在介绍的过程中,遵循以下的格式:

  • Fun 代表一个没有参数的函数,比如 now

  • Fun <int>, <int> 代表有几个固定数量参数的函数,比如 sub <int> <int><int> 代表了参数的类型,常用类型还有

    • int:整数类型

      • int64:64 位的整数类型,需要通过类型转换函数和 int 类型做转换
    • float:浮点数类型

      • float64:64 位的浮点数类型,需要通过类型转换函数和 float 类型做转换
    • list:列表类型

    • str:字符串类型

    • bool:布尔类型(true 或者 false

    • Time:时间对象类型(这个类型会在后面有更加详细的解释)

  • Func [<int>,] 代表有不定长数量参数的函数,比如 add 后面可以跟好多个整数

同时我还会给出一些使用范例,以及渲染后的效果,以供参考。

常用数值计算函数

案例:

- add
  - {{ add 1 2 3 4 }} -> 10
- sub
  - {{ sub 4 1 }} -> 3
- mul
  - {{ mul 1 2 3 4 }} -> 24
- div
  - {{ div 5 2 }} -> 2.5
- mod
  - {{ mod 5 2 }} -> 1
- min/max
  - {{ min 5 1 }} -> 1
  - {{ max 5 1 }} -> 5
- pow
  - {{ pow 5 2 }} -> 25
- powf
  - {{ powf 2.5 2 }} -> 6.25
- log
  - {{ log 5 2 }} -> 2
- logf
  - {{ logf 5 2 }} -> 约 2.32
- FormatFloat
  - {{ FormatFloat "#,###.##" 2345.6789 }} -> 2,345.68

以上的模板会被渲染为:

  • add

    • 10 -> 10
  • sub

    • 3 -> 3
  • mul

    • 24 -> 24
  • div

    • 2 -> 2.5
  • mod

    • 1 -> 1
  • min/max

    • 1 -> 1
    • 5 -> 5
  • pow

    • 25 -> 25
  • powf

    • 6.25 -> 6.25
  • log

    • 2 -> 2
  • logf

    • 2.321928094887362 -> 约 2.32
  • FormatFloat

    • 2,345.68 -> 2,345.68

常用的时间函数

  • Sprig 常用函数

    • 所有函数见:https://masterminds.github.io/sprig/date.html

    • nowTime,返回当前的时间

    • date <fmt str> <Time>str, 将输入的时间对象格式化为字符串

      • fmt 使用 2006-01-02 15:04:05 这个固定时间格式(知乎讨论

      • 教你如何记忆这个 🗑️ 垃圾到 😡 爆炸的 magin number

        • 首先年份固定是 2006
        • 后面的月日时分秒从 01 开始依次递增到 05
        • 所以标准格式为:2006-01-02 03:04:05
        • 但是 03:04:05 是 12 小时制,所以如果想用 24 小时制,要换算成 2006-01-02 15:04:05
    • toDate <fmt str> <str>Time ,将一个字符串转换为一个时间对象

      • 注:思源内置的 parseTime 函数使用体验比这个函数要好一点
    • duratioin <second: int>Duration, 将传入的秒数(int)转换为 Duration 对象

  • 思源内置时间函数

    • ISOWeek <Time>int,返回对应的时间对应今天的第几周
    • Weekday <Time>int,返回对应的时间是星期几 ;Sunday=0, Monday=1, ..., Saturday=6
    • WeekdayCN <Time>str,返回对应的时间是星期几;Sunday=日, Monday=一, ..., Saturday=六
    • WeekdayCN2 <Time>str,返回对应的时间是星期几;Sunday=天, Monday=一, ..., Saturday=六
    • parseTime <string>Time,解析传入的时间字符串,返回一个时间类型
  • Time:Golang 的 time.Time 类型,这个类型里面有不少有用的属性可以访问

    • 完整函数参考https://pkg.go.dev/time#Time

      • 请查找格式为 func (t Time) Func(xxx) XXX 的 API 文档
      • 这类函数(属性)都可以在模板中通过 t.Func 来调用
    • Year: int

    • Month: Month,这是一个枚举类型

      • 虽然显示是英文字符串,但是可以当作数值类型参与计算,例如 add 1 now.Month
      • 详情参考:https://pkg.go.dev/time#Month
    • Day: int

    • Hour: int

    • Minute: int

    • Second: int

    • Sub <Time>: 计算两个时间之间的差,返回 Duration

    • Compare <Time>:比较两个时间对象,返回 int, -1 或 0 或 1

    • AddDate <year int> <month int> <day int>: int,在当前的时间对象的基础上计算 N 天(月、 年)后的日期(参数可以为负数)

  • Duration: Golang 的 time.Duration 类型

    • 完整文档见https://pkg.go.dev/time#Duration

      • 请查找格式为 func (d Duration) Func(xxx) XXX 的 API 文档
      • 这类函数是可以在模板中通过 t.Func 来调用
    • Hoursfloat64,将 Duration 转换为以小时为单位的数值

    • Minutes: float64,将 Duration 转换为以分钟为单位的数值

    • Seconds: float64,将 Duration 转换为以秒为单位的数值

    • String: str, 将 Duration 按照小时制转换为字符串,显示的格式为 "72h3m0.5s"

案例:

- now
  - {{ now }}
- date
  - {{ date "2006-01-02 15:04:05" now }}
- toDate
  - {{ toDate "2006-01-02" "2020-01-01" }}
- duration
  - 1800 second: {{ duration 1800 }}
- ISOWeek
  - 第 {{ now | ISOWeek }} 周
- Weekday
  - 今天是星期:
  - {{ now | Weekday }} {{ now | WeekdayCN }} {{ now | WeekdayCN2 }}
- parseTime
  - {{ parseTime "2020-01-01 12:00:00" }}
- Time 对象
  - {{ $t := parseTime "2020-01-01 12:00:00" }}
  - {{ $t.Year }}/{{ $t.Month }}/{{ $t.Day }} {{ $t.Hour }}:{{ $t.Minute }}:{{ $t.Second }}
  - {{ now.Sub $t }}
  - {{ $t.Compare now }}
  - {{ $t.AddDate 0 0 7}}
- Duration 对象
  - {{ $du := now.Sub $t }}
  - {{ $du }}
  - {{ $du.Hours }}; {{ $du.Minutes }}; {{ $du.Seconds }}
  - {{ $du.String }}

以上的模板会被渲染为:

  • now

    • 2024-05-06 22:08:49.9662126 +0800 CST m=+477.037392501
  • date

    • 2024-05-06 22:08:49
  • toDate

    • 2020-01-01 00:00:00 +0800 CST
  • duration

    • 1800 second: 0s
  • ISOWeek

    • 第 19 周
  • Weekday

    • 今天是星期:
    • 1 一 一
  • parseTime

    • 2020-01-01 12:00:00 +0800 CST
  • Time 对象

    • 2020/January/1 12:0:0
    • 38098h8m49.9662126s
    • -1
    • 2020-01-08 12:00:00 +0800 CST
  • Duration 对象

    • 38098h8m49.9662126s
    • 38098.14721283683; 2.28588883277021e+06; 1.371533299662126e+08
    • 38098h8m49.9662126s

日期计算可能是思源用户最常接触到的函数了,如果你是一个 daily note 用户,现在打开你主日记本,查看一下你日记的模板会发现它可能是这个样子:

image

/daily note/{{now | date "2006/01"}}/{{now | date "2006-01-02"}}

现在我们有了理论基础,不妨就这个案例来看一下这个日记模板是怎么回事:

  1. {{}} 是 Golang 标准的模板语法,没什么好说的

  2. now 函数返回了一个 Time 对象

  3. | 通过管道运算,把 now 的结果传给后面,所以相当于在运行 date "2006/01"

    你可以把 {{now | date "2006/01"} 换成 {{date "2006/01" now}};他们两个是完全等价的

  4. date <fmt str> <Time> 是固定搭配的用法,这里 "2006/01" 也是固定的用法

  5. 所以最后,这个模板会被渲染为 yyyy/mm 这样的格式,和前面的组合起来,就会形成 /daily note/<年份>/<月份> 这样的路径字符串

分享几个日期计算的模板片段

就经验来看,在正常 md 模板中会写得比较复杂的也只有和日期计算相关的模板了。这里随便分享几个。

  • 可能会用到日记里面的

    .action{ $weekday := WeekdayCN now }
    .action{ $datestr := now | date "2006-01-02" }
    .action{ $datestr_sy := now | date "20060102" }
    
    今天是 .action{$datestr} 星期.action{ $weekday }
    
    {: custom-dailynote-${ $datestr_sy }=".action{$datestr_sy}" }
    
  • 计算这周周日、上周周六等

    .action{ $weekday := Weekday now }
    .action{ $begSunday := now.AddDate 0 0 (mul -1 $weekday | int) }
    .action{ $last6 := $begSunday.AddDate 0 0 -1 }
    .action{ $this6 := $begSunday.AddDate 0 0 6 }
    
    
    - 如果以周日为一周的起始,那么本周周日为:.action{ $begSunday | date "2006-01-02" }
    - 上周周六为:.action{ $last6 | date "2006-01-02" }
    - 本周周六为:.action{ $this6 | date "2006-01-02" }
    
  • 周规划

    .action{ $week := now | ISOWeek }
    .action{ $weekday := now | Weekday }
    .action{ $monday_delta := sub $weekday 1 }
    .action{if eq $weekday -1}
      .action{ $monday_delta := 6 }
    .action{ end }
    .action{ $monday := now.AddDate 0 0 (mul -1 $monday_delta | int) }
    .action{ $sunday := $monday.AddDate 0 0 6 }
    
    ## 第 .action{$week} 周: .action{$monday | date "2006-01-02"} ~ .action{$sunday| date "2006-01-02"}
    

常用字符串操作函数

字符串操作函数在正常 md 模板里面用的没那么多,但是在数据库模板列里面可能会大量用到。

只列举了很小的一部分,完整文档见 https://masterminds.github.io/sprig/strings.htmlhttps://masterminds.github.io/sprig/string_slice.html

  • trim <str>: str, 将前后的空白字符去掉
  • repeat <int> <str>str,将给定的字符串重复若干次
  • substr <start int> <end int> <str>: str,提取子字符串,index 从 0 开始
  • trunc <int> <str>: str,将给定的字符串按照最大长度来截断;<int> 参数可以是负数,代表从末尾反向截断
  • abbrev <int> <str>: str,同样是截断字符串,但是会在后面加上一个 ...
  • contains <part str> <whole str>: bool,检测 whole 中是否包含 part
  • cat [<str>,]: str,将若干字符串拼接起来,中间通过空格间隔
  • replace <from str> <to str> <src str> : str,将 src 中所有的 from 替换成 to
  • 正则表达式系列的函数(请自行翻阅文档
  • join <ch str> <List[str]>: str,将给定的字符串列表通过 ch 连接起来
  • splitList <ch str> <src str>: List[str],将给定的 src 字符串根据 ch 字符分割为列表

案例:

- trim
  - {{ trim "   aa   " }} -> "aa"
- repeat
  - {{ repeat 5 "12" }} -> "1212121212"
- substr
  - {{ substr 1 3 "abcedfg" }} -> "bc"
- trunc
  - {{ trunc 3 "abcedfg" }} -> "abc"
  - {{ trunc -3 "abcedfg" }} -> "efg"
- abbrev
  - {{ abbrev 5 "hello world" }} -> "he..."
- contains
  - {{ contains "bb" "aabb" }} -> true
- cat
  - {{ cat "1" "2" "3" }} -> "1 2 3"
- replace
  - {{ replace "aa" "bb" "11aaccaa" }} -> "11bbccbb"
- join
  - {{ list "hello" "siyuan" | join "?" }} -> "hello?siyuan"
- splitList
  - {{ splitList "$" "foo$bar$baz" }} -> ["foo", "bar", "baz"]

渲染结果为:

  • trim

    • aa -> "aa"
  • repeat

    • 1212121212 -> "1212121212"
  • substr

    • bc -> "bc"
  • trunc

    • abc -> "abc"
    • dfg -> "efg"
  • abbrev

    • he... -> "he..."
  • contains

    • true -> true
  • cat

    • 1 2 3 -> "1 2 3"
  • replace

    • 11bbccbb -> "11bbccbb"
  • join

    • hello?siyuan -> "hello?siyuan"
  • splitList

    • [foo bar baz] -> ["foo", "bar", "baz"]

列表操作函数

列表类函数常常会配合 queryBlocks (见后面的小节) 一同使用。

完整文档请见:https://masterminds.github.io/sprig/lists.html

  • list [<value>,]List,将后面传入的参数变成一个列表(类型要相同)

  • first <List>:返回第一个列表项

  • last <List>:返回最后一个列表项

  • append <List> <value>List,在列表后面增加一个元素

  • prepend <List> <value>List,在列表前面增加一个元素

  • concat [<List>,]List,将多个列表合并成一个

  • reverse <List>List,将列表逆序

  • has <value> <List>bool,检查给定的项目是否在列表中

  • index <List> <int>List,根据后面的索引值,索引列表中的内容

  • slice <List> <beg int> {<end int>}List,对给定的列表进行切片

    • slice $myList returns [1 2 3 4 5]. It is same as myList[:].
    • slice $myList 3 returns [4 5]. It is same as myList[3:].
    • slice $myList 1 3 returns [2 3]. It is same as myList[1:3].
    • slice $myList 0 3 returns [1 2 3]. It is same as myList[:3].
  • empty <List>bool,检查列表是否为空

  • len <List>int,获取列表的长度

- list
  - {{ list 1 2 3 4 }} -> [1 2 3 4]
  - {{ list "a" "b" "c" }} -> ["a" "b" "c"]
- first
  - {{ first (list 1 2 3) }} -> 1
  - {{ first (list "a" "b" "c") }} -> "a"
- last
  - {{ last (list 1 2 3) }} -> 3
  - {{ last (list "a" "b" "c") }} -> "c"
- append
  - {{ append (list 1 2 3) 4 }} -> [1 2 3 4]
  - {{ append (list "a" "b" "c") "d" }} -> ["a" "b" "c" "d"]
- prepend
  - {{ prepend (list 1 2 3) 0 }} -> [0 1 2 3]
  - {{ prepend (list "a" "b" "c") "z" }} -> ["z" "a" "b" "c"]
- concat
  - {{ concat (list 1 2) (list 3 4) }} -> [1 2 3 4]
  - {{ concat (list "a" "b") (list "c" "d") }} -> ["a" "b" "c" "d"]
- reverse
  - {{ reverse (list 1 2 3) }} -> [3 2 1]
  - {{ reverse (list "a" "b" "c") }} -> ["c" "b" "a"]
- has
  - {{ has 2 (list 1 2 3) }} -> true
  - {{ has "d" (list "a" "b" "c") }} -> false
- index
  - {{ index (list 1 2 3 4) 2 }} -> 3
  - {{ index (list "a" "b" "c" "d") 0}} -> "a"
- slice
  - {{ slice (list 1 2 3 4 5) 1 3 }} -> [2 3]
  - {{ slice (list "a" "b" "c" "d") 2 }} -> ["c" "d"]
- len
  - {{ list 1 2 3 4 | len }} -> 4

渲染结果为

  • list

    • [1 2 3 4] -> [1 2 3 4]
    • [a b c] -> ["a" "b" "c"]
  • first

    • 1 -> 1
    • a -> "a"
  • last

    • 3 -> 3
    • c -> "c"
  • append

    • [1 2 3 4] -> [1 2 3 4]
    • [a b c d] -> ["a" "b" "c" "d"]
  • prepend

    • [0 1 2 3] -> [0 1 2 3]
    • [z a b c] -> ["z" "a" "b" "c"]
  • concat

    • [1 2 3 4] -> [1 2 3 4]
    • [a b c d] -> ["a" "b" "c" "d"]
  • reverse

    • [3 2 1] -> [3 2 1]
    • [c b a] -> ["c" "b" "a"]
  • has

    • true -> true
    • false -> false
  • index

    • 3 -> 3
    • a -> "a"
  • slice

    • [2 3] -> [2 3]
    • [c d] -> ["c" "d"]
  • len

    • 4 -> 4

类型转换函数

在思源一个笔记软件里面,纠结数据类型这种纯编程性的问题挺古怪的——但是只要你开始用模板功能,有些时候偏偏就会出现类型兼容性问题。

例如这个例子,他的作用是计算上一个周日的日期(这里我们姑且认为每周从周一开始)。

  • 首先我们使用 Weekday 函数获取今天是星期几,数值范围为 0~6
  • 然后调用 AddDate 函数,减去星期数,就获得了周日的日期
{{ $weekday := Weekday now }}
{{ now.AddDate 0 0 (mul -1 $weekday) }}

不过运行的这个模板的时候会报错,无法运行:

模板解析失败:template: :2:27: executing "" at <$weekday>: wrong type for value; expected int; got int64 v3.0.12

附:顺便教一下怎么看这个报错信息。

  • 关注 template: :2:27,这代表了传入的模板的第二行,第二十七个字符的地方出现了错误
  • 具体的错误就是:「wrong type for value; expected int; got int64」,也就是传入的值的类型出了错。

报错的原因在于,mul 这些 Sprig 算术函数返回的类型是 int64,而 Time.AddDate 函数接受的类型却是 int,所以出现了类型不兼容问题。😡

为了解决这一问题,我们不得不进行使用类型转换函数,把 in64 转成 int 类型:

{{ $weekday := Weekday now }}
{{ now.AddDate 0 0 (mul -1 $weekday | int) }}

类型转换函数同样是 Sprig 提供的,文档见:https://masterminds.github.io/sprig/conversion.html

  • atoi: Convert a string to an integer.
  • float64: Convert to a float64.
  • int: Convert to an int at the system’s width.
  • int64: Convert to an int64.
  • toDecimal: Convert a unix octal to a int64.
  • toString: Convert to a string.
  • toStrings: Convert a list, slice, or array to a list of strings.

逻辑运算函数

逻辑运算函数,主要是在 if 这类流程控制语句内需要用到。比如这个例子当中,eq 就是一个逻辑运算函数,他用来比较传入的两个参数是否相等(equal 的简写)。

.action{ if eq 1 2 }
1
.action{ else }
2
.action{ end }

在更加实际的例子中,可能我们要写好几个逻辑运算,建议将每个逻辑运算囊括在 () 当中来保证运算的正确性。

.action{ if or (eq 1 2) (ne 2 3) }
1
.action{ else }
2
.action{ end }

逻辑运算部分可以分为两大类:布尔运算类和比较运算类。这里的参数类型 <interface{}> 表示是任意类型。

  • 布尔运算

    • and [<interface{}>,]interface{},如果所有参数都为真,则返回最后一个参数;否则返回第一个为假的参数
    • or [<interface{}>,]interface{},如果任一参数为真,则返回第一个为真的参数;否则返回最后一个参数
    • not <interface{}>bool,对单个参数进行逻辑非运算;如果参数为真则返回假,为假则返回真
  • 比较运算:对两个类型进行比较,注意这里的参数必须要是同样的类型

    • eq <interface{}>, <interface{}>bool,判断两个参数是否相等;相等返回真,不等返回假
    • ne <interface{}>, <interface{}>bool,判断两个参数是否不相等;不相等返回真,相等返回假
    • lt <interface{}>, <interface{}>bool,比较两个参数,如果第一个小于第二个,则返回真,否则返回假
    • le <interface{}>, <interface{}>bool,比较两个参数,如果第一个小于等于第二个,则返回真,否则返回假
    • gt <interface{}>, <interface{}>bool,比较两个参数,如果第一个大于第二个,则返回真,否则返回假
    • ge <interface{}>, <interface{}>bool,比较两个参数,如果第一个大于等于第二个,则返回真,否则返回假

在常规的逻辑运算函数之外,还有必要介绍一类列表、对象判断类函数,这部份函数由 Default Functions | sprig 提供

  • empty <interface{}>bool,判断给定的对象是否为空

    • 在思源中,最常见的用法是判断一个列表是不是空的
  • all [<interface{}>,]bool,判断给定一系列对象,是否每个都是非空的

  • any [<interface{}>,]bool,判断给定一系列对象,是否存在某一个是非空的

案例如下:

- and 
  - {{ and true true }} -> true
  - {{ and true false }} -> false
  - {{ and 1 2.3 "str" }}  -> "str"
- or
  - {{ or false false }} -> false  
  - {{ or true false }} -> true
- not
  - {{ not true }} -> false
  - {{ not 0 }} -> true
- eq
  - {{ eq 2 3 }} -> false
  - {{ eq "a" "a" }} -> true
- ne 
  - {{ ne 4 3 }} -> true
  - {{ ne 2 2 }} -> false
- lt
  - {{ lt 4 3 }} -> false
  - {{ lt "a" "b" }} -> true  
- le
  - {{ le 2 2 }} -> true
  - {{ le 4 3 }} -> false
- gt 
  - {{ gt 5 3 }} -> true
  - {{ gt 2 2 }} -> false
- ge
  - {{ ge 3 3 }} -> true  
  - {{ ge 1 4 }} -> false
- empty
  - {{ list 1 | empty }} -> false
  - {{ list | empty }} -> true
  • and

    • true -> true
    • false -> false
    • str -> "str"
  • or

    • false -> false
    • true -> true
  • not

    • false -> false
    • true -> true
  • eq

    • false -> false
    • true -> true
  • ne

    • true -> true
    • false -> false
  • lt

    • false -> false
    • true -> true
  • le

    • true -> true
    • false -> false
  • gt

    • true -> true
    • false -> false
  • ge

    • true -> true
    • false -> false
  • empty

    • false -> false
    • true -> true

附:使用 ternary 函数计算内联条件

我们上面谈到了最常用的 if 条件语句:

.action{ if eq 1 2 }
1
.action{ else }
2
.action{ end }

如果对编程比较有经验的人应该知道,很多语言中都支持计算内联条件,以 js 为例子

const x = (1 == 2)? 1 : 2;

在模板函数中,这个内联条件计算可以用 ternary 函数来完成,基本用法为:ternary <arg1> <arg2> <cond bool>

.action{ ternary "Show 1" "Show 2" (eq 1 2) }

思源模板片段特殊函数

有几个思源内置的特殊模板函数在文档里有所介绍,这几个函数**==只能用在 md 模板文件==当中**。

  • title:该变量用于插入当前文档名。比如模板内容为 # .action{.title},则调用后会以一级标题语法插入到当前文档内容中
  • id:该变量用于插入当前文档 ID
  • name:该变量用于插入当前文档命名
  • alias:该变量用于插入当前文档别名

这四个实际上是在「访问正在插入模板的容器文档的一些属性」。

我们可以把下面这个例子保存到 template 目录下的 md 模板文件当中。

这个是当前插入文档块的标题:.action{.title}
这个是当前插入文档块的ID:.action{.id}
这个是当前插入文档块的命名:.action{.name}
这个是当前插入文档块的别名:.action{.alias}

然后在某个文档当中插入这个模板,你就会发现这几个字段被替代为被插入的文档块的具体的属性。

这个是当前插入文档块的标题:思源模板片段新人指南
这个是当前插入文档块的 ID:20231014181953-m94pl9u
这个是当前插入文档块的命名:
这个是当前插入文档块的别名:

注意这里使用的时候必须前面要加上 .,也就是 .title 而非 title。至于为什么要加 .,有兴趣可以参考这个:Template · Go 语言中文文档

SQL 查询函数

思源还额外提供了两个模板函数,用于做 SQL 查询。

如果你对思源的 SQL 功能不了解,请阅读:思源 SQL 新人指南:SQL 语法 + Query + 模板

  • queryBlocks:该函数用于查询数据库,返回值为 blocks 列表,请参考下面的例子

    • ⭐ 这个更常用!
  • querySpans:该函数用于查询数据库,返回值为 spans 列表,请参考下面的例子

    • 🤷‍♂️ 这个很少用。

这两个模板函数的返回值都是块的列表。

queryBlocks

queryBlocks 可能是最常用的,他的作用就是在 blocks 表中做查询。

.action{$blocks := queryBlocks "select * from blocks where 1 limit 1"}
返回结果是列表,这里取出第一个元素:.action{ $b := first $blocks}
块的 ID .action{$b.ID}
块的路径 .action{$b.Path}
完整的块的内容:
‍‍‍‍```json
.action{toPrettyJson $b }
‍‍‍‍```

我们通过 toPrettyJson 函数(见 Default Functions | sprig)将结果变成一个 Json,可以发现返回的就是一个 Block 对象。

返回结果是列表,这里取出第一个元素:
块的 ID 20220316145831-f0lgt06
块的路径 /20220316145830-u0u6srg/20220316145830-x6kvftp/20220316145831-f0lgt06.sy
完整的块的内容:

{
  "ID": "20220316145831-f0lgt06",
  "ParentID": "",
  "RootID": "20220316145831-f0lgt06",
  "Hash": "d7b97e4",
  "Box": "20220305173526-4yjl33h",
  "Path": "/20220316145830-u0u6srg/20220316145830-x6kvftp/20220316145831-f0lgt06.sy",
  "HPath": "/daily note/2022/03",
  "Name": "",
  "Alias": "",
  "Memo": "",
  "Tag": "",
  "Content": "03",
  "FContent": "03",
  "Markdown": "",
  "Length": 2,
  "Type": "d",
  "SubType": "",
  "IAL": "{: custom-sy-readonly=\"true\" id=\"20220316145831-f0lgt06\" title=\"03\" type=\"doc\" updated=\"20220316145831\"}",
  "Sort": 0,
  "Created": "20220316145831",
  "Updated": "20220316145831"
}

在这里我们使用了如下的写法:

.action{$blocks := queryBlocks "select * from blocks where 1 limit 1"}

但是某些情况下,可能 SQL 里面的需要插入一些变量,这种情况下,可以使用 ? 占位符来插入变量:

.action{$id := "20240507145154-4g8hqau"}
.action{$blocks := queryBlocks "select * from blocks where id='?'" $id}

querySpans

querySpans 则很少用,他是用来给 spans 表做查询用的

.action{$spans := querySpans "select * from spans  where 1 limit 1"}

‍‍```json
.action{toJson  $spans  }
‍‍```
[{"ID":"20240420235208-65m9ro6","BlockID":"20220324170238-169kwsw","RootID":"20220324170216-lp8jviy","Box":"20220305173526-4yjl33h","Path":"/20220316145830-u0u6srg/20220316145830-x6kvftp/20220316145831-f0lgt06/20220324170216-lp8jviy.sy","Content":"随想录","Markdown":"((20220320164548-ienx5sl '随想录'))","Type":"textmark block-ref","IAL":""}]

案例:链接到昨天的日记

querySpans 且不谈,配合 queryBlocks 我们可以在模板中玩一些奇妙的花活。本小节作为模板语法部分的收尾,给大家介绍一个有趣的案例。


前两天我看有人在论坛里问:日记模板怎样设置可以在今天日记页面中自动添加昨天的日记链接?

他提得需求就完全可以通过 queryBlocks 来完成。

要实现这个功能,必须知道两个前置知识:

  1. 在思源里,日记文档会自动添加 custom-dailynote-<yyyymmdd> 属性;比如 2024-05-01 的日记,会自动添加文档属性 custom-dailynote-20240501
  2. 思源中,超链接的格式为 siyuan://blocks/<块 ID>

有了这两个知识,我们就有大致的实现思路了:

  1. 获取昨天的日期
  2. 根据昨天的日期构建日记文档属性
  3. 查询符合文档属性的文档
  4. 如果查询到,就去除文档的 ID 并构建块链接

首先要获得 yyyymmdd 属性,这个我在「常用的时间函数」当中已经给出写法了:

.action{ $datestr_sy := now | date "20060102" }
{: custom-dailynote-${ $datestr_sy }=".action{$datestr_sy}" }

这里我们要把 now 替换成昨天,改成这样以下这样;其中用到了 list、join 两个函数,我在前面也介绍过。

.action{ $datestr_sy := now.AddDate 0 0 -1 | date "20060102" }
.action{ $attr := list "custom-dailynote-" $datestr_sy | join "" }

image

接下来我们就要在模板中调用 queryBlock 来查询昨天的日记:

.action{ $datestr_sy := now.AddDate 0 0 -1 | date "20060102" }
.action{ $attr := list "custom-dailynote-" $datestr_sy | join "" }

.action{ $docs := queryBlocks "select * from blocks where type='d' and ial like '%custom-dailynote-20240506%' limit 1"}

获得了 $docs 我们需要做以下的工作:

  1. 首先判断列表是否为空(用 empty 函数),因为很可能昨天没有写日记
  2. 如果不为空,就使用 first 函数取出第一个文档元素
  3. 通过 .ID 获取文档的 ID,并构造 markdown 链接

最后完整的模板内容如下:

.action{ $datestr_sy := now.AddDate 0 0 -1 | date "20060102" }
.action{ $attr := list "custom-dailynote-" $datestr_sy | join "" }

.action{ $docs := queryBlocks "select * from blocks where type='d' and ial like '%custom-dailynote-20240506%' limit 1" }

.action{ if not (empty $docs) }
.action{ $doc := first $docs }
[.action{$doc.Content}](siyuan://blocks/.action{ $doc.ID })
.action{ end }

image

数据库中的模板列

思源的数据库中,有一种类型的列叫做「模板列」,通过模板列,我们可以让数据库发挥出更加强大的功能。

口说无凭,来看一个数据库的效果:

image

在这个数据库当中,「评级」是一个模板列,他会根据左边打分的数值,自动调整显示为不同的星星数量。那么这是怎么做到的呢?

  1. 首先新建评级列,将它设置为一个模板列

  2. 编辑模板为

    .action{ $scale := (div .打分 20) }
    .action{ repeat (int $scale) "⭐" }
    

看起来非常简单!你应该能记得,我们之前讨论过这里用到的三个函数:

  • div:进行除法运算,并将结果赋值给 $scale 变量
  • 由于 div 的结果类型为 int64 所以通过 int 函数把他转为整型
  • 通过调用 repeat 函数,根据 $scale 变量的数值来重复显示 ⭐ 符号

插件测试样例,注:由于这里是在插件当中测试,无法访问 .打分 属性,所以用常量 50 来替换以完成测试。

image

当然以上都不是重点,这里的重点是—— .打分 是什么,为什么我们可以访问他?

. 对象

也许你还记得,. 符号在此前也出现过很多次。比如之前的 now.Year 就表示:

  • 通过函数 now 返回的对象 Time
  • 对对象 Time 使用 . 获取他的属性 Year

同样在这里 . 符号也表示获取属性。但是由于之前没有指定被索引的对象,所以这里使用了传入模板的默认数据对象——在思源数据库中,他就**对应了模板列单元所在的****==行本身==**。

例如一个数据库拥有 日期分数备注 这些列,我们就可以通过 .日期.分数.备注 在模板列中访问他们。

那么除了列本身,还有可以访问的对象吗?

当然有!如果你想知道更加细节的可访问字段,这里有一个简单的技巧:

  • 在数据库中新建一个模板列
  • 将模板设置为 .action{toPrettyJson .}
  • 将模板列设置为「换行」

image

然后你就可以在模板列中看到本数据库中的所有可访问字段了,比如通过 .created 访问行创建的时间。

image

但是注意,并不是所有的属性都可以通过 . 来访问,比如如果我们尝试访问一个名为 .custom-b 的对象,那么会喜提一个报错——因为 custom-b 不符合属性的命名规范。

对于这种情况,可以通过 index . "name" 来对属性进行访问,比如这个例子中,我们填写 .action{ index . "custom-b"} 就可以访问 custom-b 属性的值了。

image

附注:

模板使用关联或汇总时,填充值为数组,所以可能需要使用 index .汇总 0 来访问第一个值,或者使用 range 迭代所有值。

参考:Issue #11029 · siyuan-note/siyuan

两种主键

思源的数据库中的主键可以分为两种:

  1. 普通的文本主键
  2. 被绑定到一个块的主键

为了方便测试,我们现在新建一个测试用的块,并填写命名、属性、备注还有自定义属性:

image

以下是不同的两行内部属性的差异:

  1. 普通文本主键只有 id、update、created、主键等属性

  2. 绑定了块的主键,还多出来

    • 数据库相关的属性,如 av-names、custom-avs 等
    • 块的内置属性,如命名、别名等
    • 自定义属性,如 "custom-a"
  3. 绑定了块的主键,其 ID 指向了被绑定的块,而普通文本主键的 ID 是它自身的 ID

  4. 注意:普通的文本主键的 ID 并非指向一个块,所以如果你尝试用 SQL 来查询这个 ID 对应的块,是不会得到任何有意义的结果的!

image

和 queryBlocks 结合

对于主键绑定了块的数据库而言,由于 ID 属性和对应的块打通了沟通渠道,所以把模板列和 queryBlocks 方法结合玩一些更加花哨的功能。

这里给出一个简单的案例:自动获取绑定块的文档的标题

  1. 首先使用 queryBlocks 执行 SQL 查询获取对应的文档块,SQL 语句的设计如下:

    select * from blocks where id in (select root_id from blocks where id='<id>')
    
  2. 通过 if 语句过滤掉不存在文档块的情况

    1. 通过 first 函数取出第一个文档块
    2. 然后获取它的 Content 字段
  3. 对于不存在文档块的情况,简单输出一个「无」

.action{ $blocks := queryBlocks "select * from blocks where id in (select root_id from blocks where id='?')" .id}
.action{ if not (empty $blocks)}
.action{ (first $blocks).Content }
.action{ else }
.action{ end }

效果如下:

image

论坛里有一个帖子,询问:数据库一些列的值能否自动获取文档里的数据。其实解决思路和上面的是一样的:

  1. 获取绑定块的 ID
  2. 构造 SQL,查询到想要的内容
  3. 读取 queryBlocks 的结果,并写入数据库模板列中

和 html 标签结合

有一个很重要,但是可能很少人知道的事情:数据库的文本列里面是==可以直接填写 html 元素的==。

举一个例子,我们把如下的 html 粘贴到一个文本列当中:

<div style="
    background: conic-gradient(#4CAF50 0%, #4CAF50 75%, #ddd 75%);
    border-radius: 50%;
    width: 40px;
    height: 40px;
    position: relative;">
  <span style="
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%);
      font-size: 12px;">
    75%
  </span>
</div>

然后,这个元素就会被完整地渲染在数据库单元内:

image

坏消息是:当你再次点击编辑的时候,里面就只剩下纯文本了。

image

🤨 更坏的消息:可能未来某个版本内,文本列不会再支持插入 html 了。

详情请跟踪:https://github.com/siyuan-note/siyuan/issues/11255

好消息是:模板列里面也支持使用自定义的 html 元素。我们不妨再做一个测试:

  1. 新建一个数值列“比例”,取值范围从 0 ~ 100

  2. 将如下的模板粘贴到一个模板列当中,他做的事情很简单:读取「比例」的数值,然后替换到 div 元素当中

    .action{ $portion := index . "比例"}
    <div style="
        background: conic-gradient(#4CAF50 0%, #4CAF50 .action{$portion}%, #ddd .action{$portion}%);
        border-radius: 50%;
        width: 40px;
        height: 40px;
        position: relative;">
      <span style="
          position: absolute;
          top: 50%;
          left: 50%;
          transform: translate(-50%, -50%);
          font-size: 12px;">
        .action{$portion}%
      </span>
    </div>
    

做完以上的工作,数据库就会根据 "比例" 这一列的数值来绘制模板列的样式,从而显示出不同的效果出来:

image

奇奇怪怪的玩法(存在风险,慎用)

拓展性花活,普通用户慎用

自定义 html + 可执行的 js 是存在安全风险的,这里只是通过这个例子告诉大家数据库可以做到这种程度。

如果你不能百分百确定你在干什么,就不要这么玩。

先给大家看一个神奇的东西:

recording

在前面我们提到过,数据库的文本实际上是可以直接插入 html 标签的——那既然可以插入 html 标签,那执行一下 js 代码也是非常合理的对吧。

现在我来解释一下上面这个效果的实现方案:

  1. 首先将数据库的主键绑定到一个块上——从而保证使用 .id 能访问到块的 id

  2. 新建一个 css 文本列,用来填写具体的 css 属性表

  3. 新建一个模板列,里面填写如下内容

    <button style="margin: 3px;" class="b3-button"
      onclick='event.stopImmediatePropagation(); runJs.api.setBlockAttrs(".action{.id}", {style: ".action{index . "css"}"});'
    >
      添加样式
    </button>
    

我们来分析一下这个模板:

  • <button style="margin: 3px;" class="b3-button" /> 构建了一个按钮元素, b3-button 是思源的内置 class 名称

  • 关键是 onclick 元素,我们在 onclick 当中直接填写我们要执行的代码:

    1. event.stopImmediatePropagation();

      • 这行代码是为了阻止思源默认的数据表行为,如果去掉了以后,思源就会弹出一个编辑框,而不会触发点击按钮的事件了
    2. runJs.api.setBlockAttrs

    3. setBlockAttrs 接受两个参数:块的 ID,具体的块属性

      1. 块 ID:通过模板功能的 .action{.id} 获取

      2. 块属性:由于我们要设置内联样式属性,所以 API 的 payload body 填写为 {style: "<具体css代码>"}

        • 使用 .action{index . "css"} 获取 css 列的内容,填充到 payload body 中

思源 Markdown 块语法

严格来说,思源的 Markdown 块语法和模板语法没有半毛钱关系。但是由于在 md 模板文件里面经常需要使用 md 块语法,所以有必要在这里一起介绍一下。

我们都知道思源是以块为单位的,同时思源一定程度上兼容了 markdown 语法格式(例如在模板文件中)——那么问题来了,markdown 原始的语法就是个瘸子,根本不存在块的概念,更不用提块属性这种概念了——这可怎么办呢。

对于最普通的情况来说来讲,一个 markdown 内置的语法元素,例如标题、列表、引述等等,也会被转化为一个单独的块。

image

而对于更加复杂的情况,思源实现了一种基于 kramdown 的 markdown 方言。通过这个方言我们可以在一个普通的 markdown 纯文本文件中定义块及其内部的属性。

  • 思源的 markdown 这些拓展属性语法,不仅仅可以用在模板的 md 文件里, 还可以直接用在编辑器中。
  • 在阅读下面的示例的时候,你可以把给出的 md 模板样例复制之后直接粘贴到思源的编辑器中,思源同样能够正确识别定义的块属性。

块属性声明语法

思源可以通过 {: } 这种内联样式表 (简称:ial) 的形式来声明一个具备特定属性的块,内联样式表的基本语法格式为如下,注意括号两侧必须留出空格。

{: <key1>="<value>1" <key1>="<value>1" }

例如,你可以把以下的文本填入 templates 下的 Test.md 文件:

这是一个简单的段落,但是我会声明「备注」内联样式和「custom-a」自定义属性。
{: memo="这是一个块"  custom-a="a" }

而将这个模板应用到思源正文后,就会创建一个填充了备注和 custom-a 属性的块。

image

再比如,在使用了 callout (由 Savor 主题以及 callout 插件提供)功能后,想要通过模板快速插入一个 callout——通过观察发现 callout 样式主要依赖于对一个引述块添加 custom-b 属性,所以我们可以首先在 Test.md 中编写引述块的 markdown 语法,然后在下方紧贴着添加 {: } 属性表:

> Meta
> - 🚩 文档类型:主题文档 | 事件记录
> - ⭐ 相关主题:
> - 📝 基本介绍:
{: custom-b="info" }

image

注 :可以使用的块属性包括了块的内置属性自定义属性

  1. 可以使用内置样式包括:id, name, alias, bookmark, memo, style
  2. 在设置自定义样式的时候,不要忘了添加 custom- 前缀

Special Case: ID 属性

我们可以在 ial 表中定义 ID 属性,比如这样:

这是一个块,你猜他的 ID 会是什么?
{: id="20231004221035-p9r4sh7"}

然而,在 ial 表中定义的 ID 属性并不会真正应用到编辑器的块中——也就是说就算使用了上述的模板,新创建的块的 ID 并不会等于 "20231004221035-p9r4sh7",而是思源自己生成的新的块 ID。

id 属性在 ial 中唯一的作用是作为 ial 中的占位符来声明一个块——因为一个空的 ial 表是无效的。

听起来像是脱裤子放屁——确实,在大部分情况下,这个 trick 都没有什么用。

但是在处理复杂容器块的时候,我们会不得不使用这个 trick 来区分容器块和内部的内容块。这里给一个例子:

假定:我们需要编写一个内容为空的列表块,并给这个列表块添加 style="border: 1px solid blue;" 属性(也就是添加外边框的内联 css 样式)。请问要怎么办呢?

你可能会写出如下的模板样式

- 
{: style="background: red;" }

但是以上的模板是无效的。

原因在于,思源中的列表块是一个非常复杂的嵌套对象,一个最简单的单个项目的列表,实际上也包含了三个部分:

  • 最内部的段落块
  • 段落块外部的列表项块
  • 列表项从属的列表块

而在以上的模板中,思源无法识别 {: style="background: red;" } 到底是应用在哪个块上面。

在这种情况下,我们必须要通过类似 {: id="20231004221035-p9r4sh7"} 这样的样式表,来明确声明三层块结构,也就是要写成这样子:

- {: id="202001010000-abcdefg"}
  {: id="202001010000-abcdefg"}
{: style="border: 1px solid blue;" }

image

另外虽然 id 的值实际上没有什么卵用,但是在填写的时候必须要遵守 ID 的格式规范,否则无法被正常识别:

yyyymmddHHMMSS-<7位字符或数字>

诸如 "202001010000-abcdefg"、"202001010000-1234567"这些都是可以的,而 "123" 这种是不行的。

超级块排版

思源支持通过超级块来进行复杂排版,这个排版同样可以通过拓展的 markdown 语法来定义:

  • 多行超级块(垂直布局)

    {{{row
    <内部内容>
    }}}
    
  • 多列超级块(水平布局)

    {{{col
    <内部内容>
    }}}
    

具体编写的时候,只需要把 < 内部内容 > 当中替换为正常的块定义即可。然后渲染的时候,内部内容就会按照垂直或者水平布局被囊括在一个新的超级块当中。

这里给一个稍微复杂一点的样例,如果你把这个样例理解了,超级块排版的布局基本上就掌握了。

{{{col

{{{row

### TODO:高优先

- [x]

}}}
{: style="border: 2px solid blue;"}

{{{row

### TODO:低优先

- [x]

}}}
{: style="border: 2px solid blue;"}

}}}
{: style="border: 2px solid red; padding: 10px;"}

插入编辑器后,样式如下:

  • 最外部是一个水平布局的超级块,内部有两列垂直布局的超级块

  • 内部的两个列中,各自都是一个垂直布局的超级块,包括

    • 最上面一个标题块
    • 最下面一个任务块

image

相关参考资料

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    19136 引用 • 71939 回帖
4 操作
Frostime 在 2024-05-17 22:53:35 更新了该帖
Frostime 在 2024-05-07 17:47:47 更新了该帖
Frostime 在 2024-05-07 15:35:18 更新了该帖
Frostime 在 2024-05-07 15:11:22 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
请输入回帖内容 ...