在我们老家,一些私人的重要纪念日还是按照农历日期的,比如生日什么的。如果是一些公共的假期,比如中秋、端午还好说,有很多现成的公共农历日历。如果是自己关心的几个农历日期就要自己添加提醒了,尤其是要批量添加20、30年的农历日历提醒,而且每年好几个。

为了省事,有两个事情需要解决:

  • 获取特定农历日期对应的公历日期
  • 在命令行下添加一个提醒到 Google Calendar 或者 Apple Calendar

用 lunar 来获取农历和公历的对应日期

编译

在 Debian 的仓库里面有一个名为 lunar 的软件可以查询农历和公历的对应日期。这个软件非常的老了,最新的版本是2001年10月发布的 2.2 版本。能够查询的日期范围是1900年到2049年之间的。在 macOS 上需要自己下载编译,目前无法从 Debian 的 git 仓库中获取源码,还好有打包好的 lunar 源码可以下载。

下载并解压后,执行 make 命令即可。虽然可以看到很多的警告信息,但在源码目录下已经编译成功。


基本用法

使用上非常简单,直接执行会看到简单的帮助信息。

注意到有一个选项 -h 是输出中文信息的,但由于这个软件太过于『古老』,以至于没有支持 UTF-8。所以如果你的终端的字符集不是 GBK 或者 GB18030,在使用 -h 选项的时候需要用 iconv 命令来把输出转换为当前终端的字符集。

lunar -h 2019 10 13 | iconv -f gb18030

有一个比较有趣的选项是 -b 可以以『位图』的方式输出

查询公历对应的农历日期

比如:要查询2019年10月13日对应的农历日期

lunar 2019 10 13

可以看到那天是农历的九月十五日。

-i 选项来查询农历对应的公历日期

比如:要查询2019年农历九月十五日对应的公历日期

lunar -i 2019 9 15

使用 Fantastical2 来添加日历

我购买了 Fantastical 2 for Mac,这是一个非常易用的 macOS 桌面日历工具。有一些比较强大的功能,比如:

  • 支持自然语言来添加提醒
  • 跨日历合并相同的提醒
  • 支持 AppleScript
  • ……

给一个特定的日期添加一个提醒

只要在 Fantastical 的输入框里面输入一行文字,带上日期即可。要指定一个地点,就在用『at』加上地址。如果有多个不同的日历来源,希望添加到特定的日历中,那就在行首加上『/日历名称』。

只有日期没有具体的时间时,默认是全天的事项。由于 Fantastical2 对中文的自然语言其实是没有什么支持了,所以精准的时间还是别用自然语言了。

比如我的默认日历是『工作』,希望添加到『家庭』的日历,就在最前面添加『/家庭』。

把添加事项自动化!

在 Fantastical2 的帮助文档的与其他应用整合里面有提到多种自动化的添加方式,比如用 URL 的方式:

open "x-fantastical2://parse?s=/家庭 2018-6-7 6:45 在太原机场打印行程单"

这会直接打开 Fantastical 的快速添加窗口,按下回车键即可添加一个事项。

这需要我们手工确认一下!自动化一定是 100% 的,没有 99% 的自动化。还有有个 add 选项,用上选项 add=1 就可以直接添加事项而无需确认了。

open "x-fantastical2://parse?s=/家庭 2018-6-7 6:45 在太原机场打印行程单&add=1"

执行这个命令后就直接在『家庭』日历中添加了一个事项。看上去不错了,但有个问题是,每次执行了命令 Fantastical 的窗口都会打开。毕竟 open 命令就是打开应用程序的。

自动化的实现中还是要避免本没有必要显示的窗口。

用 AppleScript 来实现完全的自动化

在 Fantastical 的帮助文档中可以看到一个 AppleScript 的例子

tell application "Fantastical 2" 
    parse sentence "Wake up at 8am" 
end tell

要实现完全的自动化,就加上 with add immediately

