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?

Obsidianのデイリーノートに追記するとiCloudで競合する【開発日誌 Day24】

0
Posted at

先に、いま動いている追記処理の中核を丸ごと置く。約30行、これがiPhoneで送ったメモを、iCloud Drive上のObsidianの保管庫へ安全に書き足す全部だ。長く見えるが、削れる行はほとんど残っていない。なぜこの形に落ち着いたのかを、あとのセクションで一枚ずつ剥がしていく。

// 送信済みメモ1件を、iCloud Drive上のObsidian保管庫のノートへ安全に追記する
func appendToVault(line: String, vaultBookmark: Data) throws {
    var stale = false
    // 1) フォルダ選択時に保存したsecurity-scopedブックマークからURLを復元する
    let folder = try URL(resolvingBookmarkData: vaultBookmark,
                         options: .withSecurityScope,
                         relativeTo: nil,
                         bookmarkDataIsStale: &stale)
    guard folder.startAccessingSecurityScopedResource() else {
        throw VaultError.accessDenied
    }
    defer { folder.stopAccessingSecurityScopedResource() }

    // 2) 追記先(その日のデイリーノート yyyy-MM-dd.md)のURLを組み立てる
    let noteURL = folder.appendingPathComponent(dailyNoteName(for: Date()))

    // 3) NSFileCoordinatorで「読む→足す→書き戻す」を直列化する
    var coordErr: NSError?
    var writeErr: Error?
    NSFileCoordinator(filePresenter: nil).coordinate(
        writingItemAt: noteURL, options: .forMerging, error: &coordErr
    ) { url in
        do {
            let old = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
            let merged = old.isEmpty ? line : old + "\n" + line
            try merged.write(to: url, atomically: true, encoding: .utf8)
        } catch { writeErr = error }
    }
    if let e = coordErr ?? writeErr { throw e }
}

結論から書く。iPhoneアプリからiCloud Drive上のObsidian保管庫へ安全に追記する鍵は、3つだけだ。フォルダ選択で得たsecurity-scopedブックマークでアクセス権を次回起動へ持ち越すこと、NSFileCoordinatorで「読む・足す・書き戻す」を直列化すること、そして保管庫がiCloud Driveに無いときはobsidian:// URLスキームへ静かに逃がすこと。素のString.write(to:)だけで済ませると、iCloud同期の裏側やMacで開きっぱなしのObsidianと衝突し、追記どころか既存のノートを丸ごと潰す。この記事は、その潰し方を一度やってしまってから、上の30行に落ち着くまでの記録だ。

前回は、日本語変換の未確定文字を送ってしまう入力側のバグを直した。今度は送った後、メモが保管庫のファイルに着地する一瞬の話になる。キャプチャはiPhone、整理はObsidian——その分業の継ぎ目は、実はこのファイル書き込みの数十行に全部集まっていた。

なぜ素のString.write(to:)だとObsidianの保管庫で壊れるのか

最初の実装は、これ以上ないほど素朴だった。ノートをStringで読み、末尾に1行足し、write(to:atomically:)で書き戻す。ローカルのDocumentsフォルダなら、これで何の問題も起きない。

問題は、Obsidianの保管庫がiCloud Drive上にあることだ。同じ2026-08-04.mdという1ファイルに、書き手が3人いる。1人目は追記しにくるこのアプリ。2人目はMacで同じノートを開いているObsidian本体。3人目は、その編集を裏で運んでくるiCloudの同期デーモンだ。write(to:atomically:)は「いまメモリにある全文」でファイルをまるごと置き換える。自分が読んだ後、書き戻す前に、iCloudがMac側の新しい版(さっき自分がMacで打った3行)を引いてきていたら、その3行は自分の上書きで消える。

