diff --git a/lib/exe.rb b/lib/exe.rb index 17ff9d3..cf9fc8a 100644 --- a/lib/exe.rb +++ b/lib/exe.rb @@ -78,14 +78,13 @@ end end -# Obsidian Vaultの初期化 -$vault = ObsidianFetch::Vault.new(vault_paths) -STDERR.puts "Found #{$vault.notes.size} notes" -STDERR.puts "Found #{$vault.links_by_file_name.size} links and #{$vault.links_by_file_path.size} files linked by notes" +$manager = ObsidianFetch::VaultManager.new(vault_paths) +STDERR.puts "Found #{$manager.vault.notes.size} notes" +STDERR.puts "Found #{$manager.vault.links_by_file_name.size} links and #{$manager.vault.links_by_file_path.size} files linked by notes" STDERR.puts "Transport type: #{transport_type}" STDERR.puts "Port: #{port}" if transport_type == 'streamable-http' -# readツールの定義 +# MCPツール定義 class ReadTool < MCP::Tool description "Read a note from Obsidian vault. If multiple notes with the same name are found, all will be shown." input_schema( @@ -94,18 +93,13 @@ class ReadTool < MCP::Tool }, required: ["name"] ) - def self.call(name:) - # 名前が文字列でない場合 return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String) - - # Vaultからノートを読み取る - result = $vault.tool_read(name) + result = ObsidianFetch.read_note($manager.vault, name) MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error) end end -# listツールの定義 class ListTool < MCP::Tool description "Search for files with matching names partially." input_schema( @@ -116,11 +110,8 @@ class ListTool < MCP::Tool ) def self.call(name:) - # 名前が文字列でない場合 return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String) - - # Vaultからノートを検索 - result = $vault.tool_list(name) + result = ObsidianFetch.list_notes($manager.vault, name) MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error) end end @@ -128,7 +119,7 @@ def self.call(name:) # MCPサーバーの初期化 server = MCP::Server.new( name: "obsidian-fetch", - version: "0.1.0", + version: ObsidianFetch::VERSION, tools: [ReadTool, ListTool], ) @@ -137,31 +128,22 @@ def self.call(name:) when 'streamable-http' require 'puma' require 'rack' - - # Streamable HTTP トランスポートを使用してサーバーを起動 transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true) - - # PumaでRack appとして起動 $stdout.sync = true $stderr.sync = true - server_obj = Puma::Server.new(transport) do max_threads 16 min_threads 1 end server_obj.add_tcp_listener "0.0.0.0", port - STDERR.puts "Streamable HTTP transport started on http://localhost:#{port}" STDERR.puts "Press Ctrl+C to stop" - - # サーバーを起動してメインプロセスをブロックする server_thread = server_obj.run server_thread.join when 'stdio' - # Stdioトランスポートを使用してサーバーを起動 transport = MCP::Server::Transports::StdioTransport.new(server) transport.open else STDERR.puts "Unknown transport type: #{transport_type}. Supported: stdio, sse" exit 1 -end \ No newline at end of file +end diff --git a/lib/obsidian_fetch.rb b/lib/obsidian_fetch.rb index a3ecc1b..3586300 100644 --- a/lib/obsidian_fetch.rb +++ b/lib/obsidian_fetch.rb @@ -4,254 +4,10 @@ require 'date' require_relative "obsidian_fetch/version" +require_relative "obsidian_fetch/vault" +require_relative "obsidian_fetch/vault_manager" +require_relative "obsidian_fetch/logic" module ObsidianFetch class Error < StandardError; end - - # ツール呼び出しの結果を保持する - Result = Struct.new(:text, :error, keyword_init: true) do - def initialize(text:, error: false) - super(text: text, error: error) - end - end - - class Vault - attr_reader :notes, :links_by_file_path, :links_by_file_name - - def initialize vault_pathes - @vault_pathes = vault_pathes - - # key: note_name, value: [file_pathes] - @notes = {} - - # key: file_path, value: [file_pathes] - @links_by_file_path = {} - - # ノートが見つからなかった場合に被リンクを表示するためのハッシュ - # key: note_name, value: [file_pathes] - @links_by_file_name = {} - - collect_pathes() - collect_notes() - collect_links() - end - - private - - def collect_pathes - @all_pathes = @vault_pathes.flat_map do |vault_path| - Dir.glob("#{vault_path}/**/*.md") - end - end - - # ノート名を正規化する - # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。 - def self.normalize_note_name(note_name) - # ノート名を小文字に変換し、リンクに使えない文字を除去する - note_name.gsub(/[\[\]#\^\|]/, '') - end - - def collect_notes - # ノート名を整理するための処理 - @all_pathes.each do |file_path| - file_name = File.basename(file_path, '.md') - file_name = Vault.normalize_note_name(file_name) - @notes[file_name] ||= [] - @notes[file_name] << file_path - content = open(file_path) { |f| f.read.force_encoding('UTF-8') } - # もしaliasが設定されていれば、それも追加する - frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1) - if frontmatter_str - begin - frontmatter = YAML.safe_load(frontmatter_str, symbolize_names: true, aliases: true, permitted_classes: [Date]) - rescue Psych::SyntaxError => e - puts "YAML syntax error in #{file_path}: #{e.message}" - next - end - Array(frontmatter&.[](:aliases) || []).each do |alias_name| - alias_name = Vault.normalize_note_name(alias_name) - @notes[alias_name] ||= [] - @notes[alias_name] << file_path - end - end - end - @notes.each{|_name, file_pathes| file_pathes.uniq!} - end - - def collect_links - # リンクを整理するための処理 - # リンクを整理するためにノート名のリストを使用するため、ここで処理する - @all_pathes.each do |file_path| - file_name = File.basename(file_path, '.md') - file_name = Vault.normalize_note_name(file_name) - content = open(file_path) { |f| f.read.force_encoding('UTF-8') } - # もしリンクが設定されていれば、linksに追加する - # [[link]]と[[link|displayname]]の場合 - # linkに.mdが付いている場合、付いていない場合両方を考慮する - # TODO : linkにディレクトリ名が着くことがあるので、それを除去する - content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match| - link_name = match[0] - link_name = link_name.sub(/\.md$/, '') # .mdを削除 - # #、^以降の文字列を削除 - link_name = link_name.sub(/#.*$/, '') - link_name = Vault.normalize_note_name(link_name) - next if link_name == file_name # 自分自身をリンクしている場合は無視する - @links_by_file_name[link_name] ||= [] - @links_by_file_name[link_name] << file_path - # 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい - # ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する - # ただし、aliasは除外する - linked_candidates = (@notes[link_name] || []).filter{|path| path.include?(link_name + '.md') } - linked_candidates.each do |linked_candidate| - @links_by_file_path[linked_candidate] ||= [] - @links_by_file_path[linked_candidate] << file_path - end - end - # [displayname](path)の場合 - # pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する - content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(? MAX_LIST_SIZE - matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h - preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface - end - # マッチした名前のリストで返す - list = matched_notes.keys.shuffle.map do |note_name| - "- #{note_name}" - end.join("\n") - Result.new(text: preface + list) - end - end - end diff --git a/lib/obsidian_fetch/logic.rb b/lib/obsidian_fetch/logic.rb new file mode 100644 index 0000000..29616d4 --- /dev/null +++ b/lib/obsidian_fetch/logic.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module ObsidianFetch + # ツール呼び出しの結果を保持する + Result = Struct.new(:text, :error, keyword_init: true) do + def initialize(text:, error: false) + super(text: text, error: error) + end + end + + MAX_LIST_SIZE = 20 + + # ノート名を正規化する + # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。 + def self.normalize_note_name(note_name) + note_name.gsub(/[\[\]\#\^|]/, '') + end + + # --- ツールロジック --- + def self.read_note(vault, name) + name = ObsidianFetch.normalize_note_name(name) + file_pathes = vault.notes[name] + + preface = "" + # 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す + if file_pathes.nil? && name.include?('/') + fixed_name = File.basename(name, '.md') + file_pathes = vault.notes[fixed_name] + if file_pathes.nil? + # もしも名前で見つからなければ、リンクにも存在しないか確認する + link_pathes = vault.links_by_file_name[fixed_name] + if link_pathes.nil? + return Result.new(text: note_not_found(name), error: true) + else + # リンク先のノートが見つかった場合は、prefaceを追加する + preface = <<~EOS + Presumably a path was specified. The process was automatically renamed and processed. + EOS + return Result.new(text: list_links(fixed_name, preface, vault), error: true) + end + else + # ノート名が見つかった場合は、prefaceを追加する + preface = <<~EOS + Presumably a path was specified. The process was automatically renamed and processed. + EOS + return Result.new(text: open_file(fixed_name, file_pathes, preface, vault)) + end + end + + # 名前のノートが存在しない場合 + if file_pathes.nil? + return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil? + return Result.new(text: list_links(name, preface, vault), error: true) + end + + Result.new(text: open_file(name, file_pathes, preface, vault)) + end + + def self.list_notes(vault, name) + name = ObsidianFetch.normalize_note_name(name) + split_name = name.split(/[\t \u3000]+/) + # 空白文字で検索された場合は失敗した旨を返す + # 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため + if split_name.empty? + return Result.new(text: <<~EOS, error: true) + It cannot be listed in a blank string. + EOS + end + matched_notes = vault.notes.select do |note_name, _file_pathes| + split_name.map { |name_part| note_name.include?(name_part) }.all? + end + # 名前で検索したが見つからない場合 + if matched_notes.empty? + has_found_link = vault.links_by_file_name[name] && !vault.links_by_file_name[name].empty? + return Result.new(text: <<~EOS, error: true) unless has_found_link + Note not found: #{name} + Search again with a substring or a string with a different notation. + EOS + return Result.new(text: <<~EOS, error: true) + Note not found: #{name} + However, I found other notes linked to this note. + #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")} + EOS + end + # マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ + preface = "Notes matching '#{name}' are as follows.\n" + if matched_notes.size > MAX_LIST_SIZE + matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h + preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface + end + # マッチした名前のリストで返す + list = matched_notes.keys.shuffle.map { |note_name| "- #{note_name}" }.join("\n") + Result.new(text: preface + list) + end + + # --- ヘルパーメソッド --- + def self.note_not_found(name) + <<~EOS + Note not found: #{name} + EOS + end + + def self.list_links(name, preface, vault) + <<~EOS + #{preface} + Note not found: #{name} + However, I found other notes linked to this note. + #{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")} + EOS + end + + def self.open_file(name, file_pathes, preface, vault) + # 複数のファイルがある場合は、---とファイル名で区切って返す + result = file_pathes.map do |file_path| + content = open(file_path) { |f| f.read.force_encoding('UTF-8') } + link_notes = if vault.links_by_file_path[file_path].nil? + "" + else + <<~EOS + This note is linked by the following notes: + #{(vault.links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")} + EOS + end + metadata = <<~EOS + The contents of the note '#{name}' is as follows. + #{link_notes} + --- + EOS + metadata + content + end.join("\n\n---\n\n") + preface + result + end +end diff --git a/lib/obsidian_fetch/vault.rb b/lib/obsidian_fetch/vault.rb new file mode 100644 index 0000000..0482a52 --- /dev/null +++ b/lib/obsidian_fetch/vault.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ObsidianFetch + # Vaultスナップショットを表現するイミュータブルなデータ構造 + class Vault + attr_reader :vault_pathes + attr_reader :notes + attr_reader :links_by_file_path + attr_reader :links_by_file_name + + # @param vault_pathes [Array] 保管庫パスのリスト + # @param notes [Hash{String => Array}] {ノート名 → [ファイルパス]} + # @param links_by_file_path [Hash{String => Array}] {ファイルパス → [逆引きパス]} + # @param links_by_file_name [Hash{String => Array}] {ノート名 → [逆引きパス]} + def initialize(vault_pathes, notes:, links_by_file_path:, links_by_file_name:) + @vault_pathes = vault_pathes + @notes = notes + @links_by_file_path = links_by_file_path + @links_by_file_name = links_by_file_name + end + end +end diff --git a/lib/obsidian_fetch/vault_manager.rb b/lib/obsidian_fetch/vault_manager.rb new file mode 100644 index 0000000..c26fc15 --- /dev/null +++ b/lib/obsidian_fetch/vault_manager.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'set' + +module ObsidianFetch + # Vaultスナップショットのライフサイクルを管理 + # ファイル変更を検知し、Vaultを再構築する + class VaultManager + + def initialize(vault_pathes) + @vault_pathes = vault_pathes + scan! + end + + # 最新のVaultを返す。変更があれば自動的に再スキャンする。 + def vault + refresh_if_changed + @vault + end + + private + + # 変更があれば再スキャン + def refresh_if_changed + return unless changed? + scan! + end + + # 再スキャン: Vaultを再構築し、内部状態を更新 + def scan! + @last_scan_time = Time.now + @notes_cache = collect_notes + @vault = build_vault + @known_paths = collect_pathes.to_set + @known_mtimes = {} + @known_sizes = {} + @known_paths.each do |path| + stat = File.stat(path) rescue nil + @known_mtimes[path] = stat&.mtime&.to_f + @known_sizes[path] = stat&.size + end + end + + # 変更検知 + # チェックサムは計算しない(mtimeとサイズだけで変更を検知) + # パスごとに stat を1回だけ実行し、mtime と size を両方取得 + # mtime は浮動小数点で比較(マイクロ秒の差も検知) + # 1. 既知のパスがファイルシステムに存在しない → 削除 + # 2. mtime または size が前回と異なる → 変更 + # 3. 新しい.mdファイルが追加されている → 追加 + def changed? + current_paths = collect_pathes + + # 追加検知 + if (current_paths.to_set - @known_paths).any? + return true + end + + # 削除検知と変更検知を1パスで実行 + @known_paths.each do |path| + stat = File.stat(path) rescue nil + unless stat + return true # 既知のパスが存在しなくなった = 削除 + end + previous_mtime = @known_mtimes[path] + previous_size = @known_sizes[path] + if stat.mtime.to_f != previous_mtime || stat.size != previous_size + return true + end + end + + false + end + + # .mdファイルのパスを収集 + def collect_pathes + @vault_pathes.flat_map do |vault_path| + Dir.glob("#{vault_path}/**/*.md") + end + end + + # ノート名・aliasの抽出 + # 戻り値: {ノート名 → [ファイルパス]} のハッシュ + def collect_notes + # ノートを整理するための処理 + notes = {} + collect_pathes.each do |file_path| + file_name = File.basename(file_path, '.md') + # ノート名を正規化する + # ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。 + file_name = ObsidianFetch.normalize_note_name(file_name) + notes[file_name] ||= [] + notes[file_name] << file_path + + content = open(file_path) { |f| f.read.force_encoding('UTF-8') } + frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1) + if frontmatter_str + begin + frontmatter = YAML.safe_load(frontmatter_str, + symbolize_names: true, aliases: true, permitted_classes: [Date]) + rescue Psych::SyntaxError => e + puts "YAML syntax error in #{file_path}: #{e.message}" + next + end + Array(frontmatter&.[](:aliases) || []).each do |alias_name| + # もしaliasが設定されていれば、それも追加する + alias_name = ObsidianFetch.normalize_note_name(alias_name) + notes[alias_name] ||= [] + notes[alias_name] << file_path + end + end + end + notes.each { |_name, file_pathes| file_pathes.uniq! } + notes + end + + # [[link]]と[link](path)の解析 + # 戻り値: [links_by_file_path, links_by_file_name] の2要素配列 + def collect_links + links_by_file_path = {} + links_by_file_name = {} + + # リンクを整理するための処理 + # リンクを整理するためにノート名のリストを使用するため、ここで処理する + collect_pathes.each do |file_path| + file_name = File.basename(file_path, '.md') + file_name = ObsidianFetch.normalize_note_name(file_name) + content = open(file_path) { |f| f.read.force_encoding('UTF-8') } + # もしリンクが設定されていれば、linksに追加する + # [[link]]と[[link|displayname]]の場合 + # linkに.mdが付いている場合、付いていない場合両方を考慮する + # TODO : linkにディレクトリ名が着くことがあるので、それを除去する + content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match| + link_name = match[0] + link_name = link_name.sub(/\.md$/, '') # .mdを削除 + # #、^以降の文字列を削除 + link_name = link_name.sub(/#.*$/, '') + link_name = ObsidianFetch.normalize_note_name(link_name) + next if link_name == file_name # 自分自身をリンクしている場合は無視する + # 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい + # ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する + # ただし、aliasは除外する + links_by_file_name[link_name] ||= [] + links_by_file_name[link_name] << file_path + linked_candidates = (@notes_cache[link_name] || []).filter { |path| path.include?(link_name + '.md') } + linked_candidates.each do |linked_candidate| + links_by_file_path[linked_candidate] ||= [] + links_by_file_path[linked_candidate] << file_path + end + end + + # [displayname](path)の場合 + # pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する + content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(?= 25 - vault = ObsidianFetch::Vault.new([@test_vault]) - result = vault.tool_list('MassNote_') + vault = ObsidianFetch::VaultManager.new([@test_vault]) + result = ObsidianFetch.list_notes(vault.vault, 'MassNote_') refute result.error @@ -125,7 +131,7 @@ def test_tool_list_limits_results_to_max_list_size class VaultLinkFormatTest < Minitest::Test def setup @test_vault = Dir.mktmpdir('obsidian_link_test_') - FileUtils.cp_r(FIXTURE_VAULT, @test_vault) + copy_fixture_vault(@test_vault) # 様々なリンク形式を含むノートを追加 File.write(File.join(@test_vault, 'Has MD Link.md'), "# Has MD Link\n\nThis note has a [[Hello World.md]] link.\n") @@ -141,32 +147,32 @@ def teardown end def test_md_extension_link_is_stripped - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), "'.md' が除去されてリンク先が登録されていない" - refute vault.links_by_file_name.key?('Hello World.md'), "'.md' 付きで登録されてはいけない" + assert vault.vault.links_by_file_name.key?('Hello World'), "'.md' が除去されてリンク先が登録されていない" + refute vault.vault.links_by_file_name.key?('Hello World.md'), "'.md' 付きで登録されてはいけない" - backlinks = vault.links_by_file_name['Hello World'] + backlinks = vault.vault.links_by_file_name['Hello World'] assert backlinks.any? { |p| p.include?('Has MD Link') } end def test_displayname_link_uses_link_part_not_display - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), 'リンク部分 Hello World が登録されていない' - refute vault.links_by_file_name.key?('HW Note'), '表示名 HW Note が登録されてはいけない' + assert vault.vault.links_by_file_name.key?('Hello World'), 'リンク部分 Hello World が登録されていない' + refute vault.vault.links_by_file_name.key?('HW Note'), '表示名 HW Note が登録されてはいけない' - backlinks = vault.links_by_file_name['Hello World'] + backlinks = vault.vault.links_by_file_name['Hello World'] assert backlinks.any? { |p| p.include?('Has DisplayName Link') } end def test_displaypath_link_uses_path_not_display - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), 'パス部分 Hello World が登録されていない' - refute vault.links_by_file_name.key?('Link to HW'), '表示名 Link to HW が登録されてはいけない' + assert vault.vault.links_by_file_name.key?('Hello World'), 'パス部分 Hello World が登録されていない' + refute vault.vault.links_by_file_name.key?('Link to HW'), '表示名 Link to HW が登録されてはいけない' - backlinks = vault.links_by_file_name['Hello World'] + backlinks = vault.vault.links_by_file_name['Hello World'] assert backlinks.any? { |p| p.include?('Has Display Path Link') } end @@ -177,40 +183,40 @@ def test_displaypath_link_with_md_extension "# Has Display Path Link with MD\n\nThis note has a [Link to HW](Hello World.md) link.\n" ) - vault = ObsidianFetch::Vault.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), "'.md' が除去されてリンク先が登録されていない" + vault = ObsidianFetch::VaultManager.new([@test_vault]) + assert vault.vault.links_by_file_name.key?('Hello World'), "'.md' が除去されてリンク先が登録されていない" end def test_external_url_excluded - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - refute vault.links_by_file_name.key?('example.com'), '外部リンクが登録されてはいけない' - refute vault.links_by_file_name.key?('https'), '外部リンクのスキームが登録されてはいけない' + refute vault.vault.links_by_file_name.key?('example.com'), '外部リンクが登録されてはいけない' + refute vault.vault.links_by_file_name.key?('https'), '外部リンクのスキームが登録されてはいけない' end def test_anchor_link_is_stripped - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), '#アンカーが除去されてリンク先が登録されていない' - refute vault.links_by_file_name.key?('Hello World#Introduction'), 'アンカー付きで登録されてはいけない' + assert vault.vault.links_by_file_name.key?('Hello World'), '#アンカーが除去されてリンク先が登録されていない' + refute vault.vault.links_by_file_name.key?('Hello World#Introduction'), 'アンカー付きで登録されてはいけない' - backlinks = vault.links_by_file_name['Hello World'] + backlinks = vault.vault.links_by_file_name['Hello World'] assert backlinks.any? { |p| p.include?('Has Anchor Link') } end def test_block_id_link_is_stripped # ^block_id 形式のリンクもテスト - vault = ObsidianFetch::Vault.new([@test_vault]) + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), '^blockid が除去されてリンク先が登録されていない' + assert vault.vault.links_by_file_name.key?('Hello World'), '^blockid が除去されてリンク先が登録されていない' end end -# tool_list のバックリンクフォールバックをテスト +# list_notes のバックリンクフォールバックをテスト class VaultBacklinkFallbackTest < Minitest::Test def setup @test_vault = Dir.mktmpdir('obsidian_backlink_test_') - FileUtils.cp_r(FIXTURE_VAULT, @test_vault) + copy_fixture_vault(@test_vault) # リンク先が存在しないノートを追加 File.write(File.join(@test_vault, 'Links to NonExistent.md'), "# Links to NonExistent\n\nThis note links to [[Phantom Note]].\n") @@ -220,15 +226,15 @@ def teardown FileUtils.rm_rf(@test_vault) if @test_vault end - def test_tool_list_backlink_fallback - vault = ObsidianFetch::Vault.new([@test_vault]) + def test_list_notes_backlink_fallback + vault = ObsidianFetch::VaultManager.new([@test_vault]) # "Phantom Note" はノート名として存在しない - refute vault.notes.key?('Phantom Note'), 'Phantom Note がノート名として登録されてはいけない' + refute vault.vault.notes.key?('Phantom Note'), 'Phantom Note がノート名として登録されてはいけない' # だが @links_by_file_name には存在する - assert vault.links_by_file_name.key?('Phantom Note'), 'links_by_file_name に Phantom Note が登録されていない' + assert vault.vault.links_by_file_name.key?('Phantom Note'), 'links_by_file_name に Phantom Note が登録されていない' - result = vault.tool_list('Phantom Note') + result = ObsidianFetch.list_notes(vault.vault, 'Phantom Note') assert result.error, 'エラーが返されていない' assert result.text.include?('However, I found other notes linked to this note'), 'バックリンクのメッセージが表示されていない' @@ -236,11 +242,11 @@ def test_tool_list_backlink_fallback end end -# `tool_read` のバックリンク表示をテスト -class VaultToolReadBacklinkTest < Minitest::Test +# read_note のバックリンク表示をテスト +class VaultReadBacklinkTest < Minitest::Test def setup @test_vault = Dir.mktmpdir('obsidian_backlink_display_test_') - FileUtils.cp_r(FIXTURE_VAULT, @test_vault) + copy_fixture_vault(@test_vault) # リンク元となるノートを追加(Hello World を参照する) File.write(File.join(@test_vault, 'Refers to Hello.md'), "# Refers to Hello\n\nThis note has a [[Hello World]] link.\n") @@ -250,12 +256,12 @@ def teardown FileUtils.rm_rf(@test_vault) if @test_vault end - def test_tool_read_shows_backlink - vault = ObsidianFetch::Vault.new([@test_vault]) + def test_read_note_shows_backlink + vault = ObsidianFetch::VaultManager.new([@test_vault]) - assert vault.links_by_file_name.key?('Hello World'), 'links_by_file_name に Hello World が登録されていない' + assert vault.vault.links_by_file_name.key?('Hello World'), 'links_by_file_name に Hello World が登録されていない' - result = vault.tool_read('Hello World') + result = ObsidianFetch.read_note(vault.vault, 'Hello World') refute result.error assert result.text.include?('Hello World') @@ -269,31 +275,31 @@ def test_tool_read_shows_backlink end end -# tool_read のフォールバック処理をテスト -class VaultToolReadNotExistsTest < Minitest::Test +# read_note のフォールバック処理をテスト +class VaultReadNotExistsTest < Minitest::Test def setup @test_vault = Dir.mktmpdir('obsidian_notexists_test_') - FileUtils.cp_r(FIXTURE_VAULT, @test_vault) + copy_fixture_vault(@test_vault) end def teardown FileUtils.rm_rf(@test_vault) if @test_vault end - def test_tool_read_not_exists_no_backlink - vault = ObsidianFetch::Vault.new([@test_vault]) - result = vault.tool_read('NonExistent Note') + def test_read_note_not_exists_no_backlink + vault = ObsidianFetch::VaultManager.new([@test_vault]) + result = ObsidianFetch.read_note(vault.vault, 'NonExistent Note') assert result.error, 'エラーが返されていない' assert result.text.include?('Note not found:'), 'ノートが見つからないメッセージが表示されていない' end - def test_tool_read_not_exists_with_backlink + def test_read_note_not_exists_with_backlink # リンク先が存在しないノートを追加 File.write(File.join(@test_vault, 'Links to Phantom.md'), "# Links to Phantom\n\nThis note links to [[Phantom Note]].\n") - vault = ObsidianFetch::Vault.new([@test_vault]) - result = vault.tool_read('Phantom Note') + vault = ObsidianFetch::VaultManager.new([@test_vault]) + result = ObsidianFetch.read_note(vault.vault, 'Phantom Note') assert result.error, 'エラーが返されていない' assert result.text.include?('However, I found other notes linked to this note'), 'バックリンクのメッセージが表示されていない' @@ -301,31 +307,31 @@ def test_tool_read_not_exists_with_backlink end end -# tool_read のパス修正後のフォールバックをテスト +# read_note のパス修正後のフォールバックをテスト class VaultPathCorrectionFallbackTest < Minitest::Test def setup @test_vault = Dir.mktmpdir('obsidian_path_correction_test_') - FileUtils.cp_r(FIXTURE_VAULT, @test_vault) + copy_fixture_vault(@test_vault) end def teardown FileUtils.rm_rf(@test_vault) if @test_vault end - def test_tool_read_path_correction_not_found_no_backlink - vault = ObsidianFetch::Vault.new([@test_vault]) - result = vault.tool_read('some/path/NonExistent') + def test_read_note_path_correction_not_found_no_backlink + vault = ObsidianFetch::VaultManager.new([@test_vault]) + result = ObsidianFetch.read_note(vault.vault, 'some/path/NonExistent') assert result.error, 'エラーが返されていない' assert result.text.include?('Note not found:'), 'ノートが見つからないメッセージが表示されていない' end - def test_tool_read_path_correction_found_in_links + def test_read_note_path_correction_found_in_links # リンク先が存在しないノートを追加 File.write(File.join(@test_vault, 'Links to Phantom.md'), "# Links to Phantom\n\nThis note links to [[Phantom Note]].\n") - vault = ObsidianFetch::Vault.new([@test_vault]) - result = vault.tool_read('some/path/Phantom Note') + vault = ObsidianFetch::VaultManager.new([@test_vault]) + result = ObsidianFetch.read_note(vault.vault, 'some/path/Phantom Note') assert result.error, 'エラーが返されていない' assert result.text.include?('However, I found other notes linked to this note'), 'バックリンクのメッセージが表示されていない' diff --git a/test/test_vault_manager.rb b/test/test_vault_manager.rb new file mode 100644 index 0000000..cba80ef --- /dev/null +++ b/test/test_vault_manager.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require_relative 'shared_setup' + +# VaultManagerの動作を確認する最小限のテスト +class VaultManagerTest < Minitest::Test + def setup + @test_vault = Dir.mktmpdir('obsidian_manager_test_') + copy_fixture_vault(@test_vault) + ensure + @manager = ObsidianFetch::VaultManager.new([@test_vault]) + end + + def teardown + FileUtils.rm_rf(@test_vault) if @test_vault + end + + # 初期スキャンで既存ノートが読み込まれる + def test_initial_scan + vault = @manager.vault + assert vault.notes.key?('Hello World') + assert vault.notes.key?('Links to HW') + end + + # 変更なしでvaultを呼ぶと再スキャンされない + def test_no_change_no_rescan + # vaultを2回呼び出し、どちらも同じ結果が返ることを確認 + first = ObsidianFetch.read_note(@manager.vault, 'Hello World') + second = ObsidianFetch.read_note(@manager.vault, 'Hello World') + assert_equal first.text, second.text + end + + # 新しい.mdファイルを追加すると反映される + def test_file_addition + File.write(File.join(@test_vault, 'New Note.md'), "# New Note\n") + vault = @manager.vault + assert vault.notes.key?('New Note') + result = ObsidianFetch.read_note(vault, 'New Note') + refute result.error + assert result.text.include?('New Note') + end + + # ファイルを削除すると反映される + def test_file_deletion + File.delete(File.join(@test_vault, 'Links to HW.md')) + vault = @manager.vault + refute vault.notes.key?('Links to HW') + result = ObsidianFetch.read_note(vault, 'Links to HW') + assert result.error + assert result.text.include?('Note not found') + end + + # ファイル内容を変更すると反映される + def test_file_modification + path = File.join(@test_vault, 'Hello World.md') + File.write(path, "---\naliases:\n - HW\n---\n# Hello World\n\nModified content.\n") + vault = @manager.vault + result = ObsidianFetch.read_note(vault, 'Hello World') + refute result.error + assert result.text.include?('Modified content') + end + + # リンク関係の変化が反映される + def test_link_change + # Links to HW が Hello World をリンクしていることを確認 + vault = @manager.vault + assert vault.links_by_file_name.key?('Hello World') + + # Links to HW のリンク先を変更 + File.write(File.join(@test_vault, 'Links to HW.md'), "# Links to HW\n\nThis note links to [[NonExistent]]. Some extra text to make the file size different.\n") + # ↑ フレーキー対策: 元のファイル(51バイト)と書き換え後のファイルでバイト数が異なるようにする。 + # mtimeの精度が秒単位であるため、同じ秒内で変更した場合にサイズ差がないと変更が検知されず再スキャンされない。 + + # Hello Worldへのリンクがなくなる + vault = @manager.vault + # NonExistentへのリンクが追加される + assert vault.links_by_file_name.key?('NonExistent') + end +end