tell application "Fantastical 2" 
    parse sentence "Wake up at 8am" with add immediately 
end tell

现在我们已经知道了所有需要的信息了,创建一个 AppleScript 脚本 fantastical.scpt,内容如下:

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

然后就可以用 osascript 来执行这个脚本,并加上我们要添加的事项。

osascript fantastical.scpt "/家庭 2018-11-4 9am 去买个蛋糕"

执行这个命令时,Fantastical 的窗口也没有打开,完美。

写个 Shell 通过 lunar、Fantastical 来按照农历日期批量添加事项提醒

虽然我们已经搞定了几乎所有的事情,也只需要用 osascript 这个命令一行就可以自动的添加提醒事项,但是我们需要总是记得这个 fantastical.scpt 脚本在哪里。我希望直接就执行一个命令,不要再带上路径什么的。虽然有 PATH 这个环境变量,但只是对命令生效。如何可以直接执行 fantastical.scpt 而无需带上路径呢?

Shebang!

我们可以在网络上搜索到很多的 AppleScript 的代码,发现里面的注释都是用类似 SQL 的方式,是两个短线 --。但其实 AppleScript 从 Mac OS X Leopard 发布的 2.0 版本开始就支持用 # 来做注释了,所以 Shebang (Unix)

在第一行添加上 #!/usr/bin/env osascript,并且给文件添加上可执行权限。

#!/usr/bin/env osascript

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

添加可执行权限

chmod +x fantastical.scpt

我习惯上把所有自己写的脚本都放到 $HOME/bin 这个目录中,并且这个目录也添加到了 PATH 环境变量里面。

把文件 fantastical.scpt 移动到 ~/bin 目录中,现在就可以直接执行啦!

mv fantastical.scpt ~/bin

fantastical.scpt "/家庭 2018-11-4 9:30 再去买一个蛋糕吧"

目前感觉已经不错了,可以无需确认的添加一个事项,而且 Fantastical 的窗口也不会打开,在执行命令的时候也不用指定路径了。

但还是有一点 小小的缺陷,就是 fantastical.scpt 其实只支持一个参数。就是说,在添加事项的时候,一定别忘记用引号,否则会出问题的。比如

fantastical.scpt 买个蛋糕 2018-11-4

其实真正传递给 Fantastical 的内容只有『买个蛋糕』,而没有后面的的日期。所以默认就是在当天添加了一个『买个蛋糕』的事项,这不是我们要的。

虽然记得加上引号不是什么问题,但我们还可以做的更好。

一个简单的解决办法就是用 AppleScript 把传递进来的 argv 数组用空格连接起来,而不是只是获取数组的第一个元素。但对于自动化的实现来说,还有一种常用的传递参数的方式,是通过管道来传递。这种方式用 AppleScript 麻烦了一些,用 Shell 就很容易了。

用 Shell 让自动化再完美一些

用 Shell 可以解决两个问题:

  • 把所有的参数用空格连接起来,作为一个参数传递给 fantastical.scpt
  • 可以把通过管道传递过来的数据,转换为调用 fantastical.scpt 的参数

把所有的参数打包作为一个参数

用 Shell 接受参数是很容易的,在脚本中使用 $1$2$3、…… 就可以获得第1、2、3、……个参数。说到这里,或许有人不知道如何获取第10个或者以上参数。难道不就是 $10 吗?还真的不是!

看下面的 Bash 脚本

#!/bin/bash

echo 第十个参数是 $10

就是直接输出第 10 个参数,如果执行命令

./test-parameter.sh 1 2 3 4 5 6 7 8 9 a b c d e f

是不是应该就是输出:第十个参数是 a?让我们执行一下看看效果。

结果竟然是『第十个参数是 10』!莫非是把 a 当做十六进制转换为十进制?其实在 Bash 中,第1-9个参数可以直接用 $ 加数字,但第10个及其以上,需要用花括号{}包含起来,所以正确的代码应该是:

