0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【開発日誌 Day9】iPhoneのメモをObsidianに自動追記する設計を分解した

0
Posted at

自分で作った機能を、自分で分解する。今日の対象は、ワンタップで送ったメモを保管庫のノートに自動で積み上げる仕組み――社内ではObsidian連携と呼んでいる層だ。表に出ているのは送信ボタン一つだけ。だが、その裏側は5つの層に分かれている。下の層から順に剥がしていく。剥がし終わったとき、なぜ「アプリ側でObsidianを開かない」とわざわざ決めたのかが見えるはずだ。

機能としては2026年5月30日に出した。リリースノートには一行しか書いていない。「送ったメモがObsidianのデイリーノートに自動で追記されます」。この一行の裏に、5層ぶんの判断が畳み込まれている。畳まれたものを開く回だと思ってほしい。

分解する対象を、先に一行で

やっていることは単純だ。アプリで送信したメモを、iCloud Drive上のObsidian保管庫の指定ノートに、- HH:mm メモ本文という1行で末尾追記する。ノートの種類はデイリーノート(yyyy-MM-dd.md)かInboxノート(固定の1ファイル)のどちらか。コミュニティプラグインは使わない。共有シートも経由しない。Obsidianアプリ自体も開かない。

この「開かない」が今回の主役だ。だが主役の話は最後に取っておく。まず、開かずにどうやって他アプリの保管庫に1行足すのか、という物理から剥がす。

分解する層は5つある。第1層が保管庫への到達、第2層が書き込み先ノートの決定、第3層が追記の原子性、第4層が実行タイミング、第5層がフォールバックだ。順番に開ける。

第1層 — 他アプリの保管庫に、どうやって触るか

最初の壁はここだった。iOSのアプリは、原則として自分のサンドボックスの外を読めない。Obsidianの保管庫は別アプリの管理下にあるフォルダだ。つまりCaptioから見れば「他人の家」になる。

解決策はiOS標準の枠組みの中にある。ユーザーに一度だけUIDocumentPickerViewControllerで保管庫フォルダを選んでもらい、その結果をsecurity-scoped bookmarkとして保存する。以降はブックマークを解決して、必要な瞬間だけアクセス権を起こす。鍵を毎回もらうのではなく、合鍵を一度だけ預かる、という設計だ。

func resolveVaultURL() throws -> URL {
    guard let data = UserDefaults.standard.data(forKey: "obsidianVaultBookmark") else {
        throw VaultError.notConfigured
    }
    var isStale = false
    let url = try URL(
        resolvingBookmarkData: data,
        options: [],                 // iOSではsecurityScope指定は不要
        relativeTo: nil,
        bookmarkDataIsStale: &isStale
    )
    if isStale {
        // 保管庫を移動された等。再ブックマークを促すフラグだけ立てる
        UserDefaults.standard.set(true, forKey: "vaultBookmarkNeedsRefresh")
    }
    return url
}

func withVaultAccess<T>(_ body: (URL) throws -> T) throws -> T {
    let url = try resolveVaultURL()
    guard url.startAccessingSecurityScopedResource() else {
        throw VaultError.accessDenied
    }
    defer { url.stopAccessingSecurityScopedResource() }
    return try body(url)
}

startAccessingSecurityScopedResource()stopAccessingSecurityScopedResource()は必ず対で呼ぶ。deferで閉じるのはそのためだ。ここを片方忘れると、数十回の追記のあとでアクセス権が枯れて、ある日突然「Obsidian iPhone メモ 自動追記」が無言で止まる。実際、テスト中に一度それをやった。deferに寄せてからは再発していない。

isStaleの扱いも地味に効く。保管庫を別の場所へ動かされた瞬間、ブックマークは古くなる。古いまま使い続けると、存在しない場所に書こうとして失敗する。だから古さを検知したら、その場で再設定を促すフラグだけ立てて、追記自体は一度だけ静かに諦める。ユーザーを止めない。

合鍵をどう作るかも書いておく。最初の一回だけ、設定画面からUIDocumentPickerViewControllerを開き、ファイルではなくフォルダを選んでもらう。保管庫はフォルダだからだ。開始位置をiCloud DriveのDocumentsに寄せておくと、Obsidianの保管庫まで数タップで辿り着く。

