searchlink 2.3.89 → 2.3.93

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ebbd03635d4a9f050a816e759bb6787a80b1b87f26eedaa861cb50397059010f
4
- data.tar.gz: e22ac97febfefa0f7f6623375b84dfcfaf62a00f8ddc026c72ab0c4351a3f201
3
+ metadata.gz: 26563c3d6842264d4ee543dbac0cd7be148658c13b2020cc09009e94c1d9d8a0
4
+ data.tar.gz: 49c8165891b3f5713b2aada1db49bae241d7f9c41f92fe24f14af03e68930364
5
5
  SHA512:
6
- metadata.gz: 3d95cf263e9d5ed2174c61332207c72dc33ac2683831590491260004ba29caabbbee32a9c2a9e3959f38049a6b3c349ddc6ceeba55b99d8eee711ab78f7066bc
7
- data.tar.gz: 131c9eeb2657ae51fb4c4460ab2e2e9e5c247a920479a5e033b4ae94b90956d1f042b6d3d797c3ce4d16f71d3419a1d9ed67e9fc2121774652cc3847b00773f8
6
+ metadata.gz: 45b47101b11cb81b5b0ca2b74a5370a396cc7a1a95d0d27650498bf9cce8de590cbb855d8f9c9601aa75cffcb1911b23c44de53478b9797c4adc24356dd520cf
7
+ data.tar.gz: 8c2b3f6db03a379899ac14cc9ec21134e46f322f31080843c425a06f234cb245f6e1ccef1ead6100c71a795a9a4656845c6eb0ca4855c3a3fb29d8c02d3dc7ab
@@ -59,6 +59,14 @@ module SL
59
59
  # output_url = CGI.unescape(result)
60
60
  output_url = result
61
61
 
62
+ begin
63
+ uri = URI.parse(output_url)
64
+ uri.path = URI::DEFAULT_PARSER.escape(uri.path)
65
+ output_url = uri.to_s
66
+ rescue URI::InvalidURIError
67
+ output_url = URI::DEFAULT_PARSER.escape(output_url)
68
+ end
69
+
62
70
  output_title = if SL.config["include_titles"] || SL.titleize
63
71
  SL::URL.title(output_url) || ""
64
72
  else
@@ -1,135 +1,214 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
4
+ require "tmpdir"
5
+
3
6
  module SL
4
7
  class HistorySearch
8
+ FIREFOX_DIR = "~/Library/Application Support/Firefox"
9
+ ZEN_DIR = "~/Library/Application Support/zen"
10
+
5
11
  class << self
12
+ # Search Firefox history
13
+ #
14
+ # @param term [String] the search terms
15
+ #
16
+ # @return [Array, false] [url, title, date] or false
17
+ #
6
18
  def search_firefox_history(term)
7
- # Firefox history
8
- base = File.expand_path("~/Library/Application Support/Firefox/Profiles")
9
- Dir.chdir(base)
10
- profile = Dir.glob("*default-release")
11
- return false unless profile
12
-
13
- src = File.join(base, profile[0], "places.sqlite")
14
-
15
- exact_match = false
16
- match_phrases = []
19
+ search_mozilla_history(mozilla_places_db(FIREFOX_DIR), term, "Firefox")
20
+ end
17
21
 
18
- # If search terms start with ''term, only search for exact string matches
19
- case term
20
- when /^ *'/
21
- exact_match = true
22
- term.gsub!(/(^ *'+|'+ *$)/, "")
23
- when /%22(.*?)%22/
24
- match_phrases = term.scan(/%22(\S.*?\S)%22/)
25
- term.gsub!(/%22(\S.*?\S)%22/, "")
26
- end
22
+ # Search Firefox bookmarks
23
+ #
24
+ # @param term [String] the search terms
25
+ #
26
+ # @return [Array, false] [url, title, date, score] or false
27
+ #
28
+ def search_firefox_bookmarks(term)
29
+ search_mozilla_bookmarks(mozilla_places_db(FIREFOX_DIR), term, "Firefox")
30
+ end
27
31
 
28
- if File.exist?(src)
29
- SL.notify("Searching Firefox History", term)
30
- tmpfile = "#{src}.tmp"
31
- FileUtils.cp(src, tmpfile)
32
-
33
- terms = []
34
- terms.push("(moz_places.url NOT LIKE '%search/?%'
35
- AND moz_places.url NOT LIKE '%?q=%'
36
- AND moz_places.url NOT LIKE '%?s=%'
37
- AND moz_places.url NOT LIKE '%duckduckgo.com/?t%')")
38
- if exact_match
39
- terms.push("(moz_places.url LIKE '%#{term.strip.downcase}%' OR moz_places.title LIKE '%#{term.strip.downcase}%')")
40
- else
41
- terms.concat(term.split(/\s+/).map do |t|
42
- "(moz_places.url LIKE '%#{t.strip.downcase}%' OR moz_places.title LIKE '%#{t.strip.downcase}%')"
43
- end)
44
- terms.concat(match_phrases.map do |t|
45
- "(moz_places.url LIKE '%#{t[0].strip.downcase}%' OR moz_places.title LIKE '%#{t[0].strip.downcase}%')"
46
- end)
47
- end
48
- query = terms.join(" AND ")
49
- most_recent = `sqlite3 -json '#{tmpfile}' "select moz_places.title, moz_places.url,
50
- datetime(moz_historyvisits.visit_date/1000000, 'unixepoch', 'localtime') as datum
51
- from moz_places, moz_historyvisits where moz_places.id = moz_historyvisits.place_id
52
- and #{query} order by datum desc limit 1 COLLATE NOCASE;"`.strip
53
- FileUtils.rm_f(tmpfile)
32
+ # Search Zen history
33
+ #
34
+ # @param term [String] the search terms
35
+ #
36
+ # @return [Array, false] [url, title, date] or false
37
+ #
38
+ def search_zen_history(term)
39
+ search_mozilla_history(mozilla_places_db(ZEN_DIR), term, "Zen")
40
+ end
54
41
 
