searchlink 2.3.92 → 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 +4 -4
- data/lib/searchlink/searches/helpers/firefox.rb +186 -107
- data/lib/searchlink/searches/history.rb +37 -83
- data/lib/searchlink/searches/kagi.rb +200 -0
- data/lib/searchlink/searches/linkding.rb +128 -160
- data/lib/searchlink/searches/shorteners/tinyurl.rb +25 -7
- data/lib/searchlink/searches.rb +3 -0
- data/lib/searchlink/version.rb +1 -1
- metadata +7 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 26563c3d6842264d4ee543dbac0cd7be148658c13b2020cc09009e94c1d9d8a0
|
|
4
|
+
data.tar.gz: 49c8165891b3f5713b2aada1db49bae241d7f9c41f92fe24f14af03e68930364
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 45b47101b11cb81b5b0ca2b74a5370a396cc7a1a95d0d27650498bf9cce8de590cbb855d8f9c9601aa75cffcb1911b23c44de53478b9797c4adc24356dd520cf
|
|
7
|
+
data.tar.gz: 8c2b3f6db03a379899ac14cc9ec21134e46f322f31080843c425a06f234cb245f6e1ccef1ead6100c71a795a9a4656845c6eb0ca4855c3a3fb29d8c02d3dc7ab
|
|
@@ -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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
91
|
+
absolute = ->(path) { path.start_with?("/") ? path : File.join(base, path) }
|
|
66
92
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
133
|
+
[mark["url"], mark["title"], Time.parse(mark["datum"])]
|
|
134
|
+
end
|
|
116
135
|
|
|
117
|
-
|
|
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
|
|
121
|
-
|
|
152
|
+
where #{query} order by datum desc limit 1;")
|
|
153
|
+
return false unless mark
|
|
122
154
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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(([
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
58
|
+
query, exact = normalize_query(search_terms)
|
|
59
|
+
bookmarks = search_bookmarks(query)
|
|
60
|
+
return false if bookmarks.nil? || bookmarks.empty?
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
call = "/api/bookmarks/?limit=8000&format=json"
|
|
62
|
+
best = best_bookmark(bookmarks, query, exact: exact)
|
|
63
|
+
return false unless best
|
|
51
64
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
offset = 0
|
|
65
|
+
[best["url"], bookmark_title(best), link_text]
|
|
66
|
+
end
|
|
55
67
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
bookmarks.concat(json["results"])
|
|
60
|
-
end
|
|
68
|
+
def server
|
|
69
|
+
value = configured_value("linkding_server")
|
|
70
|
+
return nil unless value
|
|
61
71
|
|
|
62
|
-
|
|
72
|
+
value.sub(%r{/+\z}, "").sub(%r{/api\z}i, "")
|
|
63
73
|
end
|
|
64
74
|
|
|
65
|
-
def
|
|
66
|
-
|
|
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
|
-
|
|
72
|
-
cachefile = LINKDING_CACHE
|
|
79
|
+
private
|
|
73
80
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
96
|
+
[terms, false]
|
|
115
97
|
end
|
|
98
|
+
end
|
|
116
99
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
107
|
+
active + archived
|
|
123
108
|
end
|
|
124
109
|
|
|
125
|
-
#
|
|
126
|
-
#
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
134
|
+
res.json
|
|
135
|
+
rescue StandardError
|
|
136
|
+
SL.add_error("Linkding request failed", "Could not search #{server}")
|
|
137
|
+
nil
|
|
138
|
+
end
|
|
170
139
|
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
179
|
+
def bookmark_text(bookmark, *fields)
|
|
180
|
+
fields.map { |field| field_text(bookmark[field]) }.join(" ")
|
|
181
|
+
end
|
|
218
182
|
|
|
219
|
-
|
|
183
|
+
def field_text(value)
|
|
184
|
+
value.is_a?(Array) ? value.join(" ") : value.to_s
|
|
185
|
+
end
|
|
220
186
|
|
|
221
|
-
|
|
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.
|
|
48
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
71
|
+
return false unless link && SL::URL.valid_link?(link)
|
|
72
|
+
|
|
73
|
+
link
|
|
56
74
|
end
|
|
57
75
|
|
|
58
76
|
def tinyurl_config?
|
data/lib/searchlink/searches.rb
CHANGED
data/lib/searchlink/version.rb
CHANGED
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.
|
|
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:
|
|
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.
|
|
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: []
|