タスク管理の再検討(4) To Do block とタスクページの同期 : Notion 解説(52)

1. はじめに

これまでにタスク管理ページの再構築を行なっており、すでに3本の記事を書いています。

  1. タスク管理の再検討 : Notion 解説(48) - hkob’s blog
  2. タスク管理の再検討(2) synced block の自動差し替え : Notion 解説(50) - hkob’s blog
  3. タスク管理の再検討(3) To Do ブロックの自動追加 : Notion 解説(51) - hkob’s blog

NotionRubyMapping がブロックの更新に対応できるようになったので、タスクページの完了フラグと To Do リストのチェックを同期するようにします。基本的にはどちらかにチェックが入っていたら、反対側もチェックを入れます。

NotionRubyMapping は v0.5.0 で非互換のアップデートになってしまったので、これまでのスクリプトも少し修正が必要になってしまいました。どうせなので、メソッドベースにリファクタリングしてしまいます。メソッドごとに動作確認をしながら構築していきましょう。

2. テストデータの構築

タスクデータベースにテストデータを作っておきます。昨日の6/4(土) に「タスク1昨日未完了」と「タスク2昨日完了」の二つのタスクを用意しておきます。また、今日の6/5(日)に「タスク当日1未完了」と「タスク2当日完了」の二つのタスクを用意しておきます。

タスクデータベース

毎日のタスクデータベースには、1日に一つのページが作成されます。昨日のページとして一つ作っておきました。

毎日のタスクデータベース

Launchpad の今日のタスクの部分には、参照の Synced block があります。Synced block のオリジナルは上の毎日のタスクデータベースのページ内にあります。ここには記録が必要ない単純タスクは ToDo ブロックとして作成します。また、タスクデータベースに登録されている当日のタスクは、ページメンション付きの To Do ブロックになります。

今日のタスク(実行前)

3. 作成したスクリプト

ここでは作成した関数を一つずつ紹介し、メイン側で一つずつ呼び出して結果を確認していきます。

3.1 NotionCache の作成

NotionCache の作成およびキー登録部分はメソッド化しておきます。

#!/usr/bin/env ruby
# frozen_string_literal: true

require "../lib/notion_ruby_mapping"
include NotionRubyMapping

# @return [NotionRubyMapping::NotionCache] KEY が登録された NotionCache
def init
  NotionCache.instance.create_client ENV["NOTION_API_KEY"] # NotionCache をインスタンス化し、KEY を登録
end

メイン側では呼び出すだけです。後で、hex_id メソッドだけ使うので、nc として保存しておきます。実行結果を最後にコメントで記載しておきましたが、取得した NotionCache が表示されています。

### 0. NotionCache シングルトンを生成し、NOTION_API_KEY を登録
nc = init

print nc
# => #<NotionRubyMapping::NotionCache:0x00000001090487b0>



3.2 日付文字列の作成

日付から日付文字列を作る作業もメソッドにしておきます。

# @param [Date] date 日付
# @return [String] M月D日(曜日)
def date2str(date)
  "#{date.strftime("%-m月%-d日")}(#{%w[日 月 火 水 木 金 土][date.wday]})"
end

メイン側では今日の日付を取得し、文字列を作成します。曜日がちゃんと日本語文字列になっています。

#### 1. タスク一覧ページを取得または作成
today = Date.today
today_str = date2str today

print today_str
# => 6月5日(日)

3.3 毎日のタスクから今日のタスク一覧ページを取得、なければ作成

日付と上で日付文字列から、タスク一覧ページを取得します。もし、存在しなければ作成します。

# @param [Date] today 今日の日付
# @param [String] today_str 今日の日付文字列
# @return [NotionRubyMapping::Page] 取得または作成された今日のタスク一覧ページ
def today_dt_or_create(today, today_str)
  dt_database = Database.find "0bcdd69abaa6471683a0d54e28577900" # 「毎日のタスク」データベースを取得
  dp = dt_database.properties["日付"] # 日付プロパティを取得
  # 今日をタスクを取得、なければ作成
  dt_database.query_database(dp.filter_equals(today)).first || dt_database.create_child_page do |_, pc|
    pc["名前"] << today_str
    pc["日付"].start_date = today
  end
