本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

Tropy は人文系の研究者向けに作られた写真整理アプリケーションです。Electron 製のデスクトップアプリで、史料をスキャンした画像にメタデータ・ノート・タグを付けて束ねるのが本来の使い方ですが、「コマンドラインから操作できますか?」と問われたのをきっかけに、内部構造を一通り読んで検証してみました。

結論を先に書くと、Tropy には次の 3 つの操作レイヤーがあります。

  1. プロジェクトファイル (.tpy / .tropy) は SQLite データベースそのものなので、直接読み書きできる
  2. --port <num> 付きで起動すると HTTP API サーバが立ち、外部から CRUD できる
  3. それより内側に Redux + redux-saga のレイヤーがあるが、これは Tropy 内部の JS からしか触れない

本記事ではこの 3 層を意識しながら、デモ用のプロジェクトを生成し、画像を登録し、メタデータ・ノート・タグ・選択範囲・transcription・リスト階層・JSON-LD エクスポートまで全部スクリプトから操作した記録を残します。検証の副産物として upstream に Pull Request 1 件と Issue 1 件を出すところまで進みました。

検証対象

  • Tropy 1.18.0-beta.1 (main HEAD: 11a086d70、2026-05-15 時点)
  • macOS 26.4.1 / Apple Silicon
  • Node 25.6.0 (Tropy 自体は node:sqlite ではなく better-sqlite3 を内包、本記事のスクリプトは外から動かす都合上 node:sqlite を使用)

レイヤー 1: .tpy は SQLite データベース

Tropy のプロジェクトは .tpy 形式 (単一ファイル) と .tropy 形式 (マネージドディレクトリ) の 2 種類があります。後者は中に project.tpy + assets/ ディレクトリを持つ束ですが、どちらも DB の実体は SQLite ファイルで、前者は rollback journal モード、後者は WAL (Write-Ahead Logging) モードを使います。スキーマはソースの db/schema/project.sql (約 400 行) に dump されていて、内訳は次の通り。

project          メタ情報 (project_id UUID、name、base、store)
subjects         items / photos / selections の共通 ID 発番元
items            アイテム (写真の束)
images           画像の物理属性 (width、height、angle、brightness 等)
photos           items に紐づく写真ファイル (path、checksum、mimetype 等)
selections       写真の中の矩形選択範囲
notes            写真または selection にぶら下がるリッチテキスト (ProseMirror)
transcriptions   写真の OCR/翻刻テキスト
metadata         主題と URI と値の三項 (Dublin Core + Tropy 拡張)
metadata_values  値の immutable な内部テーブル (UNIQUE 制約付き)
tags / taggings  タグとそのリンク
lists / list_items  リスト階層と item の所属
trash            論理削除のテーブル (reason: user / auto / merge)
fts_notes / fts_metadata / fts_transcriptions  FTS5 全文検索インデックス

subjects テーブルが特徴的です。items、photos、selections の id は別テーブルですが、すべて subjects.id を共有しています。つまり「アイテム ID = 1、その写真の ID = 2、写真の中の選択範囲 ID = 12」のように、ヒエラルキーの階層に関係なく ID 空間が一つにまとまっています。Tropy で id が単に整数だったら、それは subjects のどれかを指していると思って差し支えありません。

空のプロジェクトを Node スクリプトで生成する

スキーマファイルを sqlite3 CLI でそのまま流し込めば、プロジェクトファイルとして即時利用可能な .tpy ができあがります。注意点は、Tropy の dump が PRAGMA writable_schema = ON 経由で FTS5 仮想テーブルを直接書き込むスタイルになっていることです。Node 22+ 同梱の node:sqlite (DatabaseSync) はこの形式を exec() で受け付けてくれないので、初期化だけ CLI に任せて、以降の populate を Node 側でやる二段構えにしました。

import { execFileSync, readFileSync } from 'node:fs'
import { DatabaseSync } from 'node:sqlite'
import { randomUUID } from 'node:crypto'

// 1. スキーマ流し込みは sqlite3 CLI 経由
execFileSync('sqlite3', [TPY_PATH], {
  input: readFileSync(SCHEMA),
  stdio: ['pipe', 'inherit', 'inherit']
})

// 2. その後は node:sqlite で populate
const db = new DatabaseSync(TPY_PATH)
db.exec('PRAGMA foreign_keys = ON')
db.prepare(`
  INSERT INTO project (project_id, name, base, store)
  VALUES (?, ?, 'project', NULL)
`).run(randomUUID(), 'Demo')

ここまでで「Demo」という名前のプロジェクトファイルが出来上がり、Tropy で File → Open すれば空のプロジェクトとして開けます。

アイテムを登録する

