はじめに
Obsidianで大量のノートを管理していると、複数のファイルにわたって一括で文字列を置換したい場面に遭遇することはないでしょうか。例えば、
- プロジェクト名の変更
- リンク形式の統一
- タグの整理など、、
手作業では時間がかかり、ミスも発生しやすい作業です。
当然、obsidianはノートアプリなので、検索と置換をしようと思ったのですが、、、
まさかの、置換ができなかった!!
自分が見つけれてないだけで、obsidian文字列の置換ができるよーって知っている方は教えてください。ちなみに、Obsidian標準の検索機能は強力ですが、なぜか置換だけがありません。。
この課題を解決するため、Vault全体を対象とした高度な検索・置換機能を提供するプラグイン
「Editor Essentials」を開発しました。
本記事では、プラグインの機能と使い方、そして開発過程で得た知見について解説します。
プラグインの特徴
主要機能
「Editor Essentials」プラグインは、以下の機能を提供します。
- Vault全体の検索:すべてのノートを対象に、指定した文字列またはパターンを検索
- 正規表現サポート:複雑なパターンマッチングによる高度な検索
- プレビュー機能:置換前に全マッチを確認
- 選択的な置換:各マッチにチェックボックスを配置し、個別に置換可否を選択
- ファイルごとのグループ表示:検索結果をファイル別に整理して表示
- 行番号表示:マッチ箇所の位置を正確に把握
技術的な実装
開発環境
プラグインは以下の技術スタックで開発しました。
- 言語: TypeScript
- ビルドツール: esbuild
- ターゲット環境: Obsidian Plugin API
- スタイリング: CSS(Obsidianネイティブ変数使用)
プロジェクト構成
obsidian-search-replace/
├── main.ts # メインのプラグインロジック
├── manifest.json # プラグインのメタデータ
├── styles.css # スタイルシート
├── package.json # npm設定
├── tsconfig.json # TypeScript設定
└── esbuild.config.mjs # esbuild設定
コアロジック: 検索処理
Vault全体のファイルを走査し、パターンにマッチする箇所を抽出します。
async performSearch(searchText: string, useRegex: boolean, caseSensitive: boolean) {
const files = this.app.vault.getMarkdownFiles();
const results = new Map<string, SearchResult[]>();
for (const file of files) {
const content = await this.app.vault.read(file);
const lines = content.split('\n');
const fileResults: SearchResult[] = [];
let pattern: RegExp;
if (useRegex) {
const flags = caseSensitive ? 'g' : 'gi';
pattern = new RegExp(searchText, flags);
} else {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = caseSensitive ? 'g' : 'gi';
pattern = new RegExp(escaped, flags);
}
lines.forEach((line, index) => {
let match;
while ((match = pattern.exec(line)) !== null) {
fileResults.push({
line: index + 1,
text: line,
matchStart: match.index,
matchEnd: match.index + match[0].length,
selected: true // デフォルトで選択状態
});
}
});
if (fileResults.length > 0) {
results.set(file.path, fileResults);
}
}
return results;
}
ハイライト表示の実装
マッチ箇所を<mark>タグで囲み、視覚的に強調表示します。
private highlightMatch(text: string, matchStart: number, matchEnd: number): string {
const before = text.substring(0, matchStart);
const match = text.substring(matchStart, matchEnd);
const after = text.substring(matchEnd);
return `${this.escapeHtml(before)}<mark>${this.escapeHtml(match)}</mark>${this.escapeHtml(after)}`;
}
private escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
置換処理
選択されたマッチのみを対象に、ファイルを更新します。
async performReplace(
results: Map<string, SearchResult[]>,
searchText: string,
replaceText: string,
useRegex: boolean,
caseSensitive: boolean
) {
let totalReplaced = 0;
for (const [filePath, fileResults] of results) {
const selectedResults = fileResults.filter(r => r.selected);
if (selectedResults.length === 0) continue;
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) continue;
let content = await this.app.vault.read(file);
const lines = content.split('\n');
// 行番号の降順でソート(後ろから処理することで位置がずれない)
selectedResults.sort((a, b) => b.line - a.line);
for (const result of selectedResults) {
const lineIndex = result.line - 1;
const line = lines[lineIndex];
let pattern: RegExp;
if (useRegex) {
const flags = caseSensitive ? 'g' : 'gi';
pattern = new RegExp(searchText, flags);
} else {
const escaped = searchText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const flags = caseSensitive ? 'g' : 'gi';
pattern = new RegExp(escaped, flags);
}
lines[lineIndex] = line.replace(pattern, replaceText);
totalReplaced++;
}
const newContent = lines.join('\n');
await this.app.vault.modify(file, newContent);
}
return totalReplaced;
}
UIの動的生成
検索結果を表示するDOMを動的に生成します。
private renderResults(results: Map<string, SearchResult[]>) {
const container = this.containerEl.querySelector('.ee-search-results');
container.empty();
if (results.size === 0) {
container.createEl('div', {
cls: 'ee-no-results',
text: '一致する結果が見つかりませんでした'
});
return;
}
// サマリー情報
const totalMatches = Array.from(results.values())
.reduce((sum, arr) => sum + arr.length, 0);
container.createEl('div', {
cls: 'ee-search-info',
text: `${results.size}個のファイルで${totalMatches}件のマッチが見つかりました`
});
// ファイルごとの結果
for (const [filePath, fileResults] of results) {
const fileGroup = container.createEl('div', { cls: 'ee-file-result' });
// ファイルヘッダー
const header = fileGroup.createEl('div', {
cls: 'ee-file-header',
text: `📄 ${filePath} (${fileResults.length}件)`
});
header.addEventListener('click', () => {
matchContainer.toggleClass('is-collapsed', !matchContainer.hasClass('is-collapsed'));
});
// マッチアイテム
const matchContainer = fileGroup.createEl('div', { cls: 'ee-match-container' });
fileResults.forEach((result, index) => {
const matchItem = matchContainer.createEl('div', { cls: 'ee-match-item' });
// チェックボックス
const checkbox = matchItem.createEl('input', { type: 'checkbox' });
checkbox.checked = result.selected;
checkbox.addEventListener('change', () => {
result.selected = checkbox.checked;
});
// 行番号
matchItem.createEl('span', {
cls: 'ee-line-num',
text: result.line.toString()
});
// マッチテキスト
const textEl = matchItem.createEl('span', { cls: 'ee-match-text' });
textEl.innerHTML = this.highlightMatch(
result.text,
result.matchStart,
result.matchEnd
);
// クリックでファイルを開く
textEl.addEventListener('click', () => {
this.openFileAtLine(filePath, result.line);
});
});
}
}
ファイルを特定行で開く
マッチをクリックすると、該当ファイルの該当行にジャンプします。
private async openFileAtLine(filePath: string, line: number) {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) return;
const leaf = this.app.workspace.getLeaf(false);
await leaf.openFile(file);
const view = leaf.view;
if (view instanceof MarkdownView) {
const editor = view.editor;
const lineStart = { line: line - 1, ch: 0 };
editor.setCursor(lineStart);
editor.scrollIntoView({
from: lineStart,
to: lineStart
}, true);
}
}
正規表現のバリデーション
無効な正規表現が入力された場合、適切なエラー処理を行います。
private validateRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch (e) {
new Notice('無効な正規表現です: ' + e.message);
return false;
}
}
開発で得た知見
1. パフォーマンスの最適化
大規模なVaultでは、検索処理に時間がかかる可能性があります。以下の最適化を実装しました。
-
非同期処理:
async/awaitを使用してUIをブロックしない - プログレス表示: 大量のファイルを処理する際の進捗表示
- インクリメンタル検索: 入力のデバウンス処理
private debounceSearch = debounce(
async (searchText: string) => {
if (searchText.length < 2) return; // 最小2文字
await this.performSearch(searchText, this.useRegex, this.caseSensitive);
},
500 // 500ms待機
);
2. メモリ管理
検索結果を適切に管理し、メモリリークを防ぎます。
async onunload() {
// 結果をクリア
this.searchResults.clear();
// イベントリスナーを解除
this.containerEl.empty();
}
3. エラーハンドリング
ファイル読み込みや書き込みの失敗に対応します。
try {
const content = await this.app.vault.read(file);
// 処理
} catch (error) {
console.error(`Failed to read file ${file.path}:`, error);
new Notice(`ファイルの読み込みに失敗しました: ${file.path}`);
}
インストール方法
手動インストール
- GitHubリリースページから最新版をダウンロード
-
.obsidian/plugins/Editor Essentials/フォルダを作成
-
main.js、manifest.json、styles.cssを配置
- Obsidianを再起動して有効化
コミュニティプラグインから(リリース予定)
今しばらくお待ちくださいませ(+_+)
基本的な使い方
- サイドバーの「Search and Replace」アイコンをクリック
- 検索フィールドに検索したい文字列を入力
- 必要に応じて置換フィールドに置換後の文字列を入力
- オプションを設定(正規表現、大文字小文字)
- 「検索」ボタンをクリック or Enterキーを押す
- 結果を確認し、必要に応じてチェックボックスで選択
- 「選択を置換」ボタンで置換を実行
正規表現の例
設定項目
設定項目には、以下の4項目があります。
- 標準行番号の表示
- 相対行番号を表示
- 置換前の確認を表示
- 「すべて置換」ボタンを表示
標準行番号の表示
obsidianのデフォルト機能にもある標準的な行番号の表示を行う
相対行番号の表示(Vimmer大歓喜)
obsidianの行番号をVimやHelixのような相対的な行番号にする
置換前の確認を表示
置換前に確認をしてくれる
「すべて置換」ボタンを表示
間違って削除してしまう可能性がある危ない機能のためデフォルトでオフにしております。
トラブルシューティング
プラグインが動作しない場合
- Obsidianを再起動
- プラグインを無効化→有効化
- 設定を確認(必要な機能が有効になっているか)
まとめ
「Editor Essentials」プラグインを使用することで、以下のメリットが得られます。
- 時間の節約: 複数ファイルの一括置換を数分で完了
- 柔軟な検索: 正規表現により複雑なパターンマッチングが可能
- 選択的な操作: 個別にチェックして必要な箇所のみ置換
- 視認性の向上: ハイライト表示とファイルグループ化で結果を把握しやすい
大規模なVaultを管理するObsidianユーザーにとって、このプラグインは作業効率を大幅に向上させるツールとなるはずです。
リンク
※ Githubリポジトリは現在大幅機能更新中なのでお待ちください
謝辞
このプラグインはclaude様とgemini様とペアプロをして作成しました。
毎度毎度ありがとうございます。☕