end

メイン側では、今日の日付と上で作成した今日の日付文字列を渡します。Page オブジェクトが取得できています。

today_dt = today_dt_or_create today, today_str
print today_dt
# => #<NotionRubyMapping::Page:0x0000000104a4dc88>

6月5日のページは存在しなかったので、6月5日(日)の空のページが作成されています。

作成されたページ

3.4 タスク一覧ページのオリジナル同期ブロックを取得、なければ作成

タスク一覧ページからオリジナルの同期ブロックを取得します。新しくページを作成した場合には存在していないので、新規に作成します。作成した同期ブロックには今日の日付だけ書いておきます。

# @param [NotionRubyMapping::Page] today_dt 今日のタスク一覧ページ
# @param [Object] today_str 今日の日付文字列
def synced_block_org_or_create(today_dt, today_str)
  # タスクページ一覧の Synced block を取得、なければ作成。
  today_dt.children.select(&:synced_block_original?).first || today_dt.append_block_children(
    SyncedBlock.new sub_blocks:([ParagraphBlock.new(today_str)])
  )
end

メイン側では、タスク一覧ページと今日の日付文字列をメソッドに送ります。synced_from が nil となっているので、オリジナル同期ブロックが生成されていることがわかります。

#### 2. タスク一覧ページの Synced block を取得なければ作成。作成時には中身として日付文字列だけ書いておく。
synced_block_original = synced_block_org_or_create today_dt, today_str
print synced_block_original.block_json
# => {"type"=>"synced_block", "object"=>"block", "synced_block"=>{"synced_from"=>nil}}

先ほどは空だったページに同期ブロックが作成されています。中身は日付のみです。

作成された同期ブロック

3.5 今日のタスクのトグルブロックにある参照同期ブロックを取得

今日のタスクトグルブロックにある参照同期ブロックを取得します。これはメソッドではなくメインで実施します。こちらは6/4の同期ブロックを参照している参照同期ブロックになっています。

#### 3. 今日のタスク Synced block の差し替え
toggle_block = Block.find "f1b63bd647cd4d76b8086f5e4cd161ba" # トグルブロックを取得
synced_block_reference = toggle_block.children.select { |b| b.type == "synced_block" }.first # synced block reference を取得
print toggle_block.block_json
print "\n"
print synced_block_reference.block_json
# => {"type"=>"heading_2", "object"=>"block", "heading_2"=>{"rich_text"=>[{"type"=>"text", "text"=>{"content"=>"今日のタスク", "link"=>nil}, "plain_text"=>"今日のタスク", "href"=>nil, "annotations"=>{"bold"=>false, "italic"=>false, "strikethr, "underline"=>false, "code"=>false, "color"=>"default"}}], "color"=>"default"}}
# => {"type"=>"synced_block", "object"=>"block", "synced_block"=>{"synced_from"=>{"type"=>"block_id", "block_id"=>"ecd8c9fcd120451cb412ac22c7fa1ae6"}}}

3.6 同期ブロック内のチェックを同期

ここが今回新規に作成した部分になります。取得した参照同期ブロック内の ToDo ブロックに対してタスクページとの同期処理をします。実際の処理は複雑なので、sync_to_do という別メソッドを作成し、それを呼び出します。 このメソッドはタスクと繋がったメソッドを処理した際には、nil を返すようにしているため、compact することで除外されます。この結果、タスクと繋がっていない ToDo ブロックのみが残るため、このうちチェックがついていないものだけを抽出して返却します。ループとは言いつつ、select, map, compact, reject のメソッドチェーンだけで終わっています。シンプルですね。