アイテム 1 件には最低 5 件の INSERT が必要です。サブジェクト発番、items 行、サブジェクト発番 (photo 側)、images 行、photos 行。さらにメタデータを付けるなら metadata_values と metadata に 1 件ずつ。

// item を作る
const itemId = Number(insSubject.run(TPL_ITEM).lastInsertRowid)  // 'https://tropy.org/v1/templates/generic'
insItem.run(itemId)

// その item に紐づく photo を作る
const photoId = Number(insSubject.run(TPL_PHOTO).lastInsertRowid)  // 'https://tropy.org/v1/templates/photo'
insImage.run(photoId, width, height)
insPhoto.run(
  photoId, itemId, /*position*/ 1, relPath,
  'image/jpeg', checksum, filename, size
)
setCover.run(photoId, itemId)

templates/generic は items 用、templates/photo は photos 用の URI です。templates/selection を含めて、何のサブジェクトなのかは subjects.template で区別します。

metadata の書き込みは少し変則的で、metadata_valuesimmutable (update_metadata_values_abort トリガで UPDATE 禁止) のため、同じ datatype + text の組をテーブル全体で 1 行だけ持つ前提です。ON CONFLICT (datatype, text) DO NOTHING で重複を吸収しつつ value_id を引いて、metadata 側に subject_id × property URI × value_id を紐づけます。

レイヤー 1.5: managed (.tropy) 形式の違い

.tropy は中に project.tpy + assets/ を持つディレクトリです。Tropy のソース (src/asset/store.js) で確認できる通り、assets は content-addressable で、ファイル名がチェックサム + 拡張子になっています。

// src/asset/store.js
getPathFor (asset) {
  return join(this.root, `${asset.checksum}${asset.ext.toLowerCase()}`)
}

photos.path 列にはプロジェクトディレクトリからの相対パス (assets/<checksum>.jpg) を入れます。project.base = 'project' なので、Tropy はランタイムで「project の dirname + photos.path」で実ファイルを見つけます。

Tropy のチェックサムは MD5

ここで罠を一つ踏みました。スクリプト側で SHA-1 で <checksum>.jpg を作って assets/ に置いていたところ、Tropy はプロジェクトを開くと photos.checksum を再計算して照合し、ハッシュが合わないとファイルを再 import します。Tropy の checksum は SHA-1 ではなく MD5 (src/asset/asset.js:145) なので、SHA-1 名で置いたファイルは「未知のファイル」と見做され、Tropy が改めて MD5 で名前を付け直してコピーします。結果として SHA-1 名と MD5 名の二重ファイルが assets/ 配下に残ります。実害は無いものの、初回 populate は MD5 で書くのが正解です。

レイヤー 1 で操作したものたち

このレイヤーだけでもかなりのことができます。同じく Tropy ソースで確認しながら、以下を順に試しました。

  • 複数ページの item: photos に同じ item_id を持つ行を複数挿入、position で順序
  • タグ: tags (name UNIQUE) と taggings (id × tag_id) の 2 テーブル。色は tags.color 列に任意の文字列
  • リスト階層: lists の parent_list_id で再帰的に表現 (update_lists_cycle_check トリガで循環防止)、list_items で item の所属
  • 選択範囲: selections の subject に images 行 (選択矩形の dimensions) と selections 行 (photo_id + 左上座標) を組で作る
  • 写真変換: images.angle / mirror / brightness / contrast / hue / saturation / sharpen / negative を更新
  • ノート: notes テーブルに ProseMirror の state を JSON 文字列で保存 + プレーンテキスト (FTS 用) + 言語コード
  • transcription: transcriptions テーブル。text (プレーン)、data (ALTO (Analyzed Layout and Text Object) XML 等)、config (JSON、プラグイン名や OCR 設定)、status は 0 = pending / 1 = done
  • 論理削除: INSERT INTO trash (id, reason) で隠蔽、DELETE FROM trash で復元
  • JSON-LD エクスポート: items × photos × notes × selections × tags × lists の関係を辿りつつ、Dublin Core + Tropy 拡張 URI を @vocab にして書き出す

ProseMirror state は Tropy の src/editor/schema.js を読めばどんなノード/マークが許されるか分かります。ノードは doc / paragraph (align 属性付き) / text / hard_break / blockquote / horizontal_rule / ordered_list / bullet_list / list_item、マークは italic / bold / underline / overline / strikethrough / link / superscript / subscript の 8 種類です。

直接 SQL の制約

このレイヤーは強力ですが、Tropy 内部の Redux/saga パイプラインを完全にバイパスするので、以下のオートメーションは走りません。

  • キャッシュ更新
  • UI イベントの発火 (起動中の Tropy には反映されない → 一度閉じて開き直す必要あり)
  • 自動 OCR や transcription トリガ
  • バリデーション・正規化 (トリガで定義されているもの以外)

