--- /dev/null
+;;; dwim-shell-command.el --- Shell commands with DWIM behaviour -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022 Alvaro Ramirez
+
+;; Author: Alvaro Ramirez
+;; Package-Requires: ((emacs "28.1"))
+;; URL: https://github.com/xenodium/dwim-shell-command
+;; Version: 0.41
+
+;; This package 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, or (at your option)
+;; any later version.
+
+;; This package 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Provides `dwim-shell-command' as an opinionated DWIM alternative to
+;; `shell-command'.
+;;
+;; Use `dwim-shell-command-on-marked-files' to create your own command
+;; line utilities, invoked via M-x.
+;;
+;; See examples at https://github.com/xenodium/dwim-shell-command/blob/main/dwim-shell-commands.el
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'comint)
+(require 'dired)
+(require 'dired-aux)
+(require 'map)
+(require 'seq)
+(require 'shell)
+(require 'simple)
+(require 'subr-x)
+(require 'view)
+
+(defcustom dwim-shell-command-prompt
+ "DWIM shell command (<<f>> <<fne>> <<e>> <<td>> <<*>> <<cb>> <<n>>): "
+ "`dwim-shell-command' prompt. Modify if shorter is preferred."
+ :type 'string
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-default-command
+ " '<<f>>'"
+ "Set to nil if no default shell command wanted."
+ :type 'string
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-buffer-name
+ "DWIM shell command"
+ "`dwim-shell-command' buffer name. Modify if shorter is preferred."
+ :type 'string
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-prompt-on-error nil
+ "If t, prompt user to focus buffer on process error.
+Otherwise, automatically focus buffer on process error."
+ :type 'boolean
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-shell-util nil
+ "Shell util, for example: \"zsh\" or \"bash\".
+Set to nil to use `shell-file-name'."
+ :type 'string
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-shell-args nil
+ "Shell util, for example: '(\"-x\" \"-c\").
+Set to nil to use `shell-command-switch'."
+ :type '(repeat string)
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-done-buffer-name
+ (lambda (name)
+ (format "✅ %s %s" name (propertize "done" 'face 'success)))
+ "Function to format buffer name on success.
+Use `identify' to remove formatting."
+ :type 'function
+ :group 'dwim-shell-command)
+
+(defcustom dwim-shell-command-error-buffer-name
+ (lambda (name)
+ (format "⛔️ %s %s" name (propertize "error" 'face 'error)))
+ "Function to format buffer name on error.
+Use `identify' to remove formatting."
+ :type 'function
+ :group 'dwim-shell-command)
+
+(defvar dwim-shell-command--commands nil "All commands in progress.")
+
+(cl-defstruct
+ dwim-shell-command--command
+ "Describes a command in progress."
+ script
+ process
+ name
+ calling-buffer
+ reporter
+ on-completion
+ files-before
+ silent-success
+ error-autofocus
+ monitor-directory)
+
+(defun dwim-shell-command (prefix)
+ "Execute DWIM shell command asynchronously using noweb templates.
+
+Which files
+
+ `dwim-shell-command' attempts to guess which file(s) you may want
+ the command to operate on.
+
+ 1. If visiting a `dired' buffer, draw the marked file(s).
+ 2. If visiting any other buffer with an associated file, use that.
+
+Templates
+
+ Operate on drawn files using either the following:
+
+ <<f>> (file path)
+ <<fne>> (file path without extension)
+ <<e>> (extension)
+ <<td>> (generate a temporary directory)
+ <<*>> (all files joined)
+ <<cb>> (clipboard)
+ <<n>>, <<1n>>, or <<An>> (for current iteration)
+
+ For example:
+
+ With drawn files '(\"path/to/image1.png\" \"path/to/image2.png\")
+
+ \"convert '<<f>>' '<<fne>>.jpg'\" expands to
+
+ \"convert 'path/to/image1.png' 'path/to/image1.jpg'\"
+ \"convert 'path/to/image2.png' 'path/to/image2.jpg'\"
+
+ while \"ls -lh <<*>>\" expands to
+
+ \"ls -lh path/to/image1.png path/to/image2.png\"
+
+Focus
+
+ `dwim-shell-command' creates a process buffer to capture command
+ output, but doesn't display or focus on it by default. Instead,
+ it tries to guess what's more convenient to focus on.
+
+ While the process is busy, show a spinner in the minibuffer. No
+ focus changes.
+
+ After process is finished:
+
+ 1. If there were any files created in the `default-directory',
+ jump to a `dired' buffer and move point to the new file (via
+ `dired-jump').
+
+ 2. If no new files were created, automatically switch focus to the
+ process buffer and display its output.
+
+ Note: You can prevent this automatic focus by prepending your
+ command with whitespace.
+
+ |
+ V
+ \" convert '<<f>>' '<<fne>>.jpg'\"
+
+ 3. If the shell command caused any errors, offer to focus the
+ process buffer and display its output.
+
+Quick exit
+
+ Process buffers are read-only and can be quickly closed by
+ pressing `q'.
+
+Prefix
+
+ With PREFIX, execute command that number of times."
+ (interactive "p")
+ (let ((script (dwim-shell-command--read-shell-command)))
+ (dwim-shell-command-on-marked-files
+ dwim-shell-command-buffer-name script
+ :repeat prefix
+ :shell-util dwim-shell-command-shell-util
+ :shell-args dwim-shell-command-shell-args
+ :silent-success (string-prefix-p " " script)
+ :error-autofocus (not dwim-shell-command-prompt-on-error))))
+
+(defun dwim-shell-command--read-shell-command ()
+ "Read a shell command from the minibuffer, using `shell-command-history'."
+ (minibuffer-with-setup-hook
+ (lambda ()
+ (beginning-of-line)
+ (setq-local minibuffer-default-add-function
+ #'minibuffer-default-add-shell-commands))
+ (read-from-minibuffer dwim-shell-command-prompt dwim-shell-command-default-command nil nil 'shell-command-history)))
+
+(cl-defun dwim-shell-command-on-marked-files (buffer-name script &key utils extensions shell-util shell-args shell-pipe post-process-template on-completion repeat silent-success no-progress error-autofocus monitor-directory focus-now join-separator)
+ "Create DWIM utilities executing templated SCRIPT on given files.
+
+Here's a simple utility invoking SCRIPT to convert image files to jpg.
+
+ (defun dwim-shell-command-convert-image-to-jpg ()
+ \"Convert all marked images to jpg(s).\"
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ \"Convert to jpg\"
+ \"convert -verbose '<<f>>' '<<fne>>.jpg'\"
+ :utils \"convert\"))
+
+Check `dwim-shell-command-commands.el' for more examples.
+
+All command process output is written to a buffer with BUFFER-NAME.
+
+All params explained in `dwim-shell-command-execute-script'.
+
+Which files
+
+ `dwim-shell-command-on-marked-files' attempts to guess which file(s)
+ you may want the command to operate on.
+
+ 1. If visiting a `dired' buffer, draw the marked file(s).
+ 2. If visiting any other buffer with an associated file, use that.
+
+Templates
+
+ Operate on drawn files using either the following:
+
+ <<f>> (file path)
+ <<fne>> (file path without extension)
+ <<e>> (extension)
+ <<td>> (generate a temporary directory)
+ <<*>> (all files joined)
+ <<cb>> (clipboard)
+ <<n>>, <<1n>>, or <<An>> (for current iteration)
+
+ For example:
+
+ With drawn files '(\"path/to/image1.png\" \"path/to/image2.png\")
+
+ \"convert '<<f>>' '<<fne>>.jpg'\" expands to
+
+ \"convert 'path/to/image1.png' 'path/to/image1.jpg'\"
+ \"convert 'path/to/image2.png' 'path/to/image2.jpg'\"
+
+ while \"ls -lh <<*>>\" expands to
+
+ \"ls -lh path/to/image1.png path/to/image2.png\"
+
+Focus
+
+ `dwim-shell-command-on-marked-files' creates a process buffer to
+ capture command output, but doesn't display or focus on it by
+ default. Instead, it tries to guess what's more convenient to focus
+ on.
+
+ While the process is busy, show a spinner in the minibuffer. No
+ focus changes.
+
+ After process is finished:
+
+ 1. If there were any files created in the `default-directory',
+ jump to a `dired' buffer and move point to the new file (via
+ `dired-jump').
+
+ 2. If no new files were created, automatically switch focus to the
+ process buffer and display its output.
+
+ Note: You can prevent this automatic focus by prepending your
+ command with whitespace.
+
+ |
+ V
+ \" convert '<<f>>' '<<fne>>.jpg'\"
+
+ 3. If the shell command caused any errors, offer to focus the
+ process buffer and display its output.
+
+Quick exit
+
+ Process buffers are read-only and can be quickly closed by
+ pressing `q'."
+ (dwim-shell-command-execute-script buffer-name script
+ :files (dwim-shell-command--files)
+ :utils utils
+ :extensions extensions
+ :shell-util shell-util
+ :shell-args shell-args
+ :shell-pipe shell-pipe
+ :post-process-template post-process-template
+ :on-completion on-completion
+ :silent-success silent-success
+ :no-progress no-progress
+ :repeat repeat
+ :error-autofocus error-autofocus
+ :monitor-directory monitor-directory
+ :focus-now focus-now
+ :join-separator join-separator))
+
+(cl-defun dwim-shell-command-execute-script (buffer-name script &key files extensions shell-util shell-args shell-pipe utils post-process-template on-completion silent-success gen-temp-dir repeat no-progress error-autofocus monitor-directory focus-now join-separator)
+ "Execute a script asynchronously, DWIM style with SCRIPT and BUFFER-NAME.
+
+:FILES are used to instantiate SCRIPT as a noweb template.
+
+ The following are supported:
+
+ <<f>> (file path)
+ <<fne>> (file path without extension)
+ <<e>> (extension)
+ <<td>> (generate a temporary directory)
+ <<*>> (all files joined)
+ <<cb>> (clipboard)
+ <<n>>, <<1n>>, or <<An>> (for current iteration)
+
+ For example:
+
+ Given :FILES '(\"path/to/image1.png\" \"path/to/image2.png\")
+
+ \"convert '<<f>>' '<<fne>>.jpg'\" expands to
+
+ \"convert 'path/to/image1.png' 'path/to/image1.jpg'\"
+ \"convert 'path/to/image2.png' 'path/to/image2.jpg'\"
+
+ and \"ls -lh <<*>>\" expands to
+
+ \"ls -lh path/to/image1.png path/to/image2.png\"
+
+:EXTENSIONS ensures that all files in :FILES have the given
+extensions. Can be either single string \"png\" or a list '(\"png\" \"jpg\").
+
+:SHELL-UTIL and :SHELL-ARGS can be used to specify SCRIPT interpreter.
+
+ For python, use:
+
+ (dwim-shell-command-execute-script
+ \"Print Pi\"
+ \"import math
+ print math.pi\"
+ :shell-util \"python\"
+ :shell-args \"-c\")
+
+:SHELL-PIPE can be used to pipe SCRIPT to it
+
+ For swift, use:
+
+ (dwim-shell-command-on-marked-files
+ \"Print Pi\"
+ \"print(Double.pi)\"
+ :shell-pipe \"swift -\")
+
+:UTILS ensures that all needed command line utilities are installed.
+Can be either a single string \"ffmpeg\" or a list '(\"ffmpet\" \"convert\").
+
+:POST-PROCESS-TEMPLATE enables processing template further after noweb
+instantiation.
+
+:ON-COMPLETION is invoked after SCRIPT executes (disabling DWIM
+internal behavior).
+
+:SILENT-SUCCESS to avoid jumping to process buffer if neither error
+ nor file generated.
+
+:GEN-TEMP-DIR to generate a temporary directory for this command.
+This is implied when <<td>> appears in the script.
+
+:REPEAT Use to repeat script N number of times.
+
+:NO-PROGRESS Suppress progress reporting.
+
+:ERROR-AUTOFOCUS Automatically focus process buffer on error.
+
+:MONITOR-DIRECTORY Monitor this directory for new files.
+
+:FOCUS-NOW Immediately focus process buffer once started."
+ (cl-assert buffer-name nil "Script must have a buffer name")
+ (cl-assert (not (string-empty-p script)) nil "Script must not be empty")
+ (when (stringp extensions)
+ (setq extensions (list extensions)))
+ (when (and shell-util (stringp shell-util))
+ (setq shell-util (list shell-util)))
+ (setq shell-util (or shell-util
+ (when shell-file-name
+ (list shell-file-name))
+ '("zsh")))
+ (setq shell-args (or shell-args
+ (when shell-command-switch
+ (list shell-command-switch))
+ '("-x" "-c")))
+ ;; See if -x can be prepended.
+ (when (and (not (seq-contains-p shell-args "-x"))
+ (apply #'dwim-shell-command--program-test
+ (seq-concatenate
+ 'list shell-util '("-x") shell-args (list "echo"))))
+ (setq shell-args (seq-concatenate 'list '("-x") shell-args)))
+ (when (and shell-args (stringp shell-args))
+ (setq shell-args (list shell-args)))
+ (when (stringp utils)
+ (setq utils (list utils)))
+ (when (or gen-temp-dir (string-match-p "\<\<td\>\>" script 0))
+ (setq gen-temp-dir (make-temp-file "dwim-shell-command-" t)))
+ (when (and repeat (> repeat 1))
+ (cl-assert (<= (length files) 1) nil
+ "Must not repeat when multiple files are selected.")
+ (setq files (make-list repeat (or (seq-first files) "_no_file_selected_"))))
+ (when (seq-empty-p files)
+ (cl-assert (not (or (dwim-shell-command--contains-multi-file-ref script)
+ (dwim-shell-command--contains-single-file-ref script)))
+ nil "No files found to expand %s"
+ (or (dwim-shell-command--contains-multi-file-ref script)
+ (dwim-shell-command--contains-single-file-ref script))))
+ (when extensions
+ (seq-do (lambda (file)
+ (cl-assert (seq-contains-p extensions (downcase (file-name-extension file)))
+ nil "Not a .%s file" (string-join extensions " .")))
+ files))
+ (seq-do (lambda (util)
+ (cl-assert (executable-find util) nil (format "%s not installed" util)))
+ utils)
+ (let* ((replacements (dwim-shell-command--extract-queries script))
+ (proc-buffer (generate-new-buffer (format "*%s*" buffer-name)))
+ (template script)
+ (script "")
+ (files-before)
+ (proc)
+ (progress-reporter)
+ (padding (dwim-shell-command--digits (length files)))
+ (n (or (dwim-shell-command--n-start-value template padding) "1")))
+ (if (seq-empty-p files)
+ (setq script (dwim-shell-command--expand-file-template template nil post-process-template gen-temp-dir n replacements))
+ (if (dwim-shell-command--contains-multi-file-ref template)
+ (setq script (dwim-shell-command--expand-files-template template files post-process-template gen-temp-dir replacements join-separator))
+ (seq-do (lambda (file)
+ (setq script
+ (concat script "\n"
+ (dwim-shell-command--expand-file-template template file post-process-template gen-temp-dir n replacements)))
+ (setq n (dwim-shell-command--increment-string n padding)))
+ files)))
+ (setq script (string-trim script))
+ (with-current-buffer proc-buffer
+ (shell-mode)
+ (setq default-directory default-directory)
+ (shell-command-save-pos-or-erase)
+ (view-mode +1)
+ (setq view-exit-action 'kill-buffer))
+ (setq files-before (dwim-shell-command--default-directory-files monitor-directory))
+ (setq proc (apply #'start-process (seq-concatenate 'list
+ (list (buffer-name proc-buffer) proc-buffer)
+ shell-util
+ shell-args
+ (if shell-pipe
+ (list (format "echo '%s' | %s" script shell-pipe))
+ (list script)))))
+ (set-process-query-on-exit-flag proc nil)
+ (if no-progress
+ (dwim-shell-command--message "%s started" (process-name proc))
+ (setq progress-reporter (make-progress-reporter
+ ;; Append space so "done" is spaced when
+ ;; progress reporter is finished:
+ ;;
+ ;; *DWIM shell command* done
+ (concat (process-name proc) " ")))
+ (progress-reporter-update progress-reporter))
+ ;; Momentarily set buffer to same window, so it's next in recent stack.
+ ;; Makes finding the shell command buffer a lot easier.
+ (let ((current (current-buffer)))
+ (pop-to-buffer-same-window proc-buffer)
+ (pop-to-buffer-same-window current))
+ (when focus-now
+ (switch-to-buffer proc-buffer))
+ (if (equal (process-status proc) 'exit)
+ (dwim-shell-command--finalize (current-buffer)
+ files-before
+ proc
+ progress-reporter
+ on-completion
+ silent-success
+ error-autofocus
+ monitor-directory)
+ (setq dwim-shell-command--commands
+ (push (cons (process-name proc)
+ (make-dwim-shell-command--command :script script
+ :process proc
+ :name (process-name proc)
+ :calling-buffer (current-buffer)
+ :files-before files-before
+ :reporter progress-reporter
+ :on-completion on-completion
+ :silent-success silent-success
+ :error-autofocus error-autofocus
+ :monitor-directory monitor-directory))
+ dwim-shell-command--commands))
+ (set-process-sentinel proc #'dwim-shell-command--sentinel)
+ (set-process-filter proc #'dwim-shell-command--filter))))
+
+(cl-defun dwim-shell-command-read-file-name (prompt &key extension default)
+ "Invoke `read-string' with PROMPT.
+Validates :EXTENSION and returns :DEFAULT if empty input."
+ (let ((file-name (read-string prompt)))
+ (cond ((string-empty-p (string-trim file-name))
+ default)
+ ((and extension
+ (string-equal (file-name-extension file-name) extension))
+ file-name)
+ ((and extension
+ (not (string-equal (file-name-extension file-name) extension)))
+ (user-error "Name must end in .%s" extension))
+ (t
+ file-name))))
+
+(defun dwim-shell-command--message (message &rest args)
+ "Like `dwim-shell-command--message' but non-blocking.
+MESSAGE and ARGS same as `dwim-shell-command--message'."
+ (let ((message-id (random)))
+ (message (propertize (apply #'format message args) 'message-id message-id))
+ (run-with-timer 3 nil
+ (lambda ()
+ (when (and (current-message)
+ (eq (get-text-property 0 'message-id (current-message))
+ message-id))
+ (message nil))))))
+
+(defun dwim-shell-command--extract-queries (template)
+ "Extract queries from TEMPLATE.
+
+For all queries, request a value from the user.
+
+For example:
+
+ \"Hello <<Width:100>> world <<Height:200>>\" =>
+
+ ((\"<<Width:100>>\" . \"100\")
+ (\"<<Height:200>>\" . \"200\"))"
+ (let ((matches)
+ (pos 0))
+ (while (and (< pos (length template))
+ (string-match "<<\\([[:alpha:]]\\|[[:blank:]]\\)+:\\([[:alnum:]]\\|[.]\\)*>>" template pos))
+ (setq pos (1+ (match-beginning 0)))
+ (let ((match 0))
+ (push (match-string match template) matches)
+ (setq match (1+ match))))
+ (seq-map (lambda (match)
+ (let* ((query (split-string (string-remove-suffix ">>" (string-remove-prefix "<<" match)) ":"))
+ (prompt (nth 0 query))
+ (default-value (if (string-empty-p (nth 1 query))
+ nil
+ (nth 1 query)))
+ (value (if (string-match-p "^\\([[:digit:]]\\|[.]\\)+$" default-value)
+ (number-to-string (read-number (format "%s: " prompt)
+ (string-to-number default-value)))
+ (string-trim (read-string (concat prompt
+ (if default-value
+ (format " (default %s): " default-value)
+ ": "))))))
+ (result (cons match (if (string-empty-p value)
+ default-value
+ value))))
+ (cl-assert (cdr result) nil "Must have a value")result))
+ (seq-uniq (nreverse matches)))))
+
+(defun dwim-shell-command--digits (n)
+ "Return the number of digits in N."
+ (let ((count 0))
+ (while (> n 0)
+ (setq n (/ n 10))
+ (setq count (1+ count)))
+ count))
+
+(defun dwim-shell-command--number-to-string (n padding)
+ "Convert N to string using PADDING for number of digits."
+ (format (format "%%0%dd" padding) n))
+
+(defun dwim-shell-command--expand-files-template (template files &optional post-process-template temp-dir replacements join-separator)
+ "Expand TEMPLATE using FILES.
+
+Expand using <<*>> for FILES.
+
+Note: This expander cannot be used to expand <<f>>, <<fne>>, or <<e>>.
+
+ For example:
+
+ Given FILES '(\"path/to/image1.png\" \"path/to/image2.png\")
+
+ \"du -csh '<<*>>'\" expands to
+
+ \"du -csh 'path/to/image1.png' 'path/to/image2.png'\"
+
+Use POST-PROCESS-TEMPLATE to further expand template given own logic.
+
+Set TEMP-DIR to a unique temp directory to this template.
+
+REPLACEMENTS is a cons list of literals to replace with values.
+
+JOIN-SEPARATOR is used to join files from <<*>>."
+ (cl-assert (not (and (dwim-shell-command--contains-multi-file-ref template)
+ (dwim-shell-command--contains-single-file-ref template)))
+ nil "Must not have %s and %s in the same template"
+ (dwim-shell-command--contains-multi-file-ref template)
+ (dwim-shell-command--contains-single-file-ref template))
+ (setq files (seq-map (lambda (file)
+ (expand-file-name file))
+ files))
+
+ (mapc (lambda (replacement)
+ (setq template
+ (string-replace (car replacement) (cdr replacement) template)))
+ replacements)
+
+ ;; Try to use quotes surrounding <<*>> in each path.
+ ;; "'<<*>>'" with '("path/to/image1.png" "path/to/image2.png") -> "'path/to/image1.png' 'path/to/image2.png'"
+ (when-let* ((quoting (dwim-shell-command--escaped-quote-around "\<\<\\*\>\>" template))
+ (unescaped-quote (nth 0 quoting))
+ (escaped-quote (nth 1 quoting)))
+ (setq template (replace-regexp-in-string "\\([^ ]\\)\\(\<\<\\*\>\>\\)\\([^ ]\\)"
+ (string-join (seq-map (lambda (file)
+ (concat unescaped-quote
+ (string-replace unescaped-quote
+ escaped-quote file) unescaped-quote))
+ files)
+ (or join-separator " "))
+ template nil nil 0)))
+
+ ;; "<<some.txt(u)>>" -> some.txt (if unique)
+ ;; -> some(1).txt (if it exist)
+ (when-let* ((found (string-match "\<\<\\([^ ]?+\\)(u)\>\>" template))
+ (name (match-string 1 template)))
+ (setq template (replace-regexp-in-string "\<\<\\([^ ]?+\\)(u)\>\>"
+ (dwim-shell-command--unique-new-file-path name)
+ template nil nil 0)))
+
+ ;; "<<~>>" -> "/home/user" (or equivalent).
+ (when (string-match "\<\<~\>\>" template)
+ (setq template (replace-regexp-in-string "\<\<~\>\>"
+ (expand-file-name "~")
+ template nil nil 0)))
+
+ ;; "<<*>>" with '("path/to/image1.png" "path/to/image2.png") -> "path/to/image1.png path/to/image2.png"
+ (setq template (replace-regexp-in-string "\\(\<\<\\*\>\>\\)" (string-join files (or join-separator " ")) template nil nil 1))
+
+ ;; "<<td>>" with TEMP-DIR -> "/var/folders/m7/ky091cp56d5g68nyhl4y7frc0000gn/T/dwim-shell-command-JNK4V5"
+ (setq template (replace-regexp-in-string "\\(\<\<td\>\>\\)" temp-dir template nil nil 1))
+
+ ;; "<<cb>>" with (current-kill 0) -> "whatever was in kill ring"
+ (when (string-match "\<\<cb\>\>" template)
+ (setq template (replace-regexp-in-string "\\(\<\<cb\>\>\\)" (current-kill 0)
+ template nil nil 1)))
+
+ (when post-process-template
+ (setq template (funcall post-process-template template files)))
+ template)
+
+(defun dwim-shell-command--escaped-quote-around (needle haystack &optional unbalanced)
+ "Find NEEDLE in HAYSTACK that's surrounded by either ' or \".
+
+Set UNBALANCED to t if NEEDLE isn't surrounded by quotes on both sides.
+
+For example:
+
+ \"\<\<fne\>\>\" \"before \"<<fne>>\" after\" => (\"\"\" \"\\\\\"\")
+
+ \"\<\<fne\>\>\" \"before '<<fne>>' after\" => (\"'\" \"'\"'\"'\")"
+ (when-let ((found (string-match (format "\\([^ ]\\)\\(%s\\)\\([^ ]\\)" needle) haystack))
+ (unescaped-quote (if unbalanced
+ (or (match-string 1 haystack)
+ (match-string 3 haystack))
+ (cl-assert (string-equal (match-string 1 haystack)
+ (match-string 3 haystack)) nil
+ "%s must match %s"
+ (match-string 1 haystack)
+ (match-string 3 haystack))
+ (match-string 1 haystack)))
+ (escaped-quote "'"))
+ ;; Known quoted quotes.
+ (cond
+ ((string-equal unescaped-quote "\"")
+ (setq escaped-quote "\\\\\""))
+ ((string-equal unescaped-quote "'")
+ (setq escaped-quote "'\"'\"'"))
+ (t
+ (error "Couldn't figure out how to quote for \"%s\" using %s and %s"
+ haystack
+ (match-string 1 haystack)
+ (match-string 3 haystack))))
+ (list unescaped-quote escaped-quote)))
+
+(defun dwim-shell-command--expand-file-template (template file &optional post-process-template temp-dir current replacements)
+ "Expand TEMPLATE using FILE.
+
+Expand using <<f>> for FILE, <<fne>> for FILE without extension, and
+<<e>> for FILE extension. <<n>>, <<1n>>, or <<an>> is replaced with
+CURRENT. <<some.txt(u)>> expands to unique \"some(1).txt\".
+
+Note: This expander cannot be used to expand <<*>>.
+
+ For example:
+
+ Given FILE \"path/to/image.png\"
+
+ \"convert '<<f>>' '<<fne>>.jpg'\" expands to
+
+ \"convert 'path/to/image.png' 'path/to/image.jpg'\"
+
+Use POST-PROCESS-TEMPLATE to further expand template given own logic.
+
+Set TEMP-DIR to a unique temp directory to this template.
+
+REPLACEMENTS is a cons list of literals to replace with values."
+ (cl-assert (not (and (dwim-shell-command--contains-multi-file-ref template)
+ (dwim-shell-command--contains-single-file-ref template)))
+ nil "Must not have %s and %s in the same template"
+ (dwim-shell-command--contains-multi-file-ref template)
+ (dwim-shell-command--contains-single-file-ref template))
+
+ (mapc (lambda (replacement)
+ (setq template
+ (string-replace (car replacement) (cdr replacement) template)))
+ replacements)
+
+ (when file
+ (setq file (expand-file-name file))
+ ;; "<<fne>>" with "/path/tmp.txt" -> "/path/tmp"
+ (if-let* ((quoting (dwim-shell-command--escaped-quote-around "\<\<fne\>\>" template t))
+ (unescaped-quote (nth 0 quoting))
+ (escaped-quote (nth 1 quoting)))
+ (setq template (replace-regexp-in-string "\\([^ ]\\)\\(\<\<fne\>\>\\)"
+ (string-replace unescaped-quote escaped-quote (file-name-sans-extension file))
+ template nil nil 2))
+ (setq template (replace-regexp-in-string "\\(\<\<fne\>\>\\)" (file-name-sans-extension file) template nil nil 1)))
+
+ ;; "<<fbn>>" with "/path/tmp.txt" -> "tmp.txt"
+ (if-let* ((quoting (dwim-shell-command--escaped-quote-around "\<\<fbn\>\>" template t))
+ (unescaped-quote (nth 0 quoting))
+ (escaped-quote (nth 1 quoting)))
+ (setq template (replace-regexp-in-string "\\(\<\<fbn\>\>\\)\\([^ ]\\)"
+ (string-replace unescaped-quote escaped-quote (file-name-nondirectory file))
+ template nil nil 1))
+ (setq template (replace-regexp-in-string "\\(\<\<fbn\>\>\\)" (file-name-nondirectory file) template nil nil 1)))
+
+ ;; "<<e>>" with "/path/tmp.txt" -> "txt"
+ (if (file-name-extension file)
+ (setq template (replace-regexp-in-string "\\(\<\<e\>\>\\)" (file-name-extension file) template nil nil 1))
+ ;; File had no extension. Attempt to remove .<<e>>.
+ (setq template (replace-regexp-in-string "\\(\.\<\<e\>\>\\)" "" template nil nil 1)))
+
+ ;; "<<f>>" with "/path/file.jpg" -> "/path/file.jpg"
+ (if-let* ((quoting (dwim-shell-command--escaped-quote-around "\<\<f\>\>" template))
+ (unescaped-quote (nth 0 quoting))
+ (escaped-quote (nth 1 quoting)))
+ (setq template (replace-regexp-in-string "\\([^ ]\\)\\(\<\<f\>\>\\)\\([^ ]\\)"
+ (string-replace unescaped-quote escaped-quote file)
+ template nil nil 2))
+ (setq template (replace-regexp-in-string "\\(\<\<f\>\>\\)" file template nil nil 1))))
+
+ ;; "<<some.txt(u)>>" -> some.txt (if unique)
+ ;; -> some(1).txt (if it exist)
+ (when-let* ((found (string-match "\<\<\\([^ ]?+\\)(u)\>\>" template))
+ (name (match-string 1 template)))
+ (setq template (replace-regexp-in-string "\<\<\\([^ ]?+\\)(u)\>\>"
+ (dwim-shell-command--unique-new-file-path name)
+ template nil nil 0)))
+
+ ;; "<<~>>" -> "/home/user" (or equivalent).
+ (when (string-match "\<\<~\>\>" template)
+ (setq template (replace-regexp-in-string "\<\<~\>\>"
+ (expand-file-name "~")
+ template nil nil 0)))
+
+ ;; "<<td>>" with TEMP-DIR -> "/var/folders/m7/ky091cp56d5g68nyhl4y7frc0000gn/T/dwim-shell-command-JNK4V5"
+ (setq template (replace-regexp-in-string "\\(\<\<td\>\>\\)" temp-dir template nil nil 1))
+
+ ;; "<<cb>>" with (current-kill 0) -> "whatever was in kill ring"
+ (when (string-match "\<\<cb\>\>" template)
+ (setq template (replace-regexp-in-string "\\(\<\<cb\>\>\\)" (current-kill 0)
+ template nil nil 1)))
+
+ ;; "<<n>>" or "<<an>" or "<<1n>" with current.
+ (setq template (replace-regexp-in-string "\\(\<\<[[:alnum:]]?+n\>\>\\)" current
+ template nil nil 1))
+
+ (when post-process-template
+ (setq template (funcall post-process-template template file)))
+ template)
+
+(defun dwim-shell-command--contains-single-file-ref (template)
+ "Check for <<f>>, <<fne>>, or <<e>> in TEMPLATE."
+ (cond ((string-match "\<\<f\>\>" template)
+ "<<f>>")
+ ((string-match "\<\<fne\>\>" template)
+ "<<fne>>")
+ ((string-match "\<\<fbn\>\>" template)
+ "<<fbn>>")
+ ((string-match "\<\<e\>\>" template)
+ "<<e>>")))
+
+(defun dwim-shell-command--contains-multi-file-ref (template)
+ "Check for <<*>> in TEMPLATE."
+ (when (string-match "\<\<\\*\>\>" template)
+ "<<*>>"))
+
+(defun dwim-shell-command--default-directory-files (override)
+ "List of files in current buffer's `default-directory'.
+Use OVERRIDE to override `default-directory'."
+ (when-let ((default-directory (or override default-directory)))
+ (seq-map (lambda (filename)
+ (file-name-concat default-directory filename))
+ (process-lines "ls" "-1"))))
+
+(defun dwim-shell-command--last-modified-between (before after)
+ "Compare files in BEFORE and AFTER and return oldest file in diff."
+ (car (last (seq-sort #'file-newer-than-file-p
+ (seq-difference after before)))))
+
+(defun dwim-shell-command--finalize (calling-buffer files-before process progress-reporter on-completion silent-success error-autofocus monitor-directory)
+ "Finalize script execution.
+
+CALLING-BUFFER, FILES-BEFORE, PROCESS, PROGRESS-REPORTER,
+ERROR-AUTOFOCUS, ON-COMPLETION, SILENT-SUCCESS, and MONITOR-DIRECTORY are
+all needed to finalize processing."
+ (let ((oldest-new-file))
+ (when progress-reporter
+ (progress-reporter-done progress-reporter))
+ (if (= (process-exit-status process) 0)
+ (progn
+ (dwim-shell-command--message (funcall dwim-shell-command-done-buffer-name (process-name process)))
+ (with-current-buffer (process-buffer process)
+ (rename-buffer (generate-new-buffer-name (funcall dwim-shell-command-done-buffer-name (process-name process)))))
+ (if on-completion
+ (funcall on-completion (process-buffer process))
+ (with-current-buffer calling-buffer
+ (when (equal major-mode 'dired-mode)
+ (when revert-buffer-function
+ (funcall revert-buffer-function nil t))
+ ;; Region is not accurate if new files added. Wipe it.
+ (when mark-active
+ (deactivate-mark)))
+ (setq oldest-new-file
+ (dwim-shell-command--last-modified-between
+ files-before
+ (dwim-shell-command--default-directory-files monitor-directory)))
+ (when oldest-new-file
+ (dired-jump nil oldest-new-file)))
+ (unless (equal (process-buffer process)
+ (window-buffer (selected-window)))
+ (if (or oldest-new-file silent-success)
+ (kill-buffer (process-buffer process))
+ (unless silent-success
+ (switch-to-buffer (process-buffer process)))))))
+ (if (and (buffer-name (process-buffer process))
+ (or error-autofocus
+ ;; Buffer already selected. Don't ask.
+ (equal (process-buffer process)
+ (window-buffer (selected-window)))
+ (ignore-error quit
+ (y-or-n-p (format "%s error, see output? "
+ (buffer-name (process-buffer process)))))))
+ (progn
+ (with-current-buffer (process-buffer process)
+ (rename-buffer (generate-new-buffer-name (funcall dwim-shell-command-error-buffer-name (process-name process)))))
+ (when (or error-autofocus
+ (equal (process-buffer process)
+ (window-buffer (selected-window))))
+ (dwim-shell-command--message (funcall dwim-shell-command-error-buffer-name (process-name process))))
+ (switch-to-buffer (process-buffer process)))
+ (kill-buffer (process-buffer process))))
+ (setq dwim-shell-command--commands
+ (map-delete dwim-shell-command--commands (process-name process)))))
+
+(cl-defun dwim-shell-command--unique-new-file-path (file-path &key expand)
+ "Return a unique FILE-PATH using :EXPAND to expand FILE-PATH.
+\"/tmp/blah.txt\" -> \"/tmp/blah(1).txt\"
+\"/tmp/blah\" -> \"/tmp/blah(1)\""
+ (let ((counter 1)
+ (name (file-name-sans-extension file-path))
+ (extension (file-name-extension file-path)))
+ (while (file-exists-p file-path)
+ (if extension
+ (setq file-path (format "%s(%d).%s" name counter extension))
+ (setq file-path (format "%s(%d)" name counter)))
+ (setq counter (1+ counter)))
+ (if expand
+ (expand-file-name file-path)
+ file-path)))
+
+(defun dwim-shell-command--sentinel (process _)
+ "Handles PROCESS sentinel and STATE."
+ (let ((exec (map-elt dwim-shell-command--commands (process-name process))))
+ (dwim-shell-command--finalize (dwim-shell-command--command-calling-buffer exec)
+ (dwim-shell-command--command-files-before exec)
+ process
+ (dwim-shell-command--command-reporter exec)
+ (dwim-shell-command--command-on-completion exec)
+ (dwim-shell-command--command-silent-success exec)
+ (dwim-shell-command--command-error-autofocus exec)
+ (dwim-shell-command--command-monitor-directory exec))))
+
+(defun dwim-shell-command--filter (process output)
+ "Handles PROCESS filtering and STATE and OUTPUT."
+ (when-let* ((exec (map-elt dwim-shell-command--commands (process-name process)))
+ (reporter (dwim-shell-command--command-reporter exec)))
+ (progress-reporter-update reporter))
+ (comint-output-filter process output))
+
+(defun dwim-shell-command--files ()
+ "Return buffer file (if available) or marked/region files for a `dired' buffer."
+ (cl-assert (not (and mark-active (let ((files (dired-get-marked-files nil nil nil t)))
+ ;; Based on `dired-number-of-marked-files'.
+ (cond ((null (cdr files))
+ nil)
+ ((and (= (length files) 2)
+ (eq (car files) t))
+ t)
+ (t
+ (not (seq-empty-p (length files))))))))
+ nil "Region and marked files both active. Choose one only.")
+ (if (buffer-file-name)
+ (list (buffer-file-name))
+ (or
+ (dwim-shell-command--dired-paths-in-region)
+ (dired-get-marked-files))))
+
+(defun dwim-shell-command--dired-paths-in-region ()
+ "If `dired' buffer, return region files. nil otherwise."
+ (when (and (equal major-mode 'dired-mode)
+ mark-active)
+ (let ((start (region-beginning))
+ (end (region-end))
+ (paths))
+ (save-excursion
+ (save-restriction
+ (goto-char start)
+ (while (< (point) end)
+ ;; Skip non-file lines.
+ (while (and (< (point) end) (dired-between-files))
+ (forward-line 1))
+ (when (dired-get-filename nil t)
+ (setq paths (append paths (list (dired-get-filename nil t)))))
+ (forward-line 1))))
+ paths)))
+
+(defun dwim-shell-command--n-start-value (template padding)
+ "Extract n start value from TEMPLATE using PADDING.
+Falls back to \"1\"."
+ (when (string-match "\<\<\\([[:alnum:]]?+\\)n\>\>" template)
+ (if (string-empty-p (match-string 1 template))
+ (dwim-shell-command--increment-string (number-to-string 0) padding)
+ (if-let* ((start-string (match-string 1 template))
+ (start-n (string-to-number start-string)))
+ (dwim-shell-command--increment-string (number-to-string (1- start-n)) padding)
+ (match-string 1 template)))))
+
+(defun dwim-shell-command--increment-string (text padding)
+ "Increment TEXT using PADDING.
+\"a\" -> \"b\"
+\"1\" -> \"2\""
+ (cond ((string-match "^[[:alpha:]]$" text) ;; char
+ (char-to-string (1+ (string-to-char (match-string 0 text)))))
+ ((string-match "^[[:digit:]]+$" text) ;; char
+ (dwim-shell-command--number-to-string (1+ (string-to-number (match-string 0 text))) padding))))
+
+(defun dwim-shell-command--program-test (program &rest args)
+ "Test that running PROGRAM with ARGS is successful."
+ (eq 0 (apply #'call-process program nil nil nil args)))
+
+(cl-defun dwim-shell-command-foreach (fun &key monitor-directory)
+ "Execute FUN for each file.
+Monitor :MONITOR-DIRECTORY for new file and `dired-jump' to it."
+ (let ((files (dwim-shell-command--files))
+ (files-before (dwim-shell-command--default-directory-files monitor-directory))
+ (oldest-new-file)
+ (created-file)
+ (jump-to))
+ (mapc (lambda (file-path)
+ (setq created-file (or (funcall fun file-path)
+ created-file)))
+ files)
+ (setq oldest-new-file (dwim-shell-command--last-modified-between
+ files-before
+ (dwim-shell-command--default-directory-files monitor-directory)))
+ (setq jump-to (or oldest-new-file created-file))
+ (when jump-to
+ (dired-jump nil jump-to))))
+
+(provide 'dwim-shell-command)
+
+;;; dwim-shell-command.el ends here
--- /dev/null
+;;; dwim-shell-commands.el --- Useful commands -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2022 Alvaro Ramirez
+
+;; Author: Alvaro Ramirez
+;; URL: https://github.com/xenodium/dwim-shell-command
+
+;; This package 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, or (at your option)
+;; any later version.
+
+;; This package 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 <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; A collection of useful commands created via
+;; `dwim-shell-command-on-marked-files'.
+
+;;; Code:
+
+(require 'browse-url)
+(require 'cl-lib)
+(require 'dwim-shell-command)
+(require 'files)
+(require 'proced)
+(require 'seq)
+(require 'subr-x)
+
+(defun dwim-shell-commands-audio-to-mp3 ()
+ "Convert all marked audio to mp3(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to mp3"
+ "ffmpeg -stats -n -i '<<f>>' -acodec libmp3lame '<<fne>>.mp3'"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-open-clipboard-url ()
+ "Open clipboard URL. Offer to stream if possible."
+ (interactive)
+ (let ((url (or (current-kill 0)
+ (user-error "Nothing in clipboard"))))
+ (dwim-shell-commands-url-browse url)))
+
+(defun dwim-shell-commands-url-browse (url &rest args)
+ "If URL is playable media, offer to open in mpv. Else browser.
+Optional argument ARGS as per `browse-url-default-browser'"
+ (if (and (or (string-match-p "^http[s]?://.*youtube.com" url)
+ (string-match-p "^http[s]?://.*m.youtube.com" url)
+ (string-match-p "^http[s]?://.*youtu.be" url)
+ (string-match-p "^http[s]?://.*soundcloud.com" url)
+ (string-match-p "^http[s]?://.*redditmedia.com" url)
+ (string-match-p "^http[s]?://.*reddit.com" url)
+ (string-match-p "^http[s]?://.*bandcamp.com" url))
+ (y-or-n-p "Stream from mpv? "))
+ (dwim-shell-command-on-marked-files
+ "Streaming"
+ (format "mpv --geometry=30%%x30%%+100%%+0%% '%s'" url)
+ :utils "mpv"
+ :no-progress t
+ :error-autofocus t
+ :silent-success t)
+ (funcall #'browse-url-default-browser url args)))
+
+(defun dwim-shell-commands-stream-clipboard-url ()
+ "Stream clipboard URL using mpv."
+ (interactive)
+ (cl-assert (string-match-p "^http[s]?://" (current-kill 0)) nil "Not a URL")
+ (dwim-shell-command-on-marked-files
+ "Streaming"
+ "mpv --geometry=30%x30%+100%+0% \"<<cb>>\""
+ :utils "mpv"
+ :no-progress t
+ :error-autofocus t
+ :silent-success t))
+
+(defun dwim-shell-commands-download-clipboard-stream-url ()
+ "Download clipboard URL."
+ (interactive)
+ (cl-assert (string-match-p "^http[s]?://" (current-kill 0)) nil "Not a URL")
+ (dwim-shell-command-on-marked-files
+ "Downloading"
+ "youtube-dl --newline -o \"~/Downloads/%(title)s.%(ext)s\" \"<<cb>>\""
+ :utils "youtube-dl"
+ :no-progress t
+ :error-autofocus t
+ :monitor-directory "~/Downloads"
+ :silent-success t))
+
+(defun dwim-shell-commands-image-exif-metadata ()
+ "View EXIF metadata in image(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "View EXIF"
+ "exiftool '<<f>>'"
+ :utils "exiftool"))
+
+(defun dwim-shell-commands-image-browse-location ()
+ "Open image(s) location in browser."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Browse location"
+ "lat=\"$(exiftool -csv -n -gpslatitude -gpslongitude '<<f>>' | tail -n 1 | cut -s -d',' -f2-2)\"
+ if [ -z \"$lat\" ]; then
+ echo \"no latitude\"
+ exit 1
+ fi
+ lon=\"$(exiftool -csv -n -gpslatitude -gpslongitude '<<f>>' | tail -n 1 | cut -s -d',' -f3-3)\"
+ if [ -z \"$lon\" ]; then
+ echo \"no longitude\"
+ exit 1
+ fi
+ if [[ $OSTYPE == darwin* ]]; then
+ open \"http://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&layers=C\"
+ else
+ xdg-open \"http://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&layers=C\"
+ fi"
+ :utils "exiftool"
+ :error-autofocus t
+ :silent-success t))
+
+(defun dwim-shell-commands-image-reverse-geocode-location ()
+ "Reverse geocode image(s) location."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Reverse geocode"
+ "lat=\"$(exiftool -csv -n -gpslatitude -gpslongitude '<<f>>' | tail -n 1 | cut -s -d',' -f2-2)\"
+ if [ -z \"$lat\" ]; then
+ echo \"no latitude\"
+ exit 1
+ fi
+ lon=\"$(exiftool -csv -n -gpslatitude -gpslongitude '<<f>>' | tail -n 1 | cut -s -d',' -f3-3)\"
+ if [ -z \"$lon\" ]; then
+ echo \"no longitude\"
+ exit 1
+ fi
+ json=$(curl \"https://nominatim.openstreetmap.org/reverse?format=json&accept-language=en&lat=${lat}&lon=${lon}&zoom=18&addressdetails=1\")
+ echo \"json_start $json json_end\""
+ :utils '("exiftool" "curl")
+ :silent-success t
+ :error-autofocus t
+ :on-completion (lambda (buffer)
+ (with-current-buffer buffer
+ (goto-char (point-min))
+ (let ((matches '()))
+ (while (re-search-forward "^json_start\\(.*?\\)json_end" nil t)
+ (push (match-string 1) matches))
+ (message "%s" (string-join (seq-map (lambda (json)
+ (map-elt (json-parse-string json :object-type 'alist) 'display_name))
+ matches)
+ "\n")))
+ (kill-buffer buffer)))))
+
+(defun dwim-shell-commands-image-to-jpg ()
+ "Convert all marked images to jpg(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to jpg"
+ "convert -verbose '<<f>>' '<<fne>>.jpg'"
+ :utils "convert"))
+
+(defun dwim-shell-commands-image-to-png ()
+ "Convert all marked images to png(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to png"
+ "convert -verbose '<<f>>' '<<fne>>.png'"
+ :utils "convert"))
+
+(defun dwim-shell-commands-svg-to-png ()
+ "Convert all marked svg(s) to png(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to png"
+ "rsvg-convert -b white '<<f>>' -f png -o '<<fne>>.png'"
+ :utils "rsvg-convert"))
+
+(defun dwim-shell-commands-make-transparent-png ()
+ "Create a transparent png."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Create transparent png"
+ "convert -verbose -size <<width:200>>x<<height:200>> xc:none '<<empty<<width:200>>x<<height:200>>.png(u)>>'"
+ :utils "convert"))
+
+(defun dwim-shell-commands-join-as-pdf ()
+ "Join all marked images as a single pdf."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Join as pdf"
+ (format "convert -verbose '<<*>>' '%s'"
+ (dwim-shell-command-read-file-name
+ "Join as pdf named (default \"joined.pdf\"): "
+ :extension "pdf"
+ :default "<<joined.pdf(u)>>"))
+ :utils "convert"))
+
+(defun dwim-shell-commands-image-to-grayscale ()
+ "Convert all marked images to grayscale."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert image to grayscale"
+ "convert -verbose -type Grayscale '<<f>>' '<<fne>>_grayscale.<<e>>'"
+ :utils "convert"))
+
+(defun dwim-shell-commands-reorient-image ()
+ "Reorient images."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Reorient image"
+ "convert -verbose -auto-orient '<<f>>' '<<fne>>_reoriented.<<e>>'"
+ :utils "convert"))
+
+(defun dwim-shell-commands-video-to-gif ()
+ "Convert all marked videos to gif(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to gif"
+ "ffmpeg -loglevel quiet -stats -y -i '<<f>>' -pix_fmt rgb24 -r 15 '<<fne>>.gif'"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-video-to-webp ()
+ "Convert all marked videos to webp(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to webp"
+ "ffmpeg -i '<<f>>' -vcodec libwebp -filter:v fps=fps=10 -compression_level 3 -lossless 1 -loop 0 -preset default -an -vsync 0 '<<fne>>'.webp"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-video-to-hevc-mkv ()
+ "Convert all marked videos to hevc mkv."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert video to h265 "
+ "REPO_DIR=/tmp/other_video_transcoding
+ if ! [ -d \"$REPO_DIR\" ]
+ then
+ git clone https://github.com/donmelton/other_video_transcoding.git $REPO_DIR
+ fi
+ pushd $REPO_DIR
+ git pull origin master || echo \"skipping repo update...\"
+ popd
+ ruby $REPO_DIR/bin/other-transcode --hevc '<<f>>'"
+ :utils '("git" "ffmpeg" "mkvtoolnix" "mpv")))
+
+(defun dwim-shell-commands-video-to-optimized-gif ()
+ "Convert all marked videos to optimized gif(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to optimized gif"
+ "ffmpeg -loglevel quiet -stats -y -i '<<f>>' -pix_fmt rgb24 -r 15 '<<fne>>.gif'
+ gifsicle -O3 '<<fne>>.gif' --lossy=80 -o '<<fne>>.gif'"
+ :utils '("ffmpeg" "gifsicle")))
+
+(defun dwim-shell-commands-unzip ()
+ "Unzip all marked archives (of any kind) using `atool'."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Unzip" "atool --extract --explain '<<f>>'"
+ :utils "atool"))
+
+(defun dwim-shell-commands-speed-up-gif ()
+ "Speeds up gif(s)."
+ (interactive)
+ (let ((factor (string-to-number
+ (completing-read "Speed up x times: " '("1" "1.5" "2" "2.5" "3" "4")))))
+ (dwim-shell-command-on-marked-files
+ "Speed up gif"
+ (format "gifsicle -U '<<f>>' <<frames>> -O2 -o '<<fne>>_x%s.<<e>>'" factor)
+ :extensions "gif" :utils '("gifsicle" "identify")
+ :post-process-template (lambda (script file)
+ (string-replace "<<frames>>" (dwim-shell-commands--gifsicle-frames-every factor file) script)))))
+
+(defun dwim-shell-commands-resize-gif ()
+ "Resize marked gif(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Resize marked gif(s)"
+ "gifsicle --scale <<Scaling factor:0.5>> '<<f>>' -o '<<fne>>_x<<Scaling factor:0.5>>.gif'"
+ :extensions "gif"
+ :utils "gifsicle"))
+
+(defun dwim-shell-commands-epub-to-org ()
+ "Convert epub(s) to org."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "epub to org"
+ "pandoc --from=epub --to=org '<<f>>' > '<<fne>>.org'"
+ :extensions "epub"
+ :utils "pandoc"))
+
+(defun dwim-shell-commands-docx-to-pdf ()
+ "Convert docx(s) to pdf (via latex)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "docx to pdf (via latex)"
+ "pandoc -t latex '<<f>>' -o '<<fne>>.pdf'"
+ :extensions "docx" ;; brew install mactex
+ :utils "pdflatex"))
+
+(defun dwim-shell-commands-kill-process ()
+ "Select and kill process."
+ (interactive)
+ (let* ((pid-width 5)
+ (comm-width 25)
+ (user-width 10)
+ (processes (proced-process-attributes))
+ (candidates
+ (mapcar (lambda (attributes)
+ (let* ((process (cdr attributes))
+ (pid (format (format "%%%ds" pid-width) (map-elt process 'pid)))
+ (user (format (format "%%-%ds" user-width)
+ (truncate-string-to-width
+ (map-elt process 'user) user-width nil nil t)))
+ (comm (format (format "%%-%ds" comm-width)
+ (truncate-string-to-width
+ (map-elt process 'comm) comm-width nil nil t)))
+ (args-width (- (window-width) (+ pid-width user-width comm-width 3)))
+ (args (map-elt process 'args)))
+ (cons (if args
+ (format "%s %s %s %s" pid user comm (truncate-string-to-width args args-width nil nil t))
+ (format "%s %s %s" pid user comm))
+ process)))
+ processes))
+ (selection (map-elt candidates
+ (completing-read "kill process: "
+ (seq-sort
+ (lambda (p1 p2)
+ (string-lessp (nth 2 (split-string (string-trim (car p1))))
+ (nth 2 (split-string (string-trim (car p2))))))
+ candidates) nil t)))
+ (prompt-title (format "%s %s %s"
+ (map-elt selection 'pid)
+ (map-elt selection 'user)
+ (map-elt selection 'comm))))
+ (when (y-or-n-p (format "Kill? %s" prompt-title))
+ (dwim-shell-command-on-marked-files
+ (format "Kill %s" prompt-title)
+ (format "kill -9 %d" (map-elt selection 'pid))
+ :utils "kill"
+ :error-autofocus t
+ :silent-success t))))
+
+(defun dwim-shell-commands-macos-toggle-bluetooth-device-connection ()
+ ""
+ (interactive)
+ (let* ((devices (seq-filter
+ (lambda (line)
+ ;; Keep lines like: af-8c-3b-b1-99-af - Device name
+ (string-match-p "^[0-9a-f]\\{2\\}" line))
+ (with-current-buffer (get-buffer-create "*BluetoothConnector*")
+ (erase-buffer)
+ ;; BluetoothConnector exits with 64 if no param is given.
+ ;; Invoke with no params to get a list of devices.
+ (unless (eq 64 (call-process "BluetoothConnector" nil (current-buffer)))
+ (kill-buffer (current-buffer))
+ (error (buffer-string)))
+ (let ((lines (split-string (buffer-string) "\n")))
+ (kill-buffer (current-buffer))
+ lines))))
+ (candidates (mapcar (lambda (device)
+ ;; key (device name) : value (address)
+ (cons (nth 1 (split-string device " - "))
+ (nth 0 (split-string device " - "))))
+ devices))
+ (selected-name (completing-read "Toggle connection: "
+ (seq-sort #'string-lessp candidates) nil t))
+ (address (map-elt candidates selected-name)))
+ (dwim-shell-command-on-marked-files
+ (format "Toggle %s" selected-name)
+ (format "BluetoothConnector %s --notify" address)
+ :utils "BluetoothConnector"
+ ;; :error-autofocus t
+ ;; :silent-success t
+ )))
+
+(defun dwim-shell-commands-macos-bin-plist-to-xml ()
+ "Convert binary plist to xml."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert binary plist to xml"
+ "plutil -convert xml1 -o '<<fne>>.xml' '<<f>>'"
+ :utils "plutil"))
+
+(defun dwim-shell-commands-macos-toggle-dark-mode ()
+ "Toggle macOS dark mode."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Toggle dark mode"
+ "dark-mode"
+ :utils "dark-mode" ;; brew install dark-mode
+ :silent-success t))
+
+(defun dwim-shell-commands-pdf-to-txt ()
+ "Convert pdf to txt."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "pdf to txt"
+ "pdftotext -layout '<<f>>' '<<fne>>.txt'"
+ :utils "pdftotext"))
+
+(defun dwim-shell-commands-resize-image ()
+ "Resize marked image(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Resize image"
+ (let ((factor (read-number "Resize scaling factor: " 0.5)))
+ (format "convert -resize %%%d '<<f>>' '<<fne>>_x%.2f.<<e>>'"
+ (* 100 factor) factor))
+ :utils "convert"))
+
+(defun dwim-shell-commands-pdf-password-protect ()
+ "Speeds up gif(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Password protect pdf"
+ (format "qpdf --verbose --encrypt '%s' '%s' 256 -- '<<f>>' '<<fne>>_enc.<<e>>'"
+ (read-passwd "user-password: ")
+ (read-passwd "owner-password: "))
+ :utils "qpdf"
+ :extensions "pdf"))
+
+(defun dwim-shell-commands--gifsicle-frames-every (skipping-every file)
+ "Generate frames SKIPPING-EVERY count for video FILE."
+ (string-join
+ (seq-map (lambda (n) (format "'#%d'" n))
+ (number-sequence 0 (string-to-number
+ ;; Get total frames count.
+ (seq-first (process-lines "identify" "-format" "%n\n" file)))
+ skipping-every)) " "))
+
+(defun dwim-shell-commands-video-to-mp3 ()
+ "Drop audio from all marked videos."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert to mp3"
+ "ffmpeg -i '<<f>>' -vn -ab 128k -ar 44100 -y '<<fne>>.mp3'"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-video-trim-beginning ()
+ "Drop audio from all marked videos."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Trim beginning"
+ "ffmpeg -i '<<f>>' -y -ss <<Seconds:5>> -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ :silent-success t
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-video-trim-end ()
+ "Drop audio from all marked videos."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Trim beginning"
+ "ffmpeg -sseof -<<Seconds:5>> -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
+ :silent-success t
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-drop-video-audio ()
+ "Drop audio from all marked videos."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Drop audio"
+ "ffmpeg -i '<<f>>' -c copy -an '<<fne>>_no_audio.<<e>>'"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-ping-google ()
+ "Ping duckduckgo.com."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Ping google.com"
+ "ping -c 3 google.com"
+ :utils "ping"
+ :focus-now t))
+
+(defun dwim-shell-commands-speed-up-video ()
+ "Speed up video(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Speed up video"
+ (let ((factor (read-number "Resize scaling factor: " 2)))
+ (format "ffmpeg -i '<<f>>' -an -filter:v 'setpts=%s*PTS' '<<fne>>_x%s.<<e>>'"
+ (/ 1 (float factor)) factor))
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-resize-video ()
+ "Resize marked images."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Resize video"
+ "
+eval $(ffprobe -v quiet -show_format -of flat=s=_ -show_entries stream=width '<<f>>');
+width=${streams_stream_0_width};
+zmodload zsh/mathfunc
+width=$((rint($width * <<Scaling factor:0.5>>)));
+# Make it even or face 'not divisible by 2' errors.
+if [[ $((width%2)) -ne 0 ]] then
+ width=$(($width - 1))
+fi
+ffmpeg -n -i '<<f>>' -vf \"scale=$width:-2\" '<<fne>>_x<<Scaling factor:0.5>>.<<e>>'"
+ :utils "ffmpeg"))
+
+(defun dwim-shell-commands-clipboard-to-qr ()
+ "Generate a QR code from clipboard."
+ (interactive)
+ (let ((temp-file (concat (temporary-file-directory) "qr-code")))
+ (dwim-shell-command-on-marked-files
+ "Generate a QR code from clipboard"
+ (format "qrencode -s10 -o %s %s" temp-file (shell-quote-argument (current-kill 0)))
+ :utils "qrencode"
+ :on-completion (lambda (buffer)
+ (kill-buffer buffer)
+ (switch-to-buffer (find-file-noselect temp-file t))))))
+
+(defun dwim-shell-commands-open-externally ()
+ "Open file(s) externally."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Open externally"
+ (if (eq system-type 'darwin)
+ "open '<<f>>'"
+ "xdg-open '<<f>>'")
+ :silent-success t
+ :utils (if (eq system-type 'darwin)
+ "open"
+ "xdg-open")))
+
+(defun dwim-shell-commands-macos-caffeinate ()
+ "Invoke caffeinate to prevent mac from sleeping."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Caffeinate"
+ "caffeinate"
+ :utils "caffeinate"
+ :no-progress t
+ :focus-now t))
+
+(defun dwim-shell-commands-macos-make-finder-alias ()
+ "Make macOS Finder alias."
+ (interactive)
+ (let ((files (dwim-shell-command--files))
+ (target-dir (read-directory-name "Select target dir: " "/Applications" nil t)))
+ (dwim-shell-command-on-marked-files
+ "Make macOS alias"
+ (format "osascript -e 'tell application \"Finder\" to make alias file to POSIX file \"<<f>>\" at POSIX file \"%s\"'"
+ target-dir)
+ :utils "osascript"
+ :no-progress t
+ :silent-success t
+ :on-completion (lambda (buffer)
+ (kill-buffer buffer)
+ (dired-jump nil (file-name-concat target-dir (file-name-nondirectory (nth 0 files))))))))
+
+(defun dwim-shell-commands-macos-hardware-overview ()
+ "View macOS hardware overview."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "macOS hardware overview"
+ "system_profiler SPHardwareDataType"
+ :utils "system_profiler"))
+
+(defun dwim-shell-commands-macos-reveal-in-finder ()
+ "Reveal selected files in macOS Finder."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Reveal in Finder"
+ "import AppKit
+ NSWorkspace.shared.activateFileViewerSelecting([\"<<*>>\"].map{URL(fileURLWithPath:$0)})"
+ :silent-success t
+ :shell-pipe "swift -"
+ :join-separator ", "
+ :utils "swift"))
+
+(defun dwim-shell-commands--macos-sharing-services ()
+ "Return a list of sharing services."
+ (let* ((source (format "import AppKit
+ NSSharingService.sharingServices(forItems: [
+ %s
+ ]).forEach {
+ print(\"\\($0.title)\")
+ }"
+ (string-join (mapcar (lambda (file)
+ (format "URL(fileURLWithPath: \"%s\")" file))
+ (dwim-shell-command--files))
+ ", ")))
+ (services (split-string (string-trim (shell-command-to-string (format "echo '%s' | swift -" source)))
+ "\n")))
+ (when (seq-empty-p services)
+ (error "No sharing services available"))
+ services))
+
+(defun dwim-shell-commands-macos-share ()
+ "Share selected files from macOS."
+ (interactive)
+ (let* ((services (dwim-shell-commands--macos-sharing-services))
+ (service-name (completing-read "Share via: " services))
+ (selection (seq-position services service-name #'string-equal)))
+ (dwim-shell-command-on-marked-files
+ "Share"
+ (format
+ "import AppKit
+
+ _ = NSApplication.shared
+
+ NSApp.setActivationPolicy(.regular)
+
+ class MyWindow: NSWindow, NSSharingServiceDelegate {
+ func sharingService(
+ _ sharingService: NSSharingService,
+ didShareItems items: [Any]
+ ) {
+ NSApplication.shared.terminate(nil)
+ }
+
+ func sharingService(
+ _ sharingService: NSSharingService, didFailToShareItems items: [Any], error: Error
+ ) {
+ let error = error as NSError
+ if error.domain == NSCocoaErrorDomain && error.code == NSUserCancelledError {
+ NSApplication.shared.terminate(nil)
+ }
+ exit(1)
+ }
+ }
+
+ let window = MyWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 0, height: 0),
+ styleMask: [],
+ backing: .buffered,
+ defer: false)
+
+ let services = NSSharingService.sharingServices(forItems: [\"<<*>>\"].map{URL(fileURLWithPath:$0)})
+ let service = services[%s]
+ service.delegate = window
+ service.perform(withItems: [\"<<*>>\"].map{URL(fileURLWithPath:$0)})
+
+ NSApp.run()" selection)
+ :silent-success t
+ :shell-pipe "swift -"
+ :join-separator ", "
+ :no-progress t
+ :utils "swift")))
+
+(defun dwim-shell-commands-macos-toggle-display-rotation ()
+ "View macOS hardware overview."
+ (interactive)
+ ;; # Display_ID Resolution ____Display_Bounds____ Rotation
+ ;; 2 0x2b347692 1440x2560 0 0 1440 2560 270 [main]
+ ;; From fb-rotate output, get the `current-rotation' from Column 7, row 1 zero-based.
+ (let ((current-rotation (nth 7 (split-string (nth 1 (process-lines "fb-rotate" "-i"))))))
+ (dwim-shell-command-on-marked-files
+ "macOS hardware overview"
+ (format "fb-rotate -d 1 -r %s" (if (equal current-rotation "270") "0" "270"))
+ :utils "fb-rotate")))
+
+(defun dwim-shell-commands--macos-apps ()
+ "Return alist of macOS apps (\"Emacs\" . \"/Applications/Emacs.app\")."
+ (mapcar (lambda (path)
+ (cons (file-name-base path) path))
+ (seq-sort
+ #'string-lessp
+ (seq-mapcat (lambda (paths)
+ (directory-files-recursively
+ paths "\\.app$" t (lambda (path)
+ (not (string-suffix-p ".app" path)))))
+ '("/Applications" "~/Applications" "/System/Applications")))))
+
+(defun dwim-shell-commands-macos-set-default-app ()
+ "Set default app for file(s)."
+ (interactive)
+ (let* ((apps (dwim-shell-commands--macos-apps))
+ (selection (progn
+ (cl-assert apps nil "No apps found")
+ (completing-read "Set default app: " apps nil t))))
+ (dwim-shell-command-on-marked-files
+ "Set default app"
+ (format "duti -s \"%s\" '<<e>>' all"
+ (string-trim
+ (shell-command-to-string (format "defaults read '%s/Contents/Info.plist' CFBundleIdentifier"
+ (map-elt apps selection)))))
+ :silent-success t
+ :no-progress t
+ :utils "duti")))
+
+(defun dwim-shell-commands-macos-open-with ()
+ "Open file(s) with specific external app."
+ (interactive)
+ (let* ((apps (dwim-shell-commands--macos-apps))
+ (selection (progn
+ (cl-assert apps nil "No apps found")
+ (completing-read "Open with: " apps nil t))))
+ (dwim-shell-command-on-marked-files
+ "Open with"
+ (format "open -a '%s' '<<*>>'" (map-elt apps selection))
+ :silent-success t
+ :no-progress t
+ :utils "open")))
+
+(defun dwim-shell-commands-files-combined-size ()
+ "Get files combined file size."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Get files combined file size"
+ "du -csh '<<*>>'"
+ :utils "du"
+ :on-completion (lambda (buffer)
+ (with-current-buffer buffer
+ (message "Total size: %s"
+ (progn
+ (re-search-backward "\\(^[ 0-9.,]+[A-Za-z]+\\).*total$")
+ (match-string 1))))
+ (kill-buffer buffer))))
+
+(defun dwim-shell-commands-image-to-icns ()
+ "Convert png to icns icon."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Convert png to icns icon."
+ "
+ # Based on http://stackoverflow.com/questions/12306223/how-to-manually-create-icns-files-using-iconutil
+ # Note: png must be 1024x1024
+ mkdir '<<fne>>.iconset'
+ sips -z 16 16 '<<f>>' --out '<<fne>>.iconset/icon_16x16.png'
+ sips -z 32 32 '<<f>>' --out '<<fne>>.iconset/icon_16x16NOSPAM2x.png'
+ sips -z 32 32 '<<f>>' --out '<<fne>>.iconset/icon_32x32.png'
+ sips -z 64 64 '<<f>>' --out '<<fne>>.iconset/icon_32x32NOSPAM2x.png'
+ sips -z 128 128 '<<f>>' --out '<<fne>>.iconset/icon_128x128.png'
+ sips -z 256 256 '<<f>>' --out '<<fne>>.iconset/icon_128x128NOSPAM2x.png'
+ sips -z 256 256 '<<f>>' --out '<<fne>>.iconset/icon_256x256NOSPAM2x.png'
+ sips -z 512 512 '<<f>>' --out '<<fne>>.iconset/icon_512x512.png'
+ sips -z 512 512 '<<f>>' --out '<<fne>>.iconset/icon_256x256NOSPAM2x.png'
+ sips -z 1024 1024 '<<f>>' --out '<<fne>>.iconset/icon_512x512NOSPAM2x.png'
+ iconutil -c icns '<<fne>>.iconset'"
+ :utils '("sips" "iconutil")
+ :extensions "png"))
+
+(defun dwim-shell-commands-git-clone-clipboard-url-to-downloads ()
+ "Clone git URL in clipboard to \"~/Downloads/\"."
+ (interactive)
+ (cl-assert (string-match-p "^\\(http\\|https\\|ssh\\)://" (current-kill 0)) nil "No URL in clipboard")
+ (let* ((url (current-kill 0))
+ (download-dir (expand-file-name "~/Downloads/"))
+ (project-dir (concat download-dir (file-name-base url)))
+ (default-directory download-dir))
+ (when (or (not (file-exists-p project-dir))
+ (when (y-or-n-p (format "%s exists. delete?" (file-name-base url)))
+ (delete-directory project-dir t)
+ t))
+ (dwim-shell-command-on-marked-files
+ (format "Clone %s" (file-name-base url))
+ (format "git clone %s" url)
+ :utils "git"
+ :on-completion (lambda (buffer)
+ (kill-buffer buffer)
+ (dired project-dir))))))
+
+(defun dwim-shell-commands-http-serve-dir ()
+ "HTTP serve current directory."
+ (interactive)
+ (cond ((executable-find "python3")
+ (dwim-shell-command-on-marked-files
+ "HTTP serve current dir"
+ "python3 -m http.server"
+ :utils "python3"
+ :focus-now t
+ :no-progress t))
+ ((executable-find "python2")
+ (dwim-shell-command-on-marked-files
+ "HTTP serve current dir"
+ "python2 -m SimpleHTTPServer"
+ :utils "python2"
+ :focus-now
+ :no-progress t))
+ ((executable-find "python")
+ (dwim-shell-command-on-marked-files
+ "HTTP serve current dir"
+ "python -m SimpleHTTPServer"
+ :utils "python"
+ :focus-now
+ :no-progress t))
+ (t
+ (error "No python found"))))
+
+(defun dwim-shell-commands-git-clone-clipboard-url ()
+ "Clone git URL in clipboard to `default-directory'."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ (format "Clone %s" (file-name-base (current-kill 0)))
+ "git clone <<cb>>"
+ :utils "git"))
+
+(defun dwim-shell-commands-external-ip ()
+ "Copy external IP to kill ring."
+ (interactive)
+ (let ((ip (car (last (process-lines "curl" "ifconfig.me")))))
+ (kill-new ip)
+ (message "Copied %s" ip)))
+
+(defun dwim-shell-commands-install-iphone-device-ipa ()
+ "Install iPhone device .ipa.
+Needs ideviceinstaller and libmobiledevice installed."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Install .ipa"
+ "ideviceinstaller -i '<<f>>'"
+ :utils "ideviceinstaller"))
+
+(defun dwim-shell-commands-copy-to-downloads ()
+ "Copy file to ~/Downloads."
+ (interactive)
+ (dwim-shell-command-foreach
+ (lambda (file)
+ (copy-file file "~/Downloads/" 1)
+ (file-name-concat "~/Downloads" (file-name-nondirectory file)))
+ :monitor-directory "~/Downloads"))
+
+(defun dwim-shell-commands-rename-all ()
+ "Rename all marked file(s)."
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Rename all"
+ "mv '<<f>>' '<<New name:Renamed>>(<<n>>).<<e>>'"
+ :utils "mv"))
+
+(defun dwim-shell-commands-move-to-downloads ()
+ "Move file to ~/Downloads."
+ (interactive)
+ (dwim-shell-command-foreach
+ (lambda (file)
+ (rename-file file "~/Downloads/" 1)
+ (when (buffer-file-name)
+ (rename-buffer (file-name-nondirectory file))
+ (set-visited-file-name
+ (file-name-concat "~/Downloads" (file-name-nondirectory file)))
+ (set-buffer-modified-p nil))
+ (file-name-concat "~/Downloads" (file-name-nondirectory file)))
+ :monitor-directory "~/Downloads"))
+
+(defun dwim-shell-commands-copy-to-desktop ()
+ "Copy file to ~/Desktop."
+ (interactive)
+ (dwim-shell-command-foreach
+ (lambda (file)
+ (copy-file file "~/Desktop/" 1)
+ (file-name-concat "~/Desktop" (file-name-nondirectory file)))
+ :monitor-directory "~/Desktop"))
+
+(defun dwim-shell-commands-move-to-desktop ()
+ "Move file to ~/Desktop."
+ (interactive)
+ (dwim-shell-command-foreach
+ (lambda (file)
+ (rename-file file "~/Desktop/" 1)
+ (when (buffer-file-name)
+ (rename-buffer (file-name-nondirectory file))
+ (set-visited-file-name
+ (file-name-concat "~/Desktop" (file-name-nondirectory file)))
+ (set-buffer-modified-p nil))
+ (file-name-concat "~/Desktop" (file-name-nondirectory file)))
+ :monitor-directory "~/Desktop"))
+
+(defun dwim-shell-commands-kill-gpg-agent ()
+ "Kill (thus restart) gpg agent.
+
+Useful for when you get this error:
+
+gpg: public key decryption failed: No pinentry
+gpg: decryption failed: No pinentry"
+ (interactive)
+ (dwim-shell-command-on-marked-files
+ "Kill gpg agent"
+ "gpgconf --kill gpg-agent"
+ :utils "gpgconf"
+ :silent-success t))
+
+(provide 'dwim-shell-commands)
+
+;;; dwim-shell-commands.el ends here