func presentVaultPicker(from vc: UIViewController) {
    let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.folder])
    picker.directoryURL = FileManager.default
        .url(forUbiquityContainerIdentifier: nil)?       // iCloud Driveのルート
        .appendingPathComponent("Documents")
    picker.allowsMultipleSelection = false
    picker.delegate = self
    vc.present(picker, animated: true)
}

func documentPicker(_ c: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
    guard let folder = urls.first else { return }
    let bookmark = try? folder.bookmarkData()             // 合鍵を作って保存
    UserDefaults.standard.set(bookmark, forKey: "obsidianVaultBookmark")
}

フォルダピッカーにした理由は単純で、追記先のノートは日々変わるからだ。2026-06-03.mdは今日しか存在しない。明日は2026-06-04.mdになる。だから特定ファイルではなく、保管庫フォルダごと合鍵を預かる。預かるのはフォルダ単位の権限1つだけ。これで配下のデイリーノートにもInboxノートにも、追加の許可なしで届く。設定で「上級者モード」を有効にしている人だけに見える導線にしてあるのも、ここで誤って関係ないフォルダを選ばせないためだ。

第2層 — どのノートに、どんな名前で書くか

保管庫に触れるようになったら、次は「どのファイルに書くか」だ。選択肢は2つに絞った。デイリーノートとInboxノート。3つ目以降は作らなかった。選択肢が増えるほど、設定画面で迷う時間が伸びるからだ。

デイリーノートはyyyy-MM-dd.mdという命名のファイル。2026年6月3日なら2026-06-03.mdになる。ここでDateFormatterの罠を踏みやすい。ユーザーのカレンダーやロケールに引っ張られると、yyyyが和暦になったり、月名が現地語になったりする。ファイル名は機械可読でなければならないので、ロケールを固定する。

private let dailyNoteFormatter: DateFormatter = {
    let f = DateFormatter()
    f.locale = Locale(identifier: "en_US_POSIX")  // ロケール非依存に固定
    f.calendar = Calendar(identifier: .gregorian)
    f.timeZone = .current                          // 日付の境目は端末の時間帯
    f.dateFormat = "yyyy-MM-dd"
    return f
}()

func targetNoteURL(in vault: URL, mode: AppendTarget, now: Date = .init()) -> URL {
    switch mode {
    case .dailyNote(let folder):
        let name = dailyNoteFormatter.string(from: now) + ".md"
        return vault.appendingPathComponent(folder).appendingPathComponent(name)
    case .inbox(let path):
        return vault.appendingPathComponent(path)   // 例: "Inbox.md"
    }
}

en_US_POSIXに固定するのは、Appleのドキュメントでも機械可読な日付には推奨されている古典的な作法だ。timeZoneだけは.currentにする。日付の境目は、世界標準時ではなくユーザーが今いる時間帯で切り替わるべきだからだ。深夜0時を跨いだメモが前日のノートに落ちると、人は混乱する。

Inboxノートは固定の1ファイル。デイリーノートのように日々増えない。「とにかく1か所に放り込みたい」人向けだ。この設計思想は、フォルダを持たないメモアプリの話としてDay8の記事でも書いた。Obsidian側のInboxノートは、あの「Inbox一本」をObsidianの中に持ち込む受け皿になる。

第3層 — 1行を、壊さずに足す

ここが技術的には一番神経を使った層だ。やりたいのは「ファイルの末尾に1行足す」だけ。だが相手はiCloud Driveで同期されるファイルで、しかも別アプリ(Obsidian本体)が同時に開いているかもしれない。雑に上書きすると、同期の衝突や、最悪ノート丸ごと破損が起きる。

だからNSFileCoordinatorで読み書きを協調させる。読んで、末尾に- HH:mm メモを足して、書き戻す。ファイルが無ければ作る。この一連を1つの協調ブロックに包む。