長時間掴んでいる Tropy がいるとき外から書き込むのは衝突するので、BEGIN IMMEDIATE で fail-fast にしてから書くか、Tropy を Cmd+Q してから流すのが安全です。

レイヤー 2: HTTP API

npm start -- --port 2019 path/to/some.tropy で起動すると、Tropy は内蔵の HTTP サーバを 127.0.0.1:2019 で待ち受けます。ソースは src/main/api.jssrc/common/api.js の 2 ファイル、Koa + @koa/router 製で約 30 ルートです。src/common/api.js:427-465 で定義されているエンドポイントを表にすると次の通り。

GET  /                                  ルート情報
GET  /version                           バージョン
POST /project/import                    ファイル/JSON で item を import
GET  /project/items                     item 一覧 (?tag=, ?q=, ?sort=)
GET  /project/items/:id                 item 詳細
GET  /project/items/:id/photos          item の写真一覧
GET  /project/items/:id/tags            item のタグ一覧
POST /project/items/:id/tags            item にタグを link
DELETE /project/items/:id/tags          item からタグを外す
GET  /project/items/:id/transcriptions  item の transcription 一覧
GET  /project/tags                      タグ一覧
POST /project/tags                      タグ作成 (item= も同時指定可)
DELETE /project/tags                    タグ全削除
GET  /project/tags/:id                  タグ詳細
GET  /project/data/:id                  metadata 取得
POST /project/data/:id                  metadata 保存 (JSON body)
GET  /project/lists{/:id}               リスト
GET  /project/lists/:id/items           リスト内の item 一覧
GET  /project/notes/:id                 ノート取得 (?format=html|text|md)
POST /project/notes                     ノート作成 (html + photo|selection)
DELETE /project/notes/:id               ノート削除 (tombstone)
GET  /project/transcriptions/:id        transcription 取得 (?format=text|alto)
POST /project/transcriptions            transcription 作成
GET  /project/photos/:id                写真メタデータ
GET  /project/photos/:id/raw            生バイナリ
GET  /project/photos/:id/file.:format   jpg/png/webp/raw に変換取得
GET  /project/selections/:id            選択範囲
GET  /project/selections/:id/file.:format  選択範囲を画像で取得

API は current = () => app.state.recent[0] の通り「現在開いているプロジェクト」に対して動作します。起動時のコマンドライン引数で渡したプロジェクトがそのまま対象になるので、別プロジェクトを使いたい場合は --port 付きで再起動するか、UI 側で別プロジェクトを開けば API 経由でもそちらに切り替わります。

起動中の Tropy がポートを開いているか確認する

--port 無しで起動した場合、API サーバは立ちません (src/main/api.js:35 の条件分岐)。lsof -nP -iTCP -sTCP:LISTEN -p <pid> で 1 件もリッスンしていなければ未起動なので、Cmd+Q して --port 付きで再起動が必要です。

POST 系の感触

実際に叩いてみて分かったボディ形式と挙動。

  • POST /project/notes: application/x-www-form-urlencodedhtml (本文) + photo または selection (親 ID) + language (任意)。レスポンスは { id: [<note_id>] }即時 UI に反映されるので、注釈系を流し込むには相性が良い
  • POST /project/data/:id: ここだけ application/json。body は { "<property-uri>": { type, text } } 形式 (例: { "http://purl.org/dc/elements/1.1/description": { type: "http://www.w3.org/2001/XMLSchema#string", text: "..." } })。これも即時反映される
  • POST /project/tags + item=: 「タグを作成して同時に指定 item にリンク」が一発で終わる。タグ名から既存のタグを引きたい場合はこちらを使う
  • POST /project/items/:id/tags: 既存タグ名でリンクするだけ。未知のタグ名を渡すと内部の findTagIds が NaN になって SQLITE_ERROR: no such column: NaN で 500
  • POST /project/import: file=<path> でローカルファイルを取り込んで新しい item を作る。JSON-LD を直接流し込むなら data=<json-string>

POST /project/notes の本文に html を渡すと、Tropy 側で fromHTML(html) を経由して ProseMirror state に変換してくれます。直接 ProseMirror の JSON を投げる必要が無いのは助かります。

画像の取得

GET /project/photos/:id/raw で元ファイルを生バイナリでダウンロードできるほか、file.png / file.jpg / file.webp / file.raw:format に指定すると、Tropy が内部で sharp を使って変換した結果を返してくれます。試したところ、file.webp は元 JPEG (18 KB) を 6.7 KB まで圧縮しました。サムネイル生成を Tropy に任せたいケースに使えそうです。

副次的に分かったこと

Transcription にはバージョン履歴 UI がある