# @param [NotionRubyMapping::SyncedBlock] synced_block
# @return [Array<NotionRubyMapping::ToDoBlock] チェックがついていない通常 To Do
def sync_to_do_synced_block(sb_ref)
  return [] if sb_ref.nil? # 初回で synced_block がなければ終了

  # synced_block の子供がタスクページに繋がった To Do であれば、To Do の同期処理を実施
  # 単なる To Do の場合には、チェックがついていない To Do のみを取得
  sb_ref.synced_block_original
        .children
        .select { |b| b.type == "to_do" } # To Do のみを抽出
        .map { |to_do| sync_to_do to_do } # タスクページとの同期処理
        .compact # 同期処理したタスクは除外
        .reject { |to_do| to_do.checked } # チェック済のタスクは除外
end

sync_to_do は少し複雑です。最初にページメンションが存在するかどうかを調べ、なければ受け取った to_do をそのまま返します。すなわち、通常の To Do ブロックはそのまま返却されます。

タスクページが存在する場合には、「Done」のプロパティを取得します。「Done」がチェックされており、To Do ブロックがチェックされていない場合には、To Do ブロックのチェックを入れます。逆に To Do ブロックがチェックされており、「Done」プロパティがチェックされていない場合には、プロパティもチェックを入れます。これによりどちらかにチェックが入っていれば反対側もチェックされることになります。タスクに繋がっているページについては、これ以上の処理をしないので nil を返します。

# @param [NotionRubyMapping::ToDoBlock] to_do
# @return [NotionRubyMapping::ToDoBlock, nil] タスクに繋がっていない To Do、繋がっているときには nil
def sync_to_do(to_do)
  # To Do の RichTextArray 内のメンションオブジェクトからページを取得、取得できなければ終了
  return to_do if (mention_object = to_do.rich_text_array.select { |rto| rto.is_a? MentionObject }.first).nil? ||
    (page_id = mention_object.options["page_id"]).nil? ||
    (page = Page.find page_id).nil?

  cp = page.properties["Done"] # タスク Done プロパティを取得
  if cp.checkbox == true # タスク Done プロパティが true の時
    unless to_do.checked # To Do ブロックのチェックを確認
      to_do.checked = true # To Do ブロックがチェックされていなければチェック
      to_do.save
    end
  elsif to_do.checked # Done プロパティが false でかつ、To Do ブロックがチェックされている時
    unless page.archived # ページが削除されていなかったら
      cp.checkbox = true # To Do ブロックをチェック
      page.save
    end
  end
  nil
end

メインは先ほど取得した参照同期ブロックを渡すだけです。結果として、チェックされていない通常の To Do ブロックが一つだけ unfinished_to_do に入っていることがわかります。

unfinished_todos = sync_to_do_synced_block synced_block_reference # synced block 内の To Do とタスクページのチェックボックスを同期
print unfinished_to_do
# => [NotionRubyMapping::ToDoBlock-d33910492fb6448aa6b4c0f738319044]

同期の結果、実際に完了しているタスク「タスク2昨日完了」の To Do がチェックされています。

チェックの同期後

3.7 参照同期ブロックの差し替え

チェックの同期が終わったところで、参照ブロックを今日のものに差し替えます。すでに今日のものになっている場合(オリジナル同期ブロックの id と参照先の id が一致する場合)には、特に何も実施しません。

# @param [NotionRubyMapping::ToggleBlock] tb Synced block が保持されるトグルブロック
# @param [NotionRubyMapping::SyncedBlock] sb_ref トグルブロックに登録されている参照 Synced block
# @param [NotionRubyMapping::SyncedBlock] sb_org オリジナル Synced block
def replace_synced_block(tb, sb_ref, sb_org)
  return if sb_ref&.synced_block_original_id == sb_org.id # 参照 Synced block がないか、参照先が異なる時

  sb_ref&.destroy # 現在の参照 Synced block を削除
  tb.append_block_children SyncedBlock.new(block_id: sb_org.id) # 新しい Synced block reference を作成
end