55
- return false if most_recent.strip.empty?
42
+ # Search Zen bookmarks
43
+ #
44
+ # @param term [String] the search terms
45
+ #
46
+ # @return [Array, false] [url, title, date, score] or false
47
+ #
48
+ def search_zen_bookmarks(term)
49
+ search_mozilla_bookmarks(mozilla_places_db(ZEN_DIR), term, "Zen")
50
+ end
56
51
 
57
- marks = JSON.parse(most_recent)
52
+ # Locate the places.sqlite database for a Mozilla-based browser. Profiles
53
+ # listed in profiles.ini are tried in order of preference (install
54
+ # default, then the profile marked Default=1, then any other profile),
55
+ # falling back to a *default-release folder.
56
+ #
57
+ # @param app_dir [String] the browser's Application Support folder
58
+ #
59
+ # @return [String, false] path to places.sqlite or false
60
+ #
61
+ def mozilla_places_db(app_dir)
62
+ base = File.expand_path(app_dir)
63
+ return false unless File.directory?(base)
64
+
65
+ ini = File.join(base, "profiles.ini")
66
+ candidates = File.exist?(ini) ? mozilla_ini_profiles(File.read(ini), base) : []
67
+ candidates.concat(Dir.glob(File.join(base, "Profiles", "*default-release")))
68
+
69
+ db = candidates.uniq.map { |dir| File.join(dir, "places.sqlite") }.find { |f| File.exist?(f) }
70
+ db || false
71
+ end
58
72
 
59
- marks.map! do |bm|
60
- date = Time.parse(bm["datum"])
61
- score = score_mark({ url: bm["url"], title: bm["title"] }, term)
62
- { url: bm["url"], title: bm["title"], date: date, score: score }
73
+ # Parse profiles.ini into a list of profile directories, most preferred first
74
+ #
75
+ # @param contents [String] the contents of profiles.ini
76
+ # @param base [String] the folder containing profiles.ini
77
+ #
78
+ # @return [Array<String>] absolute profile directories
79
+ #
80
+ def mozilla_ini_profiles(contents, base)
81
+ sections = []
82
+ contents.each_line do |line|
83
+ line = line.strip
84
+ if line =~ /^\[(.+)\]$/
85
+ sections << { name: Regexp.last_match(1) }
86
+ elsif line =~ /^([^=]+)=(.*)$/ && sections.any?
87
+ sections.last[Regexp.last_match(1).strip] = Regexp.last_match(2).strip
63
88
  end
89
+ end
64
90
 
65
- m = marks.max_by { |m| [m[:url].length * -1, m[:score]] }
91
+ absolute = ->(path) { path.start_with?("/") ? path : File.join(base, path) }
66
92
 
67
- [m[:url], m[:title], m[:date]]
68
- else
69
- false
70
- end
93
+ installs = sections.select { |s| s[:name] =~ /^Install/ && s["Default"] }
94
+ .map { |s| absolute.call(s["Default"]) }
95
+ .sort_by { |dir| -places_mtime(dir) }
96
+ profiles = sections.select { |s| s[:name] =~ /^Profile/ && s["Path"] }
97
+ defaults, others = profiles.partition { |s| s["Default"] == "1" }
98
+
99
+ installs + (defaults + others).map { |s| absolute.call(s["Path"]) }
71
100
  end
72
101
 
73
- def search_firefox_bookmarks(term)
74
- # Firefox history
75
- base = File.expand_path("~/Library/Application Support/Firefox/Profiles")
76
- Dir.chdir(base)
77
- profile = Dir.glob("*default-release")
78
- return false unless profile
79
-
80
- src = File.join(base, profile[0], "places.sqlite")
81
-
82
- exact_match = false
83
- match_phrases = []
84
-
85
- # If search terms start with ''term, only search for exact string matches
86
- if term =~ /^ *'/
87
- exact_match = true
88
- term.gsub!(/(^ *'+|'+ *$)/, "")
89
- elsif term =~ /%22(.*?)%22/
90
- match_phrases = term.scan(/%22(\S.*?\S)%22/)
91
- term.gsub!(/%22(\S.*?\S)%22/, "")
92
- end
102
+ # Modification time of a profile's places.sqlite, used to prefer the
103
+ # most recently used install when several share a profiles.ini
104
+ #
105
+ # @param dir [String] the profile directory
106
+ #
107
+ # @return [Float] seconds since epoch, or 0 if there is no database
108
+ #
109
+ def places_mtime(dir)
110
+ db = File.join(dir, "places.sqlite")
111
+ File.exist?(db) ? File.mtime(db).to_f : 0
112
+ end
93
113
 
