--- === ClipboardTool ===
---
--- Keep a history of the clipboard for text entries and manage the entries with a context menu
---
--- Originally based on TextClipboardHistory.spoon by Diego Zamboni with additional functions provided by a context menu
--- 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).
---
--- Download: [https://github.com/Hammerspoon/Spoons/raw/master/Spoons/ClipboardTool.spoon.zip](https://github.com/Hammerspoon/Spoons/raw/master/Spoons/ClipboardTool.spoon.zip)

local obj={}
obj.__index = obj

-- Metadata
obj.name = "ClipboardTool"
obj.version = "0.7"
obj.author = "Alfred Schilken <alfred@schilken.de>"
obj.homepage = "https://github.com/Hammerspoon/Spoons"
obj.license = "MIT - https://opensource.org/licenses/MIT"

local getSetting = function(label, default) return hs.settings.get(obj.name.."."..label) or default end
local setSetting = function(label, value)   hs.settings.set(obj.name.."."..label, value); return value end

--- ClipboardTool.frequency
--- Variable
--- 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.
obj.frequency = 0.8

--- ClipboardTool.hist_size
--- Variable
--- How many items to keep on history. Defaults to 100
obj.hist_size = 100

--- ClipboardTool.max_entry_size
--- Variable
--- maximum size of a text entry
obj.max_entry_size = 4990

--- ClipboardTool.max_size
--- Variable
--- Whether to check the maximum size of an entry. Defaults to `false`.
obj.max_size = getSetting('max_size', false)

--- ClipboardTool.show_copied_alert
--- Variable
--- If `true`, show an alert when a new item is added to the history, i.e. has been copied.
obj.show_copied_alert = true

--- ClipboardTool.honor_ignoredidentifiers
--- Variable
--- 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`
obj.honor_ignoredidentifiers = true

--- ClipboardTool.paste_on_select
--- Variable
--- Whether to auto-type the item when selecting it from the menu. Can be toggled on the fly from the chooser. Defaults to `false`.
obj.paste_on_select = getSetting('paste_on_select', false)

--- ClipboardTool.logger
--- Variable
--- Logger object used within the Spoon. Can be accessed to set the default log level for the messages coming from the Spoon.
obj.logger = hs.logger.new('ClipboardTool')

--- ClipboardTool.ignoredIdentifiers
--- Variable
--- Types of clipboard entries to ignore, see http://nspasteboard.org. Code from https://github.com/asmagill/hammerspoon-config/blob/master/utils/_menus/newClipper.lua.
---
--- Notes:
---  * Default value (don't modify unless you know what you are doing):
--- ```
---  {
---     ["de.petermaurer.TransientPasteboardType"] = true, -- Transient : Textpander, TextExpander, Butler
---     ["com.typeit4me.clipping"]                 = true, -- Transient : TypeIt4Me
---     ["Pasteboard generator type"]              = true, -- Transient : Typinator
---     ["com.agilebits.onepassword"]              = true, -- Confidential : 1Password
---     ["org.nspasteboard.TransientType"]         = true, -- Universal, Transient
---     ["org.nspasteboard.ConcealedType"]         = true, -- Universal, Concealed
---     ["org.nspasteboard.AutoGeneratedType"]     = true, -- Universal, Automatic
---  }
--- ```
obj.ignoredIdentifiers = {
   ["de.petermaurer.TransientPasteboardType"] = true, -- Transient : Textpander, TextExpander, Butler
   ["com.typeit4me.clipping"]                 = true, -- Transient : TypeIt4Me
   ["Pasteboard generator type"]              = true, -- Transient : Typinator
   ["com.agilebits.onepassword"]              = true, -- Confidential : 1Password
   ["org.nspasteboard.TransientType"]         = true, -- Universal, Transient
   ["org.nspasteboard.ConcealedType"]         = true, -- Universal, Concealed
   ["org.nspasteboard.AutoGeneratedType"]     = true, -- Universal, Automatic
}

--- ClipboardTool.deduplicate
--- Variable
--- Whether to remove duplicates from the list, keeping only the latest one. Defaults to `true`.
obj.deduplicate = true

--- ClipboardTool.show_in_menubar
--- Variable
--- Whether to show a menubar item to open the clipboard history. Defaults to `true`
obj.show_in_menubar = true

--- ClipboardTool.menubar_title
--- Variable
--- 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)
obj.menubar_title   = "\u{1f4cb}"

--- ClipboardTool.display_max_length
--- Variable
--- 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.
obj.display_max_length = 200

----------------------------------------------------------------------

-- Internal variable - Chooser/menu object
obj.selectorobj = nil
-- Internal variable - Cache for focused window to work around the current window losing focus after the chooser comes up
obj.prevFocusedWindow = nil
-- Internal variable - Timer object to look for pasteboard changes
obj.timer = nil

local pasteboard = require("hs.pasteboard") -- http://www.hammerspoon.org/docs/hs.pasteboard.html
local hashfn   = require("hs.hash").MD5

-- Keep track of last change counter
local last_change = nil;
-- Array to store the clipboard history
local clipboard_history = nil

-- Internal function - persist the current history so it survives across restarts
function _persistHistory()
   setSetting("items",clipboard_history)
end

--- ClipboardTool:togglePasteOnSelect()
--- Method
--- Toggle the value of `ClipboardTool.paste_on_select`
---
--- Parameters:
---  * None
function obj:togglePasteOnSelect()
   self.paste_on_select = setSetting("paste_on_select", not self.paste_on_select)
   hs.notify.show("ClipboardTool", "Paste-on-select is now " .. (self.paste_on_select and "enabled" or "disabled"), "")
end

function obj:toggleMaxSize()
   self.max_size = setSetting("max_size", not self.max_size)
   hs.notify.show("ClipboardTool", "Max Size is now " .. (self.max_size and "enabled" or "disabled"), "")
end

-- Internal method - process the selected item from the chooser. An item may invoke special actions, defined in the `actions` variable.
function obj:_processSelectedItem(value)
   local actions = {
      none = function() end,
      clear = hs.fnutils.partial(self.clearAll, self),
      toggle_paste_on_select = hs.fnutils.partial(self.togglePasteOnSelect, self),
      toggle_max_size = hs.fnutils.partial(self.toggleMaxSize, self),
   }
   if self.prevFocusedWindow ~= nil then
      self.prevFocusedWindow:focus()
   end
   if value and type(value) == "table" then
      if value.action and actions[value.action] then
         actions[value.action](value)
      elseif value.text then
         if value.type == "text" then
            pasteboard.setContents(value.data)
         elseif value.type == "image" then 
            pasteboard.writeObjects(hs.image.imageFromURL(value.data))
         end
--         self:pasteboardToClipboard(value.text)
         if (self.paste_on_select) then
            hs.eventtap.keyStroke({"cmd"}, "v")
         end
      end
      last_change = pasteboard.changeCount()
   end
end

--- ClipboardTool:clearAll()
--- Method
--- Clears the clipboard and history
---
--- Parameters:
---  * None
function obj:clearAll()
   pasteboard.clearContents()
   clipboard_history = {}
   _persistHistory()
   last_change = pasteboard.changeCount()
end

--- ClipboardTool:clearLastItem()
--- Method
--- Clears the last added to the history
---
--- Parameters:
---  * None
function obj:clearLastItem()
   table.remove(clipboard_history, 1)
   _persistHistory()
   last_change = pasteboard.changeCount()
end

-- Internal method: deduplicate the given list, and restrict it to the history size limit
function obj:dedupe_and_resize(list)
   local res={}
   local hashes={}
   for i,v in ipairs(list) do
      if #res < self.hist_size then
         local hash=hashfn(v.content)
         if (not self.deduplicate) or (not hashes[hash]) then
            table.insert(res, v)
            hashes[hash]=true
         end
      end
   end
   return res
end

--- ClipboardTool:pasteboardToClipboard(item)
--- Method
--- Add the given string to the history
---
--- Parameters:
---  * item - string to add to the clipboard history
---
--- Returns:
---  * None
function obj:pasteboardToClipboard(item_type, item)
   table.insert(clipboard_history, 1, {type=item_type, content=item})
   clipboard_history = self:dedupe_and_resize(clipboard_history)
   _persistHistory() -- updates the saved history
end

-- Internal method: actions of the context menu, special paste
function obj:pasteAllWithDelimiter(row, delimiter)
  if self.prevFocusedWindow ~= nil then
      self.prevFocusedWindow:focus()
   end
   print("pasteAllWithTab row:" .. row)
   for ix = row, 1, -1 do
     local entry = clipboard_history[ix]
     print("pasteAllWithTab ix:" .. ix .. ":" .. entry)
--      pasteboard.setContents(entry)
--      os.execute("sleep 0.2")
--      hs.eventtap.keyStroke({"cmd"}, "v")
       hs.eventtap.keyStrokes(entry.content)
--      os.execute("sleep 0.2")
      hs.eventtap.keyStrokes(delimiter)
--      os.execute("sleep 0.2")
   end
end

-- Internal method: actions of the context menu, delete or rearrange of clips
function obj:manageClip(row, action)
    print("manageClip row:" .. row .. ",action:" .. action)
    if action == 0 then
      table.remove (clipboard_history, row)
    elseif action == 2 then
      	local i = 1
        local j = row
        while i < j do
          clipboard_history[i], clipboard_history[j] = clipboard_history[j], clipboard_history[i]
          i = i + 1
          j = j - 1
        end
    else
      local value = clipboard_history[row]
      local new = row + action
      if new < 1 then new = 1 end
      if new < row then
        table.move(clipboard_history, new, row - 1, new + 1)
      else
        table.move(clipboard_history, row + 1, new, row)
      end
      clipboard_history[new] = value
    end
    self.selectorobj:refreshChoicesCallback()
end

-- Internal method:
function obj:_showContextMenu(row)
  print("_showContextMenu row:" .. row)
  point = hs.mouse.getAbsolutePosition()
  local menu = hs.menubar.new(false)
  local menuTable = {
       { title = "Alle Schnipsel mit Tab einfügen", fn = hs.fnutils.partial(self.pasteAllWithDelimiter, self, row, "\t") },
       { title = "Alle Schnipsel mit Zeilenvorschub einfügen", fn = hs.fnutils.partial(self.pasteAllWithDelimiter, self, row, "\n") },
       { title = "-" },
       { title = "Eintrag entfernen",   fn = hs.fnutils.partial(self.manageClip, self, row, 0) },
       { title = "Eintrag an erste Stelle",   fn = hs.fnutils.partial(self.manageClip, self, row, -100)  },
       { title = "Eintrag nach oben",   fn = hs.fnutils.partial(self.manageClip, self, row, -1)  },
       { title = "Eintrag nach unten",   fn = hs.fnutils.partial(self.manageClip, self, row, 1) },
       { title = "Tabelle invertieren",   fn = hs.fnutils.partial(self.manageClip, self, row, 2) },
       { title = "-" },
       { title = "disabled item", disabled = true },
       { title = "checked item", checked = true },
   }
  menu:setMenu(menuTable)
  menu:popupMenu(point)
  print(hs.inspect(point))
end

-- Internal function - fill in the chooser options, including the control options
function obj:_populateChooser(query)
   query = query:lower()
   menuData = {}
   for k,v in pairs(clipboard_history) do
      if (v.type == "text" and (query == "" or v.content:lower():find(query))) then
         table.insert(menuData, { text = string.sub(v.content, 0, obj.display_max_length),
                                  data = v.content,
                                  type = v.type})
      elseif (v.type == "image") then
         table.insert(menuData, { text = "《Image data》",
                                  type = v.type,
                                  data = v.content,
                                  image = hs.image.imageFromURL(v.content)})
      end
   end
   if #menuData == 0 then
      table.insert(menuData, { text="",
                               subText="《Clipboard is empty》",
                               action = 'none',
                               image = hs.image.imageFromName('NSCaution')})
   else
      table.insert(menuData, { text="《Clear Clipboard History》",
                               action = 'clear',
                               image = hs.image.imageFromName('NSTrashFull') })
   end
   table.insert(menuData, {
                   text="《" .. (self.paste_on_select and "Disable" or "Enable") .. " Paste-on-select》",
                   action = 'toggle_paste_on_select',
                   image = (self.paste_on_select and hs.image.imageFromName('NSSwitchEnabledOn') or hs.image.imageFromName('NSSwitchEnabledOff'))
   })
   table.insert(menuData, {
                   text="《" .. (self.max_size and "Disable" or "Enable") .. " max size " .. self.max_entry_size .. "》",
                   action = 'toggle_max_size',
                   image = (self.max_size and hs.image.imageFromName('NSSwitchEnabledOn') or hs.image.imageFromName('NSSwitchEnabledOff'))
   })
   self.logger.df("Returning menuData = %s", hs.inspect(menuData))
   return menuData
end

--- ClipboardTool:shouldBeStored()
--- Method
--- Verify whether the pasteboard contents matches one of the values in `ClipboardTool.ignoredIdentifiers`
---
--- Parameters:
---  * None
function obj:shouldBeStored()
   -- Code from https://github.com/asmagill/hammerspoon-config/blob/master/utils/_menus/newClipper.lua
   local goAhead = true
   for i,v in ipairs(hs.pasteboard.pasteboardTypes()) do
      if self.ignoredIdentifiers[v] then
         goAhead = false
         break
      end
   end
   if goAhead then
      for i,v in ipairs(hs.pasteboard.contentTypes()) do
         if self.ignoredIdentifiers[v] then
            goAhead = false
            break
         end
      end
   end
   return goAhead
end

-- Internal method:
function obj:reduceSize(text)
  print(#text .. " ? " .. tostring(max_entry_size))
  local endingpos = 3000
  local lastLowerPos = 3000
  repeat
    lastLowerPos = endingpos
    _, endingpos = string.find(text, "\n\n", endingpos+1)
    print("endingpos:" .. endingpos)
  until endingpos > obj.max_entry_size
  return string.sub(text, 1, lastLowerPos)
end


--- ClipboardTool:checkAndStorePasteboard()
--- Method
--- If the pasteboard has changed, we add the current item to our history and update the counter
---
--- Parameters:
---  * None
function obj:checkAndStorePasteboard()
   now = pasteboard.changeCount()
   if (now > last_change) then
      if (not self.honor_ignoredidentifiers) or self:shouldBeStored() then
         current_clipboard = pasteboard.getContents()
         self.logger.df("current_clipboard = %s", tostring(current_clipboard))
         if (current_clipboard == nil) and (pasteboard.readImage() ~= nil) then
            current_clipboard = pasteboard.readImage()
            self:pasteboardToClipboard("image", current_clipboard:encodeAsURLString())
            if self.show_copied_alert then
                hs.alert.show("Copied image")
            end
            self.logger.df("Adding image (hashed) %s to clipboard history clipboard", hashfn(current_clipboard:encodeAsURLString()))
         elseif current_clipboard ~= nil then
           local size = #current_clipboard
           if obj.max_size and size > obj.max_entry_size then
             local answer = hs.dialog.blockAlert("Clipboard", "The maximum size of " .. obj.max_entry_size .. " was exceeded.", "Copy partially", "Copy all", "NSCriticalAlertStyle")
              print("answer: " .. answer)
              if answer == "Copy partially" then
                current_clipboard = self:reduceSize(current_clipboard)
                size = #current_clipboard
                end
            end
            if self.show_copied_alert then
                hs.alert.show("Copied " .. size .. " chars")
            end
            self.logger.df("Adding %s to clipboard history", current_clipboard)
            self:pasteboardToClipboard("text", current_clipboard)
         else
            self.logger.df("Ignoring nil clipboard content")
         end
      else
         self.logger.df("Ignoring pasteboard entry because it matches ignoredIdentifiers")
      end
      last_change = now
   end
end

--- ClipboardTool:start()
--- Method
--- Start the clipboard history collector
---
--- Parameters:
---  * None
function obj:start()
   obj.logger.level = 0
   clipboard_history = self:dedupe_and_resize(getSetting("items", {})) -- If no history is saved on the system, create an empty history
   last_change = pasteboard.changeCount() -- keeps track of how many times the pasteboard owner has changed // Indicates a new copy has been made
   self.selectorobj = hs.chooser.new(hs.fnutils.partial(self._processSelectedItem, self))
   self.selectorobj:choices(hs.fnutils.partial(self._populateChooser, self, ""))
   self.selectorobj:queryChangedCallback(function(query)
      self.selectorobj:choices(hs.fnutils.partial(self._populateChooser, self, query))
   end)
   self.selectorobj:rightClickCallback(hs.fnutils.partial(self._showContextMenu, self))
   --Checks for changes on the pasteboard. Is it possible to replace with eventtap?
   self.timer = hs.timer.new(self.frequency, hs.fnutils.partial(self.checkAndStorePasteboard, self))
   self.timer:start()
   if self.show_in_menubar then
      self.menubaritem = hs.menubar.new()
         :setTitle(obj.menubar_title)
         :setClickCallback(hs.fnutils.partial(self.toggleClipboard, self))
   end
end

--- ClipboardTool:showClipboard()
--- Method
--- Display the current clipboard list in a chooser
---
--- Parameters:
---  * None
function obj:showClipboard()
   if self.selectorobj ~= nil then
      self.selectorobj:refreshChoicesCallback()
      self.prevFocusedWindow = hs.window.focusedWindow()
      self.selectorobj:show()
   else
      hs.notify.show("ClipboardTool not properly initialized", "Did you call ClipboardTool:start()?", "")
   end
end

--- ClipboardTool:toggleClipboard()
--- Method
--- Show/hide the clipboard list, depending on its current state
---
--- Parameters:
---  * None
function obj:toggleClipboard()
   if self.selectorobj:isVisible() then
      self.selectorobj:hide()
   else
      self:showClipboard()
   end
end

--- ClipboardTool:bindHotkeys(mapping)
--- Method
--- Binds hotkeys for ClipboardTool
---
--- Parameters:
---  * mapping - A table containing hotkey objifier/key details for the following items:
---   * show_clipboard - Display the clipboard history chooser
---   * toggle_clipboard - Show/hide the clipboard history chooser
function obj:bindHotkeys(mapping)
   local def = {
      show_clipboard = hs.fnutils.partial(self.showClipboard, self),
      toggle_clipboard = hs.fnutils.partial(self.toggleClipboard, self),
   }
   hs.spoons.bindHotkeysToSpec(def, mapping)
   obj.mapping = mapping
end

return obj

