obsidian_fetch 1.1.2 → 1.3.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/CHANGELOG.md +38 -0
- data/README.md +1 -1
- data/README_ja.md +1 -1
- data/Rakefile +12 -1
- data/lib/exe.rb +23 -33
- data/lib/obsidian_fetch/logic.rb +270 -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 +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6b142d17c24902e0a12e9cec6e76cd0b013847ec51f32e25ce902cd882eab987
|
|
4
|
+
data.tar.gz: 4c3783977dd9554b9bcba102a73b77a5a59d6e5915004cd3a304011e91a08588
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c550ba9a5d0c37f0a35d46f4667a1c7d4b79fedf4e10120e7f64877d9b34e5312ae1d913e74056317b5274ad90bf95bdeeec855fb3d146003b8fe656d40679ee
|
|
7
|
+
data.tar.gz: 17cd3805955d03eec7030e52ed07cb4f32c41f65cae11f1fc34a9ea07ffb0b0b4792fc4564b941b9347824a24d327a8f4ff56c3197eaf061101034e30d41820b
|
data/AGENTS.md
ADDED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.3.0] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
- Dependencies: mcp 1.8.2 (constraint `~> 1.8`), puma 8.0.2, rack 3.2.7, simplecov 1.3.2. Required Ruby is 3.3.0 or later.
|
|
6
|
+
- list tool: stopped shuffling. Results are ordered by note name, compared case-insensitively with an ASCII tiebreak. Backlinks, link fallback lists and file enumerations are ordered the same way. When the result is truncated by limit, the truncated priority group shows its head and tail with a line reporting the omitted count.
|
|
7
|
+
- list tool: added the `limit` argument (1 to 200, default 20) and the total number of matches in the response.
|
|
8
|
+
- list tool: folder names are searched, matches are ranked by the distance from the file, and the response reports the breakdown of matches.
|
|
9
|
+
- read tool: a case-insensitive fuzzy match is accepted when no exact match exists, and case variants are suggested when an exact match exists.
|
|
10
|
+
- The initialize response advertises only the tools capability.
|
|
11
|
+
|
|
12
|
+
## [1.2.0] - 2026-07-10
|
|
13
|
+
|
|
14
|
+
- Vault files are reloaded when they change.
|
|
15
|
+
- isError handling was added to the tools.
|
|
16
|
+
- Tests were restructured and coverage measurement was added.
|
|
17
|
+
|
|
18
|
+
## [1.1.2] - 2026-04-28
|
|
19
|
+
|
|
20
|
+
- The Puma bind address was changed from 127.0.0.1 to 0.0.0.0 to allow external access.
|
|
21
|
+
- The `enable_json_response` option added in 1.1.1 was reverted.
|
|
22
|
+
|
|
23
|
+
## [1.1.1] - 2026-04-28
|
|
24
|
+
|
|
25
|
+
- Added the `enable_json_response` option for mcp 0.14.0.
|
|
26
|
+
|
|
27
|
+
## [1.1.0] - 2026-04-28
|
|
28
|
+
|
|
29
|
+
- Added the Streamable HTTP transport.
|
|
30
|
+
- README was made multilingual.
|
|
31
|
+
- The mcp gem was upgraded to 0.14.0.
|
|
32
|
+
|
|
33
|
+
## [1.0.0] - 2025-08-20
|
|
34
|
+
|
|
35
|
+
- Switched to the official MCP Ruby SDK.
|
|
36
|
+
- Fixed the way tools are specified.
|
|
37
|
+
- read tool normalizes a path given as `name`.
|
|
38
|
+
- Display shows note names instead of paths.
|
|
39
|
+
- Refactored and extended the list tool.
|
|
40
|
+
|
|
3
41
|
## [0.1.0] - 2025-04-27
|
|
4
42
|
|
|
5
43
|
- Initial release
|
data/README.md
CHANGED
|
@@ -48,7 +48,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
|
|
|
48
48
|
## Tools
|
|
49
49
|
|
|
50
50
|
- **read**: Read a note from the Obsidian vault. If multiple notes with the same name are found, all will be shown.
|
|
51
|
-
- **list**: Search for
|
|
51
|
+
- **list**: Search for notes whose name or containing folder names partially match. It returns 20 notes by default. The `limit` argument accepts an integer between 1 and 200, and the response states how many notes matched in total.
|
|
52
52
|
|
|
53
53
|
## Contributing
|
|
54
54
|
|
data/README_ja.md
CHANGED
|
@@ -52,7 +52,7 @@ obsidian_fetch /path/to/your/vault --transport streamable-http --port 3000
|
|
|
52
52
|
## ツール
|
|
53
53
|
|
|
54
54
|
- **read**: Obsidian Vaultからノートを取得します。同名のノートが複数存在する場合、すべて取得します。
|
|
55
|
-
- **list**:
|
|
55
|
+
- **list**: ノート名とフォルダ名で検索します。部分一致での検索が可能です。既定では20件を返します。limit 引数には1から200の整数を受け付けます。応答にはマッチした総件数が含まれます。
|
|
56
56
|
|
|
57
57
|
## 貢献
|
|
58
58
|
|
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,42 +93,42 @@ 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
|
-
description "Search for
|
|
104
|
+
description "Search for notes whose name or containing folder names partially match."
|
|
111
105
|
input_schema(
|
|
112
106
|
properties: {
|
|
113
|
-
name: { type: "string", description: "Note name to search" }
|
|
107
|
+
name: { type: "string", description: "Note name to search" },
|
|
108
|
+
limit: { type: "integer", description: "Number of notes to return. Between 1 and #{ObsidianFetch::MAX_LIST_LIMIT}. Default: #{ObsidianFetch::MAX_LIST_SIZE}." }
|
|
114
109
|
},
|
|
115
110
|
required: ["name"]
|
|
116
111
|
)
|
|
117
112
|
|
|
118
|
-
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
|
+
def self.call(name:, limit: ObsidianFetch::MAX_LIST_SIZE)
|
|
114
|
+
return MCP::Tool::Response.new([{ type: "text", text: "Name must be a string" }], error: true) unless name.is_a?(String)
|
|
115
|
+
result = ObsidianFetch.list_notes($manager.vault, name, limit: limit)
|
|
116
|
+
MCP::Tool::Response.new([{ type: "text", text: result.text }], error: result.error)
|
|
125
117
|
end
|
|
126
118
|
end
|
|
127
119
|
|
|
128
120
|
# MCPサーバーの初期化
|
|
121
|
+
#
|
|
122
|
+
# capabilities を明示的に指定しています。
|
|
123
|
+
# MCP::Server は capabilities を渡さない場合、default_capabilities を使います。
|
|
124
|
+
# default_capabilities には tools だけでなく prompts、resources、logging が含まれるため、
|
|
125
|
+
# リソースを持たないこのサーバーでもクライアントが resources/list を呼び出し、空の一覧が返ってしまいます。
|
|
126
|
+
# https://github.com/modelcontextprotocol/ruby-sdk/blob/v1.8.2/lib/mcp/server.rb
|
|
129
127
|
server = MCP::Server.new(
|
|
130
128
|
name: "obsidian-fetch",
|
|
131
|
-
version:
|
|
129
|
+
version: ObsidianFetch::VERSION,
|
|
132
130
|
tools: [ReadTool, ListTool],
|
|
131
|
+
capabilities: { tools: { listChanged: true } },
|
|
133
132
|
)
|
|
134
133
|
|
|
135
134
|
# トランスポートを選択してサーバーを起動
|
|
@@ -137,31 +136,22 @@ case transport_type
|
|
|
137
136
|
when 'streamable-http'
|
|
138
137
|
require 'puma'
|
|
139
138
|
require 'rack'
|
|
140
|
-
|
|
141
|
-
# Streamable HTTP トランスポートを使用してサーバーを起動
|
|
142
139
|
transport = MCP::Server::Transports::StreamableHTTPTransport.new(server, stateless: true)
|
|
143
|
-
|
|
144
|
-
# PumaでRack appとして起動
|
|
145
140
|
$stdout.sync = true
|
|
146
141
|
$stderr.sync = true
|
|
147
|
-
|
|
148
142
|
server_obj = Puma::Server.new(transport) do
|
|
149
143
|
max_threads 16
|
|
150
144
|
min_threads 1
|
|
151
145
|
end
|
|
152
146
|
server_obj.add_tcp_listener "0.0.0.0", port
|
|
153
|
-
|
|
154
147
|
STDERR.puts "Streamable HTTP transport started on http://localhost:#{port}"
|
|
155
148
|
STDERR.puts "Press Ctrl+C to stop"
|
|
156
|
-
|
|
157
|
-
# サーバーを起動してメインプロセスをブロックする
|
|
158
149
|
server_thread = server_obj.run
|
|
159
150
|
server_thread.join
|
|
160
151
|
when 'stdio'
|
|
161
|
-
# Stdioトランスポートを使用してサーバーを起動
|
|
162
152
|
transport = MCP::Server::Transports::StdioTransport.new(server)
|
|
163
153
|
transport.open
|
|
164
154
|
else
|
|
165
155
|
STDERR.puts "Unknown transport type: #{transport_type}. Supported: stdio, sse"
|
|
166
156
|
exit 1
|
|
167
|
-
end
|
|
157
|
+
end
|
|
@@ -0,0 +1,270 @@
|
|
|
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
|
+
# list ツールの既定の返却件数
|
|
12
|
+
MAX_LIST_SIZE = 20
|
|
13
|
+
# list ツールで指定できる返却件数の上限
|
|
14
|
+
MAX_LIST_LIMIT = 200
|
|
15
|
+
|
|
16
|
+
# ノート名を正規化する
|
|
17
|
+
# ファイル名に使えない文字については除去しない。aliasで設定されている場合もあるため。
|
|
18
|
+
def self.normalize_note_name(note_name)
|
|
19
|
+
note_name.gsub(/[\[\]\#\^|]/, '')
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
PATH_CORRECTION_PREFACE = <<~EOS
|
|
23
|
+
Presumably a path was specified. The process was automatically renamed and processed.
|
|
24
|
+
EOS
|
|
25
|
+
|
|
26
|
+
# --- ツールロジック ---
|
|
27
|
+
def self.read_note(vault, name)
|
|
28
|
+
name = ObsidianFetch.normalize_note_name(name)
|
|
29
|
+
file_pathes = vault.notes[name]
|
|
30
|
+
|
|
31
|
+
preface = ""
|
|
32
|
+
# 名前のノートが見つからないが、nameがパスっぽい場合は、パスを修正したうえでもう一度試す
|
|
33
|
+
if file_pathes.nil? && name.include?('/')
|
|
34
|
+
fixed_name = File.basename(name, '.md')
|
|
35
|
+
file_pathes = vault.notes[fixed_name]
|
|
36
|
+
if file_pathes.nil?
|
|
37
|
+
# 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
|
|
38
|
+
candidates = case_variant_candidates(vault, fixed_name)
|
|
39
|
+
return fuzzy_match_response(vault, fixed_name, candidates, PATH_CORRECTION_PREFACE) unless candidates.empty?
|
|
40
|
+
|
|
41
|
+
# もしも名前で見つからなければ、リンクにも存在しないか確認する
|
|
42
|
+
link_pathes = vault.links_by_file_name[fixed_name]
|
|
43
|
+
if link_pathes.nil?
|
|
44
|
+
return Result.new(text: note_not_found(name), error: true)
|
|
45
|
+
else
|
|
46
|
+
# リンク先のノートが見つかった場合は、prefaceを追加する
|
|
47
|
+
return Result.new(text: list_links(fixed_name, PATH_CORRECTION_PREFACE, vault), error: true)
|
|
48
|
+
end
|
|
49
|
+
else
|
|
50
|
+
# ノート名が見つかった場合は、prefaceを追加する
|
|
51
|
+
return Result.new(text: PATH_CORRECTION_PREFACE + case_variant_suggestion(vault, fixed_name) + open_file(fixed_name, file_pathes, "", vault))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 名前のノートが存在しない場合
|
|
56
|
+
if file_pathes.nil?
|
|
57
|
+
# 完全一致がない場合のみ、大文字小文字の差異を許容した検索をする
|
|
58
|
+
candidates = case_variant_candidates(vault, name)
|
|
59
|
+
return fuzzy_match_response(vault, name, candidates, preface) unless candidates.empty?
|
|
60
|
+
|
|
61
|
+
return Result.new(text: note_not_found(name), error: true) if vault.links_by_file_name[name].nil?
|
|
62
|
+
return Result.new(text: list_links(name, preface, vault), error: true)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
Result.new(text: case_variant_suggestion(vault, name) + open_file(name, file_pathes, preface, vault))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.list_notes(vault, name, limit: MAX_LIST_SIZE)
|
|
69
|
+
unless limit.is_a?(Integer) && limit >= 1 && limit <= MAX_LIST_LIMIT
|
|
70
|
+
return Result.new(text: <<~EOS, error: true)
|
|
71
|
+
The limit must be an integer between 1 and #{MAX_LIST_LIMIT}.
|
|
72
|
+
EOS
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
name = ObsidianFetch.normalize_note_name(name)
|
|
76
|
+
split_name = name.split(/[\t \u3000]+/)
|
|
77
|
+
# 空白文字で検索された場合は失敗した旨を返す
|
|
78
|
+
# 仮に全ノートからランダムに limit 件返してしまうと、LLMが誤って呼び出した場合に偽の関連性を持ってしまうため
|
|
79
|
+
if split_name.empty?
|
|
80
|
+
return Result.new(text: <<~EOS, error: true)
|
|
81
|
+
It cannot be listed in a blank string.
|
|
82
|
+
EOS
|
|
83
|
+
end
|
|
84
|
+
matched_notes = vault.notes.filter_map do |note_name, file_pathes|
|
|
85
|
+
match_note(vault, note_name, file_pathes, split_name)
|
|
86
|
+
end
|
|
87
|
+
# 名前で検索したが見つからない場合
|
|
88
|
+
if matched_notes.empty?
|
|
89
|
+
has_found_link = vault.links_by_file_name[name] && !vault.links_by_file_name[name].empty?
|
|
90
|
+
return Result.new(text: <<~EOS, error: true) unless has_found_link
|
|
91
|
+
Note not found: #{name}
|
|
92
|
+
Search again with a substring or a string with a different notation.
|
|
93
|
+
EOS
|
|
94
|
+
return Result.new(text: <<~EOS, error: true)
|
|
95
|
+
Note not found: #{name}
|
|
96
|
+
However, I found other notes linked to this note.
|
|
97
|
+
#{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
|
|
98
|
+
EOS
|
|
99
|
+
end
|
|
100
|
+
# マッチしたノートの数が limit より多い場合は、優先順位の順に枠を埋める
|
|
101
|
+
# truncate されるグループは先頭と末尾を出す
|
|
102
|
+
total = matched_notes.size
|
|
103
|
+
by_name = matched_notes.count { |entry| entry[:matched_by_name] }
|
|
104
|
+
tiers = matched_notes.group_by { |entry| entry[:distance] }.keys.sort.map do |distance|
|
|
105
|
+
matched_notes.select { |entry| entry[:distance] == distance }.sort_by { |entry| [entry[:note_name].downcase, entry[:note_name]] }
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
segments = []
|
|
109
|
+
remaining = limit
|
|
110
|
+
tiers.each do |tier|
|
|
111
|
+
break if remaining.zero?
|
|
112
|
+
|
|
113
|
+
if tier.size <= remaining
|
|
114
|
+
segments << {entries: tier, omitted_after: 0}
|
|
115
|
+
remaining -= tier.size
|
|
116
|
+
elsif remaining < 3
|
|
117
|
+
segments << {entries: tier.first(remaining), omitted_after: tier.size - remaining}
|
|
118
|
+
remaining = 0
|
|
119
|
+
else
|
|
120
|
+
head_size = (remaining * 2) / 3
|
|
121
|
+
tail_size = remaining - head_size
|
|
122
|
+
segments << {entries: tier.first(head_size), omitted_after: tier.size - remaining}
|
|
123
|
+
segments << {entries: tier.last(tail_size), omitted_after: 0}
|
|
124
|
+
remaining = 0
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
shown = segments.sum { |segment| segment[:entries].size }
|
|
129
|
+
preface = "#{total} notes matched '#{name}'"
|
|
130
|
+
preface += " (#{by_name} by note name, #{total - by_name} by folder name)" if total > by_name
|
|
131
|
+
preface += ".\n"
|
|
132
|
+
if shown < total
|
|
133
|
+
preface += "I will show you #{shown} of them. To see the rest, narrow the search or raise the limit.\n"
|
|
134
|
+
end
|
|
135
|
+
preface += "Notes matching '#{name}' are as follows.\n"
|
|
136
|
+
lines = []
|
|
137
|
+
segments.each do |segment|
|
|
138
|
+
lines += segment[:entries].map do |entry|
|
|
139
|
+
if entry[:matched_by_name]
|
|
140
|
+
"- #{entry[:note_name]}"
|
|
141
|
+
else
|
|
142
|
+
"- #{entry[:note_name]} (#{entry[:display_folder]})"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
lines << "... (#{segment[:omitted_after]} notes omitted) ..." if segment[:omitted_after].positive?
|
|
146
|
+
end
|
|
147
|
+
Result.new(text: preface + lines.join("\n") + "\n")
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# ノート1件について検索語のマッチを判定し、マッチしていれば情報を返す
|
|
151
|
+
# 距離はファイルからの距離で、ノート名は距離0、直近の親フォルダは距離1、その上が距離2とする
|
|
152
|
+
# 各語の最も近いマッチ位置のうち、最も遠いものがそのノートの距離になる
|
|
153
|
+
def self.match_note(vault, note_name, file_pathes, words)
|
|
154
|
+
lowered_words = words.map(&:downcase)
|
|
155
|
+
best = nil
|
|
156
|
+
|
|
157
|
+
file_pathes.each do |file_path|
|
|
158
|
+
vault_name, rel_path = relative_path(vault, file_path)
|
|
159
|
+
folders = rel_path.split('/')[0...-1]
|
|
160
|
+
|
|
161
|
+
distances = lowered_words.map do |word|
|
|
162
|
+
distance = note_name.downcase.include?(word) ? 0 : nil
|
|
163
|
+
folders.each_with_index do |folder, index|
|
|
164
|
+
next unless folder.downcase.include?(word)
|
|
165
|
+
candidate = folders.size - index
|
|
166
|
+
distance = candidate if distance.nil? || candidate < distance
|
|
167
|
+
end
|
|
168
|
+
distance
|
|
169
|
+
end
|
|
170
|
+
next if distances.any?(&:nil?)
|
|
171
|
+
|
|
172
|
+
display_folder = folders.join('/')
|
|
173
|
+
display_folder = File.join(vault_name, display_folder) if vault.vault_pathes.size > 1 && vault_name
|
|
174
|
+
|
|
175
|
+
entry = {
|
|
176
|
+
note_name: note_name,
|
|
177
|
+
distance: distances.max,
|
|
178
|
+
matched_by_name: distances.max.zero?,
|
|
179
|
+
display_folder: display_folder,
|
|
180
|
+
}
|
|
181
|
+
best = entry if best.nil? || entry[:distance] < best[:distance]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
best
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# 保管庫ルートからの相対パスと、その保管庫名を返す
|
|
188
|
+
def self.relative_path(vault, file_path)
|
|
189
|
+
vault.vault_pathes.each do |vault_path|
|
|
190
|
+
pattern = /\A#{Regexp.escape(vault_path)}\/+/
|
|
191
|
+
return [File.basename(vault_path), file_path.sub(pattern, '')] if file_path.match?(pattern)
|
|
192
|
+
end
|
|
193
|
+
[nil, File.basename(file_path)]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# --- ヘルパーメソッド ---
|
|
197
|
+
def self.note_not_found(name)
|
|
198
|
+
<<~EOS
|
|
199
|
+
Note not found: #{name}
|
|
200
|
+
EOS
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# 大文字小文字は揃えて比較し、同値は原名の ASCII 順でタイブレークする
|
|
204
|
+
def self.sort_case_insensitive(values)
|
|
205
|
+
values.sort_by { |value| [value.downcase, value] }
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# 完全一致を除いた、大文字小文字の差異だけのキーをノート名順で返す
|
|
209
|
+
def self.case_variant_candidates(vault, target)
|
|
210
|
+
sort_case_insensitive(vault.notes.keys.reject { |note_name| note_name == target }.select { |note_name| note_name.downcase == target.downcase })
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# 完全一致を読むときに、揺らぎマッチしたノート名のサジェストを組み立てる
|
|
214
|
+
def self.case_variant_suggestion(vault, target)
|
|
215
|
+
candidates = case_variant_candidates(vault, target)
|
|
216
|
+
return "" if candidates.empty?
|
|
217
|
+
|
|
218
|
+
if candidates.size == 1
|
|
219
|
+
"There is also a note with the same name in different capitalization:\n- #{candidates.first}\n\n"
|
|
220
|
+
else
|
|
221
|
+
"There are also notes with the same name in different capitalization:\n" +
|
|
222
|
+
candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n\n"
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# 揺らぎマッチの応答を組み立てる
|
|
227
|
+
# 候補が1件のときはその内容を読み、複数のときは候補名の一覧を返す
|
|
228
|
+
def self.fuzzy_match_response(vault, target, candidates, preface)
|
|
229
|
+
if candidates.size == 1
|
|
230
|
+
note_name = candidates.first
|
|
231
|
+
message = "There is no note named '#{target}'. The following note has the same name with different capitalization:\n- #{note_name}\n\n"
|
|
232
|
+
Result.new(text: preface + message + open_file(note_name, vault.notes[note_name], "", vault), error: false)
|
|
233
|
+
else
|
|
234
|
+
message = "There is no note named '#{target}'. The following notes have the same name with different capitalization:\n" +
|
|
235
|
+
candidates.map { |note_name| "- #{note_name}" }.join("\n") + "\n"
|
|
236
|
+
Result.new(text: preface + message, error: false)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.list_links(name, preface, vault)
|
|
241
|
+
<<~EOS
|
|
242
|
+
#{preface}
|
|
243
|
+
Note not found: #{name}
|
|
244
|
+
However, I found other notes linked to this note.
|
|
245
|
+
#{sort_case_insensitive(vault.links_by_file_name[name].map { |file_path| File.basename(file_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
|
|
246
|
+
EOS
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def self.open_file(name, file_pathes, preface, vault)
|
|
250
|
+
# 複数のファイルがある場合は、---とファイル名で区切って返す
|
|
251
|
+
result = sort_case_insensitive(file_pathes).map do |file_path|
|
|
252
|
+
content = open(file_path) { |f| f.read.force_encoding('UTF-8') }
|
|
253
|
+
link_notes = if vault.links_by_file_path[file_path].nil?
|
|
254
|
+
""
|
|
255
|
+
else
|
|
256
|
+
<<~EOS
|
|
257
|
+
This note is linked by the following notes:
|
|
258
|
+
#{sort_case_insensitive(vault.links_by_file_path[file_path].map { |linked_path| File.basename(linked_path, '.md') }).map { |note_name| "- #{note_name}" }.join("\n")}
|
|
259
|
+
EOS
|
|
260
|
+
end
|
|
261
|
+
metadata = <<~EOS
|
|
262
|
+
The contents of the note '#{name}' is as follows.
|
|
263
|
+
#{link_notes}
|
|
264
|
+
---
|
|
265
|
+
EOS
|
|
266
|
+
metadata + content
|
|
267
|
+
end.join("\n\n---\n\n")
|
|
268
|
+
preface + result
|
|
269
|
+
end
|
|
270
|
+
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- sou7
|
|
@@ -15,14 +15,14 @@ dependencies:
|
|
|
15
15
|
requirements:
|
|
16
16
|
- - "~>"
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
|
-
version:
|
|
18
|
+
version: '1.8'
|
|
19
19
|
type: :runtime
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
23
|
- - "~>"
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
|
-
version:
|
|
25
|
+
version: '1.8'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: rack
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -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
|
|
@@ -82,7 +86,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
82
86
|
requirements:
|
|
83
87
|
- - ">="
|
|
84
88
|
- !ruby/object:Gem::Version
|
|
85
|
-
version: 3.
|
|
89
|
+
version: 3.3.0
|
|
86
90
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
87
91
|
requirements:
|
|
88
92
|
- - ">="
|