dotfiles

My personal shell configs and stuff
git clone git://git.alex.balgavy.eu/dotfiles.git
Log | Files | Refs | Submodules | README | LICENSE

init.lua (18851B)


      1 --- === ClipboardTool ===
      2 ---
      3 --- Keep a history of the clipboard for text entries and manage the entries with a context menu
      4 ---
      5 --- Originally based on TextClipboardHistory.spoon by Diego Zamboni with additional functions provided by a context menu
      6 --- and on [code by VFS](https://github.com/VFS/.hammerspoon/blob/master/tools/clipboard.lua), but with many changes and some contributions and inspiration from [asmagill](https://github.com/asmagill/hammerspoon-config/blob/master/utils/_menus/newClipper.lua).
      7 ---
      8 --- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/ClipboardTool.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/ClipboardTool.spoon.zip)
      9 
     10 local obj = {}
     11 obj.__index = obj
     12 
     13 -- Metadata
     14 obj.name = "ClipboardTool"
     15 obj.version = "0.7"
     16 obj.author = "Alfred Schilken <alfred@schilken.de>"
     17 obj.homepage = "https://github.com/Hammerspoon/Spoons"
     18 obj.license = "MIT - https://opensource.org/licenses/MIT"
     19 
     20 local getSetting = function(label, default)
     21   return hs.settings.get(obj.name .. "." .. label) or default
     22 end
     23 local setSetting = function(label, value)
     24   hs.settings.set(obj.name .. "." .. label, value)
     25   return value
     26 end
     27 
     28 --- ClipboardTool.frequency
     29 --- Variable
     30 --- Speed in seconds to check for clipboard changes. If you check too frequently, you will degrade performance, if you check sparsely you will loose copies. Defaults to 0.8.
     31 obj.frequency = 0.8
     32 
     33 --- ClipboardTool.hist_size
     34 --- Variable
     35 --- How many items to keep on history. Defaults to 100
     36 obj.hist_size = 100
     37 
     38 --- ClipboardTool.max_entry_size
     39 --- Variable
     40 --- maximum size of a text entry
     41 obj.max_entry_size = 4990
     42 
     43 --- ClipboardTool.max_size
     44 --- Variable
     45 --- Whether to check the maximum size of an entry. Defaults to `false`.
     46 obj.max_size = getSetting("max_size", false)
     47 
     48 --- ClipboardTool.show_copied_alert
     49 --- Variable
     50 --- If `true`, show an alert when a new item is added to the history, i.e. has been copied.
     51 obj.show_copied_alert = true
     52 
     53 --- ClipboardTool.honor_ignoredidentifiers
     54 --- Variable
     55 --- If `true`, check the data identifiers set in the pasteboard and ignore entries which match those listed in `ClipboardTool.ignoredIdentifiers`. The list of identifiers comes from http://nspasteboard.org. Defaults to `true`
     56 obj.honor_ignoredidentifiers = true
     57 
     58 --- ClipboardTool.paste_on_select
     59 --- Variable
     60 --- Whether to auto-type the item when selecting it from the menu. Can be toggled on the fly from the chooser. Defaults to `false`.
     61 obj.paste_on_select = getSetting("paste_on_select", false)
     62 
     63 --- ClipboardTool.logger
     64 --- Variable
     65 --- Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.
     66 obj.logger = hs.logger.new("ClipboardTool")
     67 
     68 --- ClipboardTool.ignoredIdentifiers
     69 --- Variable
     70 --- Types of clipboard entries to ignore, see http://nspasteboard.org. Code from https://github.com/asmagill/hammerspoon-config/blob/master/utils/_menus/newClipper.lua.
     71 ---
     72 --- Notes:
     73 ---  * Default value (don't modify unless you know what you are doing):
     74 --- ```
     75 ---  {
     76 ---     ["de.petermaurer.TransientPasteboardType"] = true, -- Transient : Textpander, TextExpander, Butler
     77 ---     ["com.typeit4me.clipping"]                 = true, -- Transient : TypeIt4Me
     78 ---     ["Pasteboard generator type"]              = true, -- Transient : Typinator
     79 ---     ["com.agilebits.onepassword"]              = true, -- Confidential : 1Password
     80 ---     ["org.nspasteboard.TransientType"]         = true, -- Universal, Transient
     81 ---     ["org.nspasteboard.ConcealedType"]         = true, -- Universal, Concealed
     82 ---     ["org.nspasteboard.AutoGeneratedType"]     = true, -- Universal, Automatic
     83 ---  }
     84 --- ```
     85 obj.ignoredIdentifiers = {
     86   ["de.petermaurer.TransientPasteboardType"] = true, -- Transient : Textpander, TextExpander, Butler
     87   ["com.typeit4me.clipping"] = true, -- Transient : TypeIt4Me
     88   ["Pasteboard generator type"] = true, -- Transient : Typinator
     89   ["com.agilebits.onepassword"] = true, -- Confidential : 1Password
     90   ["org.nspasteboard.TransientType"] = true, -- Universal, Transient
     91   ["org.nspasteboard.ConcealedType"] = true, -- Universal, Concealed
     92   ["org.nspasteboard.AutoGeneratedType"] = true, -- Universal, Automatic
     93 }
     94 
     95 --- ClipboardTool.deduplicate
     96 --- Variable
     97 --- Whether to remove duplicates from the list, keeping only the latest one. Defaults to `true`.
     98 obj.deduplicate = true
     99 
    100 --- ClipboardTool.show_in_menubar
    101 --- Variable
    102 --- Whether to show a menubar item to open the clipboard history. Defaults to `true`
    103 obj.show_in_menubar = true
    104 
    105 --- ClipboardTool.menubar_title
    106 --- Variable
    107 --- String to show in the menubar if `ClipboardTool.show_in_menubar` is `true`. Defaults to `"\u{1f4cb}"`, which is the [Unicode clipboard character](https://codepoints.net/U+1F4CB)
    108 obj.menubar_title = "\u{1f4cb}"
    109 
    110 --- ClipboardTool.display_max_length
    111 --- Variable
    112 --- Number of characters to which each clipboard item will be truncated, when displaying in the menu. This only truncates in display, the full content will be used for searching and for pasting.
    113 obj.display_max_length = 200
    114 
    115 ----------------------------------------------------------------------
    116 
    117 -- Internal variable - Chooser/menu object
    118 obj.selectorobj = nil
    119 -- Internal variable - Cache for focused window to work around the current window losing focus after the chooser comes up
    120 obj.prevFocusedWindow = nil
    121 -- Internal variable - Timer object to look for pasteboard changes
    122 obj.timer = nil
    123 
    124 local pasteboard = require("hs.pasteboard") -- http://www.hammerspoon.org/docs/hs.pasteboard.html
    125 local hashfn = require("hs.hash").MD5
    126 
    127 -- Keep track of last change counter
    128 local last_change = nil
    129 -- Array to store the clipboard history
    130 local clipboard_history = nil
    131 
    132 -- Internal function - persist the current history so it survives across restarts
    133 function _persistHistory()
    134   setSetting("items", clipboard_history)
    135 end
    136 
    137 --- ClipboardTool:togglePasteOnSelect()
    138 --- Method
    139 --- Toggle the value of `ClipboardTool.paste_on_select`
    140 ---
    141 --- Parameters:
    142 ---  * None
    143 function obj:togglePasteOnSelect()
    144   self.paste_on_select = setSetting("paste_on_select", not self.paste_on_select)
    145   hs.notify.show("ClipboardTool", "Paste-on-select is now " .. (self.paste_on_select and "enabled" or "disabled"), "")
    146 end
    147 
    148 function obj:toggleMaxSize()
    149   self.max_size = setSetting("max_size", not self.max_size)
    150   hs.notify.show("ClipboardTool", "Max Size is now " .. (self.max_size and "enabled" or "disabled"), "")
    151 end
    152 
    153 -- Internal method - process the selected item from the chooser. An item may invoke special actions, defined in the `actions` variable.
    154 function obj:_processSelectedItem(value)
    155   local actions = {
    156     none = function() end,
    157     clear = hs.fnutils.partial(self.clearAll, self),
    158     toggle_paste_on_select = hs.fnutils.partial(self.togglePasteOnSelect, self),
    159     toggle_max_size = hs.fnutils.partial(self.toggleMaxSize, self),
    160   }
    161   if self.prevFocusedWindow ~= nil then
    162     self.prevFocusedWindow:focus()
    163   end
    164   if value and type(value) == "table" then
    165     if value.action and actions[value.action] then
    166       actions[value.action](value)
    167     elseif value.text then
    168       if value.type == "text" then
    169         pasteboard.setContents(value.data)
    170       elseif value.type == "image" then
    171         pasteboard.writeObjects(hs.image.imageFromURL(value.data))
    172       end
    173       --         self:pasteboardToClipboard(value.text)
    174       if self.paste_on_select then
    175         hs.eventtap.keyStroke({ "cmd" }, "v")
    176       end
    177     end
    178     last_change = pasteboard.changeCount()
    179   end
    180 end
    181 
    182 --- ClipboardTool:clearAll()
    183 --- Method
    184 --- Clears the clipboard and history
    185 ---
    186 --- Parameters:
    187 ---  * None
    188 function obj:clearAll()
    189   pasteboard.clearContents()
    190   clipboard_history = {}
    191   _persistHistory()
    192   last_change = pasteboard.changeCount()
    193 end
    194 
    195 --- ClipboardTool:clearLastItem()
    196 --- Method
    197 --- Clears the last added to the history
    198 ---
    199 --- Parameters:
    200 ---  * None
    201 function obj:clearLastItem()
    202   table.remove(clipboard_history, 1)
    203   _persistHistory()
    204   last_change = pasteboard.changeCount()
    205 end
    206 
    207 -- Internal method: deduplicate the given list, and restrict it to the history size limit
    208 function obj:dedupe_and_resize(list)
    209   local res = {}
    210   local hashes = {}
    211   for i, v in ipairs(list) do
    212     if #res < self.hist_size then
    213       local hash = hashfn(v.content)
    214       if (not self.deduplicate) or not hashes[hash] then
    215         table.insert(res, v)
    216         hashes[hash] = true
    217       end
    218     end
    219   end
    220   return res
    221 end
    222 
    223 --- ClipboardTool:pasteboardToClipboard(item)
    224 --- Method
    225 --- Add the given string to the history
    226 ---
    227 --- Parameters:
    228 ---  * item - string to add to the clipboard history
    229 ---
    230 --- Returns:
    231 ---  * None
    232 function obj:pasteboardToClipboard(item_type, item)
    233   table.insert(clipboard_history, 1, { type = item_type, content = item })
    234   clipboard_history = self:dedupe_and_resize(clipboard_history)
    235   _persistHistory() -- updates the saved history
    236 end
    237 
    238 -- Internal method: actions of the context menu, special paste
    239 function obj:pasteAllWithDelimiter(row, delimiter)
    240   if self.prevFocusedWindow ~= nil then
    241     self.prevFocusedWindow:focus()
    242   end
    243   print("pasteAllWithTab row:" .. row)
    244   for ix = row, 1, -1 do
    245     local entry = clipboard_history[ix]
    246     print("pasteAllWithTab ix:" .. ix .. ":" .. entry)
    247     --      pasteboard.setContents(entry)
    248     --      os.execute("sleep 0.2")
    249     --      hs.eventtap.keyStroke({"cmd"}, "v")
    250     hs.eventtap.keyStrokes(entry.content)
    251     --      os.execute("sleep 0.2")
    252     hs.eventtap.keyStrokes(delimiter)
    253     --      os.execute("sleep 0.2")
    254   end
    255 end
    256 
    257 -- Internal method: actions of the context menu, delete or rearrange of clips
    258 function obj:manageClip(row, action)
    259   print("manageClip row:" .. row .. ",action:" .. action)
    260   if action == 0 then
    261     table.remove(clipboard_history, row)
    262   elseif action == 2 then
    263     local i = 1
    264     local j = row
    265     while i < j do
    266       clipboard_history[i], clipboard_history[j] = clipboard_history[j], clipboard_history[i]
    267       i = i + 1
    268       j = j - 1
    269     end
    270   else
    271     local value = clipboard_history[row]
    272     local new = row + action
    273     if new < 1 then
    274       new = 1
    275     end
    276     if new < row then
    277       table.move(clipboard_history, new, row - 1, new + 1)
    278     else
    279       table.move(clipboard_history, row + 1, new, row)
    280     end
    281     clipboard_history[new] = value
    282   end
    283   self.selectorobj:refreshChoicesCallback()
    284 end
    285 
    286 -- Internal method:
    287 function obj:_showContextMenu(row)
    288   print("_showContextMenu row:" .. row)
    289   point = hs.mouse.getAbsolutePosition()
    290   local menu = hs.menubar.new(false)
    291   local menuTable = {
    292     {
    293       title = "Alle Schnipsel mit Tab einfügen",
    294       fn = hs.fnutils.partial(self.pasteAllWithDelimiter, self, row, "\t"),
    295     },
    296     {
    297       title = "Alle Schnipsel mit Zeilenvorschub einfügen",
    298       fn = hs.fnutils.partial(self.pasteAllWithDelimiter, self, row, "\n"),
    299     },
    300     { title = "-" },
    301     { title = "Eintrag entfernen", fn = hs.fnutils.partial(self.manageClip, self, row, 0) },
    302     { title = "Eintrag an erste Stelle", fn = hs.fnutils.partial(self.manageClip, self, row, -100) },
    303     { title = "Eintrag nach oben", fn = hs.fnutils.partial(self.manageClip, self, row, -1) },
    304     { title = "Eintrag nach unten", fn = hs.fnutils.partial(self.manageClip, self, row, 1) },
    305     { title = "Tabelle invertieren", fn = hs.fnutils.partial(self.manageClip, self, row, 2) },
    306     { title = "-" },
    307     { title = "disabled item", disabled = true },
    308     { title = "checked item", checked = true },
    309   }
    310   menu:setMenu(menuTable)
    311   menu:popupMenu(point)
    312   print(hs.inspect(point))
    313 end
    314 
    315 -- Internal function - fill in the chooser options, including the control options
    316 function obj:_populateChooser(query)
    317   query = query:lower()
    318   menuData = {}
    319   for k, v in pairs(clipboard_history) do
    320     if v.type == "text" and (query == "" or v.content:lower():find(query)) then
    321       table.insert(
    322         menuData,
    323         { text = string.sub(v.content, 0, obj.display_max_length), data = v.content, type = v.type }
    324       )
    325     elseif v.type == "image" then
    326       table.insert(
    327         menuData,
    328         { text = "《Image data》", type = v.type, data = v.content, image = hs.image.imageFromURL(v.content) }
    329       )
    330     end
    331   end
    332   if #menuData == 0 then
    333     table.insert(
    334       menuData,
    335       { text = "", subText = "《Clipboard is empty》", action = "none", image = hs.image.imageFromName("NSCaution") }
    336     )
    337   else
    338     table.insert(
    339       menuData,
    340       { text = "《Clear Clipboard History》", action = "clear", image = hs.image.imageFromName("NSTrashFull") }
    341     )
    342   end
    343   table.insert(menuData, {
    344     text = "《" .. (self.paste_on_select and "Disable" or "Enable") .. " Paste-on-select》",
    345     action = "toggle_paste_on_select",
    346     image = (self.paste_on_select and hs.image.imageFromName("NSSwitchEnabledOn") or hs.image.imageFromName(
    347       "NSSwitchEnabledOff"
    348     )),
    349   })
    350   table.insert(menuData, {
    351     text = "《" .. (self.max_size and "Disable" or "Enable") .. " max size " .. self.max_entry_size .. "》",
    352     action = "toggle_max_size",
    353     image = (self.max_size and hs.image.imageFromName("NSSwitchEnabledOn") or hs.image.imageFromName(
    354       "NSSwitchEnabledOff"
    355     )),
    356   })
    357   self.logger.df("Returning menuData = %s", hs.inspect(menuData))
    358   return menuData
    359 end
    360 
    361 --- ClipboardTool:shouldBeStored()
    362 --- Method
    363 --- Verify whether the pasteboard contents matches one of the values in `ClipboardTool.ignoredIdentifiers`
    364 ---
    365 --- Parameters:
    366 ---  * None
    367 function obj:shouldBeStored()
    368   -- Code from https://github.com/asmagill/hammerspoon-config/blob/master/utils/_menus/newClipper.lua
    369   local goAhead = true
    370   for i, v in ipairs(hs.pasteboard.pasteboardTypes()) do
    371     if self.ignoredIdentifiers[v] then
    372       goAhead = false
    373       break
    374     end
    375   end
    376   if goAhead then
    377     for i, v in ipairs(hs.pasteboard.contentTypes()) do
    378       if self.ignoredIdentifiers[v] then
    379         goAhead = false
    380         break
    381       end
    382     end
    383   end
    384   return goAhead
    385 end
    386 
    387 -- Internal method:
    388 function obj:reduceSize(text)
    389   print(#text .. " ? " .. tostring(max_entry_size))
    390   local endingpos = 3000
    391   local lastLowerPos = 3000
    392   repeat
    393     lastLowerPos = endingpos
    394     _, endingpos = string.find(text, "\n\n", endingpos + 1)
    395     print("endingpos:" .. endingpos)
    396   until endingpos > obj.max_entry_size
    397   return string.sub(text, 1, lastLowerPos)
    398 end
    399 
    400 --- ClipboardTool:checkAndStorePasteboard()
    401 --- Method
    402 --- If the pasteboard has changed, we add the current item to our history and update the counter
    403 ---
    404 --- Parameters:
    405 ---  * None
    406 function obj:checkAndStorePasteboard()
    407   now = pasteboard.changeCount()
    408   if now > last_change then
    409     if (not self.honor_ignoredidentifiers) or self:shouldBeStored() then
    410       current_clipboard = pasteboard.getContents()
    411       self.logger.df("current_clipboard = %s", tostring(current_clipboard))
    412       if (current_clipboard == nil) and (pasteboard.readImage() ~= nil) then
    413         current_clipboard = pasteboard.readImage()
    414         self:pasteboardToClipboard("image", current_clipboard:encodeAsURLString())
    415         if self.show_copied_alert then
    416           hs.alert.show("Copied image")
    417         end
    418         self.logger.df(
    419           "Adding image (hashed) %s to clipboard history clipboard",
    420           hashfn(current_clipboard:encodeAsURLString())
    421         )
    422       elseif current_clipboard ~= nil then
    423         local size = #current_clipboard
    424         if obj.max_size and size > obj.max_entry_size then
    425           local answer = hs.dialog.blockAlert(
    426             "Clipboard",
    427             "The maximum size of " .. obj.max_entry_size .. " was exceeded.",
    428             "Copy partially",
    429             "Copy all",
    430             "NSCriticalAlertStyle"
    431           )
    432           print("answer: " .. answer)
    433           if answer == "Copy partially" then
    434             current_clipboard = self:reduceSize(current_clipboard)
    435             size = #current_clipboard
    436           end
    437         end
    438         if self.show_copied_alert then
    439           hs.alert.show("Copied " .. size .. " chars")
    440         end
    441         self.logger.df("Adding %s to clipboard history", current_clipboard)
    442         self:pasteboardToClipboard("text", current_clipboard)
    443       else
    444         self.logger.df("Ignoring nil clipboard content")
    445       end
    446     else
    447       self.logger.df("Ignoring pasteboard entry because it matches ignoredIdentifiers")
    448     end
    449     last_change = now
    450   end
    451 end
    452 
    453 --- ClipboardTool:start()
    454 --- Method
    455 --- Start the clipboard history collector
    456 ---
    457 --- Parameters:
    458 ---  * None
    459 function obj:start()
    460   obj.logger.level = 0
    461   clipboard_history = self:dedupe_and_resize(getSetting("items", {})) -- If no history is saved on the system, create an empty history
    462   last_change = pasteboard.changeCount() -- keeps track of how many times the pasteboard owner has changed // Indicates a new copy has been made
    463   self.selectorobj = hs.chooser.new(hs.fnutils.partial(self._processSelectedItem, self))
    464   self.selectorobj:choices(hs.fnutils.partial(self._populateChooser, self, ""))
    465   self.selectorobj:queryChangedCallback(function(query)
    466     self.selectorobj:choices(hs.fnutils.partial(self._populateChooser, self, query))
    467   end)
    468   self.selectorobj:rightClickCallback(hs.fnutils.partial(self._showContextMenu, self))
    469   --Checks for changes on the pasteboard. Is it possible to replace with eventtap?
    470   self.timer = hs.timer.new(self.frequency, hs.fnutils.partial(self.checkAndStorePasteboard, self))
    471   self.timer:start()
    472   if self.show_in_menubar then
    473     self.menubaritem =
    474       hs.menubar.new():setTitle(obj.menubar_title):setClickCallback(hs.fnutils.partial(self.toggleClipboard, self))
    475   end
    476 end
    477 
    478 --- ClipboardTool:showClipboard()
    479 --- Method
    480 --- Display the current clipboard list in a chooser
    481 ---
    482 --- Parameters:
    483 ---  * None
    484 function obj:showClipboard()
    485   if self.selectorobj ~= nil then
    486     self.selectorobj:refreshChoicesCallback()
    487     self.prevFocusedWindow = hs.window.focusedWindow()
    488     self.selectorobj:show()
    489   else
    490     hs.notify.show("ClipboardTool not properly initialized", "Did you call ClipboardTool:start()?", "")
    491   end
    492 end
    493 
    494 --- ClipboardTool:toggleClipboard()
    495 --- Method
    496 --- Show/hide the clipboard list, depending on its current state
    497 ---
    498 --- Parameters:
    499 ---  * None
    500 function obj:toggleClipboard()
    501   if self.selectorobj:isVisible() then
    502     self.selectorobj:hide()
    503   else
    504     self:showClipboard()
    505   end
    506 end
    507 
    508 --- ClipboardTool:bindHotkeys(mapping)
    509 --- Method
    510 --- Binds hotkeys for ClipboardTool
    511 ---
    512 --- Parameters:
    513 ---  * mapping - A table containing hotkey objifier/key details for the following items:
    514 ---   * show_clipboard - Display the clipboard history chooser
    515 ---   * toggle_clipboard - Show/hide the clipboard history chooser
    516 function obj:bindHotkeys(mapping)
    517   local def = {
    518     show_clipboard = hs.fnutils.partial(self.showClipboard, self),
    519     toggle_clipboard = hs.fnutils.partial(self.toggleClipboard, self),
    520   }
    521   hs.spoons.bindHotkeysToSpec(def, mapping)
    522   obj.mapping = mapping
    523 end
    524 
    525 return obj