94
- if File.exist?(src)
95
- SL.notify("Searching Firefox Bookmarks", term)
96
- tmpfile = "#{src}.tmp"
97
- FileUtils.cp(src, tmpfile)
98
-
99
- terms = []
100
- terms.push("(h.url NOT LIKE '%search/?%'
101
- AND h.url NOT LIKE '%?q=%'
102
- AND h.url NOT LIKE '%?s=%'
103
- AND h.url NOT LIKE '%duckduckgo.com/?t%')")
104
- if exact_match
105
- terms.push("(h.url LIKE '%#{term.strip.downcase}%' OR h.title LIKE '%#{term.strip.downcase}%')")
106
- else
107
- terms.concat(term.split(/\s+/).map do |t|
108
- "(h.url LIKE '%#{t.strip.downcase}%' OR h.title LIKE '%#{t.strip.downcase}%')"
109
- end)
110
- terms.concat(match_phrases.map do |t|
111
- "(h.url LIKE '%#{t[0].strip.downcase}%' OR h.title LIKE '%#{t[0].strip.downcase}%')"
112
- end)
113
- end
114
+ # Search the history of a Mozilla-based browser
115
+ #
116
+ # @param src [String, false] path to places.sqlite
117
+ # @param term [String] the search terms
118
+ # @param browser [String] browser name for notifications
119
+ #
120
+ # @return [Array, false] [url, title, date] or false
121
+ #
122
+ def search_mozilla_history(src, term, browser)
123
+ return false unless src && File.exist?(src)
124
+
125
+ SL.notify("Searching #{browser} History", term)
126
+ query = mozilla_query(term, "moz_places.url", "moz_places.title")
127
+ mark = query_places(src, "select moz_places.title, moz_places.url,
128
+ datetime(moz_historyvisits.visit_date/1000000, 'unixepoch', 'localtime') as datum
129
+ from moz_places, moz_historyvisits where moz_places.id = moz_historyvisits.place_id
130
+ and #{query} order by datum desc limit 1;")
131
+ return false unless mark
114
132
 
115
- query = terms.join(" AND ")
133
+ [mark["url"], mark["title"], Time.parse(mark["datum"])]
134
+ end
116
135
 
117
- most_recent = `sqlite3 -json '#{tmpfile}' "select h.url, b.title,
136
+ # Search the bookmarks of a Mozilla-based browser
137
+ #
138
+ # @param src [String, false] path to places.sqlite
139
+ # @param term [String] the search terms
140
+ # @param browser [String] browser name for notifications
141
+ #
142
+ # @return [Array, false] [url, title, date, score] or false
143
+ #
144
+ def search_mozilla_bookmarks(src, term, browser)
145
+ return false unless src && File.exist?(src)
146
+
147
+ SL.notify("Searching #{browser} Bookmarks", term)
148
+ query = mozilla_query(term, "h.url", "h.title")
149
+ mark = query_places(src, "select h.url, b.title,
118
150
  datetime(b.dateAdded/1000000, 'unixepoch', 'localtime') as datum
119
151
  FROM moz_places h JOIN moz_bookmarks b ON h.id = b.fk
120
- where #{query} order by datum desc limit 1 COLLATE NOCASE;"`.strip
121
- FileUtils.rm_f(tmpfile)
152
+ where #{query} order by datum desc limit 1;")
153
+ return false unless mark
122
154
 
123
- return false if most_recent.strip.empty?
124
-
125
- bm = JSON.parse(most_recent)[0]
155
+ score = score_mark({ url: mark["url"], title: mark["title"] }, term)
156
+ [mark["url"], mark["title"], Time.parse(mark["datum"]), score]
157
+ end
126
158
 
127
- date = Time.parse(bm["datum"])
128
- score = score_mark({ url: bm["url"], title: bm["title"] }, term)
129
- [bm["url"], bm["title"], date, score]
159
+ # Build the WHERE clause for a places.sqlite search. Terms starting with
160
+ # a single quote match the exact string, %22-quoted phrases must match
161
+ # as a whole, and all other words must each match the url or title.
162
+ #
163
+ # @param term [String] the search terms
164
+ # @param url_col [String] the url column
165
+ # @param title_col [String] the title column
166
+ #
167
+ # @return [String] SQL conditions
168
+ #
169
+ def mozilla_query(term, url_col, title_col)
170
+ words = []
171
+ case term
172
+ when /^ *'/
173
+ words << term.gsub(/(^ *'+|'+ *$)/, "")
174
+ when /%22(.*?)%22/
175
+ words.concat(term.scan(/%22(\S.*?\S)%22/).flatten)
176
+ words.concat(term.gsub(/%22(\S.*?\S)%22/, "").split(/\s+/))
130
177
  else
131
- false
178
+ words.concat(term.split(/\s+/))
132
179
  end
180
+
181
+ conditions = ["(#{url_col} NOT LIKE '%search/?%'
182
+ AND #{url_col} NOT LIKE '%?q=%'
183
+ AND #{url_col} NOT LIKE '%?s=%'
184
+ AND #{url_col} NOT LIKE '%duckduckgo.com/?t%')"]
185
+ words.map(&:strip).reject(&:empty?).each do |word|
186
+ like = "'%#{word.downcase.gsub("'", "''")}%'"
187
+ conditions << "(#{url_col} LIKE #{like} OR #{title_col} LIKE #{like})"
188
+ end
189
+ conditions.join(" AND ")
190
+ end
191
+
192
+ # Run a query against a copy of places.sqlite, which the browser keeps
193
+ # locked while running. The write-ahead log is copied too so recent
194
+ # visits are included.
195
+ #
196
+ # @param src [String] path to places.sqlite
197
+ # @param sql [String] the query
198
+ #
199
+ # @return [Hash, nil] the first result row
200
+ #
201
+ def query_places(src, sql)
202
+ tmpfile = File.join(Dir.tmpdir, "searchlink-places-#{Process.pid}.sqlite")
203
+ FileUtils.cp(src, tmpfile)
204
+ FileUtils.cp("#{src}-wal", "#{tmpfile}-wal") if File.exist?("#{src}-wal")
205
+
206
+ out, status = Open3.capture2("sqlite3", "-json", tmpfile, sql)
207
+ return nil unless status.success? && !out.strip.empty?
208
+
209
+ JSON.parse(out).first
210
+ ensure
211
+ FileUtils.rm_f([tmpfile, "#{tmpfile}-wal", "#{tmpfile}-shm"]) if tmpfile
133
212
  end
134
213
  end
135
214
  end
@@ -12,10 +12,20 @@ require_relative "helpers/safari"
12
12
  module SL
13
13
  # Browser history/bookmark search
14
14
  class HistorySearch
15
+ BROWSERS = {
16
+ "s" => "safari",
17
+ "c" => "chrome",
18
+ "f" => "firefox",
19
+ "z" => "zen",
20
+ "e" => "edge",
21
+ "b" => "brave",
22
+ "a" => "arc"
23
+ }.freeze
24
+
15
25
  class << self
16
26
  def settings
17
27
  {
18
- trigger: "h(([scfabe])([hb])?)*",
28
+ trigger: "h(([scfzabe])([hb])?)*",
19
29
  searches: [
20
30
  ["h", "Browser History/Bookmark Search"],
21
31
  ["hsh", "Safari History Search"],
@@ -30,6 +40,10 @@ module SL
30
40
  ["hfb", "Firefox Bookmark Search"],
31
41
  ["hfhb", nil],
32
42
  ["hfbh", nil],
43
+ ["hzh", "Zen History Search"],
44
+ ["hzb", "Zen Bookmark Search"],
45
+ ["hzhb", nil],
46
+ ["hzbh", nil],
33
47
  ["hah", "Arc History Search"],
34
48
  ["hab", "Arc Bookmark Search"],
35
49
  ["hahb", nil],
@@ -59,6 +73,8 @@ module SL
59
73
  chrome_bookmarks
60
74
  firefox_bookmarks
61
75
  firefox_history
76
+ zen_bookmarks
77
+ zen_history
62
78
  edge_bookmarks
63
79
  edge_history
64
80
  brave_bookmarks
@@ -72,92 +88,30 @@ module SL
72
88
  end
73
89
 
74
90
  def search(search_type, search_terms, link_text)
75
- str = search_type.match(/^h(([scfabe])([hb])?)*$/)[1]
91
+ url, title = search_history(search_terms, history_types_for(search_type))
92
+ link_text = title if link_text == ""
93
+ [url, title, link_text]
94
+ end
76
95
 
96
+ # Convert a search type such as "hshcb" into history types. Each browser
97
+ # letter can be followed by "h" (history), "b" (bookmarks), or both;
98
+ # no suffix searches both.
99
+ #
100
+ # @param search_type [String] the search type, e.g. "hzh"
101
+ #
102
+ # @return [Array<String>] history types, e.g. ["zen_history"]
103
+ #
104
+ def history_types_for(search_type)
77
105
  types = []
78
- while str&.length&.positive?
79
- if str =~ /^s([hb]*)/
80
- t = Regexp.last_match(1)
81
- if t.length > 1 || t.empty?
82
- types.push("safari_history")
83
- types.push("safari_bookmarks")
84
- elsif t == "h"
85
- types.push("safari_history")
86
- elsif t == "b"
87
- types.push("safari_bookmarks")
88
- end
89
- str.sub!(/^s([hb]*)/, "")
90
- end
91
-
92
- if str =~ /^c([hb]*)/
93
- t = Regexp.last_match(1)
94
- if t.length > 1 || t.empty?
95
- types.push("chrome_bookmarks")
96
- types.push("chrome_history")
97
- elsif t == "h"
98
- types.push("chrome_history")
99
- elsif t == "b"
100
- types.push("chrome_bookmarks")
101
- end
102
- str.sub!(/^c([hb]*)/, "")
103
- end
104
-
105
- if str =~ /^f([hb]*)$/
106
- t = Regexp.last_match(1)
107
- if t.length > 1 || t.empty?
108
- types.push("firefox_bookmarks")
109
- types.push("firefox_history")
110
- elsif t == "h"
111
- types.push("firefox_history")
112
- elsif t == "b"
113
- types.push("firefox_bookmarks")
114
- end
115
- str.sub!(/^f([hb]*)/, "")
116
- end
117
-
118
- if str =~ /^e([hb]*)$/
119
- t = Regexp.last_match(1)
120
- if t.length > 1 || t.empty?
121
- types.push("edge_bookmarks")
122
- types.push("edge_history")
123
- elsif t == "h"
124
- types.push("edge_history")
125
- elsif t == "b"
126
- types.push("edge_bookmarks")
127
- end
128
- str.sub!(/^e([hb]*)/, "")
129
- end
130
-
131
- if str =~ /^b([hb]*)$/
132
- t = Regexp.last_match(1)
133
- if t.length > 1 || t.empty?
134
- types.push("brave_bookmarks")
135
- types.push("brave_history")
136
- elsif t == "h"
137
- types.push("brave_history")
138
- elsif t == "b"
139
- types.push("brave_bookmarks")
140
- end
141
- str.sub!(/^b([hb]*)/, "")
106
+ search_type.sub(/^h/, "").scan(/([scfzeba])([hb]*)/) do |browser, kinds|
107
+ name = BROWSERS[browser]
108
+ case kinds
109
+ when "h" then types.push("#{name}_history")
110
+ when "b" then types.push("#{name}_bookmarks")
111
+ else types.push("#{name}_history", "#{name}_bookmarks")
142
112
  end
143
-
144
- next unless str =~ /^a([hb]*)$/
145
-
146
- t = Regexp.last_match(1)
147
- if t.length > 1 || t.empty?
148
- types.push("arc_bookmarks")
149
- types.push("arc_history")
150
- elsif t == "h"
151
- types.push("arc_history")
152
- elsif t == "b"
153
- types.push("arc_bookmarks")
154
- end
155
- str.sub!(/^a([hb]*)/, "")
156
113
  end
157
-
158
- url, title = search_history(search_terms, types)
159
- link_text = title if link_text == ""
160
- [url, title, link_text]
114
+ types
161
115
  end
162
116
 
163
117
  def search_history(term, types = [])
@@ -0,0 +1,200 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SL
4
+ # Kagi Search and FastGPT
5
+ class KagiSearch
6
+ class << self
7
+ # Returns the settings hash for the KagiSearch plugin.
8
+ #
9
+ # @return [Hash] settings including trigger, searches, and config entries
10
+ #
11
+ def settings
12
+ {
13
+ trigger: "(?:k|gpt)",
14
+ searches: [
15
+ ["k", "Kagi Search"],
16
+ ["gpt", "Kagi FastGPT Embed"]
17
+ ],
18
+ config: [
19
+ {
20
+ key: "kagi_api_key",
21
+ value: "xxxxxx",
22
+ required: false,
23
+ description: "Kagi Bot API key for paid search and FastGPT (see https://kagi.com/settings/api)"
24
+ }
25
+ ]
26
+ }
27
+ end
28
+
29
+ # Returns true if a usable Kagi API key is configured.
30
+ #
31
+ # @return [Boolean] true when a non-placeholder API key is present
32
+ #
33
+ def api_key?
34
+ return false unless SL.config.key?("kagi_api_key") && SL.config["kagi_api_key"]
35
+
36
+ key = SL.config["kagi_api_key"].to_s.strip
37
+ return false if key.empty? || key =~ /^(x{4,})?$/i
38
+
39
+ @api_key = key
40
+
41
+ true
42
+ end
43
+
44
+ # Perform a Kagi search or FastGPT request.
45
+ #
46
+ # @param search_type [String] the search shortcut ("k" or "gpt")
47
+ # @param search_terms [String] the search terms or FastGPT query
48
+ # @param link_text [String] the link text to display
49
+ #
50
+ # @return [Array] result array suitable for SearchLink: [url_or_embed, title_or_body, link_text]
51
+ #
52
+ def search(search_type, search_terms, link_text)
53
+ case search_type
54
+ when /^k$/
55
+ kagi_search(search_terms, link_text)
56
+ when /^gpt$/
57
+ kagi_fastgpt(search_terms, link_text)
58
+ else
59
+ SL.ddg(search_terms, link_text)
60
+ end
61
+ rescue StandardError => e
62
+ SL.notify("Kagi error", "Error fetching Kagi results, switching to DuckDuckGo")
63
+ SL.add_error("Kagi error", e.message)
64
+ SL.ddg(search_terms, link_text)
65
+ end
66
+
67
+ # Perform a Kagi web search for the given terms.
68
+ #
69
+ # @param search_terms [String] the terms to search for
70
+ # @param link_text [String] the text to display for the link
71
+ #
72
+ # @return [Array] [url, title, link_text] or DuckDuckGo fallback
73
+ #
74
+ def kagi_search(search_terms, link_text)
75
+ unless api_key?
76
+ SL.notify("Kagi not configured", "Missing Kagi API key, defaulting to DuckDuckGo")
77
+ return SL.ddg(search_terms, link_text, google: false)
78
+ end
79
+
80
+ query = ERB::Util.url_encode(search_terms.gsub("%22", '"'))
81
+ url = "https://kagi.com/api/v0/search?q=#{query}&limit=1"
82
+
83
+ headers = {
84
+ "Authorization" => "Bot #{@api_key}"
85
+ }
86
+
87
+ res = Curl::Json.new(url, headers: headers, symbolize_names: true)
88
+ return SL.ddg(search_terms, link_text, google: false) unless res && res.code.to_i == 200 && res.json
89
+
90
+ data = res.json[:data]
91
+ return SL.ddg(search_terms, link_text, google: false) unless data.is_a?(Array) && data.first.is_a?(Hash)
92
+
93
+ first = data.first
94
+ output_url = first[:url]
95
+ output_title = first[:title] || link_text
96
+
97
+ return SL.ddg(search_terms, link_text, google: false) unless output_url
98
+
99
+ if defined?(SL.config) && SL.config.respond_to?(:[]) && output_title && SL.config["remove_seo"]
100
+ output_title.remove_seo!(output_url)
101
+ end
102
+
103
+ [output_url, output_title, link_text]
104
+ rescue StandardError
105
+ SL.notify("Kagi error", "Error fetching Kagi results, switching to DuckDuckGo")
106
+ SL.ddg(search_terms, link_text, google: false)
107
+ end
108
+
109
+ # Perform a Kagi FastGPT query and return an embed response.
110
+ #
111
+ # @param search_terms [String] the FastGPT query
112
+ # @param link_text [String] the text to display for the link
113
+ #
114
+ # @return [Array] ["embed", embed_markdown, link_text]
115
+ #
116
+ def kagi_fastgpt(search_terms, link_text)
117
+ return ["embed", fastgpt_fallback(search_terms, "Kagi FastGPT is not configured."), link_text] unless api_key?
118
+
119
+ headers = {
120
+ "Authorization" => "Bot #{@api_key}",
121
+ "Content-Type" => "application/json"
122
+ }
123
+ body = { query: search_terms }.to_json
124
+
125
+ res = Curl::Json.new("https://kagi.com/api/v0/fastgpt", data: body, headers: headers, symbolize_names: true)
126
+ return ["embed", fastgpt_fallback(search_terms, "Kagi FastGPT request failed."), link_text] unless res && res.code.to_i == 200 && res.json
127
+
128
+ data = res.json[:data]
129
+ return ["embed", fastgpt_fallback(search_terms, "Kagi FastGPT response missing data."), link_text] unless data.is_a?(Hash)
130
+
131
+ output = data[:output].to_s
132
+ references = data[:references] || []
133
+
134
+ embed = build_fastgpt_embed(output, references, search_terms)
135
+ ["embed", embed, link_text]
136
+ rescue StandardError
137
+ ["embed", fastgpt_fallback(search_terms, "Kagi FastGPT encountered an error."), link_text]
138
+ end
139
+
140
+ # Build a Markdown embed string from FastGPT output and references.
141
+ #
142
+ # @param output [String] main answer text
143
+ # @param references [Array<Hash>] reference entries with :title, :snippet, and :url
144
+ # @param query [String] original query string
145
+ #
146
+ # @return [String] Markdown-formatted embed content
147
+ #
148
+ def build_fastgpt_embed(output, references, query)
149
+ header = "### Answer for: #{query}\n\n"
150
+ body = clean_fastgpt_output(output.to_s)
151
+
152
+ refs_section = ""
153
+ if references.is_a?(Array) && !references.empty?
154
+ lines = references.each_with_index.map do |ref, _idx|
155
+ url = ref[:url] || ref["url"]
156
+ next unless url
157
+
158
+ title = ref[:title] || ref["title"] || url
159
+ "- [#{title}](#{url})"
160
+ end.compact
161
+
162
+ refs_section = "\n\n**References:**\n#{lines.join("\n")}" unless lines.empty?
163
+ end
164
+
165
+ header + body + refs_section
166
+ end
167
+
168
+ # Build a fallback embed body when FastGPT is unavailable.
169
+ #
170
+ # @param query [String] the original query
171
+ # @param message [String] description of the failure
172
+ #
173
+ # @return [String] fallback embed content
174
+ #
175
+ def fastgpt_fallback(query, message)
176
+ "### Answer for: #{query}\n\n#{message}"
177
+ end
178
+
179
+ # Clean FastGPT output by removing in-text numeric reference markers and
180
+ # normalizing whitespace.
181
+ #
182
+ # @param output [String] raw FastGPT output text
183
+ #
184
+ # @return [String] cleaned output
185
+ #
186
+ def clean_fastgpt_output(output)
187
+ cleaned = output.dup
188
+ # Remove reference markers like [1], [23], and full-width 【1】, possibly with surrounding spaces
189
+ cleaned.gsub!(/\s*[\[\u3010]\d+[\]\u3011]\s*/, " ")
190
+ # Compress runs of whitespace to a single space, but preserve newlines
191
+ cleaned.gsub!(/[ \t]+/, " ")
192
+ # Remove spaces directly before sentence punctuation
193
+ cleaned.gsub!(/ +([.!?;:,])/, '\1')
194
+ cleaned.strip
195
+ end
196
+ end
197
+
198
+ SL::Searches.register "kagi", :search, self
199
+ end
200
+ end
@@ -1,8 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SL
4
+ # Search a Linkding instance.
5
+ #
6
+ # Linkding authenticates with an API token and lists bookmarks at
7
+ # GET /api/bookmarks/. The q parameter uses the same search syntax as the
8
+ # Linkding UI (words, "phrases", #tags, and/or/not). Results are ranked
9
+ # the same way as Pinboard: an exact title or tag match wins, then an exact
10
+ # match in the description or notes, then the newest partial match.
4
11
  class LinkdingSearch
5
- LINKDING_CACHE = SL::Util.cache_file_for("linkding")
12
+ PAGE_SIZE = 100
13
+ MAX_PAGES = 10
6
14
 
7
15
  class << self
8
16
  def settings
@@ -13,13 +21,13 @@ module SL
13
21
  ],
14
22
  config: [
15
23
  {
16
- description: "Linkding server URL.",
24
+ description: "Linkding server URL, for example https://links.example.com",
17
25
  key: "linkding_server",
18
26
  value: "''",
19
27
  required: true
20
28
  },
21
29
  {
22
- description: "Linkding API key.\nYou can find your api key here: https://your_server/settings/integrations",
30
+ description: "Linkding API token.\nCreate one under Settings, Integrations on your Linkding server.",
23
31
  key: "linkding_api_key",
24
32
  value: "''",
25
33
  required: true
@@ -28,197 +36,157 @@ module SL
28
36
  }
29
37
  end
30
38
 
31
- def get_json(call)
32
- curl = TTY::Which.which("curl")
33
- bookmarks = `#{curl} -SsL -H "Authorization: Token #{SL.config["linkding_api_key"]}" "#{SL.config["linkding_server"]}#{call}"`
34
-
35
- bookmarks = bookmarks.force_encoding("utf-8")
36
- bookmarks.gsub!(/[^[:ascii:]]/) do |non_ascii|
37
- non_ascii.force_encoding("utf-8")
38
- .encode("utf-16be")
39
- .unpack1("H*")
40
- .gsub(/(....)/, '\u\1')
39
+ # Search Linkding bookmarks.
40
+ #
41
+ # Begin the query with ' to require an exact phrase. Quoted phrases,
42
+ # #tags, and and/or/not are passed through to Linkding.
43
+ #
44
+ # @return [Array, false] [url, title, link_text] or false when nothing matches
45
+ def search(_, search_terms, link_text)
46
+ unless server
47
+ SL.add_error("Missing Linkding server",
48
+ "Add your server URL to the configuration (linkding_server: https://YOUR_SERVER)")
49
+ return false
41
50
  end
42
51
 
43
- bookmarks.gsub!(/[\u{1F600}-\u{1F6FF}]/, "")
52
+ unless api_key
53
+ SL.add_error("Missing Linkding API token",
54
+ "Create a token under Settings, Integrations and add it to the configuration (linkding_api_key: YOURKEY)")
55
+ return false
56
+ end
44
57
 
45
- JSON.parse(bookmarks)
46
- end
58
+ query, exact = normalize_query(search_terms)
59
+ bookmarks = search_bookmarks(query)
60
+ return false if bookmarks.nil? || bookmarks.empty?
47
61
 
48
- def get_linkding_bookmarks
49
- TTY::Which.which("curl")
50
- call = "/api/bookmarks/?limit=8000&format=json"
62
+ best = best_bookmark(bookmarks, query, exact: exact)
63
+ return false unless best
51
64
 
52
- json = get_json(call)
53
- bookmarks = json["results"]
54
- offset = 0
65
+ [best["url"], bookmark_title(best), link_text]
66
+ end
55
67
 
56
- while json["next"]
57
- offset += 8000
58
- json = get_json(call + "&offset=#{offset}")
59
- bookmarks.concat(json["results"])
60
- end
68
+ def server
69
+ value = configured_value("linkding_server")
70
+ return nil unless value
61
71
 
62
- bookmarks
72
+ value.sub(%r{/+\z}, "").sub(%r{/api\z}i, "")
63
73
  end
64
74
 
65
- def linkding_bookmarks
66
- bookmarks = get_linkding_bookmarks
67
- updated = Time.now
68
- { "update_time" => updated, "bookmarks" => bookmarks }
75
+ def api_key
76
+ configured_value("linkding_api_key")
69
77
  end
70
78
 
71
- def save_linkding_cache(cache)
72
- cachefile = LINKDING_CACHE
79
+ private
73
80
 
74
- # file = File.new(cachefile,'w')
75
- # file = Zlib::GzipWriter.new(File.new(cachefile,'w'))
76
- begin
77
- File.open(cachefile, "wb") { |f| f.write(Marshal.dump(cache)) }
78
- rescue IOError
79
- SL.add_error("Linkding cache error", "Failed to write stash to disk")
80
- return false
81
- end
82
- true
81
+ def configured_value(key)
82
+ value = SL.config[key].to_s.strip
83
+ return nil if value.empty? || value == "''"
84
+
85
+ value
83
86
  end
84
87
 
85
- def linkding_cache
86
- refresh_cache = false
87
- cachefile = LINKDING_CACHE
88
-
89
- if File.exist?(cachefile)
90
- begin
91
- # file = IO.read(cachefile) # Zlib::GzipReader.open(cachefile)
92
- # cache = Marshal.load file
93
- cache = Marshal.load(File.binread(cachefile))
94
- # file.close
95
- rescue IOError # Zlib::GzipFile::Error
96
- SL.add_error("Error loading linkding cache", "IOError reading #{cachefile}")
97
- cache = linkding_bookmarks
98
- save_linkding_cache(cache)
99
- rescue StandardError
100
- SL.add_error("Error loading linkding cache", "StandardError reading #{cachefile}")
101
- cache = linkding_bookmarks
102
- save_linkding_cache(cache)
103
- end
104
- TTY::Which.which("curl")
105
- updated = get_json("/api/bookmarks/?limit=1&format=json")["results"][0]
106
- last_bookmark = Time.parse(updated["date_modified"])
107
- if cache&.key?("update_time")
108
- last_update = cache["update_time"]
109
- refresh_cache = true if last_update < last_bookmark
110
- else
111
- refresh_cache = true
112
- end
88
+ # A leading quote forces an exact phrase, matching Pinboard.
89
+ # %22 is accepted because some callers hand in an encoded query.
90
+ def normalize_query(search_terms)
91
+ terms = search_terms.to_s.gsub("%22", '"').strip
92
+ if terms =~ /\A'/
93
+ phrase = terms.gsub(/\A'+|'+\z/, "")
94
+ [%("#{phrase}"), true]
113
95
  else
114
- refresh_cache = true
96
+ [terms, false]
115
97
  end
98
+ end
116
99
 
117
- if refresh_cache
118
- cache = linkding_bookmarks
119
- save_linkding_cache(cache)
120
- end
100
+ def search_bookmarks(query)
101
+ active = fetch_pages("/api/bookmarks/", query)
102
+ return nil if active.nil?
103
+
104
+ archived = fetch_pages("/api/bookmarks/archived/", query)
105
+ return nil if archived.nil?
121
106
 
122
- cache
107
+ active + archived
123
108
  end
124
109
 
125
- # Search linkding bookmarks
126
- # Begin query with '' to force exact matching (including description text)
127
- # Regular matching searches for each word of query and scores the bookmarks
128
- # exact matches in title get highest score
129
- # exact matches in description get second highest score
130
- # other bookmarks are scored based on the number of words that match
131
- #
132
- # After sorting by score, bookmarks will be sorted by date and the most recent
133
- # will be returned
134
- #
135
- # Exact matching is case and punctuation insensitive
136
- def search(_, search_terms, link_text)
137
- unless SL.config["linkding_server"] && !SL.config["linkding_server"].empty?
138
- SL.add_error("Missing Linkding server",
139
- "add it to your configuration (linkding_server: https://YOUR_SERVER)")
140
- return false
141
- end
110
+ # Page with our own offset. Linkding's next URL can point at an
111
+ # internal hostname, so it is only used as a "more results" flag.
112
+ def fetch_pages(path, query)
113
+ bookmarks = []
114
+ offset = 0
142
115
 
143
- unless SL.config["linkding_api_key"] && !SL.config["linkding_api_key"].empty?
144
- SL.add_error("Missing Linkding API token",
145
- "Find your api key at https://your_server/settings/integrations and add it
146
- to your configuration (linkding_api_key: YOURKEY)")
147
- return false
148
- end
116
+ MAX_PAGES.times do
117
+ json = get_json(path, query, offset)
118
+ return nil if json.nil?
149
119
 
150
- exact_match = false
151
- match_phrases = []
152
-
153
- # If search terms start with ''term, only search for exact string matches
154
- case search_terms
155
- when /^ *'/
156
- exact_match = true
157
- search_terms.gsub!(/(^ *'+|'+ *$)/, "")
158
- when /%22(.*?)%22/
159
- match_phrases = search_terms.scan(/%22(\S.*?\S)%22/)
160
- search_terms.gsub!(/%22(\S.*?\S)%22/, "")
120
+ page = Array(json["results"])
121
+ bookmarks.concat(page)
122
+ offset += PAGE_SIZE
123
+ break if page.empty? || json["next"].nil? || bookmarks.size >= json["count"].to_i
161
124
  end
162
125
 
163
- cache = linkding_cache
164
- # cache = linkding_bookmarks
165
- bookmarks = cache["bookmarks"]
126
+ bookmarks
127
+ end
128
+
129
+ def get_json(path, query, offset)
130
+ url = "#{server}#{path}?q=#{query.url_encode}&limit=#{PAGE_SIZE}&offset=#{offset}&sort=added_desc"
131
+ res = Curl::Json.new(url, headers: { "Authorization" => "Token #{api_key}" })
132
+ return json_error(res) unless res&.code.to_s == "200" && res.json.is_a?(Hash)
166
133
 
167
- if exact_match
168
- bookmarks.each do |bm|
169
- text = [bm["title"], bm["description"], bm["tag_names"].join(" ")].join(" ")
134
+ res.json
135
+ rescue StandardError
136
+ SL.add_error("Linkding request failed", "Could not search #{server}")
137
+ nil
138
+ end
170
139
 
171
- return [bm["url"], bm["title"]] if text.matches_exact(search_terms)
172
- end
140
+ def json_error(res)
141
+ detail = res&.json.is_a?(Hash) ? res.json["detail"] : nil
142
+ SL.add_error("Linkding request failed", detail || "Could not search #{server}")
143
+ nil
144
+ end
173
145
 
174
- return false
175
- end
146
+ def best_bookmark(bookmarks, query, exact:)
147
+ terms = query.gsub(/\A["']+|["']+\z/, "")
148
+ matches = bookmarks.filter_map { |bm| score_bookmark(bm, terms, exact: exact) }
149
+ return nil if matches.empty? && exact
176
150
 
177
- unless match_phrases.empty?
178
- bookmarks.delete_if do |bm|
179
- matched = tru
180
- full_text = [bm["title"], bm["description"], bm["tag_names"].join(" ")].join(" ")
181
- match_phrases.each do |phrase|
182
- matched = false unless full_text.matches_exact(phrase)
183
- end
184
- !matched
185
- end
186
- end
151
+ picked = matches.max_by { |match| [match[:score], match[:date]] }
152
+ picked ? picked[:bookmark] : bookmarks.max_by { |bm| bm["date_added"].to_s }
153
+ end
187
154
 
188
- matches = []
189
- bookmarks.each do |bm|
190
- title_tags = [bm["title"], bm["description"]].join(" ")
191
- full_text = [bm["title"], bm["description"], bm["tag_names"].join(" ")].join(" ")
192
-
193
- score = if title_tags.matches_exact(search_terms)
194
- 14.0
195
- elsif full_text.matches_exact(search_terms)
196
- 13.0
197
- elsif full_text.matches_any(search_terms)
198
- full_text.matches_score(search_terms)
199
- else
200
- 0
201
- end
202
-
203
- return [bm["url"], bm["title"]] if score == 14
204
-
205
- next unless score.positive?
206
-
207
- matches.push({
208
- score: score,
209
- href: bm["url"],
210
- title: bm["title"],
211
- date: bm["date_added"]
212
- })
155
+ def score_bookmark(bookmark, terms, exact:)
156
+ title_tags = bookmark_text(bookmark, "title", "tag_names")
157
+ full_text = bookmark_text(bookmark, "title", "description", "notes", "tag_names", "url")
158
+
159
+ if exact
160
+ return nil unless full_text.matches_exact(terms)
161
+
162
+ return { score: 14.0, date: bookmark["date_added"].to_s, bookmark: bookmark }
213
163
  end
214
164
 
215
- return false if matches.empty?
165
+ score = if title_tags.matches_exact(terms)
166
+ 14.0
167
+ elsif full_text.matches_exact(terms)
168
+ 13.0
169
+ elsif full_text.matches_any(terms)
170
+ full_text.matches_score(terms)
171
+ else
172
+ 0
173
+ end
174
+ return nil unless score.positive?
175
+
176
+ { score: score, date: bookmark["date_added"].to_s, bookmark: bookmark }
177
+ end
216
178
 
217
- top = matches.max_by { |bm| [bm[:score], bm[:date]] }
179
+ def bookmark_text(bookmark, *fields)
180
+ fields.map { |field| field_text(bookmark[field]) }.join(" ")
181
+ end
218
182
 
219
- return false unless top
183
+ def field_text(value)
184
+ value.is_a?(Array) ? value.join(" ") : value.to_s
185
+ end
220
186
 
221
- [top[:href], top[:title], link_text]
187
+ def bookmark_title(bookmark)
188
+ title = bookmark["title"].to_s.strip
189
+ title.empty? ? bookmark["url"] : title
222
190
  end
223
191
  end
224
192
 
@@ -42,17 +42,35 @@ module SL
42
42
  "Authorization" => "Bearer #{SL.config['tinyurl_access_token']}"
43
43
  }
44
44
  data_obj = {
45
- "url" => url
45
+ "url" => url,
46
+ "domain" => "tinyurl.com"
46
47
  }
47
- data = Curl::Json.new("https://tinyurl.ph/api/url/add", data: data_obj.to_json, headers: headers,
48
- symbolize_names: true)
48
+ data = Curl::Json.new("https://api.tinyurl.com/create",
49
+ data: data_obj.to_json,
50
+ headers: headers,
51
+ symbolize_names: true)
49
52
 
50
- if data.json[:error].positive?
51
- SL.add_error("Error creating tinyurl", data.json[:error])
52
- return false
53
+ return false unless data && data.json
54
+
55
+ json = data.json
56
+
57
+ if json.is_a?(Hash)
58
+ errors = json[:errors]
59
+ code = json[:code]
60
+ if (errors.is_a?(Array) && !errors.empty?) || (code && code.to_i != 0)
61
+ SL.add_error("Error creating tinyurl", errors || code)
62
+ return false
63
+ end
64
+
65
+ data_hash = json[:data] || {}
66
+ link = data_hash[:tiny_url]
67
+ else
68
+ link = false
53
69
  end
54
70
 
55
- data.json[:shorturl]
71
+ return false unless link && SL::URL.valid_link?(link)
72
+
73
+ link
56
74
  end
57
75
 
58
76
  def tinyurl_config?
@@ -202,6 +202,9 @@ require_relative "searches/duckduckgo"
202
202
  # import
203
203
  require_relative "searches/github"
204
204
 
205
+ # import
206
+ require_relative "searches/kagi"
207
+
205
208
  # import
206
209
  require_relative "searches/google"
207
210
 
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SL
4
- VERSION = '2.3.89'
4
+ VERSION = '2.3.93'
5
5
  end
6
6
 
7
7
  # Main module
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: searchlink
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.89
4
+ version: 2.3.93
5
5
  platform: ruby
6
6
  authors:
7
7
  - Brett Terpstra
8
+ autorequire:
8
9
  bindir: bin
9
10
  cert_chain: []
10
- date: 2025-05-02 00:00:00.000000000 Z
11
+ date: 2026-10-02 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: base64
@@ -300,6 +301,7 @@ files:
300
301
  - lib/searchlink/searches/history.rb
301
302
  - lib/searchlink/searches/hook.rb
302
303
  - lib/searchlink/searches/itunes.rb
304
+ - lib/searchlink/searches/kagi.rb
303
305
  - lib/searchlink/searches/lastfm.rb
304
306
  - lib/searchlink/searches/linkding.rb
305
307
  - lib/searchlink/searches/lyrics.rb
@@ -331,6 +333,7 @@ homepage: https://github.com/ttscoff/searchlink
331
333
  licenses:
332
334
  - MIT
333
335
  metadata: {}
336
+ post_install_message:
334
337
  rdoc_options: []
335
338
  require_paths:
336
339
  - lib
@@ -345,7 +348,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
345
348
  - !ruby/object:Gem::Version
346
349
  version: '0'
347
350
  requirements: []
348
- rubygems_version: 3.6.6
351
+ rubygems_version: 3.5.3
352
+ signing_key:
349
353
  specification_version: 4
350
354
  summary: Create Markdown links from web searches without leaving your editor.
351
355
  test_files: []