実際にやった。5月30日の夜、駅で思いついた見出しを21:05に送信し、机に戻ってMacのObsidianを開いたら、Macで足していた行が消えていて、代わりに2026-05-30 2.mdというコンフリクトコピーが生まれていた。追記のつもりが、履歴の破壊になっていた。しかもatomically: trueが、この事態を余計に分かりにくくしていた。アトミック書き込みは、隣に一時ファイルを作ってから元の名前へ差し替える方式だ。この差し替えを、iCloudの同期デーモンは「ファイルがまるごと入れ替わった」と受け取り、向こうが抱えていた版との食い違いをコンフリクトとして表面化させることがある。安全のためのつもりのatomicallyが、無協調のままだと逆に火種になる。ワンタップ送信の全体像はDay9で分解したが(iPhoneのメモをObsidianに自動追記する設計を分解した)、そこで軽く流した「安全な書き込み」の一語に、この落とし穴が丸ごと隠れていた。

選定:3つの追記方式を並べて、実測で1つに絞った

書き込み方式の候補は3つあった。素の直書き、FileHandleでの末尾追記、そしてNSFileCoordinator経由の協調書き込み。条件は揃えた。同一のiCloudアカウントでiPhoneとMacを繋ぎ、Mac側はObsidianで当日のデイリーノートを開いたまま、iPhoneから数秒間隔で50件送る。そのうえで、コンフリクトコピーが何回生まれるかを数えた。

方式 既存行の保全 iCloud競合コピー(50送信中) 実装量 圏外書き込み
String.write 直書き 上書きで消える 7回 最小 可
FileHandle 末尾追記 末尾追記のみ保全 2回 小 可
NSFileCoordinator + bookmark 読んで足すので保全 0回 中 可

FileHandleの末尾追記は、既存行を消さない点で直書きより良い。それでも2回コピーが出たのは、同期デーモンがファイルを差し替えた瞬間にハンドルが古いinodeを掴んでいたからだ。数字を見て、実装量が中くらいのコーディネート方式を採った。速さではなく、コンフリクト0回を買った。

セクション1:フォルダを選ぶ、とはブックマークを持ち越すこと

冒頭コードのvaultBookmark: Dataが第1の勘所だ。ユーザーがUIDocumentPickerで保管庫フォルダを一度選ぶ。その瞬間だけ、アプリはサンドボックスの外にあるそのフォルダへアクセスできる。だが、選んだパス文字列をそのまま保存しても、次回起動では無効だ。iOSのサンドボックスは、パスではなくsecurity-scopedブックマークという「鍵」でしか外部フォルダを再訪させてくれない。

// フォルダ選択の結果を、次回起動後も使える「鍵」として保存する
func saveVaultBookmark(from folder: URL) throws -> Data {
    guard folder.startAccessingSecurityScopedResource() else {
        throw VaultError.accessDenied
    }
    defer { folder.stopAccessingSecurityScopedResource() }
    return try folder.bookmarkData(
        options: .withSecurityScope,
        includingResourceValuesForKeys: nil,
        relativeTo: nil
    )
}

保存したDataをURL(resolvingBookmarkData:options:.withSecurityScope...)で解くと、あのフォルダへの権利が戻る。bookmarkDataIsStaleがtrueなら、フォルダが移動・改名されたということなので、鍵を作り直す。ここを省くと、翌朝の1件目からaccessDeniedで追記が止まる。公式ページが「アプリはそのフォルダへの安全なアクセス権だけを保存します」と書いているのは、まさにこのブックマークのことだ。

セクション2:NSFileCoordinatorで読み書きを直列化する

心臓部はここだ。coordinate(writingItemAt:options:.forMerging)は、同じファイルを見張っている他のプレゼンタ——iCloudの同期デーモンやObsidian——に「いったん最新を書き出して手を離せ」と要求してから、クロージャの中身を走らせる。だから自分がファイルを読むときには、Mac側の最新も反映済みの状態になっている。読んで、足して、書き戻す。この3手が他の書き手と噛み合わないよう、間に割り込ませない。

完全なロックではない点は正直に書いておく。コーディネートは「みんなを一度落ち着かせてから書く」仕組みであって、OSレベルの排他ロックではない。それでも、無協調で殴り書きするのと比べれば、衝突の窓は桁で縮む。