func appendLine(_ memo: String, to noteURL: URL, now: Date = .init()) throws {
    let stamp = timeFormatter.string(from: now)            // "HH:mm"
    let line = "- \(stamp) \(memo.trimmingCharacters(in: .whitespacesAndNewlines))\n"

    let coordinator = NSFileCoordinator()
    var coordError: NSError?
    var thrown: Error?

    coordinator.coordinate(writingItemAt: noteURL, options: [], error: &coordError) { url in
        do {
            // 親フォルダ(Daily配下など)が無ければ作る
            try FileManager.default.createDirectory(
                at: url.deletingLastPathComponent(),
                withIntermediateDirectories: true
            )
            let existing = (try? Data(contentsOf: url)) ?? Data()
            var data = existing
            // 直前が改行で終わっていなければ改行を補ってから足す
            if let last = data.last, last != 0x0A { data.append(0x0A) }
            data.append(Data(line.utf8))
            try data.write(to: url, options: .atomic)
        } catch {
            thrown = error
        }
    }
    if let e = thrown { throw e }
    if let e = coordError { throw e }
}

細部に判断が3つ入っている。1つ目、options: .atomicで書く。途中でアプリが落ちても半端なファイルを残さない。2つ目、既存末尾が改行で終わっていなければ0x0Aを1個補う。これを忘れると、前回の行と今回の行がくっついてMarkdownのリストが崩れる。3つ目、本文の前後の空白と改行を削ぐ。メモに紛れ込んだ余計な改行が、デイリーノートの自動追記をぐちゃぐちゃにするからだ。

追記の形は- HH:mm メモというタイムスタンプ付きの箇条書き1行に統一した。これならObsidian側で見たとき、その日に何を何時に放り込んだかが時系列で並ぶ。整形はしない。整形は人間がObsidianの中でやればいい。アプリの仕事は「壊さず1行足す」までだ。

iCloud Drive特有の罠をもう一つ踏んだ。デイリーノートが端末にまだ実体として降りてきておらず、拡張子.icloudのプレースホルダだけが置かれている状態があり得る。この状態のファイルをそのままData(contentsOf:)で読むと、中身が空に見えることがある。空に見えたまま書き戻すと、クラウド上の本物を空で上書きしかねない。幸いNSFileCoordinatorの協調読み取りは、必要ならダウンロードの完了を待つ協調点として働く。明示的にstartDownloadingUbiquitousItem(at:)を呼んでから協調ブロックに入ることで、「まだ無いファイルを空とみなす」事故を塞いだ。ここはWi-Fiを切った状態でしか再現しにくく、原因の特定に半日溶かした。yyyy-MM-dd.mdへの自動追記が、特定の端末でだけ前半が消える、という不気味なバグだった。

第4層 — いつ、この処理を走らせるか

追記そのものより、タイミング設計のほうが思想が出る。結論から言うと、追記はメモ送信の「後処理」として、本流から切り離して走らせる。

Captioの送信の本流は、オフラインでもメールとして必ず届く経路だ。これはDay3のOutboxの記事で解剖した。Obsidian追記は、その送信が成功した後に、別系統でぶら下がる付加処理にすぎない。だから追記が失敗しても、メモ送信は成功扱いのまま。逆に、送信が失敗していれば追記もしない。順序に意味を持たせている。

func handleSendSucceeded(_ memo: Memo) {
    // メイン経路(メール送信)は既に成功している前提
    guard settings.obsidianEnabled else { return }
    Task.detached(priority: .utility) {
        do {
            try self.withVaultAccess { vault in
                let note = self.targetNoteURL(in: vault, mode: self.settings.appendTarget)
                try self.appendLine(memo.body, to: note)
            }
        } catch {
            // 失敗してもユーザー操作はブロックしない。静かにログだけ残す
            Log.obsidian.notice("append skipped: \(String(describing: error))")
        }
    }
}

Task.detached(priority: .utility)でバックグラウンドに逃がす。ユーザーが次のメモを書き始める動作を、追記処理が1ミリ秒でも遅らせてはいけない。起動0.3秒を売りにしているアプリで、送信後にカクつきを持ち込んだら本末転倒だ。だから追記は「成功すればうれしい、失敗しても誰も困らない」優先度に置いた。