メイン側では、トグルブロック、参照同期ブロック、オリジナルの同期ブロックを渡します。このメソッドは副作用のみで、返り値は使いません。

replace_synced_block toggle_block, synced_block_reference, synced_block_original # リファレンスが昨日のものなら差し替え

同期ブロックが無事に差し代わっています。

差し替えられた同期ブロック

3.8 オリジナル同期ブロックの内容をハッシュに登録

今日までタスクページと同期ブロック内の To Do をリンクさせるために、同期ブロックに登録されているものをハッシュで管理します。初回は何も存在しないので空のハッシュが返りますが、2回目以降はページ id から To Do ブロックが引けるハッシュが返ります。また、ページにリンクされていない ToDo についても管理したいので、To Do ブロックから "no_mention" という文字列が引けるようにしておきます。

# @param [NotionRubyMapping::SyncedBlock] sb_org オリジナル Synced block
# @return [Hash<String=>NotionRubyMapping::ToDoBlock, String>] タスクページと To Do ブロックのハッシュ
def task2to_do_hash(sb_org, nc)
  sb_org.children.select { |b| b.type == "to_do" }.each_with_object({}) do |to_do, hash| # synced_block_original の子ブロックに対して繰り返し
    mention_page_id = to_do.rich_text_array.map { |rto| rto.options["page_id"] }.compact.first # メンションページを取得
    if mention_page_id
      hash[nc.hex_id(mention_page_id)] = to_do # ページメンションの場合には、page_id から to_do ブロックを引くハッシュを登録
    else
      hash[to_do.id] = "no_mention" # ページにリンクしていない To Do は "no_mention" を値として登録
    end
  end
end

メイン側ではオリジナルの同期ブロックと NotionCache を渡します。当然ながら初回は空のハッシュが返ります。

#### 4. synced_block_original から設定済の To-Do 一覧を取得し、リンクのハッシュを作成
todo_hash = task2to_do_hash synced_block_original, nc
print todo_hash
# => {}

3.9 今日までのタスクを取得

タスクデータベースから、今日を含む今日までの未完了タスクを全て取得します。「日付」と「Done」のプロパティでフィルタをして、並び順を古い順にするクエリを用いて、タスクページを一括取得します。

# @param [NotionRubyMapping::Database] db タスクデータベース
# @param [Date] today 今日の日付
# @return [NotionRubyMapping::List] タスクページのリスト
def today_each_tasks(db, today)
  dp = db.properties["日付"]
  cp = db.properties["Done"]
  query = cp.filter_equals(false).and(dp.filter_on_or_before(today)).ascending dp # クエリを準備
  db.query_database query # 今日までの未完了のタスクを古い順に取得
end

メインではタスクデータベースと今日の日付を渡します。結果は List オブジェクトに入っています。今日のタスクですでに完了のものは取得されないので、昨日の未完了のもの一つと今日の未完了タスクの二つだけが取得されていることがわかります。

#### 5. 個別タスクデータベースから完了していない今日までのタスクを全部取得
et_database = Database.find "2395e3ffb55e4a8abc1ba426243776e3" # 個別タスクデータベースを取得
et_tasks = today_each_tasks(et_database, today) # 今日までの未完了のタスクを古い順に取得
print et_tasks
print "\n"
print et_tasks.count
# => #<NotionRubyMapping::List:0x00000001008d1d40>
# => 2

3.10 昨日の未完了 To Doを同期ブロックに追加

昨日のタスクに繋がっていない To Do のうち完了していないものは、今日の同期ブロックに追加します。遅延していることがわかるように🧨の記号を頭につけることにします。すでに登録されているかどうかは、直前に作成したハッシュで確認します。日付更新時にはハッシュは空なので、遅延タスクは全て登録されます。