書き込みオプションに.forMergingを選んだのにも理由がある。.forReplacingは「これから全部置き換えるから、他の版は捨てていい」という意味論で、まさに今回避けたい上書きの発想に近い。.forMergingは逆に、他のプレゼンタの未保存分を先に吐き出させてから書け、と促す。読んで足す自分の処理と噛み合うのは後者だ。変更通知まで欲しいならNSFilePresenterを実装して自分を登録する手もあるが、今回は送信のたびに一度書くだけで、常駐して監視する必要はない。だからfilePresenter: nilで軽く済ませ、通知ではなく毎回の協調書き込みだけに絞った。

ファイルがまだ無い日の初回だけは、追記ではなく新規作成に分岐させる。

NSFileCoordinator(filePresenter: nil).coordinate(
    writingItemAt: noteURL, options: .forMerging, error: &coordErr
) { url in
    let fm = FileManager.default
    if !fm.fileExists(atPath: url.path) {
        // その日のノートがまだ無ければ、追記ではなく新規作成
        try? "\(line)\n".write(to: url, atomically: true, encoding: .utf8)
        return
    }
    let old = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
    // 末尾改行の有無で連結を変え、行が詰まって1行に化けるのを防ぐ
    let merged = old.hasSuffix("\n") ? old + line + "\n"
                                     : old + "\n" + line + "\n"
    try? merged.write(to: url, atomically: true, encoding: .utf8)
}

セクション3:追記の一行は- HH:mm メモ、日付が変われば宛先ファイルも変わる

追記の見た目は、Obsidianのデイリーノートに馴染む1行にした。- HH:mm メモのタイムスタンプ付きリスト行だ。宛先のyyyy-MM-dd.mdは、必ず端末ローカルのカレンダーで組み立てる。ここでロケール依存の書式を使うと、地域によって08/04と04/08が混じり、Obsidianのデイリーノートと1ミリもマッチしなくなる。en_US_POSIXで固定するのは、こういう事故を消すための定番だ。

func dailyNoteName(for date: Date) -> String {
    let f = DateFormatter()
    f.calendar = Calendar(identifier: .gregorian)
    f.locale = Locale(identifier: "en_US_POSIX")
    f.dateFormat = "yyyy-MM-dd"
    return f.string(from: date) + ".md"      // 例: 2026-08-04.md
}

func timestampedLine(_ memo: String, at date: Date = Date()) -> String {
    let f = DateFormatter()
    f.locale = Locale(identifier: "en_US_POSIX")
    f.dateFormat = "HH:mm"
    return "- \(f.string(from: date)) \(memo)"  // 例: - 21:05 駅で思いついた見出し
}

固定のInboxノートに全部貯める運用なら、dailyNoteNameを固定名に差し替えるだけで、あとの追記経路は1行も変えなくていい。デイリーノートとInboxの切り替えが設定1つで済むのは、書き込み方式を先に直列化で固めておいたおかげだ。

保管庫がiCloud Driveにないなら、obsidian:// に逃がす

全員がiCloud Driveに保管庫を置いているわけではない。Obsidian Sync専用の保管庫は、ファイルAppに姿を出さないので、フォルダ選択のダイアログにそもそも現れない。ブックマークが取れない、あるいはアクセスが拒否された——このときは直書きを諦め、obsidian:// URLスキームにフォールバックする。

// フォルダを選べない保管庫(Obsidian Sync専用など)向けのフォールバック
func appendViaURLScheme(vault: String, note: String, line: String) {
    var c = URLComponents()
    c.scheme = "obsidian"
    c.host = "new"
    c.queryItems = [
        .init(name: "vault", value: vault),
        .init(name: "file", value: note),
        .init(name: "content", value: line + "\n"),
        .init(name: "append", value: "true"),
        .init(name: "silent", value: "true")
    ]
    if let url = c.url { UIApplication.shared.open(url) }
}

このフォールバックは正直、直書きほど静かではない。silent=trueを付けても、送信の一瞬だけObsidianが前面に出て戻る。バックグラウンド追記の透明さは失うが、「フォルダが見えない保管庫でも、とにかく着地はさせる」ことを優先した。二段構えにしておくと、ユーザーの同期方式を問わずに追記が成立する。

iPhoneアプリからObsidianのデイリーノートに直接追記するには?

