obsidian_fetch 1.1.2 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/AGENTS.md +5 -0
- data/Rakefile +12 -1
- data/lib/exe.rb +12 -30
- data/lib/obsidian_fetch/logic.rb +133 -0
- data/lib/obsidian_fetch/vault.rb +22 -0
- data/lib/obsidian_fetch/vault_manager.rb +187 -0
- data/lib/obsidian_fetch/version.rb +1 -1
- data/lib/obsidian_fetch.rb +3 -240
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f05f834b38c590fcbccce689668144c14f442bf7ff08899e04f2feb1f807eb95
|
|
4
|
+
data.tar.gz: cc9d23ced95dd85719d0f0666a3363929e40ff8b2983962cc58930e3d2793157
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 366348161c4e8a2352c9a715787f9f86ebb96d0aee0878d83720a4da623d3ab378878e5dd4c0c86ba9dc1ef92421c27f162c40dcbbfadf0ea033c35d5cf6fc19
|
|
7
|
+
data.tar.gz: 731c8660944590029ab2383da67fb2cfc8499bc89ef7bcde7460798b62651df76e7ec104f79165048a6d20a76760c93f3cf52c271a8cb84d5d4bcf4b5252d004
|
data/AGENTS.md
ADDED
data/Rakefile
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
|
-
|
|
4
|
+
require 'rake/testtask'
|
|
5
|
+
|
|
6
|
+
task default: [:test]
|
|
7
|
+
|
|
8
|
+
task :test do
|
|
9
|
+
# テスト実行前にカバレッジデータをリセット
|
|
10
|
+
Dir.glob('coverage/.resultset.json*').each { |f| File.delete(f) if File.exist?(f) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
Rake::TestTask.new('test') do |t|
|
|
14
|
+
t.test_files = FileList['test/test_*.rb']
|
|
15
|
+
end
|
data/lib/exe.rb
CHANGED
|
@@ -78,14 +78,13 @@ vault_paths.each do |path|
|
|
|
78
78
|
end
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
$vault
|
|
83
|
-
STDERR.puts "Found #{$vault.
|
|
84
|
-
STDERR.puts "Found #{$vault.links_by_file_name.size} links and #{$vault.links_by_file_path.size} files linked by notes"
|
|
81
|
+
$manager = ObsidianFetch::VaultManager.new(vault_paths)
|
|
82
|
+
STDERR.puts "Found #{$manager.vault.notes.size} notes"
|
|
83
|
+
STDERR.puts "Found #{$manager.vault.links_by_file_name.size} links and #{$manager.vault.links_by_file_path.size} files linked by notes"
|
|
85
84
|
STDERR.puts "Transport type: #{transport_type}"
|
|
86
85
|
STDERR.puts "Port: #{port}" if transport_type == 'streamable-http'
|
|
87
86
|
|
|
88
|
-
#
|
|
87
|
+
# MCPツール定義
|
|
89
88
|
class ReadTool < MCP::Tool
|
|
90
89
|
description "Read a note from Obsidian vault. If multiple notes with the same name are found, all will be shown."
|
|
91
90
|
input_schema(
|
|
@@ -94,18 +93,13 @@ class ReadTool < MCP::Tool
|
|
|
94
93
|
},
|
|
95
94
|
required: ["name"]
|
|
96
95
|
)
|
|
97
|
-
|
|
98
96
|
def self.call(name:)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
# Vaultからノートを読み取る
|
|
103
|
-
result = $vault.tool_read(name)
|
|
104
|
-
MCP::Tool::Response.new([{ type: "text", text: result }])
|
|
97
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
|
|
98
|
+
result = ObsidianFetch.read_note($manager.vault, name)
|
|
99
|
+
MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
|
|
105
100
|
end
|
|
106
101
|
end
|
|
107
102
|
|
|
108
|
-
# listツールの定義
|
|
109
103
|
class ListTool < MCP::Tool
|
|
110
104
|
description "Search for files with matching names partially."
|
|
111
105
|
input_schema(
|
|
@@ -116,19 +110,16 @@ class ListTool < MCP::Tool
|
|
|
116
110
|
)
|
|
117
111
|
|
|
118
112
|
def self.call(name:)
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
# Vaultからノートを検索
|
|
123
|
-
result = $vault.tool_list(name)
|
|
124
|
-
MCP::Tool::Response.new([{ type: "text", text: result }])
|
|
113
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
|
|
114
|
+
result = ObsidianFetch.list_notes($manager.vault, name)
|
|
115
|
+
MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
|
|
125
116
|
end
|
|
126
117
|
end
|
|
127
118
|
|
|
128
119
|
# MCPサーバーの初期化
|
|
129
120
|
server = MCP::Server.new(
|
|
130
121
|
name: "obsidian-fetch",
|
|
131
|
-
version:
|
|
122
|
+
version: ObsidianFetch::VERSION,
|
|
132
123
|
tools: [ReadTool, ListTool],
|
|
133
124
|
)
|
|
134
125
|
|
|
@@ -137,31 +128,22 @@ case transport_type
|
|
|
137
128
|
when 'streamable-http'
|
|
138
129
|
require 'puma'
|
|
139
130
|
require 'rack'
|
|
140
|
-
|
|
141
|
-
# Streamable HTTP トランスポートを使用してサーバーを起動
|
|
142
131
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
143
|
-
|
|
144
|
-
# PumaでRack appとして起動
|
|
145
132
|
$stdout.sync = true
|
|
146
133
|
$stderr.sync = true
|
|
147
|
-
|
|
148
134
|
server_obj = Puma::Server.new(transport) do
|
|
149
135
|
max_threads 16
|
|
150
136
|
min_threads 1
|
|
151
137
|
end
|
|
152
138
|
server_obj.add_tcp_listener "0.0.0.0", port
|
|
153
|
-
|
|
154
139
|
STDERR.puts "Streamable HTTP transport started on http://localhost:#{port}"
|
|
155
140
|
STDERR.puts "Press Ctrl+C to stop"
|
|
156
|
-
|
|
157
|
-
# サーバーを起動してメインプロセスをブロックする
|
|
158
141
|
server_thread = server_obj.run
|
|
159
142
|
server_thread.join
|
|
160
143
|
when 'stdio'
|
|
161
|
-
# Stdioトランスポートを使用してサーバーを起動
|
|
162
144
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
163
145
|
transport.open
|
|
164
146
|
else
|
|
165
147
|
STDERR.puts "Unknown transport type: #{transport_type}. Supported: stdio, sse"
|
|
166
148
|
exit 1
|
|
167
|
-
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObsidianFetch
|
|
4
|
+
# ツール呼び出しの結果を保持する
|
|
5
|
+
Result = Struct.new(:text, :error, keyword_init: true) do
|
|
6
|
+
def initialize(text:, error: false)
|
|
7
|
+
super(text: text, error: error)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
MAX_LIST_SIZE = 20
|
|
12
|
+
|
|
13
|
+
# ノート名を正規化する
|
|
14
|
+
# ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
|
|
15
|
+
def self.normalize_note_name(note_name)
|
|
16
|
+
note_name.gsub(/[\[\]\#\^|]/, '')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# --- ツールロジック ---
|
|
20
|
+
def self.read_note(vault, name)
|
|
21
|
+
name = ObsidianFetch.normalize_note_name(name)
|
|
22
|
+
file_pathes = vault.notes[name]
|
|
23
|
+
|
|
24
|
+
preface = ""
|
|
25
|
+
# 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
|
|
26
|
+
if file_pathes.nil? && name.include?('/')
|
|
27
|
+
fixed_name = File.basename(name, '.md')
|
|
28
|
+
file_pathes = vault.notes[fixed_name]
|
|
29
|
+
if file_pathes.nil?
|
|
30
|
+
# もしも名前で見つからなければ、リンクにも存在しないか確認する
|
|
31
|
+
link_pathes = vault.links_by_file_name[fixed_name]
|
|
32
|
+
if link_pathes.nil?
|
|
33
|
+
return Result.new(text: note_not_found(name), error: true)
|
|
34
|
+
else
|
|
35
|
+
# リンク先のノートが見つかった場合は、prefaceを追加する
|
|
36
|
+
preface = <<~EOS
|
|
37
|
+
Presumably a path was specified. The process was automatically renamed and processed.
|
|
38
|
+
EOS
|
|
39
|
+
return Result.new(text: list_links(fixed_name, preface, vault), error: true)
|
|
40
|
+
end
|
|
41
|
+
else
|
|
42
|
+
# ノート名が見つかった場合は、prefaceを追加する
|
|
43
|
+
preface = <<~EOS
|
|
44
|
+
Presumably a path was specified. The process was automatically renamed and processed.
|
|
45
|
+
EOS
|
|
46
|
+
return Result.new(text: open_file(fixed_name, file_pathes, preface, vault))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# 名前のノートが存在しない場合
|
|
51
|
+
if file_pathes.nil?
|
|
52
|
+
return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil?
|
|
53
|
+
return Result.new(text: list_links(name, preface, vault), error: true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Result.new(text: open_file(name, file_pathes, preface, vault))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.list_notes(vault, name)
|
|
60
|
+
name = ObsidianFetch.normalize_note_name(name)
|
|
61
|
+
split_name = name.split(/[\t \u3000]+/)
|
|
62
|
+
# 空白文字で検索された場合は失敗した旨を返す
|
|
63
|
+
# 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
|
|
64
|
+
if split_name.empty?
|
|
65
|
+
return Result.new(text: <<~EOS, error: true)
|
|
66
|
+
It cannot be listed in a blank string.
|
|
67
|
+
EOS
|
|
68
|
+
end
|
|
69
|
+
matched_notes = vault.notes.select do |note_name, _file_pathes|
|
|
70
|
+
split_name.map { |name_part| note_name.include?(name_part) }.all?
|
|
71
|
+
end
|
|
72
|
+
# 名前で検索したが見つからない場合
|
|
73
|
+
if matched_notes.empty?
|
|
74
|
+
has_found_link = vault.links_by_file_name[name] && !vault.links_by_file_name[name].empty?
|
|
75
|
+
return Result.new(text: <<~EOS, error: true) unless has_found_link
|
|
76
|
+
Note not found: #{name}
|
|
77
|
+
Search again with a substring or a string with a different notation.
|
|
78
|
+
EOS
|
|
79
|
+
return Result.new(text: <<~EOS, error: true)
|
|
80
|
+
Note not found: #{name}
|
|
81
|
+
However, I found other notes linked to this note.
|
|
82
|
+
#{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
83
|
+
EOS
|
|
84
|
+
end
|
|
85
|
+
# マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ
|
|
86
|
+
preface = "Notes matching '#{name}' are as follows.\n"
|
|
87
|
+
if matched_notes.size > MAX_LIST_SIZE
|
|
88
|
+
matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h
|
|
89
|
+
preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface
|
|
90
|
+
end
|
|
91
|
+
# マッチした名前のリストで返す
|
|
92
|
+
list = matched_notes.keys.shuffle.map { |note_name| "- #{note_name}" }.join("\n")
|
|
93
|
+
Result.new(text: preface + list)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# --- ヘルパーメソッド ---
|
|
97
|
+
def self.note_not_found(name)
|
|
98
|
+
<<~EOS
|
|
99
|
+
Note not found: #{name}
|
|
100
|
+
EOS
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.list_links(name, preface, vault)
|
|
104
|
+
<<~EOS
|
|
105
|
+
#{preface}
|
|
106
|
+
Note not found: #{name}
|
|
107
|
+
However, I found other notes linked to this note.
|
|
108
|
+
#{vault.links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
109
|
+
EOS
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def self.open_file(name, file_pathes, preface, vault)
|
|
113
|
+
# 複数のファイルがある場合は、---とファイル名で区切って返す
|
|
114
|
+
result = file_pathes.map do |file_path|
|
|
115
|
+
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
116
|
+
link_notes = if vault.links_by_file_path[file_path].nil?
|
|
117
|
+
""
|
|
118
|
+
else
|
|
119
|
+
<<~EOS
|
|
120
|
+
This note is linked by the following notes:
|
|
121
|
+
#{(vault.links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
122
|
+
EOS
|
|
123
|
+
end
|
|
124
|
+
metadata = <<~EOS
|
|
125
|
+
The contents of the note '#{name}' is as follows.
|
|
126
|
+
#{link_notes}
|
|
127
|
+
---
|
|
128
|
+
EOS
|
|
129
|
+
metadata + content
|
|
130
|
+
end.join("\n\n---\n\n")
|
|
131
|
+
preface + result
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ObsidianFetch
|
|
4
|
+
# Vaultスナップショットを表現するイミュータブルなデータ構造
|
|
5
|
+
class Vault
|
|
6
|
+
attr_reader :vault_pathes
|
|
7
|
+
attr_reader :notes
|
|
8
|
+
attr_reader :links_by_file_path
|
|
9
|
+
attr_reader :links_by_file_name
|
|
10
|
+
|
|
11
|
+
# @param vault_pathes [Array<String>] 保管庫パスのリスト
|
|
12
|
+
# @param notes [Hash{String => Array<String>}] {ノート名 → [ファイルパス]}
|
|
13
|
+
# @param links_by_file_path [Hash{String => Array<String>}] {ファイルパス → [逆引きパス]}
|
|
14
|
+
# @param links_by_file_name [Hash{String => Array<String>}] {ノート名 → [逆引きパス]}
|
|
15
|
+
def initialize(vault_pathes, notes:, links_by_file_path:, links_by_file_name:)
|
|
16
|
+
@vault_pathes = vault_pathes
|
|
17
|
+
@notes = notes
|
|
18
|
+
@links_by_file_path = links_by_file_path
|
|
19
|
+
@links_by_file_name = links_by_file_name
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'set'
|
|
4
|
+
|
|
5
|
+
module ObsidianFetch
|
|
6
|
+
# Vaultスナップショットのライフサイクルを管理
|
|
7
|
+
# ファイル変更を検知し、Vaultを再構築する
|
|
8
|
+
class VaultManager
|
|
9
|
+
|
|
10
|
+
def initialize(vault_pathes)
|
|
11
|
+
@vault_pathes = vault_pathes
|
|
12
|
+
scan!
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# 最新のVaultを返す。変更があれば自動的に再スキャンする。
|
|
16
|
+
def vault
|
|
17
|
+
refresh_if_changed
|
|
18
|
+
@vault
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# 変更があれば再スキャン
|
|
24
|
+
def refresh_if_changed
|
|
25
|
+
return unless changed?
|
|
26
|
+
scan!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# 再スキャン: Vaultを再構築し、内部状態を更新
|
|
30
|
+
def scan!
|
|
31
|
+
@last_scan_time = Time.now
|
|
32
|
+
@notes_cache = collect_notes
|
|
33
|
+
@vault = build_vault
|
|
34
|
+
@known_paths = collect_pathes.to_set
|
|
35
|
+
@known_mtimes = {}
|
|
36
|
+
@known_sizes = {}
|
|
37
|
+
@known_paths.each do |path|
|
|
38
|
+
stat = File.stat(path) rescue nil
|
|
39
|
+
@known_mtimes[path] = stat&.mtime&.to_f
|
|
40
|
+
@known_sizes[path] = stat&.size
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# 変更検知
|
|
45
|
+
# チェックサムは計算しない(mtimeとサイズだけで変更を検知)
|
|
46
|
+
# パスごとに stat を1回だけ実行し、mtime と size を両方取得
|
|
47
|
+
# mtime は浮動小数点で比較(マイクロ秒の差も検知)
|
|
48
|
+
# 1. 既知のパスがファイルシステムに存在しない → 削除
|
|
49
|
+
# 2. mtime または size が前回と異なる → 変更
|
|
50
|
+
# 3. 新しい.mdファイルが追加されている → 追加
|
|
51
|
+
def changed?
|
|
52
|
+
current_paths = collect_pathes
|
|
53
|
+
|
|
54
|
+
# 追加検知
|
|
55
|
+
if (current_paths.to_set - @known_paths).any?
|
|
56
|
+
return true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 削除検知と変更検知を1パスで実行
|
|
60
|
+
@known_paths.each do |path|
|
|
61
|
+
stat = File.stat(path) rescue nil
|
|
62
|
+
unless stat
|
|
63
|
+
return true # 既知のパスが存在しなくなった = 削除
|
|
64
|
+
end
|
|
65
|
+
previous_mtime = @known_mtimes[path]
|
|
66
|
+
previous_size = @known_sizes[path]
|
|
67
|
+
if stat.mtime.to_f != previous_mtime || stat.size != previous_size
|
|
68
|
+
return true
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
false
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# .mdファイルのパスを収集
|
|
76
|
+
def collect_pathes
|
|
77
|
+
@vault_pathes.flat_map do |vault_path|
|
|
78
|
+
Dir.glob("#{vault_path}/**/*.md")
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# ノート名・aliasの抽出
|
|
83
|
+
# 戻り値: {ノート名 → [ファイルパス]} のハッシュ
|
|
84
|
+
def collect_notes
|
|
85
|
+
# ノートを整理するための処理
|
|
86
|
+
notes = {}
|
|
87
|
+
collect_pathes.each do |file_path|
|
|
88
|
+
file_name = File.basename(file_path, '.md')
|
|
89
|
+
# ノート名を正規化する
|
|
90
|
+
# ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
|
|
91
|
+
file_name = ObsidianFetch.normalize_note_name(file_name)
|
|
92
|
+
notes[file_name] ||= []
|
|
93
|
+
notes[file_name] << file_path
|
|
94
|
+
|
|
95
|
+
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
96
|
+
frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1)
|
|
97
|
+
if frontmatter_str
|
|
98
|
+
begin
|
|
99
|
+
frontmatter = YAML.safe_load(frontmatter_str,
|
|
100
|
+
symbolize_names: true, aliases: true, permitted_classes: [Date])
|
|
101
|
+
rescue Psych::SyntaxError => e
|
|
102
|
+
puts "YAML syntax error in #{file_path}: #{e.message}"
|
|
103
|
+
next
|
|
104
|
+
end
|
|
105
|
+
Array(frontmatter&.[](:aliases) || []).each do |alias_name|
|
|
106
|
+
# もしaliasが設定されていれば、それも追加する
|
|
107
|
+
alias_name = ObsidianFetch.normalize_note_name(alias_name)
|
|
108
|
+
notes[alias_name] ||= []
|
|
109
|
+
notes[alias_name] << file_path
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
notes.each { |_name, file_pathes| file_pathes.uniq! }
|
|
114
|
+
notes
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# [[link]]と[link](path)の解析
|
|
118
|
+
# 戻り値: [links_by_file_path, links_by_file_name] の2要素配列
|
|
119
|
+
def collect_links
|
|
120
|
+
links_by_file_path = {}
|
|
121
|
+
links_by_file_name = {}
|
|
122
|
+
|
|
123
|
+
# リンクを整理するための処理
|
|
124
|
+
# リンクを整理するためにノート名のリストを使用するため、ここで処理する
|
|
125
|
+
collect_pathes.each do |file_path|
|
|
126
|
+
file_name = File.basename(file_path, '.md')
|
|
127
|
+
file_name = ObsidianFetch.normalize_note_name(file_name)
|
|
128
|
+
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
129
|
+
# もしリンクが設定されていれば、linksに追加する
|
|
130
|
+
# [[link]]と[[link|displayname]]の場合
|
|
131
|
+
# linkに.mdが付いている場合、付いていない場合両方を考慮する
|
|
132
|
+
# TODO : linkにディレクトリ名が着くことがあるので、それを除去する
|
|
133
|
+
content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match|
|
|
134
|
+
link_name = match[0]
|
|
135
|
+
link_name = link_name.sub(/\.md$/, '') # .mdを削除
|
|
136
|
+
# #、^以降の文字列を削除
|
|
137
|
+
link_name = link_name.sub(/#.*$/, '')
|
|
138
|
+
link_name = ObsidianFetch.normalize_note_name(link_name)
|
|
139
|
+
next if link_name == file_name # 自分自身をリンクしている場合は無視する
|
|
140
|
+
# 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい
|
|
141
|
+
# ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する
|
|
142
|
+
# ただし、aliasは除外する
|
|
143
|
+
links_by_file_name[link_name] ||= []
|
|
144
|
+
links_by_file_name[link_name] << file_path
|
|
145
|
+
linked_candidates = (@notes_cache[link_name] || []).filter { |path| path.include?(link_name + '.md') }
|
|
146
|
+
linked_candidates.each do |linked_candidate|
|
|
147
|
+
links_by_file_path[linked_candidate] ||= []
|
|
148
|
+
links_by_file_path[linked_candidate] << file_path
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# [displayname](path)の場合
|
|
153
|
+
# pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する
|
|
154
|
+
content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(?<!\]\])\)/) do |match|
|
|
155
|
+
path = match[1]
|
|
156
|
+
# `(.+)://`から始まる場合は除外する
|
|
157
|
+
next if path =~ /^[a-z]+:\/\// # 外部リンク
|
|
158
|
+
# #、^以降の文字列を削除
|
|
159
|
+
path = path.sub(/#.*$/, '')
|
|
160
|
+
path = path.sub(/\.md$/, '') # .mdを削除
|
|
161
|
+
link_name = File.basename(path)
|
|
162
|
+
link_name = ObsidianFetch.normalize_note_name(link_name)
|
|
163
|
+
links_by_file_name[link_name] ||= []
|
|
164
|
+
links_by_file_name[link_name] << file_path
|
|
165
|
+
path_from_vault = File.join(File.dirname(file_path), path) + '.md'
|
|
166
|
+
# Obsidianはかなり賢くて、リンク先のファイルが無い場合には、その配下のファイルを探してくれる
|
|
167
|
+
# 今回の実装ではそこまで考慮せず、pathを信用する
|
|
168
|
+
links_by_file_path[path_from_vault] ||= []
|
|
169
|
+
links_by_file_path[path_from_vault] << file_path
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
links_by_file_name.each { |_file_name, file_pathes| file_pathes.uniq! }
|
|
174
|
+
links_by_file_path.each { |_file_path, file_pathes| file_pathes.uniq! }
|
|
175
|
+
[links_by_file_path, links_by_file_name]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Vaultスナップショットを生成
|
|
179
|
+
def build_vault
|
|
180
|
+
links_by_file_path, links_by_file_name = collect_links
|
|
181
|
+
Vault.new(@vault_pathes,
|
|
182
|
+
notes: @notes_cache,
|
|
183
|
+
links_by_file_path: links_by_file_path,
|
|
184
|
+
links_by_file_name: links_by_file_name)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/obsidian_fetch.rb
CHANGED
|
@@ -4,247 +4,10 @@ require 'yaml'
|
|
|
4
4
|
require 'date'
|
|
5
5
|
|
|
6
6
|
require_relative "obsidian_fetch/version"
|
|
7
|
+
require_relative "obsidian_fetch/vault"
|
|
8
|
+
require_relative "obsidian_fetch/vault_manager"
|
|
9
|
+
require_relative "obsidian_fetch/logic"
|
|
7
10
|
|
|
8
11
|
module ObsidianFetch
|
|
9
12
|
class Error < StandardError; end
|
|
10
|
-
|
|
11
|
-
class Vault
|
|
12
|
-
attr_reader :notes, :links_by_file_path, :links_by_file_name
|
|
13
|
-
|
|
14
|
-
def initialize vault_pathes
|
|
15
|
-
@vault_pathes = vault_pathes
|
|
16
|
-
|
|
17
|
-
# key: note_name, value: [file_pathes]
|
|
18
|
-
@notes = {}
|
|
19
|
-
|
|
20
|
-
# key: file_path, value: [file_pathes]
|
|
21
|
-
@links_by_file_path = {}
|
|
22
|
-
|
|
23
|
-
# ノートが見つからなかった場合に被リンクを表示するためのハッシュ
|
|
24
|
-
# key: note_name, value: [file_pathes]
|
|
25
|
-
@links_by_file_name = {}
|
|
26
|
-
|
|
27
|
-
collect_pathes()
|
|
28
|
-
collect_notes()
|
|
29
|
-
collect_links()
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
def collect_pathes
|
|
35
|
-
@all_pathes = @vault_pathes.flat_map do |vault_path|
|
|
36
|
-
Dir.glob("#{vault_path}/**/*.md")
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# ノート名を正規化する
|
|
41
|
-
# ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
|
|
42
|
-
def self.normalize_note_name(note_name)
|
|
43
|
-
# ノート名を小文字に変換し、リンクに使えない文字を除去する
|
|
44
|
-
note_name.gsub(/[\[\]#\^\|]/, '')
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def collect_notes
|
|
48
|
-
# ノート名を整理するための処理
|
|
49
|
-
@all_pathes.each do |file_path|
|
|
50
|
-
file_name = File.basename(file_path, '.md')
|
|
51
|
-
file_name = Vault.normalize_note_name(file_name)
|
|
52
|
-
@notes[file_name] ||= []
|
|
53
|
-
@notes[file_name] << file_path
|
|
54
|
-
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
55
|
-
# もしaliasが設定されていれば、それも追加する
|
|
56
|
-
frontmatter_str = content.match(/\A---(.*?\n)---/m)&.[](1)
|
|
57
|
-
if frontmatter_str
|
|
58
|
-
begin
|
|
59
|
-
frontmatter = YAML.safe_load(frontmatter_str, symbolize_names: true, aliases: true, permitted_classes: [Date])
|
|
60
|
-
rescue Psych::SyntaxError => e
|
|
61
|
-
puts "YAML syntax error in #{file_path}: #{e.message}"
|
|
62
|
-
next
|
|
63
|
-
end
|
|
64
|
-
Array(frontmatter&.[](:aliases) || []).each do |alias_name|
|
|
65
|
-
alias_name = Vault.normalize_note_name(alias_name)
|
|
66
|
-
@notes[alias_name] ||= []
|
|
67
|
-
@notes[alias_name] << file_path
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
|
71
|
-
@notes.each{|_name, file_pathes| file_pathes.uniq!}
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def collect_links
|
|
75
|
-
# リンクを整理するための処理
|
|
76
|
-
# リンクを整理するためにノート名のリストを使用するため、ここで処理する
|
|
77
|
-
@all_pathes.each do |file_path|
|
|
78
|
-
file_name = File.basename(file_path, '.md')
|
|
79
|
-
file_name = Vault.normalize_note_name(file_name)
|
|
80
|
-
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
81
|
-
# もしリンクが設定されていれば、linksに追加する
|
|
82
|
-
# [[link]]と[[link|displayname]]の場合
|
|
83
|
-
# linkに.mdが付いている場合、付いていない場合両方を考慮する
|
|
84
|
-
# TODO : linkにディレクトリ名が着くことがあるので、それを除去する
|
|
85
|
-
content.scan(/\[\[(.*?)(?:\|.*)?\]\]/) do |match|
|
|
86
|
-
link_name = match[0]
|
|
87
|
-
link_name = link_name.sub(/\.md$/, '') # .mdを削除
|
|
88
|
-
# #、^以降の文字列を削除
|
|
89
|
-
link_name = link_name.sub(/#.*$/, '')
|
|
90
|
-
link_name = Vault.normalize_note_name(link_name)
|
|
91
|
-
next if link_name == file_name # 自分自身をリンクしている場合は無視する
|
|
92
|
-
@links_by_file_name[link_name] ||= []
|
|
93
|
-
@links_by_file_name[link_name] << file_path
|
|
94
|
-
# 同名のノートがある場合に、どこにリンクしているかを処理するのは結構難しい
|
|
95
|
-
# ここでは簡単のため、同名のノートがある場合はすべてのファイルをリンク先として追加する
|
|
96
|
-
# ただし、aliasは除外する
|
|
97
|
-
linked_candidates = (@notes[link_name] || []).filter{|path| path.include?(link_name + '.md') }
|
|
98
|
-
linked_candidates.each do |linked_candidate|
|
|
99
|
-
@links_by_file_path[linked_candidate] ||= []
|
|
100
|
-
@links_by_file_path[linked_candidate] << file_path
|
|
101
|
-
end
|
|
102
|
-
end
|
|
103
|
-
# [displayname](path)の場合
|
|
104
|
-
# pathの末尾に.mdが付いている場合、付いていない場合両方を考慮する
|
|
105
|
-
content.scan(/\[(.*?)\]\((?!\[\[)(.*?)(?<!\]\])\)/) do |match|
|
|
106
|
-
path = match[1]
|
|
107
|
-
# `(.+)://`から始まる場合は除外する
|
|
108
|
-
next if path =~ /^[a-z]+:\/\// # 外部リンク
|
|
109
|
-
# #、^以降の文字列を削除
|
|
110
|
-
path = path.sub(/#.*$/, '')
|
|
111
|
-
path = path.sub(/\.md$/, '') # .mdを削除
|
|
112
|
-
link_name = File.basename(path)
|
|
113
|
-
link_name = Vault.normalize_note_name(link_name)
|
|
114
|
-
@links_by_file_name[link_name] ||= []
|
|
115
|
-
@links_by_file_name[link_name] << file_path
|
|
116
|
-
# Obsidianはかなり賢くて、リンク先のファイルが無い場合には、その配下のファイルを探してくれる
|
|
117
|
-
# 今回の実装ではそこまで考慮せず、pathを信用する
|
|
118
|
-
path_from_vault = File.join(File.dirname(file_path), path) + '.md'
|
|
119
|
-
@links_by_file_path[path_from_vault] ||= []
|
|
120
|
-
@links_by_file_path[path_from_vault] << file_path
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
@links_by_file_name.each do |_file_name, file_pathes|
|
|
124
|
-
file_pathes.uniq!
|
|
125
|
-
end
|
|
126
|
-
@links_by_file_path.each do |_file_path, file_pathes|
|
|
127
|
-
file_pathes.uniq!
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
public
|
|
132
|
-
|
|
133
|
-
def tool_read name
|
|
134
|
-
name = Vault.normalize_note_name(name)
|
|
135
|
-
file_pathes = @notes[name]
|
|
136
|
-
|
|
137
|
-
# 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
|
|
138
|
-
preface = ""
|
|
139
|
-
if file_pathes.nil? && name.include?('/')
|
|
140
|
-
fixed_name = File.basename(name, '.md')
|
|
141
|
-
file_pathes = @notes[fixed_name]
|
|
142
|
-
if file_pathes.nil?
|
|
143
|
-
# もしも名前で見つからなければ、リンクにも存在しないか確認する
|
|
144
|
-
link_pathes = @links_by_file_name[fixed_name]
|
|
145
|
-
if link_pathes.nil?
|
|
146
|
-
return note_not_found(name)
|
|
147
|
-
else
|
|
148
|
-
# リンク先のノートが見つかった場合は、prefaceを追加する
|
|
149
|
-
preface = <<~EOS
|
|
150
|
-
Presumably a path was specified. The process was automatically renamed and processed.
|
|
151
|
-
EOS
|
|
152
|
-
return list_links(fixed_name, preface)
|
|
153
|
-
end
|
|
154
|
-
else
|
|
155
|
-
# ノート名が見つかった場合は、prefaceを追加する
|
|
156
|
-
preface = <<~EOS
|
|
157
|
-
Presumably a path was specified. The process was automatically renamed and processed.
|
|
158
|
-
EOS
|
|
159
|
-
return open_file(fixed_name, file_pathes, preface)
|
|
160
|
-
end
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# 名前のノートが存在しない場合
|
|
164
|
-
if file_pathes.nil?
|
|
165
|
-
return note_not_found(name) if @links_by_file_name[name].nil?
|
|
166
|
-
return list_links(name, preface)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
open_file(name, file_pathes, preface)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
private def note_not_found name
|
|
173
|
-
return <<~EOS
|
|
174
|
-
Note not found: #{name}
|
|
175
|
-
EOS
|
|
176
|
-
end
|
|
177
|
-
|
|
178
|
-
private def list_links name, preface
|
|
179
|
-
return <<~EOS
|
|
180
|
-
#{preface}
|
|
181
|
-
Note not found: #{name}
|
|
182
|
-
However, I found other notes linked to this note.
|
|
183
|
-
#{@links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
184
|
-
EOS
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
private def open_file name, file_pathes, preface
|
|
188
|
-
# 複数のファイルがある場合は、---とファイル名で区切って返す
|
|
189
|
-
result = file_pathes.map do |file_path|
|
|
190
|
-
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
191
|
-
link_notes = if @links_by_file_path[file_path].nil?
|
|
192
|
-
""
|
|
193
|
-
else
|
|
194
|
-
<<~EOS
|
|
195
|
-
This note is linked by the following notes:
|
|
196
|
-
#{(@links_by_file_path[file_path] || []).shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
197
|
-
EOS
|
|
198
|
-
end
|
|
199
|
-
metadata = <<~EOS
|
|
200
|
-
The contents of the note '#{name}' is as follows.
|
|
201
|
-
#{link_notes}
|
|
202
|
-
---
|
|
203
|
-
EOS
|
|
204
|
-
metadata + content
|
|
205
|
-
end.join("\n\n---\n\n")
|
|
206
|
-
preface + result
|
|
207
|
-
end
|
|
208
|
-
|
|
209
|
-
MAX_LIST_SIZE = 20
|
|
210
|
-
def tool_list name
|
|
211
|
-
name = Vault.normalize_note_name(name)
|
|
212
|
-
split_name = name.split(/[\s ]+/)
|
|
213
|
-
# 空白文字で検索された場合は失敗した旨を返す
|
|
214
|
-
# 仮に全ノートからランダムなMAX_LIST_SIZEを返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
|
|
215
|
-
if split_name.empty?
|
|
216
|
-
return <<~EOS
|
|
217
|
-
It cannot be listed in a blank string.
|
|
218
|
-
EOS
|
|
219
|
-
end
|
|
220
|
-
matched_notes = @notes.select do |note_name, _file_pathes|
|
|
221
|
-
split_name.map {|name_part| note_name.include?(name_part) }.all?
|
|
222
|
-
end
|
|
223
|
-
# 名前で検索したが見つからない場合
|
|
224
|
-
if matched_notes.empty?
|
|
225
|
-
has_found_link = @links_by_file_name[name] && !@links_by_file_name[name].empty?
|
|
226
|
-
return <<~EOS unless has_found_link
|
|
227
|
-
Note not found: #{name}
|
|
228
|
-
Search again with a substring or a string with a different notation.
|
|
229
|
-
EOS
|
|
230
|
-
return <<~EOS
|
|
231
|
-
Note not found: #{name}
|
|
232
|
-
However, I found other notes linked to this note.
|
|
233
|
-
#{@links_by_file_name[name].shuffle.map { |file_path| "- #{File.basename(file_path, '.md')}" }.join("\n")}
|
|
234
|
-
EOS
|
|
235
|
-
end
|
|
236
|
-
# マッチした名前の数が多すぎる場合は、ランダムにMAX_LIST_SIZE個選ぶ
|
|
237
|
-
preface = "Notes matching '#{name}' are as follows.\n"
|
|
238
|
-
if matched_notes.size > MAX_LIST_SIZE
|
|
239
|
-
matched_notes = matched_notes.to_a.sample(MAX_LIST_SIZE).to_h
|
|
240
|
-
preface = "Too many notes matched. I will show you only #{MAX_LIST_SIZE} of them.\n" + preface
|
|
241
|
-
end
|
|
242
|
-
# マッチした名前のリストで返す
|
|
243
|
-
list = matched_notes.keys.shuffle.map do |note_name|
|
|
244
|
-
"- #{note_name}"
|
|
245
|
-
end.join("\n")
|
|
246
|
-
preface + list
|
|
247
|
-
end
|
|
248
|
-
end
|
|
249
|
-
|
|
250
13
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: obsidian_fetch
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- sou7
|
|
@@ -58,6 +58,7 @@ executables:
|
|
|
58
58
|
extensions: []
|
|
59
59
|
extra_rdoc_files: []
|
|
60
60
|
files:
|
|
61
|
+
- AGENTS.md
|
|
61
62
|
- CHANGELOG.md
|
|
62
63
|
- LICENSE.txt
|
|
63
64
|
- README.md
|
|
@@ -66,6 +67,9 @@ files:
|
|
|
66
67
|
- exe/obsidian_fetch
|
|
67
68
|
- lib/exe.rb
|
|
68
69
|
- lib/obsidian_fetch.rb
|
|
70
|
+
- lib/obsidian_fetch/logic.rb
|
|
71
|
+
- lib/obsidian_fetch/vault.rb
|
|
72
|
+
- lib/obsidian_fetch/vault_manager.rb
|
|
69
73
|
- lib/obsidian_fetch/version.rb
|
|
70
74
|
- sig/obsidian_fetch.rbs
|
|
71
75
|
homepage: https://ob.sou7.io/2025-04/week17/obsidian_fetch
|