src/components/transcription/panel.js を読むと、同じ photo に紐づく transcription を <ol class="transcription-versions"> で全件並べ、クリックで activate を切り替える設計になっていました。

つまり「OCR を別パラメータで再実行」「アーキビストが手作業で校訂テキストを追加」のような運用で、同じ画像に対する複数バージョンのテキストを並列に保持して比較・切替できるようになっています。これは OCR 出力を直接書き換えるのではなく別レイヤーとして重ねていく方針で、研究用途として理にかなった設計だと感じました。

CJK と FTS5 トークナイザ

Tropy の FTS5 テーブルは tokenize = 'porter unicode61' を採用しています。unicode61 は Unicode カテゴリで区切るので、空白を含まない長い日本語/中国語の連続は 1 トークン として扱われます。

"京都市中心部 — 御所周辺の区画。" → 1 token (空白の左側全部)

そのため MATCH '京都' ではヒットせず、MATCH '京都*' (prefix) や MATCH '京都市中心部' (full phrase) が必要になります。完全一致だけでもユースケースによっては足りますが、部分一致を取りたい場合は trigram トークナイザに切り替える migration を別途用意すると対応できます。

upstream に出した 2 つの不具合

検証の過程で、HTTP API の挙動を 1 ルートずつ叩いていく中で 2 つの不具合に当たりました。

Bug 1: POST /project/transcriptions が常に 500

src/common/api.js:187act.transcriptions.create(...) を呼んでいますが、src/actions/api.js が export しているのは act.transcription (単数形) です。act.transcriptionsundefined なので .create にアクセスした瞬間に TypeError、結果として POST は 全 リクエストで 500 を返していました。

TypeError: Cannot read properties of undefined (reading 'create')
    at create (.../src/common/api.js:187)

修正は 1 文字、コード行で言えば transcriptionstranscription の差分だけです。

- let { payload } = await rsvp('project', act.transcriptions.create({
+ let { payload } = await rsvp('project', act.transcription.create({

gh search で過去の issue / PR を探したところ既知の報告は無さそうだったので、tropy/tropy に PR として提出しました (Pull Request #965)。

Bug 2: POST 後に Redux store が更新されない

Bug 1 を直して POST /project/transcriptions が成功するようになると、今度は直後の GET /project/transcriptions/<id> が 404 になる現象に気付きました。SQLite には行が確かに入っているのに API 経由では見えない。src/commands/api/transcription.jsTranscriptionCreate は DB 書き込みと this.undo / this.redo の設定はしますが、対応する yield put(...) が無く、Redux store には何も入りません。Show 側は state.transcriptions[id] を見るので、結果として 404 になります。

ノート側の NoteCreate (src/commands/api/note.js) と比較すると非対称です。

// note 側 — store も更新する
let note = yield call(mod.note.create, db, { id, state, text, language })
yield put(act[type].notes.add({ id, notes: [note.id] }))
yield put(act.note.select({ note: note.id, photo, selection }))

// transcription 側 — store の更新が無い
let transcriptions = yield call(db.transaction, async tx => ...)
this.undo = slice.remove(...)
this.redo = slice.restore(...)
return transcriptions.reduce(...)

修正方針はいくつか考えられ (transcriptions slice の extraReducers で api.transcription.create を購読する / TranscriptionCreate.exec に明示的な yield put(slice.insert(...)) を入れる / photo slice の transcriptions.add も一緒に dispatch する)、どの形が tropy 全体のコード規約に合うか判断がつかなかったので、PR ではなく Issue #966 として方針相談という形で提出しました。

レイヤー 3: Redux と DevTools

Tropy 内部の Redux state には外から触れませんが、Tropy ウィンドウで Cmd+Opt+I を押して DevTools を開けば、Console から window.store?.dispatch({type: 'transcriptions/insert', payload: [...]}) のように手動 dispatch ができます。今回はそこまでは試していませんが、UI の状態だけ強制的に切り替えて挙動を観察したいときに使えそうです。

学び

  • デスクトップアプリでも、データの実体が SQLite なら大抵のことは外から制御できる。Tropy に限らず、Electron + SQLite の構成は同じパターンが通用することが多い
  • 公式に CLI が無いアプリでも、HTTP API が用意されていることがある--help や args.js を読んで --port 系のオプションを探すと一発で見つかる
  • 層の使い分けが大事。バッチで巨大データを投入するなら SQL 直書き、UI を生かしたまま自動化したいなら HTTP API、UI 寄りの状態操作だけしたいなら DevTools の dispatch。今回は 3 層を組み合わせて全部試してみました
  • OSS の検証は upstream にも貢献できる。1 文字の typo でも実用上 API が壊れることはあり、見つけたら PR で出すのが最終的にいちばん楽です

関連リンク