保管庫がiCloud Driveか「このiPhone内」にあれば、手順は短い。フォルダを一度だけ選び、その日のyyyy-MM-dd.mdへNSFileCoordinator経由で1行追記する。Obsidianを開く必要も、コミュニティプラグインを入れる必要もない。フォルダがファイルAppから見えない同期方式のときだけ、obsidian://のフォールバックに切り替える。この二択の判断を自動化してしまえば、送る側は「送信」だけを意識すればよくなる。

NSFileCoordinatorを省くと、具体的に何が起きるか?

省いた版で50送信を回すと、コンフリクトコピーが7回生まれた。iCloudが裏でファイルを差し替えた瞬間と自分の書き込みが重なると、2026-08-04 2.mdのような複製が湧く。あるいはMacで足した行が消える。PKMは「書いたものが確実にそこに在る」という信頼で成り立っている。50回に数回でも記録が化けると、その信頼は一気に崩れる。コーディネートは万能の鍵ではないが、他のプレゼンタに更新を吐き出させてから書くことで、衝突を実用上ゼロに寄せられる。追記の暗号化まわりを含めた端末内ローカル処理の考え方は、Day5でも書いた(メモアプリにAES-GCMを入れて詰まった3つの落とし穴)。

FAQ

Q. String(contentsOf:)で読むと、中身が空の日があるのでは?
A. ある。まだ端末に降りていない未同期ファイルは、.icloudプレースホルダだけで本文が無い。空と誤読して上書きすると履歴を消す。startDownloadingUbiquitousItem(at:)で明示的に取り寄せるか、少なくとも「空」と「未取得」を区別し、未取得なら書き込みを見送る分岐を入れる。

Q. FileHandleの末尾追記の方が速いのでは?
A. 速い。ただしファイル生成・改行正規化・競合検知を自前で足していくと、結局コーディネート付きの読み書きへ寄っていく。1件あたり数KB・数十msの世界なので、速度差より安全を採った。

Q. Obsidianを開いていないと追記されない?
A. 開いていなくても追記される。フォルダ直書きはObsidianの起動と無関係で、ノートというただのMarkdownファイルに1行足すだけだからだ。Obsidianが前面に出るのは、フォルダを選べずURLスキームのフォールバックに落ちたときだけになる。

Q. Obsidian Syncしか使っていなくても追記できる?
A. 追記先のフォルダがファイルAppから見えないと直書きはできない。その場合はobsidian:// URLスキームのフォールバックに切り替わり、送信時にObsidianが一瞬開いて追記する。挙動の分かれ目は同期方式で決まる。

Q. 追記処理はサーバを経由する?
A. しない。フォルダ直書きは端末内で完結するローカル処理で、圏外でもその場でノートに残る。他端末へはiCloud同期が通信復帰時に運ぶだけだ。

冒頭の30行に戻る

あの短さは、3つの判断——ブックマークで持ち越す、コーディネートで直列化する、無理なら逃がす——を1つの関数に畳んだ結果だ。効いているのは行数ではなく、書かなかった選択肢の数のほうだ。直書きを捨て、FileHandleを捨て、完全なロックという幻想も捨てた。残ったのが、あのappendToVaultだった。

次に測るのは順序だ。この追記は「メール送信が成功したあと」に走る付加処理として置いてある。送信が返ってからObsidian追記までの遅延と、その隙間でアプリが落ちたら追記が飛ぶのか——付加処理の順序と再試行を、次回は実測で詰める。

一つ聞きたい。あなたなら、23:59に書いて00:01に着いたメモは、どっちの日付のデイリーノートに入れる——書いた時刻か、届いた時刻か。自分は「届いた時刻」を採ったが、ジャーナル用途なら「書いた時刻」派もいるはずだ。理由つきでコメントで教えてほしい。次に決める境目の、いい当たりになる。

参考にした公式ドキュメント


Obsidian連携シンプルメモ
ワンタップで送ったメモが、iCloud Drive上のObsidian保管庫のデイリーノートまたはInboxノートへ、アプリを開かずバックグラウンドで自動追記される、iPhone用のキャプチャ専用メモアプリ(プラグイン不要・iOS 16以降)。
App Store:https://apps.apple.com/jp/app/id6758438948

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?