この疎結合のおかげで、Obsidian連携を後から足しても、送信の本流は1行も変えずに済んだ。メールにも届き、Obsidianにも積まれる。二重に保存されるが、二つの経路は互いを知らない。

ただし「互いを知らない」には例外を1つ作った。再送だ。Outboxは送信失敗時に自動で再送する。素朴に作ると、再送のたびに同じメモがデイリーノートへ二重に追記されてしまう。だからメモ1件ごとに「Obsidianへ追記済み」フラグを持たせ、追記が成功した瞬間に立てる。再送が走っても、フラグが立っていれば追記はスキップする。送信はリトライ可、追記は一度きり。冪等性をどちらの経路に持たせるかを、別々に決めた。送信側は「何度でも試す」、追記側は「一度だけ」。同じ成功でも、求める性質が逆だった。

第5層 — iCloud Driveに保管庫が無いときは、どうするか

ここまでは「保管庫がiCloud Driveか、このiPhone内にある」前提だった。だが現実には、Obsidian Syncだけで運用していて、保管庫がファイルとして端末から見えない人もいる。その場合、第1〜3層のファイル直書きは使えない。

フォールバックとしてobsidian://のURLスキームを用意した。これはObsidian公式が提供する仕組みで、obsidian://newvaultfilecontentappendsilentといったパラメータを渡せる。これを叩くと、Obsidianが一瞬起動して、指定ノートに追記してくれる。

func appendViaURLScheme(_ memo: String, vault: String, file: String) {
    var c = URLComponents()
    c.scheme = "obsidian"
    c.host = "new"
    c.queryItems = [
        .init(name: "vault",   value: vault),
        .init(name: "file",    value: file),       // 例: "2026-06-03"
        .init(name: "content", value: "- \(timeFormatter.string(from: .init())) \(memo)\n"),
        .init(name: "append",  value: "true"),
        .init(name: "silent",  value: "true")      // 可能なら前面化を抑える
    ]
    guard let url = c.url else { return }
    UIApplication.shared.open(url)
}

正直に書くと、この経路は第1〜3層のファイル直書きほど静かではない。URLスキームはアプリの切り替えを伴うので、環境によってはObsidianが前面にちらつく。だから既定はあくまでファイル直書きで、保管庫に到達できないと判明したときだけURLスキームに切り替える。判断の分岐点は「security-scoped bookmarkが解決できるか」だ。解決できればファイル、できなければURL。

つまりこの機能は、見た目は1つでも、内部に2つの追記エンジンを持っている。プラグイン不要で動くのはファイル直書きのほうで、URLスキームは最後の保険だ。

Obsidian Syncしか使っていなくても追記できるか?

よく来る質問なので、章を立てて答える。結論、できる。ただし経路が違う。

iCloud Driveか「このiPhone内」に保管庫の実体があれば、第1〜3層のファイル直書きが効く。Obsidianを開かずに、バックグラウンドでyyyy-MM-dd.mdに1行積む。これがデフォルトの、一番静かな経路だ。

保管庫がObsidian Sync専用で、端末のファイルとして見えない場合は、第5層のobsidian://へ自動で切り替わる。この場合だけObsidianが一瞬立ち上がる。挙動の正確な切り替え基準は、Captio側のObsidian連携の挙動仕様に書いてある。要は「ファイルに触れるなら触る、触れないなら呼ぶ」だ。

キャプチャ専用アプリは、Obsidianと何が違うのか

役割が違う、と一言で言える。Obsidianは育てる場所だ。リンクを張り、見出しを切り、過去のノートと繋ぐ。対してCaptioは捕まえる場所で、それ以上のことをしない。両者は競合しない。むしろ、捕まえる側が軽いほど、育てる側に積み上がる素材が増える。

具体的な差は、入力までの距離に出る。Obsidianで1行書くには、アプリを開き、保管庫を読み込み、ノートを選び、カーソルを置く。思いつきが数秒待たされる。キャプチャ専用アプリは、開いた瞬間がもう入力欄だ。書いて送れば、その1行はバックグラウンドでObsidianのデイリーノートに積まれている。つまり「Obsidianを速く開く」のではなく、「Obsidianを開かずに済ませる」。これがキャプチャと整理の分業の、いちばん具体的な現れだ。iPhoneの思いつきはCaptioで捕まえ、PKMとしての文脈づけはObsidianで後からやる。

