├── README.md ├── browser_tabs.js ├── browser_tabs.json ├── calendar.rb ├── custom-google.rb ├── imdb.rb ├── lyrics.rb ├── makeadate.rb ├── manpage.rb ├── mixcase.rb ├── shellsnip.rb ├── shorten_amazon.rb ├── stretchlink.rb ├── test.sh ├── test.yaml ├── test2.sh └── weather.rb /README.md: -------------------------------------------------------------------------------- 1 | # SearchLink Plugins 2 | 3 | 4 | A collection of plugins for [SearchLink](https://brettterpstra.com/projects/searchlink/ "SearchLink"). 5 | 6 | ## Installation 7 | 8 | Just save any of these plugins to `~/.local/searchlink/plugins` and SearchLink will add them as valid searches. You could, if you wanted to, clone the entire project to that folder with: 9 | 10 | ``` 11 | $ mkdir -p ~/.local/searchlink 12 | $ cd ~/.local/searchlink 13 | $ git clone git@github.com:ttscoff/searchlink-plugins.git plugins 14 | ``` 15 | 16 | > If you intend to develop your own plugins or make modifications to existing ones, fork the repo first and close that to your local drive, allowing you to make pull requests from your fork. 17 | 18 | ## About Plugins 19 | 20 | All of [SearchLink's searches](https://github.com/ttscoff/searchlink/tree/main/lib/searchlink/searches) are defined as [plugins](https://github.com/ttscoff/searchlink/wiki/Plugins). You can duplicate any of them into the plugins folder and override default functionality, or use them as examples for developing your own. 21 | 22 | ## Plugins 23 | 24 | ### Calendar 25 | 26 | Another example of a text filter. This one can insert a Markdown calendar for any month and year. You can define the month and year like `!cal 5 2024` to get a calendar for May, 2024. If you use `!cal now` it will insert a calendar for the current month and year. It can also print how many days are in a month with `!days 2 2024` to show how many days are in February, 2024. Silly, and would probably be better as a TextExpander snippet, but I'm just experimenting with extending SearchLink. 27 | 28 | ### Custom Google 29 | 30 | This plugin demonstrates adding your own custom search engine through Google. Create a [custom engine here](https://programmablesearchengine.google.com/controlpanel/all) to search specific pages/sites, then modify the plugin according to the comments at the top to create your own custom site search. 31 | 32 | Requires a [Google API key](https://github.com/ttscoff/searchlink/wiki/Using-Google-Search). 33 | 34 | ### IMDB 35 | 36 | This plugin uses a custom site search from Google Programmable Search to search IMDB, actors, or titles. Just another demo of how to use custom engines to create plugins. Requires a [Google API key](https://github.com/ttscoff/searchlink/wiki/Using-Google-Search). 37 | 38 | Adds searches `!imdb`, `!imdba` (actor search), and `!imdbt` (tv/movie title search). 39 | 40 | ### Lyrics 41 | 42 | This plugin will search for a song, returning either a link (`!lyric`) or embedding the actual lyrics (`!lyrice`). It demonstrates both search and embed functionality, and is fully commented to serve as an example plugin. 43 | 44 | ### MakeADate 45 | 46 | This is a port of a TextExpander snippet I use. It takes a natural language date and inserts a formatted date. It provides the following formats: 47 | 48 | | Abbr | Result | 49 | | ---------------------- | -------------------------------------- | 50 | | `!ddate tomorrow 8am` | 2023-11-02 8:00am | 51 | | `!dshort tomorrow 8am` | 2023-11-2 8:00am | 52 | | `!diso tomorrow 8am` | 2023-11-02 08:00 | 53 | | `!dlong tomorrow 8am` | Thursday, November 2nd, 2023 at 8:00am | 54 | 55 | ***This plugin requires that PHP be installed on the system, either with the Apple Command Line Utilties (I think), or with Homebrew (`brew install php`).*** 56 | 57 | ### ManPage 58 | 59 | This plugin searches mapages.org for a matching command and returns the url, including command name and man section, e.g. `https://manpages.org/lsof/8`, as well as a title with a brief description of the command. So running SearchLink on `!man lsof` would return `[lsof](https://manpages.org/lsof/8 "lsof (8): list open files")`. If the search term contains multiple words, they'll be parsed individually until one of the words gets at least a partial search result, the shortest of which will be used. 60 | 61 | ### MixCase 62 | 63 | This plugin is a text filter that will turn `!mix A string of text` into `A STRInG of TExT`, randomly capitalizing characters. It's just to demonstrate how easily a text filter can be implemented. 64 | 65 | ### Setapp affiliate linking 66 | 67 | There are two plugins, bitly_setapp and isgd_setapp that will take an app name that's avaialable on Setapp and turn it into an affiliate link so you can make some money on recommendations. Links are shortened with either bit.ly or is.gd, depending on the plugin. Bit.ly shortening requires an access token. 68 | 69 | ### StretchLink and Amazon URL tidying 70 | 71 | The stretchlink and shorten_amazon plugins will use [StretchLink.cc](https://stretchlink.cc) to expand shortened URLs and tidy up Amazon URLs. 72 | 73 | ### Weather 74 | 75 | Can output a current conditions with searches !weather and !current, and a forecast with !forecast. All output in Markdown format. Requires a weatherapi.com API key. 76 | 77 | ## Contributing 78 | 79 | Use the sample code in `lyrics.rb` ([documented in the SearchLink wiki](https://github.com/ttscoff/searchlink/wiki/Plugins)) to generate your own plugins. Feel free to fork and submit a PR to this repository if you create something you'd like to share! 80 | -------------------------------------------------------------------------------- /browser_tabs.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/osascript -l JavaScript 2 | 3 | ObjC.import('stdlib'); 4 | ObjC.import('Foundation'); 5 | 6 | const stdout = function (msg) { 7 | $.NSFileHandle.fileHandleWithStandardOutput.writeData( 8 | $.NSString.alloc.initWithString(String(msg)) 9 | .dataUsingEncoding($.NSUTF8StringEncoding) 10 | ) 11 | } 12 | 13 | const stderr = function (msg) { 14 | $.NSFileHandle.fileHandleWithStandardError.writeData( 15 | $.NSString.alloc.initWithString(String(msg)) 16 | .dataUsingEncoding($.NSUTF8StringEncoding) 17 | ) 18 | } 19 | 20 | const args = $.NSProcessInfo.processInfo.arguments; 21 | // args[0..3] are filename, "/usr/bin/osascript", "-l", "JavaScript" 22 | if (args.count > 4) { 23 | var search_type = args.js[4].js; 24 | var search_terms = args.js[5].js; 25 | var link_text = args.js[6].js; 26 | } 27 | 28 | let saf; 29 | switch (search_type) { 30 | case 'tabb': 31 | case 'tabbf': 32 | saf = Application('Brave'); 33 | break; 34 | case 'tabe': 35 | case 'tabef': 36 | saf = Application('Microsoft Edge'); 37 | break; 38 | case 'tabc': 39 | case 'tabcf': 40 | saf = Application('Google Chrome'); 41 | break; 42 | case 'tabs': 43 | case 'tabsf': 44 | saf = Application('Safari'); 45 | } 46 | 47 | saf.includeStandardAdditions = true; 48 | 49 | 50 | String.prototype.to_rx = function(distance) { 51 | return this.split('').join(`.{0,${distance}}`); 52 | } 53 | 54 | if (search_type.endsWith('f')) { 55 | let current_tab_title; 56 | let current_tab_url; 57 | if (search_type.startsWith('tabs')) { 58 | current_tab_title = saf.windows[0].currentTab.name() 59 | current_tab_url = saf.windows[0].currentTab.url() 60 | } else { 61 | current_tab_title = saf.windows[0].activeTab.name() 62 | current_tab_url = saf.windows[0].activeTab.url() 63 | } 64 | 65 | data = { 66 | "url": current_tab_url, 67 | "title": current_tab_title, 68 | "link_text": link_text 69 | } 70 | } else { 71 | var tabs = [] 72 | 73 | saf.windows().forEach(function(win) { 74 | win.tabs().forEach(function(tab) { 75 | tabs.push({ 76 | 'title': tab.name(), 77 | 'url': tab.url() 78 | }); 79 | }); 80 | }); 81 | tabs.forEach((tab) => stderr(tab['title'])); 82 | var terms = search_terms.split(/ +/); 83 | var regex = ''; 84 | 85 | var res = null; 86 | var matches = []; 87 | var distance = 0; 88 | while (distance < 5) { 89 | var terms_rx = terms.map(term => term.to_rx(distance)); 90 | var rx = new RegExp(terms_rx.join('.*?'), 'i'); 91 | 92 | matches = tabs.filter((tab) => { return (rx.test(tab['title']) || rx.test(tab['url'])) }); 93 | if (matches.length == 0) { 94 | distance += 1; 95 | } else { 96 | break; 97 | } 98 | } 99 | 100 | if (matches.length > 0) { 101 | res = matches[0]; 102 | data = { 103 | 'url': res['url'], 104 | 'title': res['title'], 105 | 'link_text': link_text 106 | } 107 | } else { 108 | data = { 109 | 'url': false, 110 | 'title': null, 111 | 'link_text': link_text 112 | } 113 | } 114 | } 115 | 116 | stdout(JSON.stringify(data)); 117 | -------------------------------------------------------------------------------- /browser_tabs.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "browsertab", 3 | "searches": [ 4 | ["tabs", "Search Safari tabs"], 5 | ["tabsf", "Frontmost safari tab"], 6 | ["tabc", "Search Chrome tabs"], 7 | ["tabcf", "Frontmost Chrome tab"], 8 | ["tabef", "Frontmost Edge tab"], 9 | ["tabe", "Search Edge tabs"], 10 | ["tabbf", "Frontmost Brave tab"], 11 | ["tabb", "Search Brave tabs"] 12 | ], 13 | "trigger": "^tab[csceb]f?$", 14 | "script": "browser_tabs.js" 15 | } 16 | -------------------------------------------------------------------------------- /calendar.rb: -------------------------------------------------------------------------------- 1 | module SL 2 | class Calendar 3 | class << self 4 | 5 | def settings 6 | { 7 | trigger: '(cal|days?)', 8 | searches: [ 9 | ['cal', 'Calendar'], 10 | ['days', 'Days in month'] 11 | ] 12 | } 13 | end 14 | 15 | def search(search_type, search_terms, link_text) 16 | ['embed', calendar(search_type, search_terms), link_text] 17 | end 18 | 19 | def markdownify_line(line) 20 | "|#{line.scan(/.{3}/).join('|')}|" 21 | end 22 | 23 | def calendar(type, string) 24 | if type =~ /cal/ 25 | raw = cal(type, string) 26 | lines = raw.split(/\n/)[0..-2] 27 | output = [] 28 | header = "[#{lines.shift.strip}]" 29 | output << markdownify_line(lines.shift) 30 | output << '|---|---|---|---|---|---|---|' 31 | output.concat(lines.map { |l| markdownify_line(l) }) 32 | output << header 33 | output.join("\n") 34 | else 35 | cal(type, string) 36 | end 37 | end 38 | 39 | def cal(type, string) 40 | case type 41 | when /days?/ 42 | case string 43 | when /1?[0-9] \d{4}$/ 44 | `cal -h #{string} | awk 'NF {DAYS = $NF}; END {print DAYS}'` 45 | else 46 | `cal -h | awk 'NF {DAYS = $NF}; END {print DAYS}'` 47 | end 48 | when /cal/ 49 | case string 50 | when /1?[0-9] \d{4}$/ 51 | `cal -h #{string}` 52 | else 53 | `cal -h` 54 | end 55 | end 56 | end 57 | end 58 | 59 | SL::Searches.register 'calendar', :search, self 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /custom-google.rb: -------------------------------------------------------------------------------- 1 | module SL 2 | # Custom Google Search 3 | # This plugin allows use of a Custom Google Search engine 4 | # 1. Make sure you have a google_api_key configured in ~/.searchlink 5 | # 2. Give the plugin a unique class name. 6 | # 3. Create an engine at https://programmablesearchengine.google.com/controlpanel/all 7 | # and get the ID for it, and insert it into the @search_id variable at line 16 8 | # 4. Modify the trigger regex and search definition (lines 17-21) 9 | # 5. Change the plugin name on line 59 10 | # This example uses a Custom Search Engine that searches only BrettTerpstra.com 11 | class CustomGoogleSearch 12 | class << self 13 | attr_reader :api_key, :search_id 14 | 15 | def settings 16 | { 17 | trigger: "gbt", 18 | searches: [ 19 | ["gbt", "BrettTerpstra.com search"], 20 | ], 21 | } 22 | end 23 | 24 | def test_for_key 25 | return false unless SL.config.key?("google_api_key") && SL.config["google_api_key"] 26 | 27 | key = SL.config["google_api_key"] 28 | return false if key =~ /^(x{4,})?$/i 29 | 30 | @api_key = key 31 | 32 | true 33 | end 34 | 35 | def search(_, search_terms, link_text) 36 | search_id = "84d644cd1af424b7a" 37 | 38 | unless test_for_key 39 | SL.add_error("api key", "Missing Google API Key") 40 | return false 41 | end 42 | 43 | url = "https://customsearch.googleapis.com/customsearch/v1?cx=#{search_id}&q=#{ERB::Util.url_encode(search_terms)}&num=1&key=#{@api_key}" 44 | json = Curl::Json.new(url).json 45 | 46 | return SL.ddg(terms, false) unless json["queries"]["request"][0]["totalResults"].to_i.positive? 47 | 48 | result = json["items"][0] 49 | return false if result.nil? 50 | 51 | output_url = result["link"] 52 | output_title = result["title"] 53 | output_title.remove_seo!(output_url) if SL.config["remove_seo"] 54 | [output_url, output_title, link_text] 55 | rescue StandardError 56 | SL.ddg(search_terms, link_text) 57 | end 58 | end 59 | 60 | SL::Searches.register "brettterpstra", :search, self 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /imdb.rb: -------------------------------------------------------------------------------- 1 | module SL 2 | # Requires google_api_key in ~/.searchlink 3 | class IMDBSearch 4 | class << self 5 | attr_reader :api_key 6 | 7 | def settings 8 | { 9 | trigger: 'imdb[ta]?', 10 | searches: [ 11 | ['imdb', 'IMDB.com search'], 12 | ['imdba', 'IMDB.com actor search'], 13 | ['imdbt', 'IMDB.com title search'] 14 | ] 15 | } 16 | end 17 | 18 | def test_for_key 19 | return false unless SL.config.key?('google_api_key') && SL.config['google_api_key'] 20 | 21 | key = SL.config['google_api_key'] 22 | return false if key =~ /^(x{4,})?$/i 23 | 24 | @api_key = key 25 | 26 | true 27 | end 28 | 29 | def search(search_type, search_terms, link_text) 30 | search_id = case search_type 31 | when /a$/ 32 | '72c6aaca9f3144c20' 33 | when /t$/ 34 | '97e8c7f9186d54bd1' 35 | else 36 | '03c787fbdb87449d1' 37 | end 38 | 39 | unless test_for_key 40 | SL.add_error('api key', 'Missing Google API Key') 41 | return false 42 | end 43 | 44 | url = "https://customsearch.googleapis.com/customsearch/v1?cx=#{search_id}&q=#{ERB::Util.url_encode(search_terms)}&num=1&key=#{@api_key}" 45 | body = `curl -SsL '#{url}'` 46 | json = JSON.parse(body) 47 | 48 | return SL.ddg(terms, link_text) unless json['queries']['request'][0]['totalResults'].to_i.positive? 49 | 50 | result = json['items'][0] 51 | return false if result.nil? 52 | 53 | output_url = result['link'] 54 | output_title = result['title'] 55 | output_title.remove_seo!(output_url) if SL.config['remove_seo'] 56 | [output_url, output_title, link_text] 57 | rescue StandardError 58 | SL.ddg(search_terms, link_text) 59 | end 60 | end 61 | 62 | SL::Searches.register 'brettterpstra', :search, self 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lyrics.rb: -------------------------------------------------------------------------------- 1 | # Always start with module SL 2 | module SL 3 | # Give it a unique class name 4 | class LyricsSearch 5 | class << self 6 | # Settings block is required with `trigger` and `searches` 7 | def settings 8 | { 9 | # `trigger` is A regular expression that will trigger this plugin 10 | # when used with a bang. The one below will trigger on !lyrics or 11 | # !lyricse. 12 | trigger: 'lyrics?(e|e?js)?', 13 | # Every search that the plugin should execute should be individually 14 | # listed and described in the searches array. This is used for 15 | # completion and help generation. Do not include the bang (!) in the 16 | # search keyword. 17 | searches: [ 18 | ['lyric', 'Song Lyrics Search'], 19 | ['lyrice', 'Song Lyrics Embed'], 20 | ['lyricjs', 'Song Lyrics JS Embed'] 21 | ] 22 | } 23 | end 24 | 25 | # Every plugin must contain a #search method that takes 3 arguments: 26 | # 27 | # - `search_type` will contain the !search trigger that was used (minus the !) 28 | # - `search_terms` will include everything that came after the !search 29 | # - `link_text` will contain the text that will be used for the linked 30 | # text portion of the link. This can usually remain untouched but must 31 | # be passed back at the end of the function. 32 | def search(search_type, search_terms, link_text) 33 | # You can branch to multiple searches by testing the search_type 34 | case search_type 35 | when /e$/ 36 | url, title = SL.ddg("site:genius.com #{search_terms}", link_text) 37 | if url 38 | title = get_lyrics(url) 39 | # To return an embed, set url (first parameter in the return 40 | # array) to 'embed', and put the embed contents in the second 41 | # parameter. 42 | title ? ['embed', title, link_text] : false 43 | else 44 | # Use `SL#add_error(title, text)` to add errors to the HTML 45 | # report. The report will only be shown if errors have been added. 46 | SL.add_error('No lyrics found', "Song lyrics for #{search_terms} not found") 47 | false 48 | end 49 | when /js$/ 50 | url, title = SL.ddg("site:genius.com #{search_terms}", link_text) 51 | if url 52 | title = js_embed(url) 53 | title ? ['embed', title, link_text] : false 54 | else 55 | SL.add_error('No lyrics found', "Song lyrics for #{search_terms} not found") 56 | false 57 | end 58 | else 59 | # You can perform a DuckDuckGo search using SL#ddg, passing the 60 | # search terms and link_text. It will return url, title, and 61 | # link_text. SL#ddg will add its own errors, and if it returns false 62 | # that will automatically be tested for, no additional error 63 | # handling is required. 64 | url, title, link_text = SL.ddg("site:genius.com #{search_terms}", link_text) 65 | # Always return an array containing the resulting URL, the title, 66 | # and the link_text variable that was passed in, even if it's 67 | # unmodified. 68 | [url, title, link_text] 69 | end 70 | end 71 | 72 | def js_embed(url) 73 | if SL::URL.valid_link?(url) 74 | body = Curl::Html.new(url).body 75 | api_path = body.match(%r{\\"apiPath\\":\\"(/songs/.*?)\\"})[1] 76 | id = api_path.sub(/.*?(\d+)$/, '\1') 77 | title = body.match(/_sf_async_config.title = '(.*?) \| Genius Lyrics'/)[1].gsub(/\\/, '').sub(/ Lyrics$/, '').scrub 78 | 79 | <<~EOEMBED 80 | 83 | 84 | EOEMBED 85 | else 86 | false 87 | end 88 | end 89 | 90 | # Any additional helper methods can be defined after #search 91 | def get_lyrics(url) 92 | if SL::URL.valid_link?(url) 93 | # You can use Ruby's net/http methods for retrieving pages, but 94 | # `curl -SsL` is faster and easier. Curl::Html.new(url) returns a 95 | # new object containing :body 96 | body = Curl::Html.new(url).body 97 | title = body.match(/_sf_async_config.title = '(.*?) \| Genius Lyrics'/)[1].gsub(/\\/, '').sub(/ Lyrics$/, '').scrub 98 | 99 | matches = body.scan(%r{class="Lyrics-\w+-.*?>(.*?)
}, " \n").gsub(%r{}, '').gsub(/'/, "'").gsub(/You might also like/, '') 105 | "#{title}\n\n#{lyrics.code_indent}\n" 106 | else 107 | false 108 | end 109 | else 110 | false 111 | end 112 | end 113 | end 114 | 115 | # At the end of the search class, you must register it as a plugin. This 116 | # method takes a title, a type (:search for a search plugin), and the 117 | # unique class. When running #register within the search class itself, 118 | # you can just use `self`. 119 | SL::Searches.register 'lyrics', :search, self 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /makeadate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'time' 4 | 5 | class DateUtils 6 | def initialize(time_zone = 'America/Chicago') 7 | @time_zone = time_zone 8 | end 9 | 10 | def parsedate(date, fmt, unpad: false) 11 | cmd = "date_default_timezone_set('#{@time_zone}');echo strftime('%F',strtotime('#{date}'));" 12 | dirs = %w[ /usr/bin/ /usr/local/bin/ /opt/homebrew/bin ] 13 | dir = dirs.filter { |d| File.directory?(d) && File.exist?(File.join(d, 'php')) }.first 14 | php = File.join(dir, 'php') 15 | res = `#{php} -r "#{cmd}" 2> /dev/null` 16 | date = Time.parse(res) 17 | parsed = date.strftime(fmt).gsub(/%o/, ordinal(date.strftime('%e'))) 18 | parsed.gsub!(%r{(^|-|/|\s)0}, '\1') if unpad 19 | # if there's no space between time and meridiem, downcase it 20 | parsed.gsub!(/(\d)(AM|PM)/) do 21 | m = Regexp.last_match 22 | m[1] + m[2].downcase 23 | end 24 | parsed =~ /1969/ ? false : parsed 25 | end 26 | 27 | # Returns an ordinal number. 13 -> 13th, 21 -> 21st etc. 28 | def ordinal(number) 29 | if (11..13).include?(number.to_i % 100) 30 | "#{number}th" 31 | else 32 | case number.to_i % 10 33 | when 1 34 | "#{number}st" 35 | when 2 36 | "#{number}nd" 37 | when 3 38 | "#{number}rd" 39 | else 40 | "#{number}th" 41 | end 42 | end 43 | end 44 | 45 | def timeize(time_string, fmt) 46 | frmt = fmt.dup 47 | # Uses standard strftime format, but %0I will pad single-digit hours 48 | if time_string =~ /(\d{1,2})(?::(\d\d))?(?:\s*(a|p)m?)?/i 49 | m = Regexp.last_match 50 | hour = m[1].to_i 51 | minute = m[2].nil? ? '00' : m[2].to_i 52 | meridiem = m[3] 53 | meridiem = hour > 12 ? 'p' : 'a' if meridiem.nil? 54 | frmt.gsub!(/%H/) do 55 | hour = hour < 12 && meridiem == 'p' ? hour + 12 : hour 56 | hour = 0 if hour == 12 && meridiem == 'a' 57 | hour < 10 ? "0#{hour}" : hour 58 | end 59 | frmt.gsub!(/%(0)?I/) do 60 | hour = hour > 12 ? hour - 12 : hour 61 | Regexp.last_match(1) == '0' && hour < 10 ? "0#{hour}" : hour 62 | end 63 | frmt.gsub!(/%M/, format('%02d', minute.to_i)) 64 | frmt.gsub!(/%p/, "#{meridiem.downcase}m") 65 | frmt.gsub!(/%P/, "#{meridiem.upcase}M") 66 | end 67 | frmt 68 | end 69 | end 70 | 71 | module SL 72 | # make-a-date Class 73 | class MakeADate 74 | class << self 75 | def settings 76 | { 77 | trigger: 'd(d(ate)?|s(hort)?|l(ng|ong)?|i(so)?)', 78 | searches: [ 79 | ['ddate', 'Local date'], 80 | ['dshort', 'Short date'], 81 | ['dlong', 'Long date'], 82 | ['diso', 'ISO date'] 83 | ] 84 | } 85 | end 86 | 87 | def search(search_type, search_terms, link_text) 88 | ['embed', parse_date(search_type, search_terms), link_text] 89 | end 90 | 91 | def parse_date(type, string) 92 | original = "#{type} #{string}" 93 | 94 | # split input and handle thursday@3 format 95 | input = original.gsub(/(\S)@(\S)/, '\1 at \2').split(/ /) 96 | unpad = true 97 | du = DateUtils.new 98 | 99 | # %%o is replaced with ordinal day 100 | fmt = case type 101 | when /^dd(ate)?$/ 102 | unpad = false 103 | '%F' 104 | when /^ds(hort)?$/ 105 | '%F' 106 | when /^dl(o?ng)?$/ 107 | '%A, %B %%o, %Y' 108 | when /^di(so)?$/ 109 | unpad = false 110 | '%Y-%m-%d' 111 | else 112 | '%F' 113 | end 114 | 115 | # handle +# to advance # days 116 | input.map! { |part| part.gsub(/^\+(\d+)$/, '\1 days ').gsub(/^at$/, '') } 117 | input.map! { |part| part.gsub(/(\d+)d/, '\1 days ').gsub(/^at$/, '') } 118 | input.map! { |part| part.gsub(/(\d+)w/, '\1 weeks ').gsub(/^at$/, '') } 119 | input.map! { |part| part.gsub(/(\d+)y/, '\1 years ').gsub(/^at$/, '') } 120 | 121 | # time formatting 122 | time_format = '' 123 | time_string = '' 124 | timerx = /(?mi)((?:at|@) *(\d{1,2}(?::\d\d)?(?:am|pm)?)|(\d{1,2}(?::\d\d)?(?:am|pm))|(\d{1,2}:\d\d))/ 125 | if string.strip =~ /^now$/ 126 | time_string = Time.now.strftime('%H:%M') 127 | elsif original.gsub(/\+\d+/, '') =~ timerx 128 | m = Regexp.last_match 129 | time_string = if m[2] 130 | m[2] 131 | elsif m[3] 132 | m[3] 133 | elsif m[4] 134 | m[4] 135 | end 136 | 137 | time_format = case type 138 | when /^di/ 139 | du.timeize(time_string, ' %H:%M') 140 | when /^dl(o?ng)?/ 141 | du.timeize(time_string, ' at %I:%M%p') 142 | else 143 | du.timeize(time_string, ' %I:%M%P') 144 | end 145 | end 146 | 147 | date_string = if input.length > 1 148 | if original.sub(/((@|at)\s*)?#{time_string}/, '') =~ /^\s*$/m || original.strip =~ /^now$/ 149 | "today #{time_format}" 150 | else 151 | input[1..].delete_if(&:empty?).join(' ') 152 | end 153 | else 154 | 'today' 155 | end 156 | 157 | output = du.parsedate(date_string, fmt + time_format, unpad: unpad).gsub(/ +/, ' ') 158 | 159 | output || original 160 | end 161 | end 162 | 163 | SL::Searches.register 'makeadate', :search, self 164 | end 165 | end 166 | 167 | -------------------------------------------------------------------------------- /manpage.rb: -------------------------------------------------------------------------------- 1 | module SL 2 | # Man Page Search 3 | class ManPageSearch 4 | class << self 5 | # Returns a hash of settings for the search 6 | # 7 | # @return [Hash] the settings for the search 8 | # 9 | def settings 10 | { 11 | trigger: "x?man", 12 | searches: [ 13 | ["xman", "x-man-page url"], 14 | ["man", "Man Page from manpages.org"], 15 | ], 16 | } 17 | end 18 | 19 | # Searches for a man page for given command. Only the first word of the 20 | # search is used. If no man page is found on manpages.org, ss64.com is 21 | # used 22 | # 23 | # @param search_type [String] Search type, unused 24 | # @param search_terms [String] the terms to search for 25 | # @param link_text [String] the text to use for the link 26 | # 27 | # @return [Array] the url, title, and link text for the search 28 | # 29 | def search(search_type, search_terms, link_text) 30 | return ["x-man-page://#{search_terms.split(" ").first}", "#{search_terms} man page", link_text] if search_type == "xman" 31 | 32 | url, title = find_man_page(search_terms) 33 | if url 34 | [url, title, link_text] 35 | else 36 | SL.site_search("ss64.com", search_terms, link_text) 37 | end 38 | end 39 | 40 | # Uses manpages.org autocomplete to validate command 41 | # name. Shortest match returned. 42 | # 43 | # @param term [String] the terms to search for 44 | # @return [Array] the url and title for the search 45 | # 46 | def find_man_page(terms) 47 | terms.split(/ /).each do |term| 48 | autocomplete = "https://manpages.org/pagenames/autocomplete_page_name_name?term=#{ERB::Util.url_encode(term)}" 49 | data = Curl::Json.new(autocomplete).json 50 | next if data.count.zero? 51 | 52 | data.delete_if { |d| d["locale"] != "en" } 53 | shortest = data.min_by { |d| d["permalink"].length } 54 | man = shortest["permalink"] 55 | cat = shortest["category_id"] 56 | url = "https://manpages.org/#{man}/#{cat}" 57 | return [url, get_man_title(url)] 58 | end 59 | [false, false] 60 | rescue StandardError 61 | false 62 | end 63 | 64 | def get_man_title(url) 65 | body = `/usr/bin/curl -sSL '#{url}'` 66 | body.match(%r{(?<=).*?(?=)})[0].sub(/^man /, "") 67 | end 68 | end 69 | 70 | # Registers the search with the SL::Searches module 71 | SL::Searches.register "manpage", :search, self 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mixcase.rb: -------------------------------------------------------------------------------- 1 | # Always start with module SL 2 | module SL 3 | # Give it a unique class name 4 | class MixCaps 5 | class << self 6 | # Settings block is required with `trigger` and `searches` 7 | def settings 8 | { 9 | trigger: 'mix', 10 | searches: [ 11 | ['mix', 'Mix Casing'] 12 | ] 13 | } 14 | end 15 | 16 | def search(search_type, search_terms, link_text) 17 | ['embed', mix_case(search_terms), link_text] 18 | end 19 | 20 | def mix_case(string) 21 | string.split(//).map { |s| rand(20000) % 2 == 1 ? s.upcase : s.downcase } 22 | end 23 | end 24 | 25 | 26 | SL::Searches.register 'mix', :search, self 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /shellsnip.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'shellwords' 4 | 5 | module SL 6 | # Inserts a shell command result as an embed 7 | class ShellSnip 8 | class << self 9 | SCRIPTS_PATH = File.expand_path('~/.local/searchlink/scripts/').freeze 10 | 11 | def settings 12 | { 13 | trigger: 'shell', 14 | searches: [ 15 | ['shell', 'Inserts Shell-Skript'] 16 | ] 17 | } 18 | end 19 | 20 | def search(_, search_terms, link_text) 21 | result = execute_shell_script(search_terms) 22 | result ? ['embed', result, link_text] : false 23 | end 24 | 25 | def execute_shell_script(search_terms) 26 | params = Shellwords.split(search_terms) 27 | script_path = File.join(SCRIPTS_PATH, Shellwords.escape(params.shift)) 28 | params.map! { |w| Shellwords.escape(w) } 29 | 30 | full_command = "#{Shellwords.escape(script_path)} #{params.join(' ')}" 31 | 32 | `#{full_command} 2>&1` 33 | end 34 | end 35 | 36 | SL::Searches.register 'shell', :search, self 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /shorten_amazon.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true Shorten Amazon Plugin Takes an 2 | # Amazon product url and shortens it to the minimal 3 | # information needed to work 4 | # 5 | # Optional config: 6 | # 7 | # ```yaml 8 | # stretchlink: 9 | # clean_url: true 10 | # ``` 11 | # 12 | # clean_url: removes tracking information from the url 13 | # 14 | module SL 15 | # Amazon Shorten Plugin 16 | class AmazonShorten 17 | class << self 18 | def settings 19 | { 20 | trigger: "sa", 21 | searches: [ 22 | ["sa", "Shorten Amazon URLs"], 23 | ], 24 | } 25 | end 26 | 27 | def search(_, search_terms, link_text) 28 | return [search_terms, nil, link_text] unless SL::URL.url?(search_terms) && search_terms =~ /amazon\.com/ 29 | 30 | settings = if SL.config.key?("stretchlink") 31 | SL.config["stretchlink"] 32 | else 33 | { "clean_url" => true, "tidy_amazon" => false } 34 | end 35 | query = [ 36 | "url=#{ERB::Util.url_encode(search_terms)}", 37 | "clean=#{settings["clean_url"] ? "true" : "false"}", 38 | "tidy_amazon=true", 39 | "output=json", 40 | ] 41 | 42 | res = Curl::Json.new(%(https://stretchlink.cc/api/1?#{query.join("&")})) 43 | json = res.json 44 | if json.nil? || json.empty? 45 | return [search_terms, nil, link_text] 46 | end 47 | 48 | if json["error"] 49 | SL.error("StretchLink Error: #{json["error"]}") 50 | return [search_terms, nil, link_text] 51 | end 52 | 53 | rtitle = SL::URL.title(search_terms) || res["title"] 54 | if link_text.nil? || link_text.empty? || link_text == search_terms 55 | link_text = rtitle 56 | end 57 | 58 | [json["expanded_url"], rtitle, link_text] 59 | end 60 | end 61 | 62 | SL::Searches.register "shorten_amazon", :search, self 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /stretchlink.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # StretchLink Plugin 4 | # Takes a shortened url and expands it 5 | # 6 | # Optional config: 7 | # 8 | # ```yaml 9 | # stretchlink: 10 | # clean_url: true 11 | # tidy_amazon: true 12 | # ``` 13 | # clean_url: removes tracking information from the url 14 | # tidy_amazon: reduces amazon url to minimal information 15 | # 16 | module SL 17 | # StretchLink Plugin 18 | class StretchLink 19 | class << self 20 | def settings 21 | { 22 | trigger: "str(?:etch)?", 23 | searches: [ 24 | ["stretch", "Expand URLs with stretchlink.cc"], 25 | ["str", "Expand URLs with stretchlink.cc"], 26 | ], 27 | config: [ 28 | { 29 | key: "stretchlink", 30 | value: { "clean_url" => false, "tidy_amazon" => false }, 31 | required: true, 32 | description: "StretchLink plugin config", 33 | }, 34 | ], 35 | } 36 | end 37 | 38 | def search(_, search_terms, link_text) 39 | return [search_terms, nil, link_text] unless SL::URL.url?(search_terms) 40 | 41 | settings = if SL.config.key?("stretchlink") 42 | SL.config["stretchlink"] 43 | else 44 | { "clean_url" => true, "tidy_amazon" => false } 45 | end 46 | query = [ 47 | "url=#{ERB::Util.url_encode(search_terms)}", 48 | "clean=#{settings["clean_url"] ? "true" : "false"}", 49 | "tidy_amazon=false", 50 | "output=json", 51 | ] 52 | 53 | res = Curl::Json.new(%(https://stretchlink.cc/api/1?#{query.join("&")})) 54 | json = res.json 55 | return [search_terms, nil, link_text] if json.nil? || json.empty? 56 | 57 | if json["error"] 58 | SL.error("StretchLink Error: #{json["error"]}") 59 | return [search_terms, nil, link_text] 60 | end 61 | 62 | url = if settings["tidy_amazon"] && json["expanded_url"] =~ /amazon/ 63 | shorten_amazon(json["expanded_url"]) 64 | else 65 | json["expanded_url"] 66 | end 67 | 68 | rtitle = SL::URL.title(url) || res["title"] 69 | link_text = rtitle if link_text.nil? || link_text.empty? || link_text == search_terms 70 | 71 | [url, rtitle, link_text] 72 | end 73 | 74 | def shorten_amazon(url) 75 | uri = URI.parse(url) 76 | 77 | # Return nil if not an Amazon URL 78 | return url unless uri.host.include?("amazon") 79 | 80 | # Extract the product ID from the path 81 | match = uri.path.match(%r{/dp/([^/]*)}) 82 | product_id = match ? match[1] : nil 83 | 84 | # Construct the shortened URL if the product ID is found 85 | clean_url = product_id ? "https://www.amazon.com/dp/#{product_id}" : nil 86 | 87 | clean_url + uri.query.to_s.sub(/^&?/, "?") 88 | end 89 | end 90 | 91 | SL::Searches.register "stretchlink", :search, self 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo '{ "url": "https://brettterpstra.com", "title": "Script Test", "link_text": "Boogie" }' 4 | exit 0 5 | -------------------------------------------------------------------------------- /test.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: test shell script 3 | trigger: ^scr$ 4 | searches: 5 | - [test, "Test Shell Script Search"] 6 | script: test.sh 7 | -------------------------------------------------------------------------------- /test2.sh: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ttscoff/searchlink-plugins/61fc2b79bcd74f5b5efef1a0f6e61e2d125e0246/test2.sh -------------------------------------------------------------------------------- /weather.rb: -------------------------------------------------------------------------------- 1 | # Always start with module SL 2 | module SL 3 | # Weather and forecast plugin 4 | # Needs a weatherapi.com API key in config as `weather_api_key: xxxxx` 5 | # Provide a zip or city/region as the search term 6 | class WeatherSearch 7 | class << self 8 | def settings 9 | searches = [] 10 | %w[weather current weat wea curr cur].each { |s| searches << [s, 'Embed Current Weather'] } 11 | %w[forecast fore for].each { |s| searches << [s, 'Embed Weather Forecast'] } 12 | { 13 | trigger: '(for(e(cast)?)?|cur(r(ent)?)?|wea(t(her)?)?)', 14 | searches: searches 15 | } 16 | end 17 | 18 | def search(search_type, search_terms, link_text) 19 | api_key = SL.config['weather_api_key'] 20 | temp_in = SL.config['weather_temp_in'] =~ /^c/ ? 'temp_c' : 'temp_f' 21 | zip = search_terms.url_encode 22 | url = "http://api.weatherapi.com/v1/forecast.json?key=#{api_key}&q=#{zip}&aqi=no" 23 | res = Curl::Json.new(url) 24 | 25 | raise StandardError, 'Invalid JSON response' unless res 26 | 27 | data = res.json 28 | 29 | loc = "#{data['location']['name']}, #{data['location']['region']}" 30 | clock = Time.parse(data['location']['localtime']) 31 | date = clock.strftime('%Y-%m-%d') 32 | time = clock.strftime('%I:%M %p') 33 | 34 | raise StandardError, 'Missing conditions' unless data['current'] 35 | 36 | curr_temp = data['current'][temp_in] 37 | curr_condition = data['current']['condition']['text'] 38 | 39 | raise StandardError, 'Missing forecast' unless data['forecast'] 40 | 41 | forecast = data['forecast']['forecastday'][0] 42 | 43 | day = forecast['date'] 44 | high = forecast['day']["max#{temp_in}"] 45 | low = forecast['day']["min#{temp_in}"] 46 | condition = forecast['day']['condition']['text'] 47 | 48 | output = [] 49 | 50 | case search_type 51 | when /^[wc]/ 52 | output << "Weather for #{loc} on #{date} at #{time}: #{curr_temp} and #{curr_condition} " 53 | else 54 | output << "Forecast for #{loc} on #{day}: #{condition} #{high}/#{low}" 55 | output << "Currently: #{curr_temp} and #{curr_condition}" 56 | output << '' 57 | 58 | output.concat(forecast_to_markdown(forecast['hour'], temp_in)) 59 | end 60 | 61 | output.empty? ? false : ['embed', output.join("\n"), link_text] 62 | end 63 | 64 | def forecast_to_markdown(hours, temp_in) 65 | output = [] 66 | temps = [ 67 | { temp: hours[8][temp_in], condition: hours[8]['condition']['text'] }, 68 | { temp: hours[10][temp_in], condition: hours[10]['condition']['text'] }, 69 | { temp: hours[12][temp_in], condition: hours[12]['condition']['text'] }, 70 | { temp: hours[14][temp_in], condition: hours[14]['condition']['text'] }, 71 | { temp: hours[16][temp_in], condition: hours[16]['condition']['text'] }, 72 | { temp: hours[18][temp_in], condition: hours[18]['condition']['text'] }, 73 | { temp: hours[19][temp_in], condition: hours[20]['condition']['text'] } 74 | ] 75 | 76 | # Hours 77 | hours_text = %w[8am 10am 12pm 2pm 4pm 6pm 8pm] 78 | step_out = ['|'] 79 | hours_text.each_with_index do |h, i| 80 | width = temps[i][:condition].length + 1 81 | step_out << format("%#{width}s |", h) 82 | end 83 | 84 | output << step_out.join('') 85 | 86 | # table separator 87 | step_out = ['|'] 88 | temps.each do |temp| 89 | width = temp[:condition].length + 1 90 | step_out << "#{'-' * width}-|" 91 | end 92 | 93 | output << step_out.join('') 94 | 95 | # Conditions 96 | step_out = ['|'] 97 | temps.each do |temp| 98 | step_out << format(' %s |', temp[:condition]) 99 | end 100 | 101 | output << step_out.join('') 102 | 103 | # Temps 104 | step_out = ['|'] 105 | temps.each do |temp| 106 | width = temp[:condition].length + 1 107 | step_out << format("%#{width}s |", temp[:temp]) 108 | end 109 | 110 | output << step_out.join('') 111 | end 112 | end 113 | 114 | SL::Searches.register 'weather', :search, self 115 | end 116 | end 117 | --------------------------------------------------------------------------------