#!/bin/bash

echo 第十个参数是 ${10}

再次执行

./test-parameter.sh 1 2 3 4 5 6 7 8 9 a b c d e f

就会看到正确的结果:第10个参数是 a

回到我们要解决的问题上,如何用 Bash 把接受到的所有参数用『空格』连接起来传递给 fantastical.scpt 呢?

很多人会知道在 Bash 下可以用 $@ 来代表所有的参数!

#!/usr/bin/env bash

fantastical.scpt "$@" # it's wrong

这样其实是不对的!有两个解决办法,一是先把$@赋值给一个变量,然后把变量传递给 fantastical.scpt

#!/usr/bin/env bash

QUERY="$@"
fantastical.scpt "$QUERY"

另一个办法是用 $*

#!/usr/bin/env bash

fantastical.scpt "$*"

以上两种方式可以任选一个。在 Bash 里面 $@$* 是不一样的,这里就不细谈了。

把通过管道接受的数据传递过去

通过管道传递的数据是无法用 $@ 或者 $* 来获取的,但是可以用 cat 来获取,代码如下:

#!/usr/bin/env bash
set -eu

QUERY="$(cat)"
"${BASH_SOURCE}.scpt" "$QUERY"

现在有了新问题,如何判断当前是要通过管道接受数据呢?通常情况下,通过管道接受数据的时候是没有参数的,所以一个办法就是通过内置变量 $# 来判断是否有参数。但这个判断并不准确,有时候我们需要在代码中判断如果没有任何选项参数的时候就显示一个简单的帮助信息。还有一个常见的判断方法就是利用 tty 这个命令。tty 会显示当前终端的名字,如果是通过管道接受数据,则会出错,显示 not a tty 的错误信息。

利用 tty 来区分是否从管道获取数据,代码如下

#!/usr/bin/env bash
set -eu

if tty; then
  QUERY="$@"
else
  QUERY="$(cat)"
fi

fantastical.scpt "$QUERY"

最后再改进一点儿,从管道获取数据的时候允许合并命令行上额外的参数,并且不显示 tty 的任何输出。

注意:IO 重定向 >/dev/null 2>&12>&1 >/dev/null 这两个是不同的。

#!/usr/bin/env bash
set -eu

QUERY="$@"

if ! tty >/dev/null 2>&1; then
  QUERY="$QUERY $(cat)"
fi

fantastical.scpt "$QUERY"

最后如果发现没有任何信息要添加,也就是变量 QUERY 为空值,那就显示帮助信息,搞定!

完整的脚本

这两个脚本都放置在 PATH 里面某一个目录中建议是 $HOME/bin,并且用 chmod +x fantastical* 设置可执行的权限。

fantastical

#!/usr/bin/env bash
set -eu

QUERY="$@"

if ! tty >/dev/null 2>&1; then
  QUERY="$QUERY $(cat)"
fi

if [[ -z "$QUERY" ]]; then
  echo "Usage: "
  echo "  $0 /家庭 2019-10-13 8am 记得去买个蛋糕"
  exit 1
else
  "${BASH_SOURCE}.scpt" "$QUERY"
fi

fantastical.scpt

#!/usr/bin/env osascript

on run argv
  set query to (item 1 of argv)
  tell application "Fantastical" to parse sentence query with add immediately
  return "添加日历:" & query
end run

开始执行

比如我要给每年的农历九月十五这一天加一个提醒

for YEAR in {2018..2049}; do
  fantastical "$(lunar -i $YEAR 9 15 | grep -m1 -Eo "[0-9]{4}\.[0-9]{1,2}\.[0-9]{1,2}")" 这一天是农历的九月十五; 
done

就这么几行代码,搞定!写 Shell 脚本是不是很简单呢?

写完这两个脚本,并添加所有的农历日期提醒,也就半个来小时,写文章就多半天过去了。

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.