なぜアプリ側でObsidianを開かないのか

ここで主役の話に戻る。なぜ、わざわざ「開かない」を第一目標に据えたのか。

役割を分けたかったからだ。思いつきを捕まえるのはiPhoneの仕事、整理するのはObsidianの仕事。この「キャプチャと整理の分業」を1タップで成立させたかった。捕まえる瞬間に整理を求められると、人は捕まえることそのものをやめてしまう。フォルダを選べ、タグを付けろ、と問われた瞬間に、思いつきは逃げる。

セカンドブレインやZettelkastenの文脈でも同じことが言われる。入口(Inbox)は限りなく摩擦を下げ、整理は後でまとめてやる。だからCaptioの役目は、Obsidianの保管庫のInboxノードに、摩擦ゼロで1行を落とすところまで。落ちた後の育て方には口を出さない。アプリがObsidianを開いて画面を奪うのは、この分業に反する。だから開かない。

この線引きは、フォルダを持たないメモアプリの設計思想と地続きだ。整理を引き受けないことが、かえって書く回数を増やす。書く回数が増えれば、Obsidian側に積み上がる素材も増える。忘れていい、必要ならデイリーノートに残っている――その安心感が、次の一行を書かせる。

もう一段だけ踏み込む。「開かない」は、実装上の制約から逃げた結果ではない。むしろ第5層のURLスキームを主経路にして、毎回Obsidianを開いて追記させる実装のほうが、コードは短く済んだ。それでもファイル直書きを主経路にしたのは、開く一瞬が思考を中断させるからだ。0.3秒で起動するアプリを作っておきながら、追記のたびに別アプリへ画面が飛んだら、せっかく削った摩擦を自分で足し直すことになる。速さは入口だけでなく、出口でも守らなければ意味がない。だから難しいほうの経路を主役に据えた。

プライバシー — 追記処理は端末の中で完結する

最後に、信頼に関わる層を1つだけ。Obsidianへの追記処理は、すべて端末内のローカルで完結する。第1〜3層のファイル操作も、第5層のURLスキームも、外部サーバを経由しない。サーバに恒常的に保存される追記用データは無い。

既存のメール送信経路と、オフラインキュー・送信履歴のAES-GCM暗号化(これはDay5の記事で詳しく書いた)にも、今回の連携で手は入れていない。Obsidian追記は、その暗号化された本流に寄り添う形で、端末内だけで動く別レーンとして足した。新しい穴は開けていない。

一番大事なこと

5層を剥がし終えた。security-scoped bookmarkで他アプリの保管庫に触り、yyyy-MM-dd.mdかInboxノートを選び、NSFileCoordinatorで1行を壊さず足し、送信成功後の後処理として静かに走らせ、届かないときだけobsidian://に逃がす。これがObsidian連携の中身の全部だ。

だが、一番大事なのは技術ではない。「アプリがObsidianを開かない」という1つの決定だ。この決定があったから、ファイル直書きという面倒な経路をわざわざ選んだ。URLスキームを最後の保険に格下げした。バックグラウンドの優先度を下げた。全部、画面を奪わないための手段だった。

機能は足し算で説明したくなる。だが本当に効くのは、何を「やらないか」を決める引き算のほうだ。iPhoneのメモをObsidianに自動追記する仕組みで、最後まで守ったのは「開かない」だった。次にこのアプリに何かを足すときも、まず「開かない・奪わない・止めない」から確かめる。それが今回の分解で確かめ直したことだ。


Captio式シンプルメモ
ワンタップで送ったメモが、iCloud Drive上のObsidian保管庫のデイリーノート/Inboxノートにバックグラウンドで自動追記される、iPhoneのキャプチャ専用メモアプリ(プラグイン不要・iOS 16+)。
App Store:https://apps.apple.com/jp/app/captio%E5%BC%8F%E3%82%B7%E3%83%B3%E3%83%97%E3%83%AB%E3%83%A1%E3%83%A2/id6749649498

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?