1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
|
;; dwim-shell-config.el --- Dired Shell Commands -*- coding: utf-8; lexical-binding: t; -*-
;;
;;; Commentary:
;;
;; Layer: 3 (Domain Workflow).
;; Category: D/P.
;; Load shape: eager.
;; Eager reason: none; Dired/Dirvish shell commands, a command-loaded deferral
;; candidate.
;; Top-level side effects: package configuration via use-package.
;; Runtime requires: cl-lib.
;; Direct test load: yes.
;;
;; This module provides a collection of DWIM (Do What I Mean) shell commands
;; for common file operations in Dired and other buffers. It leverages the
;; `dwim-shell-command' package to execute shell commands on marked files
;; with smart templating and progress tracking.
;;
;; Features:
;; - Audio/Video conversion (mp3, opus, webp, HEVC)
;; - Image manipulation (resize, flip, format conversion)
;; - PDF operations (merge, split, password protection, OCR)
;; - Archive management (zip/unzip)
;; - Document conversion (epub to org, docx to pdf, pdf to txt)
;; - Git operations (clone from clipboard)
;; - External file opening with context awareness
;;
;; Workflow:
;; 1. *Mark files in Dired/Dirvish*
;; - Use =m= to mark individual files
;; - Use =* .= to mark by extension
;; - Use =% m= to mark by regexp
;; - Or operate on the file under cursor if nothing is marked
;;
;; 2. *Execute a DWIM command*
;; - Call the command via =M-x dwim-shell-commands-[command-name]=
;; - Or bind frequently used commands to keys
;;
;; 3. *Command execution*
;; - The command runs asynchronously in the background
;; - A =*Async Shell Command*= buffer shows progress
;; - Files are processed with smart templating (replacing =<<f>>=, =<<fne>>=, etc.)
;;
;; 4. *Results*
;; - New files appear in the Dired/Dirvish buffer
;; - Buffer auto-refreshes when command completes
;; - Errors appear in the async buffer if something fails
;;
;; Requirements:
;; The commands rely on various external utilities that need to be installed:
;; - ffmpeg: Audio/video conversion
;; - imagemagick (convert): Image manipulation
;; - qpdf: PDF operations (requires version 8.x+ for secure password handling)
;; - tesseract: OCR functionality
;; - pandoc: Document conversion
;; - atool: Archive extraction
;; - rsvg-convert: SVG to PNG conversion
;; - pdftotext: PDF text extraction
;; - git: Version control operations
;; - gpgconf: GPG agent management
;; - 7z (p7zip): Secure password-protected archives
;;
;; On Arch Linux, install the requirements with:
;; #+begin_src bash
;; sudo pacman -S --needed ffmpeg imagemagick qpdf tesseract tesseract-data-eng pandoc atool librsvg poppler git gnupg p7zip zip unzip mkvtoolnix-cli mpv ruby
;; #+end_src
;;
;; On MacOS, install the requirements with:
;; #+begin_src bash
;; brew install ffmpeg imagemagick qpdf tesseract pandoc atool librsvg poppler gnupg p7zip mkvtoolnix mpv
;; #+end_src
;;
;; Usage:
;; Commands operate on marked files in Dired or the current file in other modes.
;; The package automatically replaces standard shell commands with DWIM versions
;; for a more intuitive experience.
;;
;; Security:
;; Password-protected operations (PDF encryption, archive encryption) use secure
;; methods to avoid exposing passwords in process lists or command history:
;; - PDF operations: Use temporary files with restrictive permissions (mode 600)
;; - Archive operations: Use 7z instead of zip for better password handling
;; - Temporary password files are automatically cleaned up after use
;; - Note: Switched from zip to 7z for encryption due to zip's insecure -P flag
;;
;; Template Variables:
;; - <<f>>: Full path to file
;; - <<fne>>: File name without extension
;; - <<e>>: File extension
;; - <<b>>: Base name (file name with extension, no directory)
;; - <<d>>: Directory path
;; - <<n>>: Sequential number (for batch renaming)
;; - <<td>>: Temporary directory
;; - <<cb>>: Clipboard contents
;; - <<*>>: All marked files
;;
;;; Code:
(require 'cl-lib)
;; --------------------------- Password-file helpers ---------------------------
(defun cj/dwim-shell--password-cleanup-callback (temp-file)
"Return an on-completion callback that deletes TEMP-FILE after the process exits.
The callback fires from the dwim-shell process sentinel, on both success and
failure, so the password file is removed once the spawned process is done — not
when it was launched. On success it refreshes the calling dired buffer so new
files show up; on failure it leaves the output buffer visible for inspection."
(let ((calling-buffer (current-buffer)))
(lambda (proc-buffer process)
(when (and temp-file (file-exists-p temp-file))
(delete-file temp-file))
(if (and (processp process) (zerop (process-exit-status process)))
(progn
(when (buffer-live-p calling-buffer)
(with-current-buffer calling-buffer
(when (derived-mode-p 'dired-mode)
(revert-buffer nil t))))
(when (buffer-live-p proc-buffer)
(kill-buffer proc-buffer)))
(when (buffer-live-p proc-buffer)
(display-buffer proc-buffer))))))
(defun cj/dwim-shell--run-with-password-file (file-contents buffer-name script-fn &rest keys)
"Run a dwim-shell command needing a password file, with safe cleanup timing.
Write FILE-CONTENTS to a mode-600 temp file, call SCRIPT-FN with the temp
file's path to build the shell script, then run it via
`dwim-shell-command-on-marked-files' under BUFFER-NAME with KEYS. The temp file
is deleted only after the spawned process exits (success or failure), via an
`:on-completion' callback. If the launch throws before the async process
starts, the temp file is cleaned up synchronously instead."
(let ((temp-file (make-temp-file "dwim-pass-"))
(launched nil))
(unwind-protect
(progn
(with-temp-file temp-file (insert file-contents))
(set-file-modes temp-file #o600)
(apply #'dwim-shell-command-on-marked-files
buffer-name
(funcall script-fn temp-file)
:on-completion (cj/dwim-shell--password-cleanup-callback temp-file)
keys)
(setq launched t))
(unless launched
(when (file-exists-p temp-file)
(delete-file temp-file))))))
;; ---------------------------- Input safety helpers ---------------------------
(defun cj/dwim-shell--valid-git-url-p (url)
"Return non-nil when URL looks like a safe git clone URL.
Accepts http(s)://, git://, ssh://, and scp-style host:path forms, and rejects
empty strings, whitespace, and shell metacharacters. The clone site also
quotes the URL with `shell-quote-argument'; this check is the first line of
defense and keeps junk clipboard text from being cloned."
(and (stringp url)
(not (string-empty-p url))
(string-match-p
(concat "\\`\\(?:https?://\\|git://\\|ssh://\\|"
"[A-Za-z0-9._-]+@[A-Za-z0-9._-]+:\\)"
"[A-Za-z0-9._:/~@-]+\\'")
url)))
(defun cj/dwim-shell--valid-ffmpeg-timestamp-p (timestamp)
"Return non-nil when TIMESTAMP is a plain-seconds or HH:MM:SS ffmpeg time.
Accepts forms like \"5\", \"90.5\", \"00:05\", and \"1:02:03.5\"; rejects
negatives, non-numeric text, and anything carrying shell metacharacters."
(and (stringp timestamp)
(string-match-p "\\`\\(?:[0-9]+:\\)\\{0,2\\}[0-9]+\\(?:\\.[0-9]+\\)?\\'"
timestamp)))
(defun cj/dwim-shell--safe-rename-prefix-p (prefix)
"Return non-nil when PREFIX is a filename-safe rename prefix.
Allows alphanumerics, spaces, dot, dash, and underscore (empty is fine), and
rejects quotes, slashes, and shell metacharacters that would break out of the
single-quoted destination it is interpolated into."
(and (stringp prefix)
(string-match-p "\\`[[:alnum:] ._-]*\\'" prefix)))
(defun cj/dwim-shell--empty-dirs-command (root)
"Return a find command that deletes empty directories under ROOT.
ROOT is shell-quoted so paths with spaces survive, and a trailing inert
=<<*>>= comment makes dwim-shell run the command once rather than once per
marked file."
(format "find %s -type d -empty -delete # <<*>>" (shell-quote-argument root)))
(defun cj/dwim-shell--build-concat-filelist (files)
"Return ffmpeg concat-demuxer filelist text for FILES.
Each path becomes a single-quoted `file' line with embedded single quotes
escaped as \\='\\\\\\='\\=', so paths with spaces, quotes, or shell
metacharacters survive intact — unlike an echo/tr/sed pipeline over the raw
file list."
(mapconcat
(lambda (f)
(format "file '%s'"
(replace-regexp-in-string "'" "'\\\\''" (expand-file-name f))))
files "\n"))
;; ----------------------------- Dwim Shell Command ----------------------------
(use-package dwim-shell-command
:demand t
:bind (("<remap> <shell-command>" . dwim-shell-command)
:map dired-mode-map
("<remap> <dired-do-async-shell-command>" . dwim-shell-command)
("<remap> <dired-do-shell-command>" . dwim-shell-command)
("<remap> <dired-smart-shell-command>" . dwim-shell-command))
:config
(defun cj/dwim-shell-commands-convert-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 cj/dwim-shell-commands-convert-audio-to-opus ()
"Convert all marked audio to opus(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Convert to opus"
"ffmpeg -stats -n -i '<<f>>' -c:a libopus -vbr on -compression_level 10 -b:a 256k '<<fne>>.opus'"
:utils "ffmpeg"))
(defun cj/dwim-shell-commands-view-image-exif-metadata ()
"View EXIF metadata in image(s)."
(interactive)
(dwim-shell-command-on-marked-files
"View EXIF"
"exiftool '<<f>>'"
:utils "exiftool"))
(defun cj/dwim-shell-commands-flip-image-horizontally ()
"Horizontally flip image(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Image horizontal flip"
"convert -verbose -flop '<<f>>' '<<fne>>_h_flipped.<<e>>'"
:utils "convert"))
(defun cj/dwim-shell-commands-flip-image-vertically ()
"Horizontally flip image(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Image vertical flip"
"convert -verbose -flip '<<f>>' '<<fne>>_v_flipped.<<e>>'"
:utils "convert"))
(defun cj/dwim-shell-commands-convert-image-to ()
"Convert all marked images to a specified format."
(interactive)
(let ((format (completing-read "Convert to format: "
'("jpg" "png" "webp" "gif" "bmp" "tiff")
nil t)))
(dwim-shell-command-on-marked-files
(format "Convert to %s" format)
(format "convert -verbose '<<f>>' '<<fne>>.%s'" format)
:utils "convert")))
(defun cj/dwim-shell-commands-convert-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 cj/dwim-shell-commands-join-images-into-pdf ()
"Join all marked images as a single pdf."
(interactive)
(dwim-shell-command-on-marked-files
"Join as pdf"
(format "convert -verbose '<<*>>' '<<%s(u)>>'"
(dwim-shell-command-read-file-name
"Join as pdf named (default \"joined.pdf\"): "
:extension "pdf"
:default "joined.pdf"))
:utils "convert"))
(defun cj/dwim-shell-commands-extract-pdf-page-number ()
"Keep a page from pdf."
(interactive)
(let ((page-num (read-number "Keep page number: " 1)))
(when (<= page-num 0)
(user-error "Page number must be positive"))
(dwim-shell-command-on-marked-files
"Keep pdf page"
(format "qpdf '<<f>>' --pages . %d -- '<<fne>>_%d.<<e>>'" page-num page-num)
:utils "qpdf")))
(defun cj/dwim-shell-commands-ocr-text-from-image-using-tesseract ()
"Extract text from image via tesseract."
(interactive)
(dwim-shell-command-on-marked-files
"Extract text from image via tesseract."
"tesseract '<<f>>' -"
:utils "tesseract"))
(defun cj/dwim-shell-commands-convert-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 -loop 0 -preset default -an -vsync 0 '<<fne>>'.webp"
:utils "ffmpeg"))
(defun cj/dwim-shell-commands-convert-video-to-high-compatibility-mp4 ()
"Convert all marked video(s) to MP4 format with H.264/AAC."
(interactive)
(dwim-shell-command-on-marked-files
"Convert to MP4"
"ffmpeg -i '<<f>>' -c:v libx264 -preset slow -crf 23 -profile:v baseline -level 3.0 -c:a aac -b:a 128k '<<fne>>.mp4'"
:utils "ffmpeg"))
(defun cj/dwim-shell-commands-convert-video-to-hevc-mkv ()
"Convert all marked videos to HEVC (H.265) in MKV container."
(interactive)
(dwim-shell-command-on-marked-files
"Convert to HEVC/H.265"
"ffmpeg -i '<<f>>' -c:v libx265 -preset slower -crf 22 -x265-params profile=main10:level=4.0 -c:a copy -c:s copy '<<fne>>.mkv'"
:utils "ffmpeg"))
(defun cj/dwim-shell-commands-extract-archive-smartly ()
"Unzip all marked archives (of any kind) using =atool'.
If there's only one file, unzip it to current directory.
Otherwise, unzip it to an appropriately named subdirectory "
(interactive)
(dwim-shell-command-on-marked-files
"Unzip" "atool --extract --subdir --explain '<<f>>'"
:utils "atool"))
(defun cj/dwim-shell-commands-zip-file-or-directory ()
"Zip all marked files into archive.zip."
(interactive)
(dwim-shell-command-on-marked-files
"Zip" (if (eq 1 (seq-length (dwim-shell-command--files)))
"zip -r '<<fne>>.<<e>>' '<<f>>'"
"zip -r '<<archive.zip(u)>>' '<<*>>'")
:utils "zip"))
(defun cj/dwim-shell-commands-tar-gzip-file-or-directory ()
"Tar gzip all marked files into archive.tar.gz."
(interactive)
(dwim-shell-command-on-marked-files
"Tar gzip" (if (eq 1 (seq-length (dwim-shell-command--files)))
"tar czf '<<fne>>.tar.gz' '<<f>>'"
"tar czf '<<archive.tar.gz(u)>>' '<<*>>'")
:utils "tar"))
(defun cj/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 cj/dwim-shell-commands-document-to-pdf ()
"Convert document(s) to pdf (via latex).
Supports docx, odt, and other pandoc-compatible formats."
(interactive)
(dwim-shell-command-on-marked-files
"Document to pdf (via latex)"
"pandoc -t latex '<<f>>' -o '<<fne>>.pdf'"
:extensions '("docx" "odt" "odp" "ods" "rtf" "doc")
:utils "pdflatex"))
(defun cj/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 cj/dwim-shell-commands-resize-image-by-factor ()
"Resize image(s) by factor."
(interactive)
(let ((factor (read-number "Resize scaling factor: " 0.5)))
(when (<= factor 0)
(user-error "Scaling factor must be positive"))
(dwim-shell-command-on-marked-files
"Resize image"
(format "convert -resize %%%d '<<f>>' '<<fne>>_x%.2f.<<e>>'"
(* 100 factor) factor)
:utils "convert")))
(defun cj/dwim-shell-commands-resize-image-in-pixels ()
"Resize image(s) in pixels."
(interactive)
(let ((width (read-number "Resize width (pixels): " 500)))
(when (<= width 0)
(user-error "Width must be positive"))
(dwim-shell-command-on-marked-files
"Resize image"
(format "convert -resize %dx '<<f>>' '<<fne>>_x%d.<<e>>'" width width)
:utils "convert")))
(defun cj/dwim-shell-commands-pdf-password-protect ()
"Add a password to pdf(s).
Passwords are written to a temp file (mode 600) so they never appear in the
process list, and the file is removed only after the spawned process exits."
(interactive)
(let ((user-pass (read-passwd "user-password: "))
(owner-pass (read-passwd "owner-password: ")))
(cj/dwim-shell--run-with-password-file
(concat user-pass "\n" owner-pass)
"Password protect pdf"
(lambda (temp-file)
(format "qpdf --verbose --password-file='%s' --encrypt --use-aes=y -- '<<f>>' '<<fne>>_protected.<<e>>'"
temp-file))
:utils "qpdf"
:extensions "pdf")))
(defun cj/dwim-shell-commands-pdf-password-unprotect ()
"Remove a password from pdf(s).
Password is written to a temp file (mode 600) so it never appears in the
process list, and the file is removed only after the spawned process exits."
(interactive)
(let ((password (read-passwd "password: ")))
(cj/dwim-shell--run-with-password-file
password
"Remove protection from pdf"
(lambda (temp-file)
(format "qpdf --verbose --decrypt --password-file='%s' -- '<<f>>' '<<fne>>_unprotected.<<e>>'"
temp-file))
:utils "qpdf"
:extensions "pdf")))
(defun cj/dwim-shell-commands-video-trim ()
"Trim video with options for beginning, end, or both."
(interactive)
(let* ((trim-type (completing-read "Trim from: "
'("Beginning" "End" "Both")
nil t))
(command (pcase trim-type
("Beginning"
(let ((seconds (read-number "Seconds to trim from beginning: " 5)))
(when (< seconds 0)
(user-error "Seconds must be non-negative"))
(format "ffmpeg -i '<<f>>' -y -ss %d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
seconds)))
("End"
(let ((seconds (read-number "Seconds to trim from end: " 5)))
(when (< seconds 0)
(user-error "Seconds must be non-negative"))
(format "ffmpeg -sseof -%d -i '<<f>>' -y -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
seconds)))
("Both"
(let ((start (read-number "Seconds to trim from beginning: " 5))
(end (read-number "Seconds to trim from end: " 5)))
(when (or (< start 0) (< end 0))
(user-error "Seconds must be non-negative"))
(format "ffmpeg -i '<<f>>' -y -ss %d -sseof -%d -c:v copy -c:a copy '<<fne>>_trimmed.<<e>>'"
start end))))))
(dwim-shell-command-on-marked-files
(format "Trim video (%s)" trim-type)
command
:silent-success t
:utils "ffmpeg")))
(defun cj/dwim-shell-commands-drop-audio-from-video ()
"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 cj/dwim-shell-commands-open-externally ()
"Open file(s) externally."
(interactive)
(dwim-shell-command-on-marked-files
"Open externally"
(cond ((eq system-type 'darwin)
(if (derived-mode-p 'prog-mode)
(format "open -a Xcode --args --line %d '<<f>>'"
(line-number-at-pos (point)))
"open '<<f>>'"))
((eq system-type 'windows-nt)
"start '<<f>>'")
(t ;; Linux/Unix
"xdg-open '<<f>>' 2>/dev/null || (echo 'Failed to open with xdg-open' && exit 1)"))
:silent-success t
:utils (cond ((eq system-type 'darwin) "open")
((eq system-type 'windows-nt) "start")
(t "xdg-open"))))
(defun cj/dwim-shell-commands-git-clone-clipboard-url ()
"Clone the git URL in the clipboard to `default-directory'.
Validates the clipboard as a git URL and passes it as a quoted argument, so
clipboard contents cannot inject shell commands."
(interactive)
(let ((url (string-trim (current-kill 0))))
(unless (cj/dwim-shell--valid-git-url-p url)
(user-error "Clipboard does not contain a valid git URL: %s" url))
(dwim-shell-command-on-marked-files
(format "Clone %s" (file-name-base url))
(format "git clone %s" (shell-quote-argument url))
:utils "git")))
(defun cj/dwim-shell-commands-open-file-manager ()
"Open the default file manager in the current directory."
(interactive)
(dwim-shell-command-on-marked-files
"Open file manager"
(cond ((eq system-type 'darwin)
"open .")
((eq system-type 'windows-nt)
"explorer .")
(t ;; Linux/Unix - try multiple options
(cond ((executable-find "thunar") "thunar .")
((executable-find "nautilus") "nautilus .")
((executable-find "dolphin") "dolphin .")
((executable-find "pcmanfm") "pcmanfm .")
(t "xdg-open ."))))
:silent-success t
:no-progress t))
(defun cj/dwim-shell-commands-count-words-lines ()
"Count words, lines, and characters in text file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Word count"
"wc -lwc '<<f>>'"
:utils "wc"))
(defun cj/dwim-shell-commands-checksum ()
"Generate checksums for file(s) and save to .checksum file."
(interactive)
(let ((algorithm (completing-read "Checksum algorithm: "
'("md5" "sha1" "sha256" "sha512")
nil t)))
(dwim-shell-command-on-marked-files
(format "Generate %s checksum" algorithm)
(format "%ssum '<<f>>' | tee '<<f>>.%s'" algorithm algorithm)
:utils (format "%ssum" algorithm))))
(defun cj/dwim-shell-commands-backup-with-timestamp ()
"Create dated backup of file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Backup with date"
"cp -p '<<f>>' '<<f>>.$(date +%Y%m%d_%H%M%S).bak'"
:utils '("cp" "date")))
(defun cj/dwim-shell-commands-optimize-image-for-web ()
"Optimize image(s) for web (reduce file size)."
(interactive)
(dwim-shell-command-on-marked-files
"Optimize image"
"convert '<<f>>' -strip -interlace Plane -gaussian-blur 0.05 -quality 85% '<<fne>>_optimized.<<e>>'"
:utils "convert"))
(defun cj/dwim-shell-commands-csv-to-json ()
"Convert CSV to JSON."
(interactive)
(dwim-shell-command-on-marked-files
"CSV to JSON"
"python -c \"import csv,json,sys; print(json.dumps(list(csv.DictReader(open('<<f>>')))))\" > '<<fne>>.json'"
:extensions "csv"
:utils "python"))
(defun cj/dwim-shell-commands-json-to-yaml ()
"Convert JSON to YAML."
(interactive)
(dwim-shell-command-on-marked-files
"JSON to YAML"
"python -c \"import json,yaml,sys; yaml.dump(json.load(open('<<f>>')), open('<<fne>>.yaml', 'w'))\" && echo 'Created <<fne>>.yaml'"
:extensions "json"
:utils "python"))
(defun cj/dwim-shell-commands-extract-urls-from-file ()
"Extract all URLs from file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Extract URLs"
"grep -Eo 'https?://[^[:space:]]+' '<<f>>'"
:utils "grep"))
(defun cj/dwim-shell-commands-extract-emails-from-file ()
"Extract all email addresses from file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Extract emails"
"grep -Eo '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}' '<<f>>'"
:utils "grep"))
(defun cj/dwim-shell-commands-create-gif-from-video ()
"Create animated GIF from video."
(interactive)
(let ((fps (read-number "FPS for GIF: " 10))
(scale (read-number "Scale (pixels width): " 480)))
(when (<= fps 0)
(user-error "FPS must be positive"))
(when (<= scale 0)
(user-error "Scale must be positive"))
(dwim-shell-command-on-marked-files
"Create GIF"
(format "ffmpeg -i '<<f>>' -vf 'fps=%d,scale=%d:-1:flags=lanczos' '<<fne>>.gif'" fps scale)
:utils "ffmpeg")))
(defun cj/dwim-shell-commands-concatenate-videos ()
"Concatenate multiple videos into one.
Builds the ffmpeg concat filelist in Elisp so paths with spaces or quotes are
handled, instead of reconstructing it with echo/tr/sed over the raw file list.
The temp filelist is removed after ffmpeg finishes. The trailing =<<*>>= is an
inert shell comment whose only job is to make dwim-shell run one command over
all marked files rather than once per file."
(interactive)
(let ((listfile (make-temp-file "dwim-concat-" nil ".txt")))
(with-temp-file listfile
(insert (cj/dwim-shell--build-concat-filelist (dwim-shell-command--files))
"\n"))
(dwim-shell-command-on-marked-files
"Concatenate videos"
(format "ffmpeg -f concat -safe 0 -i %s -c copy '<<concatenated.mp4(u)>>'; rm -f %s # <<*>>"
(shell-quote-argument listfile)
(shell-quote-argument listfile))
:utils "ffmpeg")))
(defun cj/dwim-shell-commands-create-video-thumbnail ()
"Extract thumbnail from video at specific time."
(interactive)
(let ((time (read-string "Time (HH:MM:SS or seconds): " "00:00:05")))
(unless (cj/dwim-shell--valid-ffmpeg-timestamp-p time)
(user-error "Not a valid timestamp (use seconds or HH:MM:SS): %s" time))
(dwim-shell-command-on-marked-files
"Extract video thumbnail"
(format "ffmpeg -i '<<f>>' -ss %s -vframes 1 '<<fne>>_thumb.jpg'" time)
:utils "ffmpeg")))
(defun cj/dwim-shell-commands-merge-pdfs ()
"Merge multiple PDFs into one."
(interactive)
(dwim-shell-command-on-marked-files
"Merge PDFs"
"qpdf --empty --pages '<<*>>' -- '<<merged.pdf(u)>>'"
:extensions "pdf"
:utils "qpdf"))
(defun cj/dwim-shell-commands-split-pdf-by-pages ()
"Split PDF into individual pages."
(interactive)
(dwim-shell-command-on-marked-files
"Split PDF pages"
"qpdf --split-pages '<<f>>' '<<fne>>-page-%d.pdf'"
:extensions "pdf"
:utils "qpdf"))
(defun cj/dwim-shell-commands-compress-pdf ()
"Compress PDF file size."
(interactive)
(let ((quality (completing-read "PDF compression quality: "
'("screen" "ebook" "printer" "prepress")
nil t "ebook")))
(dwim-shell-command-on-marked-files
"Compress PDF"
(format "gs -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/%s -dNOPAUSE -dBATCH -sOutputFile='<<fne>>_compressed.pdf' '<<f>>'" quality)
:extensions "pdf"
:utils "gs")))
(defun cj/dwim-shell-commands-ascii-art ()
"Convert image to ASCII art."
(interactive)
(dwim-shell-command-on-marked-files
"Create ASCII art"
"jp2a --width=80 '<<f>>'"
:utils "jp2a"))
(defun cj/dwim-shell-commands-text-to-speech ()
"Convert text file to speech (audio file)."
(interactive)
(let ((voice (if (eq system-type 'darwin)
(completing-read "Text-to-speech voice: " '("Alex" "Samantha" "Victoria" "Karen") nil t "Alex")
"en")))
(dwim-shell-command-on-marked-files
"Text to speech"
(if (eq system-type 'darwin)
(format "say -v %s -o '<<fne>>.aiff' -f '<<f>>'" voice)
"espeak -f '<<f>>' -w '<<fne>>.wav'")
:utils (if (eq system-type 'darwin) "say" "espeak"))))
(defun cj/dwim-shell-commands-remove-empty-directories ()
"Remove empty directories under a chosen root.
Prompts for the root (defaulting to the current directory) and names it in the
confirmation, so the scope is explicit instead of whatever the current
directory happens to be."
(interactive)
(let ((root (expand-file-name
(read-directory-name "Remove empty dirs under: " default-directory))))
(when (yes-or-no-p (format "Remove all empty directories under %s? " root))
(dwim-shell-command-on-marked-files
"Remove empty dirs"
(cj/dwim-shell--empty-dirs-command root)
:utils "find"))))
(defun cj/dwim-shell-commands-create-thumbnail-from-image ()
"Create thumbnail(s) from image(s)."
(interactive)
(let ((size (read-number "Thumbnail size (pixels): " 200)))
(when (<= size 0)
(user-error "Thumbnail size must be positive"))
(dwim-shell-command-on-marked-files
"Create thumbnail"
(format "convert '<<f>>' -thumbnail %dx%d '<<fne>>_thumb.<<e>>'" size size)
:utils "convert")))
(defun cj/dwim-shell-commands-extract-audio-from-video ()
"Extract audio track from video file(s)."
(interactive)
(let ((bitrate (completing-read "Audio bitrate: "
'("64k" "96k" "128k" "192k")
nil t)))
(dwim-shell-command-on-marked-files
"Extract audio"
(format "ffmpeg -i '<<f>>' -vn -c:a aac -b:a %s '<<fne>>.m4a'" bitrate)
:utils "ffmpeg"
:extensions '("mp4" "mkv" "webm" "avi" "mov" "flv" "wmv" "m4v" "mpg" "mpeg" "ogv" "3gp" "ts"))))
(defun cj/dwim-shell-commands-normalize-audio-volume ()
"Normalize audio volume in file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Normalize audio"
"ffmpeg -i '<<f>>' -af 'loudnorm=I=-16:LRA=11:TP=-1.5' '<<fne>>_normalized.<<e>>'"
:utils "ffmpeg"))
(defun cj/dwim-shell-commands-remove-zip-encryption ()
"Remove password protection from archive file(s).
Works with .7z, .zip, and other password-protected archives: extracts and
re-archives without a password. The password is written to a temp file
(mode 600) removed only after the spawned process exits, so it stays out of
shell history.
Accepted tradeoff: 7z reads the password only from its controlling TTY, not
stdin or a file (verified on 7-Zip 26.01 — a piped password silently becomes
an empty one), so it must go on argv via =$(cat tempfile)= and is briefly
visible in the process list while 7z runs. On a single-user workstation, for
a short-lived process, that exposure is acceptable; closing it would mean
switching off the .7z format to gpg-wrapped tar."
(interactive)
(let ((password (read-passwd "Current password: ")))
(cj/dwim-shell--run-with-password-file
password
"Remove archive encryption"
(lambda (temp-file)
(format "TMPDIR=$(mktemp -d) && 7z x -p\"$(cat '%s')\" '<<f>>' -o\"$TMPDIR\" && 7z a -tzip '<<fne>>_decrypted.zip' \"$TMPDIR\"/* && rm -rf \"$TMPDIR\""
temp-file))
:utils "7z")))
(defun cj/dwim-shell-commands-create-encrypted-zip ()
"Create password-protected archive of file(s).
Creates a .7z archive with AES-256 encryption and encrypted headers. The
password is written to a temp file (mode 600) removed only after the spawned
process exits, so it stays out of shell history.
Accepted tradeoff: 7z reads the password only from its controlling TTY, not
stdin or a file (verified on 7-Zip 26.01 — a piped password silently becomes
an empty one), so it must go on argv via =$(cat tempfile)= and is briefly
visible in the process list while 7z runs. On a single-user workstation, for
a short-lived process, that exposure is acceptable; closing it would mean
switching off the .7z format to gpg-wrapped tar."
(interactive)
(let ((password (read-passwd "Password: "))
(archive-name (read-string "Archive name (without extension): " "archive")))
(cj/dwim-shell--run-with-password-file
password
"Create encrypted archive"
(lambda (temp-file)
(format "7z a -t7z -mhe=on -p\"$(cat '%s')\" %s '<<*>>'"
temp-file
(shell-quote-argument (concat archive-name ".7z"))))
:utils "7z")))
(defun cj/dwim-shell-commands-list-archive-contents ()
"List contents of archive without extracting."
(interactive)
(dwim-shell-command-on-marked-files
"List archive contents"
"atool --list '<<f>>'"
:utils "atool"))
(defun cj/dwim-shell-commands-make-executable ()
"Make file(s) executable."
(interactive)
(dwim-shell-command-on-marked-files
"Make executable"
"chmod +x '<<f>>'"
:silent-success t
:utils "chmod"))
(defun cj/dwim-shell-commands-secure-delete ()
"Securely delete file(s): overwrite with random data, then remove.
Uses =shred -u= so the file is unlinked after overwriting, matching the
\"delete\" the command name and prompt promise."
(interactive)
(when (yes-or-no-p "This will permanently destroy files. Continue? ")
(dwim-shell-command-on-marked-files
"Secure delete"
"shred -vfzu -n 3 '<<f>>'"
:utils "shred")))
(defun cj/dwim-shell-commands-sanitize-filename ()
"Sanitize filename(s) - remove spaces and special characters."
(interactive)
(dwim-shell-command-on-marked-files
"Sanitize filename"
"NEW_NAME=$(echo '<<b>>' | tr ' ' '_' | tr -cd '[:alnum:]._-'); mv '<<f>>' \"$(dirname '<<f>>')/${NEW_NAME}\""
:utils '("tr" "mv")))
(defun cj/dwim-shell-commands-number-files-sequentially ()
"Rename files with sequential numbers."
(interactive)
(let ((prefix (read-string "Prefix (optional): ")))
(unless (cj/dwim-shell--safe-rename-prefix-p prefix)
(user-error "Prefix may only contain letters, numbers, space, . _ -: %s" prefix))
(dwim-shell-command-on-marked-files
"Number files"
(format "mv '<<f>>' '<<d>>/%s<<n>>.<<e>>'" prefix)
:utils "mv")))
(defun cj/dwim-shell-commands-git-history ()
"Show git history for file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"Git history"
"git log --oneline --follow '<<f>>'"
:utils "git"))
(defun cj/dwim-shell-commands-encrypt-with-gpg ()
"Encrypt file(s) with GPG."
(interactive)
(let ((recipient (read-string "Recipient email (or leave empty for symmetric): ")))
(dwim-shell-command-on-marked-files
"GPG encrypt"
(if (string-empty-p recipient)
"gpg --symmetric --cipher-algo AES256 '<<f>>'"
(format "gpg --encrypt --recipient %s '<<f>>'" (shell-quote-argument recipient)))
:utils "gpg")))
(defun cj/dwim-shell-commands-decrypt-with-gpg ()
"Decrypt GPG file(s)."
(interactive)
(dwim-shell-command-on-marked-files
"GPG decrypt"
"gpg --decrypt '<<f>>' > '<<fne>>'"
:extensions '("gpg" "asc" "pgp")
:utils "gpg"))
(defun cj/dwim-shell-commands-markdown-to-html5-and-open ()
"Convert markdown file to HTML in specified directory and open it."
(interactive)
(let ((files (dwim-shell-command--files)))
;; verify it's a markdown file
(unless (and files
(= 1 (length files))
(string-match-p "\\.\\(md\\|markdown\\|mkd\\|mdown\\)\\'" (car files)))
(user-error "Please place cursor on a single markdown file"))
(let* ((dest-dir (expand-file-name (read-directory-name "Destination directory: " default-directory)))
(base-name (file-name-sans-extension (file-name-nondirectory (car files))))
(output-file (expand-file-name (concat base-name ".html") dest-dir)))
(dwim-shell-command-on-marked-files
"Convert markdown to HTML"
(format "pandoc --standalone --from=markdown --to=html5 --metadata title='<<fne>>' '<<f>>' -o '%s'"
output-file)
:utils "pandoc"
:on-completion (lambda (&rest _args)
(when (file-exists-p output-file)
(cj/xdg-open output-file)
(message "Opened %s" output-file)))))))
(defun cj/dwim-shell-commands-optimize-image-for-email ()
"Optimize image(s) for email - reduces file size while maintaining quality.
Resizes to max 1200px (only if larger), strips metadata, and applies JPEG
optimization."
(interactive)
(dwim-shell-command-on-marked-files
"Optimize for email"
"magick '<<f>>' -strip -resize '1200x1200>' -quality 85 -interlace Plane -colorspace sRGB -sampling-factor 4:2:0 '<<fne>>_email.<<e>>'"
:utils "magick"))
(defun cj/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))
;; Dwim shell commands menu
(defun dwim-shell-commands-menu ()
"Select and execute a dwim-shell-command function with prettified names."
(interactive)
(let* ((commands (cl-loop for symbol being the symbols
when (and (fboundp symbol)
(string-prefix-p "cj/dwim-shell-commands-" (symbol-name symbol))
(not (eq symbol 'dwim-shell-commands-menu)))
collect symbol))
;; Create alist of (pretty-name . command-symbol)
(command-alist (mapcar (lambda (cmd)
(cons (replace-regexp-in-string
"-" " "
(replace-regexp-in-string
"^cj/dwim-shell-commands-"
""
(symbol-name cmd)))
cmd))
commands))
(selected (completing-read "Run dwim-shell command: "
command-alist
nil
t
nil
'dwim-shell-command-history))
(command (alist-get selected command-alist nil nil #'string=)))
(when command
(call-interactively command))))
;; Bind menu to keymaps after function is defined
(with-eval-after-load 'dired
(keymap-set dired-mode-map "M-S-d" #'dwim-shell-commands-menu)) ;; was M-D, overrides kill-word
(with-eval-after-load 'dirvish
(keymap-set dirvish-mode-map "M-D" #'dwim-shell-commands-menu)))
(provide 'dwim-shell-config)
;;; dwim-shell-config.el ends here.
|