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