Ulysses 最近刚刚改为订阅制收费,有很多人都在考虑转到其他 Markdown 编辑器。今天看到 Checked 听众群里有人说 iOS 上 Ulysses 不能批量导出,所以我想讲一下如何利用 Ulysses 的 URL Scheme 和 Workflow 批量导出 Ulysses 中的文稿。

Ulysses 是一款很优秀的 Markdown 写作 App,优点之一就是丰富的 URL Scheme 支持。在 Ulysses 的官网上可以看到 URL Scheme 官方文档。利有 URL Scheme 和 Workflow 就可以导出 Ulysses 的所有文档。

导出 Ulysses 的 Workflow 可以做到什么?

批量导出 Ulysses 文稿效果图

  • 批量导出 Ulysses 中的文稿到 iCloud Drive 或 Dropbox 并保留文稿库的文件夹结构。
  • 文档内容为 Markdown 格式,但因为 Workflow 的限制导出的文件后缀为 .txt。
  • 这个 Workflow 会忽视没有内容的空文稿。
  • 文稿的题目为第一个标题,在文稿内没有标题的情况下为文章开头。

Workflow 怎么使用?


下载 Export Ulysses 这个 Workflow 并运行,Workflow 会请求获取 Ulysses 文稿库的权限。之后会需要选择保存到 iCloud Drive 还是 Dropbox。在这里需要注意:如果保存到 Dropbox 请一定保证文稿题目不重复,如果在同一文件夹下有重复会覆盖重名文件。并且,保存到 iCloud Drive 的文件名去掉了 Markdown 格式(比如 #),而 Dropbox 并不能去掉 Markdown 格式。Workflow 根据文稿数量可能会运行一段时间。

Workflow 的制作之旅


可能有一些人知道我最初发出来的批量导出 Ulysses 的 Workflow 有三个,后来我改成了两个,而现在仅有一个 Workflow 来完成全部的导出工作。我也从想要写一篇单纯的介绍 Workflow 怎么用怎么做的文章,到想讲一讲我这三次做 Workflow 的过程中遇到的 Workflow 的局限、我在这个 Workflow 中如何用一些小技巧绕过了这些局限,以及我对于 iOS 设备的「生产力」的看法。

Ulysses URL Schemes


Workflow 有内置 Ulysses 的动作,但是不足以实现导出功能。所以整个 Workflow 将使用 URL Schemes 实现。要实现这个 Workflow 我们先要了解 Ulysses 的 URL Schemes,可以在官网查到详细的介绍。(如果对于 URL Schemes 不了解可以先看 JailbreakHum 的「URL Schemes 使用详解」)其中我们要用到的 URL Schemes 有以下几个:

  • Authorization

ulysses://x-callback-url/authorize?appname=[Name]

这个 URL Scheme 可以获取 Access TokenName 的部分填入 Workflow 即可。

  • get-root-items

ulysses://x-callback-url/get-root-items?recursive=[YES/NO]&access-token=[Access Token]

这个 URL Scheme 可以获取 Ulysses 文稿库的全部列表。recursive 是 Optional 的,如果填入 YES,结果将是一个有深度的列表。因为我们想要保留 Ulysses 的文件夹结构,所以这里填 YES。

在 get-root-items 这个动作中,对于每一个 item 返回的 JSON 都会包含有两个 key,分别是 containers 以及 sheets。这两个 key 的 value 又分别会是一个 dictionary,包含了更深一层级的信息。

  • read-sheet

ulysses://x-callback-url/read-sheet?id=[identifier]&text=[YES/NO]&access-token=[Access Token]

这个 Workflow 可以读取特定 Sheet 的标题、内容、Keywords 和 Notes 等。identifier 是 Sheet 的 ID,在 get-root-items 中可以获得。text 是是否获取文稿内容,默认是 NO,这里我们要填 YES。

Workflow 思路


(注:Workflow 思路涉及到编程算法,我已经尽力解释得简单易懂。如果不感兴趣可以直接看结尾。)

Ulysses 中允许文件夹无限层级,这就形成了一个树状结构。想要对于树状结构中的每一项都进行导出动作并不是一件轻松的事情。为什么这样说呢?当你获取到某一个文件夹的信息时,你可以知道这个文件夹下面还有没有子文件夹,但是你很难知道子文件夹下还有没有子文件夹,一共有几个文件夹。

当我们使用 Workflow 的时候,你很难轻松让 Workflow 做到「一直导出到没有子文件夹」这件事情。「一直导出到没有子文件夹」等于「一直循环导出,直到没有子文件夹时停止循环」。想要让 Workflow 一直循环是做不到的,Repeat with Each 动作是对一个有确定数目的列表执行,而 Repeat 动作也需要输入一个具体的数值。但是在这里我们可以使用 Repeat 并填入一个非常大的数来间接做到这一点,比如一千或一万。我想绝大部分人也不会在 Ulysses 里有上千上万个文件夹吧。

但是后半句「直到没有子文件夹时停止循环」是怎么也做不到的,Workflow 可以检测出有没有子文件夹,但是却缺失了一个关键的动作是「停止循环」

这时候我们需要使用两个编程算法中的思路,分别叫深度优先搜索和广度优先搜索。这两个思路(或者说算法)都是用来遍历树状结构的。其含义很简单,深度优先搜索就是对于一棵树优先向深处走,而广度优先搜索则是对于一棵树优先走完同一层级的节点。从下面的 GIF 图可以很清楚地看出来这个区别。

深度优先搜索(左)和广度优先搜索(右)

使用深度优先搜索实现 Workflow


深度优先搜索是我最先使用的思路,这个思路可以很简单地就解决导出 Ulysses 文稿这个需求。我实现这个思路使用了三个 Workflow 来完成,接下来会讲到为什么需要三个 Workflow 来完成,以及我如何把 Workflow 缩减为两个的。考虑到并不是每一个读者都了解编程,我这里使用文字的形式来描述一下这个思路实现到 Workflow 中时整个 Workflow 的结构。

批量导出 Ulysses 文稿 Workflow

  在 iCloud Drive 中新建一个名为 Ulysses 的文件夹

  使用 get-root-items 获取整个文稿库的 JSON

  对于 JSON 中的每一个 Container(这些 Container 是 Ulysses 的主文件夹)   运行「导出文件夹」Workflow

  结束

导出文件夹 Workflow

  在 iCloud Drive 的 Ulysses 文件夹下新建当前 Ulysses 文件夹对应的文件夹

  检测当前 Ulysses 文件夹内有没有子文件夹

  如果有子文件夹

  对于每一个子文件夹

  运行「导出文件夹」Workflow

  检测当前 Ulysses 文件夹内有没有文稿

  如果有文稿

  对于每一篇文稿

  获取文稿 identifier

  运行 read-sheet 获取文稿内容

  存入 iCloud Drive

  结束


可以看到,「导出文件夹」这个 Workflow 内也运行了「导出文件夹」Workflow,这种自己运行自己的行为在编程中叫做「递归」。因为会有自己运行自己的情况,所以「导出文件夹」这个 Workflow 必须独立出来以便于被运行。简而言之,这个 Workflow 会不断检查有没有子文件夹,把全部子文件夹导出之后再把当前文件夹导出。

如果发现子文件夹就对子文件夹执行 Export Container

在这个过程中,有一个很重要的变量是当前文件夹的路径。通过 JSON 我们可以知道当前文件夹的名字,但是我们无法知道这个文件夹是谁的子文件夹。如果不知道当前文件夹的完整路径,在保存文稿的时候我们就不知道应该把文稿保存在什么路径下。

我使用的是剪贴板来存储当前文件夹的完整路径。在「批量导出 Ulysses 文稿」这个 Workflow 中把剪贴板设置为 /Ulysses/。运行「导出文件夹」时,Workflow 会把当前文件夹的名字添加到剪贴板后面。比如当前文件夹是 A,那剪贴板就会变为 /Ulysses/A/,如果在 A 文件夹下有 B 子文件夹,对 B 运行「导出文件夹」Workflow 时剪贴板就会变为 /Ulysses/A/B/。导出文稿时的保存路径时填入剪贴板了。

将当前文件夹名称添加到剪贴板后面


如果导出完 B 文件夹要回到导出 A 文件夹的 Workflow 时,剪贴板还是 B 文件夹的完整路径。但是导出 A 文件夹内的文稿时需要的是 A 文件夹的完整路径,也就是说,我们需要在「导出文件夹」这个 Workflow 运行完之后把剪贴板复原为运行 Workflow 之前的状态。也就是,删除在剪贴板末尾的当前文件夹的名字。我们可以使用 Workflow 的 Replace Text 动作把名字替换为空白做到这一点。

那为什么需要第三个 Workflow 呢?是因为上面这两个 Workflow 中用到的 Ulysses URL Schemes get-root-items 和 read-sheet 都需要用到 Access Token。并没有很好的办法可以做到让这两个 Workflow 都有同一个 Access Token。于是只能单独做一个获取 Access Token 并复制到剪贴板的 Workflow 来解决这个问题,并让用户在使用这个 Workflow 前分别填入 Access Token

而后来我通过在主 Workflow「批量导出 Ulysses 文稿」中获取 Access Token,并向「导出文件夹」这个 Workflow 以列表形式同时传入 Container 的 JSON 和 Access Token 的方式把 Workflow 缩减为了两个。

你可以下载 Export Ulysses 和 Export Container 这两个 Workflow 看 Workflow 实现细节。

使用广度优先搜索实现 Workflow


深度优先搜索的思路必须使用最少两个 Workflow 来完成全部的工作,而广度优先搜索则可以使用一个 Workflow 做到。但是我一开始一直认为广度优先搜索在 Workflow 上是不可实现的。为什么不可实现?这个 Workflow 我又是怎么实现的?这要从广度优先搜索的概念说起。

广度优先搜索是先遍历同一层级的文件夹,然后再遍历下一个层级的所有文件夹,这样不断加深层级的一种遍历树状结构的方式。从上文的 GIF 图中可以看到广度优先搜索的遍历顺序。广度优先搜索在这个 Workflow 需求上的好处是,因为不需要自己运行自己的 Workflow,所以可以只使用一个 Workflow 来实现。

树状图


我们就以这个树状图为例,走到 4 节点之后我们应该怎么走到 2 节点下的 5 节点呢?在深度优先搜索的思路中,因为是父文件夹和子文件夹的关系,从 2 节点可以直接走到树状图的 5 节点(对于深度优先搜索你可以联想一下盗梦空间)。而在广度优先搜索的思路中,我们需要使用一个「待处理」队列来记录我们之后需要处理的节点。

这个「待处理」队列以上面的树状图为例实际效果是:先看队列中的第一项,也就是 1 节点。从 1 节点我们知道 1 节点有 2、3、4 子节点,并全部加入队列。之后看队列的第二项,也就是刚加入队列的 2 节点,我们从 2 节点可以知道 2 节点有 5 子节点,并把 5 节点加入队列。这样反复两次之后,队列下一项是 5 节点,我们就做到了处理完 4 节点之后就跳到 2 节点的子节点 5 节点的效果了。

所以 Workflow 中我们应该对于这个队列做类 Repeat with Each 动作。而随着我们每处理一个文件夹,这个文件夹的子文件夹就会被加到队列的末尾。也就是这个队列会随着处理逐渐变长。但是因为 Repeat with Each 只能对一个固定项目的变量进行循环处理,所以 Workflow 应该是实现不了这个思路的。但是这里我们可以用一个取巧的做法。队列最终的长度,也就是 Ulysses 中一共有多少文件夹,可以通过匹配 Ulysses 返回的 JSON 中匹配 “type”:”group” 的个数来知道。而队列的长度就是我们一共要循环的次数,所以使用 Repeat 并填入匹配到的数量就可以了。

所以整个 Workflow 的结构是:

批量导出 Ulysses 文稿 Workflow

  获取 Ulysses Access Token

  选择储存的服务并在其中创建 Ulysses 文件夹

  使用 get-root-items 获取整个文稿库的 JSON

  匹配出一共有多少个文件夹

  把 Ulysses 的主文件夹加到队列(如同上例的 1 节点)

  循环 文件夹数 次

  对于队列中的 Repeat Index 的项(每项就是一个文件夹)

  创建 Ulysses 文件夹的同名文件夹

  判断文件夹中有没有子文件夹

  如果有子文件夹

  把每一个子文件夹加入队列末尾

  判断文件夹中有没有文稿

  如果有文稿

  把文稿导出到对应文件夹

  结束


深度优先搜索中很重要的一个变量就是文稿要保存的路径。这个变量在这个 Workflow 中是在队列中和文件夹一起记录的。文件夹的路径和文件夹的 JSON 作为一个 dictionary 保存在队列中。这样从文件夹的 JSON 获取到文稿的同时,也能知道当前文件夹的路径。

你可以下载 Export Ulysses 查看具体实现细节。