;;; org-download.el --- Image drag-and-drop for Emacs org-mode. -*- lexical-binding: t -*-

;; Copyright (C) 2014-2019 Free Software Foundation, Inc.

;; Author: Oleh Krehel
;; URL: https://github.com/abo-abo/org-download
;; Package-Version: 20191016.1227
;; Version: 0.1.0
;; Package-Requires: ((async "1.2"))
;; Keywords: images, screenshots, download

;; This file is not part of GNU Emacs.

;; GNU Emacs is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; GNU Emacs is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:
;;
;; This extension facilitates moving images from point A to point B.
;;
;; Point A (the source) can be:
;; 1. An image inside your browser that you can drag to Emacs.
;; 2. An image on your file system that you can drag to Emacs.
;; 3. A local or remote image address in kill-ring.
;;    Use the `org-download-yank' command for this.
;;    Remember that you can use "0 w" in `dired' to get an address.
;; 4. An screenshot taken using `gnome-screenshot' or `scrot' or `gm'.
;;    Use the `org-download-screenshot' command for this.
;;    Customize the backend with  `org-download-screenshot-method'.
;;
;; Point B (the target) is an Emacs `org-mode' buffer where the inline
;; link will be inserted.  Several customization options will determine
;; where exactly on the file system the file will be stored.
;;
;; They are:
;; `org-download-method':
;; a. 'attach => use `org-mode' attachment machinery
;; b. 'directory => construct the directory in two stages:
;;    1. first part of the folder name is:
;;       * either "." (current folder)
;;       * or `org-download-image-dir' (if it's not nil).
;;         `org-download-image-dir' becomes buffer-local when set,
;;         so each file can customize this value, e.g with:
;;         # -*- mode: Org; org-download-image-dir: "~/Pictures/foo"; -*-
;;    2. second part is:
;;       * `org-download-heading-lvl' is nil => ""
;;       * `org-download-heading-lvl' is n => the name of current
;;         heading with level n. Level count starts with 0,
;;         i.e. * is 0, ** is 1, *** is 2 etc.
;;         `org-download-heading-lvl' becomes buffer-local when set,
;;         so each file can customize this value, e.g with:
;;         # -*- mode: Org; org-download-heading-lvl: nil; -*-
;;
;; `org-download-timestamp':
;; optionally add a timestamp to the file name.
;;
;; Customize `org-download-backend' to choose between `url-retrieve'
;; (the default) or `wget' or `curl'.
;;
;;; Code:


(eval-when-compile
  (require 'cl))
(require 'async)
(require 'url-parse)
(require 'url-http)
(require 'org)

(defgroup org-download nil
  "Image drag-and-drop for org-mode."
  :group 'org
  :prefix "org-download-")

(defcustom org-download-method 'directory
  "The way images should be stored."
  :type '(choice
          (const :tag "Directory" directory)
          (const :tag "Attachment" attach)
          (function :tag "Custom function")))

(defcustom org-download-image-dir nil
  "If set, images will be stored in this directory instead of \".\".
See `org-download--dir-1' for more info."
  :type '(choice
          (const :tag "Default" nil)
          (string :tag "Directory")))
(make-variable-buffer-local 'org-download-image-dir)

(defcustom org-download-heading-lvl 0
  "Heading level to be used in `org-download--dir-2'."
  :type 'integer)
(make-variable-buffer-local 'org-download-heading-lvl)

(defvar org-download-path-last-file nil
  "Variable to hold the full path of the last downloaded file.
See `org-download-rename-last-file'.")

(defcustom org-download-backend t
  "Method to use for downloading."
  :type '(choice
          (const :tag "wget" "wget \"%s\" -O \"%s\"")
          (const :tag "curl" "curl \"%s\" -o \"%s\"")
          (const :tag "url-retrieve" t)))

(defcustom org-download-timestamp "%Y-%m-%d_%H-%M-%S_"
  "This `format-time-string'-style string will be appended to the file name.
Set this to \"\" if you don't want time stamps."
  :type 'string)

(defcustom org-download-img-regex-list
  '("<img +src=\"" "<img +\\(class=\"[^\"]+\"\\)? *src=\"")
  "This regex is used to unalias links that look like images.
The html to which the links points will be searched for these
regexes, one by one, until one succeeds.  The found image address
will be used."
  :type '(repeat string))

(defcustom org-download-screenshot-method "gnome-screenshot -a -f %s"
  "The tool to capture screenshots."
  :type '(choice
          (const :tag "gnome-screenshot" "gnome-screenshot -a -f %s")
          (const :tag "scrot" "scrot -s %s")
          (const :tag "gm" "gm import %s")
          (const :tag "imagemagick/import" "import %s")
          (const :tag "imagemagick/import + xclip to save to clipboard"
           "export filename=\"%s\"; import png:\"$filename\" ;xclip -selection clipboard -target image/png -filter < \"$filename\" &>/dev/null")
          (const :tag "xfce4-screenshooter" "xfce4-screenshooter -r -o cat > %s")
          ;; screenshot method in ms-windows, /capture=4 stands for interactive.
          (const :tag "IrfanView" "i_view64 /capture=4 /convert=\"%s\"")
          ;; screenshot script in osx, -i stands for interactive,
          ;; press space key to toggle between selection and
          ;; window/application mode.
          (const :tag "screencapture" "screencapture -i %s")
          ;; take an image that is already on the clipboard, for Linux
          (const :tag "xclip"
           "xclip -selection clipboard -t image/png -o > %s")
          ;; take an image that is already on the clipboard, for Windows
          (const :tag "imagemagick/convert" "convert clipboard: %s")
          (function :tag "Custom function")))

(defcustom org-download-screenshot-file (expand-file-name "screenshot.png" temporary-file-directory)
  "The file to capture screenshots."
  :type 'string)

(defcustom org-download-image-html-width 0
  "When non-zero add #+attr_html: :width tag to the image."
  :type 'integer)

(defcustom org-download-image-latex-width 0
  "When non-zero add #+attr_latex: :width tag to the image."
  :type 'integer)

(defcustom org-download-image-org-width 0
  "When non-zero add #+attr_org: :width tag to the image."
  :type 'integer)

(defcustom org-download-image-attr-list nil
  "Add attr info to the image.
For example:

  (\"#+attr_html: :width 80% :align center\"
   \"#+attr_org: :width 100px\")"
  :type '(repeat string))

(defcustom org-download-delete-image-after-download nil
  "When non-nil delete local image after download."
  :type 'boolean)

(defcustom org-download-display-inline-images t
  "When non-nil display inline images in org buffer after download."
  :type
  '(choice
    (const :tag "On" t)
    (const :tag "Off" nil)
    (const :tag "Posframe" posframe)))

(defvar org-download-posframe-show-params
  '(;; Please do not remove :timeout or set it to large.
    :timeout 1
    :internal-border-width 1
    :internal-border-color "red"
    :min-width 40
    :min-height 10
    :poshandler posframe-poshandler-window-center)
  "List of parameters passed to `posframe-show'.")

(declare-function posframe-workable-p "ext:posframe")
(declare-function posframe-show "ext:posframe")

(defun org-download--display-inline-images ()
  (cond
   ((eq org-download-display-inline-images t)
    (org-display-inline-images))
   ((eq org-download-display-inline-images 'posframe)
    (require 'posframe)
    (when (posframe-workable-p)
      (let ((buffer (get-buffer-create " *org-download-image")))
        (with-current-buffer buffer
          (erase-buffer)
          (insert-image-file org-download-path-last-file))
        (apply #'posframe-show
               buffer
               org-download-posframe-show-params))))))

(defun org-download-get-heading (lvl)
  "Return the heading of the current entry's LVL level parent."
  (save-excursion
    (let ((cur-lvl (org-current-level)))
      (if cur-lvl
          (progn
            (unless (= cur-lvl 1)
              (org-up-heading-all (- (1- (org-current-level)) lvl)))
            (replace-regexp-in-string
             " " "_"
             (nth 4 (org-heading-components))))
        ""))))

(defun org-download--dir-1 ()
  "Return the first part of the directory path for `org-download--dir'.
It's `org-download-image-dir', unless it's nil.  Then it's \".\"."
  (or org-download-image-dir "."))

(defun org-download--dir-2 ()
  "Return the second part of the directory path for `org-download--dir'.
Unless `org-download-heading-lvl' is nil, it's the name of the current
`org-download-heading-lvl'-leveled heading.  Otherwise it's \"\"."
  (when org-download-heading-lvl
    (org-download-get-heading
     org-download-heading-lvl)))

(defun org-download--dir ()
  "Return the directory path for image storage.

The path is composed from `org-download--dir-1' and `org-download--dir-2'.
The directory is created if it didn't exist before."
  (if (eq major-mode 'org-mode)
      (let* ((part1 (org-download--dir-1))
             (part2 (org-download--dir-2))
             (dir (if part2
                      (format "%s/%s" part1 part2)
                    part1)))
        (unless (file-exists-p dir)
          (make-directory dir t))
        dir)
    default-directory))

(defvar org-download-file-format-function #'org-download-file-format-default)

(defun org-download--fullname (link &optional ext)
  "Return the file name where LINK will be saved to.

It's affected by `org-download--dir'.
EXT can hold the file extension, in case LINK doesn't provide it."
  (let ((filename
         (file-name-nondirectory
          (car (url-path-and-query
                (url-generic-parse-url link)))))
        (dir (org-download--dir)))
    (when (string-match ".*?\\.\\(?:png\\|jpg\\)\\(.*\\)$" filename)
      (setq filename (replace-match "" nil nil filename 1)))
    (when ext
      (setq filename (concat filename "." ext)))
    (abbreviate-file-name
     (expand-file-name
      (funcall org-download-file-format-function filename)
      dir))))

(defun org-download-file-format-default (filename)
  "It's affected by `org-download-timestamp'."
  (concat
   (format-time-string org-download-timestamp)
   filename))

(defun org-download--image (link filename)
  "Save LINK to FILENAME asynchronously and show inline images in current buffer."
  (when (string= "file" (url-type (url-generic-parse-url link)))
    (setq link (url-unhex-string (url-filename (url-generic-parse-url link)))))
  (cond ((and (not (file-remote-p link))
              (file-exists-p link))
         (copy-file link (expand-file-name filename)))
        ((eq org-download-backend t)
         (org-download--image/url-retrieve link filename))
        (t
         (org-download--image/command org-download-backend link filename))))

(defun org-download--image/command (command link filename)
  "Using COMMAND, save LINK to FILENAME.
COMMAND is a format-style string with two slots for LINK and FILENAME."
  (async-start
   `(lambda () (shell-command
                ,(format command link
                         (expand-file-name filename))))
   (lexical-let ((cur-buf (current-buffer)))
     (lambda (_x)
       (with-current-buffer cur-buf
         (org-download--display-inline-images))))))

(defun org-download--write-image (status filename)
  ;; Write current buffer to FILENAME
  (let ((err (plist-get status :error)))
    (when err
      (error
       "HTTP error %s"
       (downcase (nth 2 (assq (nth 2 err) url-http-codes))))))
  (delete-region
   (point-min)
   (progn
     (re-search-forward "\n\n" nil 'move)
     (point)))
  (let ((coding-system-for-write 'no-conversion))
    (write-region nil nil filename nil nil nil 'confirm)))

(defun org-download--image/url-retrieve (link filename)
  "Save LINK to FILENAME using `url-retrieve'."
  (let ((mode major-mode))
    (url-retrieve
     link
     (lambda (status filename buffer)
       (org-download--write-image status filename)
       (cond ((eq mode 'org-mode)
              (with-current-buffer buffer
                (org-download--display-inline-images)))
             ((eq mode 'dired-mode)
              (let ((inhibit-message t))
                (with-current-buffer (dired (file-name-directory filename))
                  (revert-buffer nil t))))))
     (list
      (expand-file-name filename)
      (current-buffer))
     nil t)))

(defun org-download-yank ()
  "Call `org-download-image' with current kill."
  (interactive)
  (org-download-image
   (replace-regexp-in-string "\n+$" "" (current-kill 0))))

(defun org-download-screenshot ()
  "Capture screenshot and insert the resulting file.
The screenshot tool is determined by `org-download-screenshot-method'."
  (interactive)
  (let ((default-directory "~"))
    (make-directory (file-name-directory org-download-screenshot-file) t)
    (if (functionp org-download-screenshot-method)
        (funcall org-download-screenshot-method
                 org-download-screenshot-file)
      (shell-command-to-string
       (format org-download-screenshot-method
               org-download-screenshot-file))))
  (org-download-image org-download-screenshot-file))

(declare-function org-attach-dir "org-attach")
(declare-function org-attach-attach "org-attach")
(declare-function org-attach-sync "org-attach")

(defun org-download-annotate-default (link)
  "Annotate LINK with the time of download."
  (format "#+DOWNLOADED: %s @ %s\n"
          (if (equal link org-download-screenshot-file)
              "screenshot"
            link)
          (format-time-string "%Y-%m-%d %H:%M:%S")))

(defvar org-download-annotate-function
  #'org-download-annotate-default
  "Function that takes LINK and returns a string.
It's inserted before the image link and is used to annotate it.")

(defvar org-download-link-format
  "[[file:%s]]\n"
  "Format of the file link to insert.")

(defun org-download-image (link)
  "Save image at address LINK to `org-download--dir'."
  (interactive "sUrl: ")
  (let (ext)
    (unless (image-type-from-file-name link)
      (with-current-buffer (url-retrieve-synchronously link t)
        (cond ((let ((regexes org-download-img-regex-list)
                     lnk)
                 (while (and (not lnk) regexes)
                   (goto-char (point-min))
                   (when (re-search-forward (pop regexes) nil t)
                     (backward-char)
                     (setq lnk (read (current-buffer)))))
                 (when lnk
                   (setq link lnk))))
              ((progn
                 (goto-char (point-min))
                 (when (re-search-forward "^Content-Type: image/\\(.*\\)$")
                   (setq ext (match-string 1)))))
              (t
               (error "link %s does not point to an image; unaliasing failed" link)))))
    (let ((filename
           (cond ((eq org-download-method 'attach)
                  (let ((org-download-image-dir (progn (require 'org-attach)
                                                       (org-attach-dir t)))
                        org-download-heading-lvl)
                    (org-download--fullname link ext)))
                 ((fboundp org-download-method)
                  (funcall org-download-method link))
                 (t
                  (org-download--fullname link ext)))))
      (setq org-download-path-last-file filename)
      (when (image-type-from-file-name filename)
        (org-download--image link filename)
        (when (eq major-mode 'org-mode)
          (when (eq org-download-method 'attach)
            (org-attach-attach filename nil 'none))
          (org-download-insert-link link filename))
        (when (and (eq org-download-delete-image-after-download t)
                   (not (url-handler-file-remote-p (current-kill 0))))
          (delete-file link delete-by-moving-to-trash))))))

(defun org-download-rename-at-point ()
  "Rename image at point."
  (interactive)
  (let* ((dir-path (org-download--dir))
         (current-name (file-name-nondirectory
                        (org-element-property :path (org-element-context))))
         (current-path (concat dir-path "/" current-name))
         (ext (file-name-extension current-name))
         (new-name (read-string "Rename file at point to: " (file-name-sans-extension current-name)))
         (new-path (concat dir-path "/" new-name "." ext)))
    (rename-file current-path new-path)
    (message "File successfully renamed...")
    (org-download-replace-all current-name (concat new-name "." ext))))

(defun org-download-rename-last-file ()
  "Rename the last downloaded file saved in your computer."
  (interactive)
  (let* ((dir-path (org-download--dir))
         (newname (read-string "Rename last file to: " (file-name-base org-download-path-last-file)))
         (ext (file-name-extension org-download-path-last-file))
         (newpath (concat dir-path "/" newname "." ext)))
    (when org-download-path-last-file
      (rename-file org-download-path-last-file newpath 1)
      (org-download-replace-all
       (file-name-nondirectory org-download-path-last-file)
       (concat newname "." ext))
      (setq org-download-path-last-file newpath)
      (org-download--display-inline-images))))

(defun org-download-replace-all (oldpath newpath)
  "Function to search for the OLDPATH inside the buffer and replace it by the NEWPATH."
  (save-excursion
    (goto-char (point-min))
    (while (re-search-forward oldpath nil t)
      (replace-match newpath))))

(defcustom org-download-abbreviate-filename-function #'file-relative-name
  "Function that takes FILENAME and returns an abbreviated file name."
  :type '(choice
          (const :tag "relative" file-relative-name)
          (const :tag "absolute" expand-file-name)))

(defun org-download-insert-link (link filename)
  (let* ((beg (point))
         (line-beg (line-beginning-position))
         (indent (- beg line-beg))
         (in-item-p (org-in-item-p))
         str)
    (if (looking-back "^[ \t]+" line-beg)
        (delete-region (match-beginning 0) (match-end 0))
      (newline))
    (insert (funcall org-download-annotate-function link))
    (dolist (attr org-download-image-attr-list)
      (insert attr "\n"))
    (insert (if (= org-download-image-html-width 0)
                ""
              (format "#+attr_html: :width %dpx\n" org-download-image-html-width)))
    (insert (if (= org-download-image-latex-width 0)
                ""
              (format "#+attr_latex: :width %dcm\n" org-download-image-latex-width)))
    (insert (if (= org-download-image-org-width 0)
                ""
              (format "#+attr_org: :width %dpx\n" org-download-image-org-width)))
    (insert
     (format org-download-link-format
             (org-link-escape
              (funcall org-download-abbreviate-filename-function filename))))
    (org-download--display-inline-images)
    (setq str (buffer-substring-no-properties line-beg (point)))
    (when in-item-p
      (indent-region line-beg (point) indent))
    str))

(defun org-download--at-comment-p ()
  "Check if current line begins with #+DOWLOADED:."
  (save-excursion
    (move-beginning-of-line nil)
    (looking-at "#\\+DOWNLOADED:")))

(defun org-download-delete ()
  "Delete inline image link on current line, and the file that it points to."
  (interactive)
  (cond ((org-download--at-comment-p)
         (delete-region (line-beginning-position)
                        (line-end-position))
         (org-download--delete (line-beginning-position)
                               nil
                               1))
        ((region-active-p)
         (org-download--delete (region-beginning)
                               (region-end))
         (delete-region (region-beginning)
                        (region-end)))

        ((looking-at org-any-link-re)
         (let ((fname (org-link-unescape
                       (match-string-no-properties 2))))
           (when (file-exists-p fname)
             (delete-file fname)
             (delete-region (match-beginning 0)
                            (match-end 0))
             (when (eolp)
               (delete-char 1)))))

        (t (org-download--delete (line-beginning-position)
                                 (line-end-position))))
  (when (eq org-download-method 'attach)
    (org-attach-sync)))

(defcustom org-download-edit-cmd "gimp %s"
  "Command for editing an image link."
  :type 'string)

(defun org-download-edit ()
  "Open the image at point for editing."
  (interactive)
  (let ((context (org-element-context)))
    (if (not (eq (car-safe context) 'link))
        (user-error "not on a link")
      (start-process-shell-command
       "org-download-edit"
       "org-download-edit"
       (format org-download-edit-cmd
               (shell-quote-wildcard-pattern
                (url-unhex-string (plist-get (cadr context) :path))))))))

(defun org-download--delete (beg end &optional times)
  "Delete inline image links and the files they point to between BEG and END.

When TIMES isn't nil, delete only TIMES links."
  (unless times
    (setq times most-positive-fixnum))
  (save-excursion
    (goto-char beg)
    (while (and (>= (decf times) 0)
                (re-search-forward "\\[\\[file:\\([^]]*\\)\\]\\]" end t))
      (let ((str (match-string-no-properties 1)))
        (delete-region beg
                       (match-end 0))
        (when (file-exists-p str)
          (delete-file str))))))

(defun org-download-dnd-fallback (uri action)
  (let ((dnd-protocol-alist
         (rassq-delete-all
          'org-download-dnd
          (copy-alist dnd-protocol-alist))))
    (dnd-handle-one-url nil action uri)))

(defun org-download-dnd (uri action)
  "When in `org-mode' and URI points to image, download it.
Otherwise, pass URI and ACTION back to dnd dispatch."
  (cond ((eq major-mode 'org-mode)
         (condition-case nil
             (org-download-image uri)
           (error
            (org-download-dnd-fallback uri action))))
        ((eq major-mode 'dired-mode)
         (org-download-dired uri))
        ;; redirect to someone else
        (t
         (org-download-dnd-fallback uri action))))

(defun org-download-dired (uri)
  "Download URI to current directory."
  (raise-frame)
  (org-download-image uri))

(defun org-download-dnd-base64 (uri _action)
  (when (eq major-mode 'org-mode)
    (when (string-match "^data:image/png;base64," uri)
      (let* ((me (match-end 0))
             (filename (org-download--fullname
                        (substring-no-properties uri me (+ me 10))
                        "png")))
        (with-temp-buffer
          (insert (base64-decode-string (substring uri me)))
          (write-file filename))
        (org-download-insert-link filename filename)))))

;;;###autoload
(defun org-download-enable ()
  "Enable org-download."
  (unless (eq (cdr (assoc "^\\(https?\\|ftp\\|file\\|nfs\\):" dnd-protocol-alist))
              'org-download-dnd)
    (setq dnd-protocol-alist
          `(("^\\(https?\\|ftp\\|file\\|nfs\\):" . org-download-dnd)
            ("^data:" . org-download-dnd-base64)
            ,@dnd-protocol-alist))))

(defun org-download-disable ()
  "Disable org-download."
  (rassq-delete-all 'org-download-dnd dnd-protocol-alist))

(org-download-enable)

(provide 'org-download)

;;; org-download.el ends here