# @param [NotionRubyMapping::SyncedBlock] sb_org オリジナル同期ブロック
# @param [Array<NotionRubyMapping::ToDoBlock>] uf_todos # 未完了 To Do ブロックの配列
# @param [Hash<String=>NotionRubyMapping::ToDoBlock, String>] td_hash タスクページと To Do ブロックのハッシュ
def append_unfinished_task(sb_org, uf_todos, td_hash)
  todos = uf_todos.reject { |to_do| td_hash[to_do.id] }.map do |to_do| # すでに登録済ならスキップ
    ToDoBlock.new "🧨#{to_do.rich_text_array.full_text}"
  end
  sb_org.append_block_children *todos unless todos.empty?
end

メイン側では、オリジナルの同期ブロック、未完了タスクの配列、チェック用ハッシュをメソッドに渡します。このメソッドも返り値は特に使いません。

#### 6. 昨日の未完了 To Do をオリジナル同期ブロックに追加

append_unfinished_task(synced_block_original, unfinished_todos, todo_hash)

確認のため一度 6/5 のページを全て削除し、再度実行しました。この結果、昨日の未完了タスクが同期ブロックにコピーされています。遅延の🧨マークがタイトルに付いています。

未完了 To Do の転記

3.11 タスクページのメンションリンクを追加

タスク一覧の配列からオリジナル同期ブロックにメンションリンクを追加します。すでに登録済かどうかは先に作成したハッシュで確認します。こちらも遅延したタスクについては、🧨マークをタイトル前に設置します。

# @param [NotionRubyMapping::SyncedBlock] sb_org オリジナル同期ブロック
# @param [Array<NotionRubyMapping::Page>] et_tasks # タスク一覧の配列
# @param [Hash<String=>NotionRubyMapping::ToDoBlock, String>] td_hash タスクページと To Do ブロックのハッシュ
# @param [Date] today 今日の日付
def append_task_mention(sb_org, et_tasks, td_hash, today)
  todos = et_tasks.reject { |task| td_hash[task.id] }.map do |task| # タスクがすでに登録されていたらスキップ
    start_date = task.properties["日付"].start_date # 開始日付を取得
    text = today == DateProperty.date_from_obj(start_date) ? [] : ["🧨"] # 開始日付が今日より前であれば、🧨をテキストに追加
    text << MentionObject.new("page_id" => task.id, "plain_text" => task.title) # タスクページへのメンションをテキストに追加
    ToDoBlock.new text # 上記のテキストおよびメンションオブジェクトを持つ To Do を配列に追加
  end
  if todos.count > 0
    Array(sb_org.append_block_children(*todos)).each do |to_do| # 準備した To Do ブロックの配列を synced_block_original に追加
      mention_page_id = to_do.rich_text_array.map { |rto| rto.options["page_id"] }.compact.first # メンションページを取得
      td_hash[mention_page_id] = to_do
    end
  end
end

メインの処理では、オリジナル同期ブロック、タスクページの配列、チェック用ハッシュと今日の日付を渡します。こちらも返り値は使用しません。

#### 7. To-Do にリンクされていないタスクを synced_block_original に登録
append_task_mention(synced_block_original, et_tasks, todo_hash, today)

この結果、タスクページへのメンションを付けた To Do が設定されています。

追加されたメンションタスク

4. 2回目以降の実行

冪等性を気にして作成しているので、このスクリプトは複数回実行しても問題ないようにしています。なお、To Do ブロックにチェックした後に、スクリプトを実行するとタスクデータベースの方がチェックが付いていました。正しく動いているようです。これまでタスクデータベースのリンクドビューでタスク一覧を管理していましたが、これでこのビューは必要なくなりますね。「期日超過未完了ビュー」も別に用意していましたが、全て遅延マーク付きで今日のタスクに表示されるので、これも必要なくなりました。これでだいぶスッキリします。

チェックボックスの同期

5. 終わりに

今日のタスクに関する部分が一通り終わりました。明日から、定時にスクリプトを起動するようにしておくだけで、よいことになります。次は今日のタスクの隣にある今週のタスクを実装しようと思います。今日はとりあえずここまでにしておきます。

今後の予定


www.notion.so