-
Notifications
You must be signed in to change notification settings - Fork 11
Expand file tree
/
Copy pathpocket-reader.el
More file actions
1485 lines (1324 loc) · 60.7 KB
/
pocket-reader.el
File metadata and controls
1485 lines (1324 loc) · 60.7 KB
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
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;;; pocket-reader.el --- Client for Pocket reading list -*- lexical-binding: t; -*-
;; Copyright (C) 2017 Free Software Foundation, Inc.
;; Author: Adam Porter <adam@alphapapa.net>
;; Created: 2017-09-25
;; Version: 0.4-pre
;; Keywords: pocket
;; Package-Requires: ((emacs "25.1") (dash "2.13.0") (kv "0.0.19") (peg "1.0.1") (pocket-lib "0.3-pre") (s "1.10") (ov "1.0.6") (org-web-tools "0.1") (ht "2.2"))
;; URL: https://github.com/alphapapa/pocket-reader.el
;; This file is NOT part of GNU Emacs.
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
;;; Commentary:
;; This is a client for Pocket (getpocket.com). It allows you to
;; manage your reading list: add, remove, delete, tag, view, favorite,
;; etc. Doing so in Emacs with the keyboard is fast and efficient.
;; Links can be opened in Emacs with any function, or in external
;; browsers, and specific sites/URLs can be opened with specific
;; browser functions. Views can be sorted by date, title, domain,
;; tags, favorite, etc, and "limited" mutt-style. Items can be
;; searched for using keywords, tags, favorite status, unread/archived
;; status, etc.
;;
;; These keys can be used in the pocket-reader buffer:
;;
;; "RET" pocket-reader-open-url
;; "TAB" pocket-reader-pop-to-url
;; "a" pocket-reader-toggle-archived
;; "b" pocket-reader-open-in-external-browser
;; "c" pocket-reader-copy-url
;; "d" pocket-reader (return to default view)
;; "D" pocket-reader-delete
;; "e" pocket-reader-excerpt
;; "E" pocket-reader-excerpt-all
;; "*" pocket-reader-toggle-favorite
;; "f" pocket-reader-toggle-favorite
;; "F" pocket-reader-show-unread-favorites
;; "g" pocket-reader-resort
;; "G" pocket-reader-refresh
;; "s" pocket-reader-search
;; "m" pocket-reader-toggle-mark
;; "M" pocket-reader-mark-all
;; "U" pocket-reader-unmark-all
;; "o" pocket-reader-more
;; "l" pocket-reader-limit
;; "R" pocket-reader-random-item
;; "ta" pocket-reader-add-tags
;; "tr" pocket-reader-remove-tags
;; "tt" pocket-reader-set-tags
;; "ts" pocket-reader-tag-search
;;
;; In eww, Org, w3m, and some other major modes,
;; `pocket-reader-add-link' can be used to add a link at point to
;; Pocket.
;;; Code:
;;;; Requirements
(require 'cl-lib)
(require 'color)
(require 'url-parse)
(require 'seq)
(require 'subr-x)
(require 'thingatpt)
(require 'dash)
(require 'kv)
(require 'ht)
(require 'ov)
(require 'peg)
(require 's)
(require 'org-web-tools)
(require 'pocket-lib)
;;;; Variables
(defvar pocket-reader-mode-map
(let ((map (make-sparse-keymap))
(mappings '(
"RET" pocket-reader-open-url
"TAB" pocket-reader-pop-to-url
"a" pocket-reader-toggle-archived
"b" pocket-reader-open-in-external-browser
"c" pocket-reader-copy-url
"d" pocket-reader ; Return to default view
"D" pocket-reader-delete
"e" pocket-reader-excerpt
"E" pocket-reader-excerpt-all
"*" pocket-reader-toggle-favorite
"f" pocket-reader-toggle-favorite
"F" pocket-reader-show-unread-favorites
"g" pocket-reader-resort
"G" pocket-reader-refresh
"s" pocket-reader-search
"m" pocket-reader-toggle-mark
"M" pocket-reader-mark-all
"u" pocket-reader-unmark-all
"o" pocket-reader-more
"l" pocket-reader-limit
"R" pocket-reader-random-item
"ta" pocket-reader-add-tags
"tr" pocket-reader-remove-tags
"tt" pocket-reader-set-tags
"ts" pocket-reader-tag-search
)))
(cl-loop for (key fn) on mappings by #'cddr
do (define-key map (kbd key) fn))
map))
(defvar pocket-reader-items nil
"Items to be shown.
This is stored in a var so we can fetch the items and calculate
settings for ‘tabulated-list-mode’ based on it. NOTE: This may
become out-of-sync with `tabulated-list-entries', so it should
not be used outside of functions that already use it.")
(defvar pocket-reader-offset 0
"The current offset.")
(defvar pocket-reader-queries nil
"List of current query strings.")
(defvar pocket-reader-mark-overlays nil
"List of overlays used to mark items.
Each item in the list is a cons cell whose first element is the
item ID and second is the overlay used to mark it.")
(defconst pocket-reader-keys
'(:item_id
:status
:favorite
(:tags . pocket-lib--process-tags)
:time_added
:time_updated
:time_read
:given_title
:resolved_title
:excerpt
:has_video
:has_image
:word_count
:given_url
:amp_url
:resolved_url)
"Keys to use in Pocket API responses.
Each item may also be a cons cell in which the cdr is a function
to filter each one through.")
;;;;; Customization
(defgroup pocket-reader nil
"Library for accessing GetPocket.com API."
:group 'external)
(defcustom pocket-reader-default-queries nil
"Default queries, used for initial view."
:type '(repeat string))
(defcustom pocket-reader-open-url-default-function
#'org-web-tools-read-url-as-org
"Default function to open items."
:type 'function)
(defcustom pocket-reader-pop-to-url-default-function
(lambda (url)
(funcall #'org-web-tools-read-url-as-org url :show-buffer-fn #'pop-to-buffer))
"Default function to pop-to items."
:type 'function)
(defcustom pocket-reader-archive-on-open t
"Mark items as read when opened."
:type 'boolean)
(defcustom pocket-reader-color-site t
"Colorize site names uniquely."
:type 'boolean)
(defcustom pocket-reader-color-title t
"Colorize titles according to site."
:type 'boolean)
(defcustom pocket-reader-show-count 50
"Show this many items in the list."
:type 'integer)
(defcustom pocket-reader-site-column-max-width 22
"Maximum width of the site column."
:type 'integer)
(defcustom pocket-reader-url-open-fn-map
'((eww-browse-url "news.ycombinator.com"))
;; FIXME: This is supposed to be an alist, but the default value
;; isn't one.
"List mapping URL-matching regexps to functions used to open the URL.
Regexps are anchored after the protocol (i.e. \"https://\" is not
matched against).
This is useful when certain sites should be opened in an external
browser. The list is backward in the sense that the functions
are listed first, followed by the regexps, in this format: (FN
REGEXP REGEXP ...)."
:type '(alist :key-type function
:value-type (repeat string)))
(defcustom pocket-reader-domain-url-type-map
'((resolved_url "reddit.com"))
"A list mapping URL types from `pocket-reader-url-priorities' to domains.
This is useful when certain sites should have certain URL types
preferred (e.g. if you prefer not to load AMP URLs for Reddit)."
:type '(alist :key-type symbol
:value-type (repeat string)))
(defcustom pocket-reader-finalize-hook
'(pocket-reader--apply-faces
pocket-reader--add-spacers)
"Functions run after printing items into the buffer."
:type 'hook
:options '(pocket-reader--apply-faces
pocket-reader--add-spacers))
(defcustom pocket-reader-url-priorities
'(amp_url resolved_url given_url)
"URLs for each item are chosen in this order.
Pocket provides multiple URLs for each item, depending on what it
can find. This allows users to choose which URLs they prefer to
use when opening, copying, etc."
:type '(repeat symbol)
:options '(amp_url resolved_url given_url))
(defcustom pocket-reader-added-column-sort-function #'pocket-reader--added-fancy<
"Function to sort the \"Added\" column."
:type '(radio (function-item :tag "Default (by date, then favorite, then tags, then domain)" pocket-reader--added-fancy<)
(function-item :tag "By date only" pocket-reader--added<)
(function :tag "Custom function")))
;;;;;; Faces
(defface pocket-reader-marked `((default :inverse-video t)) "Face for marked items")
(defface pocket-reader-unread `((default :weight bold)) "Face for unread items")
(defface pocket-reader-archived `((default :weight normal)) "Face for archived items")
(defface pocket-reader-favorite-star `((default :foreground "#b58900")) "Face for favorite items")
;;;; Macros
(defmacro pocket-reader--with-pocket-reader-buffer (&rest body)
"Run BODY in ‘pocket-reader’ buffer and read-only inhibited."
(declare (indent defun))
`(with-current-buffer "*pocket-reader*"
(let ((inhibit-read-only t))
,@body)))
(cl-defmacro pocket-reader--keywords-in-list (list &rest keywords)
"Destructively remove KEYWORDS from LIST and return the last keyword found."
(declare (debug nil))
`(car (last (cl-loop for keyword in ',keywords
when (member keyword ,list)
do (setq ,list (delete keyword ,list))
and collect (s-replace (rx ":") "" keyword)))))
(cl-defmacro pocket-reader--regexp-in-list (list regexp &optional (prefix ":"))
"Return last match of REGEXP in LIST, without PREFIX.
Also destructively removes matching strings from LIST."
`(car (last (cl-loop for string in ,list
when (string-match ,regexp string)
do (setq ,list (delete string ,list))
and collect (replace-regexp-in-string (rx-to-string `(seq bos (regexp ,,prefix))) "" string)))))
(defmacro pocket-reader--at-item (id-or-item &rest body)
"Eval BODY with point at item ID-OR-ITEM.
ID-OR-ITEM should be an integer or an alist. If it's an alist,
get the `item-id' from it."
(declare (indent defun) (debug (symbolp body)))
`(pocket-reader--with-pocket-reader-buffer
(let ((id (cl-typecase ,id-or-item
(integer ,id-or-item)
(list (alist-get 'item_id ,id-or-item)))))
(save-excursion
(goto-char (point-min))
(cl-loop while (not (eobp))
when (equal (tabulated-list-get-id) id)
return (progn
,@body)
do (forward-line 1)
finally do (error "Item ID not found: %s" id))))))
(defmacro pocket-reader--at-marked-or-current-items (&rest body)
"Execute BODY at each marked item, or current item if none are marked."
(declare (indent defun))
`(if pocket-reader-mark-overlays
;; Marked items
(cl-loop for (id . ov) in pocket-reader-mark-overlays
do (pocket-reader--at-item id
,@body))
;; Current item
,@body))
;;;; Mode
(define-derived-mode pocket-reader-mode tabulated-list-mode
"Pocket Reader"
:group 'pocket-reader
;; FIXME: Unfortunately I can't get (local 'symbol) to work with
;; `advice-add', and I can't get `add-function' to work either, so I
;; have to use `advice-add', test the buffer each time the advice is
;; called, and delete the advice manually when the buffer is killed.
(advice-add 'tabulated-list--sort-by-column-name :after 'pocket-reader--finalize)
(add-hook 'kill-buffer-hook (lambda ()
(advice-remove 'tabulated-list--sort-by-column-name 'pocket-reader--finalize))
'append 'local)
(setq tabulated-list-sort-key '("Added" . nil))
(setq pocket-reader-queries pocket-reader-default-queries)
(pocket-reader-refresh)
(unless (cdr tabulated-list-sort-key)
;; Invert initial sort order, putting most recent items on top
(tabulated-list-sort 0)))
;;;; Functions
;;;;; Commands
;;;###autoload
(defun pocket-reader ()
"Show Pocket reading list."
(interactive)
(switch-to-buffer (get-buffer-create "*pocket-reader*"))
(pocket-reader-mode))
(cl-defun pocket-reader-search (&optional query &key add)
"Search Pocket items with QUERY.
If QUERY is nil, show default list. With prefix or ADD non-nil,
add items instead of replacing."
;; This function is the main one used to get and display items.
(interactive (list (read-from-minibuffer "Query: ")))
(unless (or current-prefix-arg add)
;; Not adding; reset everything
(goto-char (point-min))
(custom-reevaluate-setting 'pocket-reader-show-count)
(pocket-reader-unmark-all)
(setq pocket-reader-offset 0
pocket-reader-queries nil
pocket-reader-items (ht)))
(let ((items (pocket-reader--get-items query)))
(if items
(progn
(cl-pushnew query pocket-reader-queries :test #'string=)
(pocket-reader--add-items items))
;; No items found
(cl-case pocket-reader-offset
(0 (message "No items for query: %s" query))
(t (message "No more items for query: %s" query))))))
(defun pocket-reader-refresh ()
"Refresh list using current queries."
(interactive)
;; TODO: Can we use the API's "since" option to just get changes?
(let ((first-line-visible-p (pos-visible-in-window-p (point-min))))
(cl-case (length pocket-reader-queries)
(1 (pocket-reader-search (car pocket-reader-queries)))
(t (let ((queries (cdr pocket-reader-queries)))
;; Run the first query as a replacing search, then the rest
;; as adding ones. We save the queries, because the
;; replacing search overwrites them.
(pocket-reader-search (car pocket-reader-queries))
(--each queries
(pocket-reader-search it :add t)))))
(when first-line-visible-p
;; If point is on the first item, and new items are added above
;; it, the new items will be off-screen, and the user won't
;; realize they have been added. So, if we started on what was
;; the first line, show what's now the first line.
(let ((pos (point)))
(goto-char (point-min))
(redisplay)
(goto-char pos)))))
(defun pocket-reader-show-unread-favorites ()
"Show unread favorite items."
(interactive)
(pocket-reader-search ":* :unread"))
(defun pocket-reader-more (count)
"Fetch and show COUNT more items."
(interactive "p")
(let* ((count (if (= 1 count)
pocket-reader-show-count
count)))
(cl-incf pocket-reader-offset count)
(--each pocket-reader-queries
(pocket-reader-search it :add t))))
(defun pocket-reader-limit (query)
"Limit display to items matching QUERY."
;; MAYBE: Search hidden properties so e.g. the URL can be matched against.
(interactive (list (read-from-minibuffer "Query: ")))
(if (s-present? query)
(save-excursion
(pocket-reader-unmark-all)
(goto-char (point-min))
(while (not (eobp))
(unless (re-search-forward query (line-end-position) t)
(ov (line-beginning-position) (1+ (line-end-position)) 'display ""))
(forward-line 1)))
;; No query; show all entries
(ov-clear 'display "")))
(defun pocket-reader-random-item (prefix)
"Open a random item from the current list.
With universal prefix, read a key and call the command bound to
that keystroke on a random item."
(interactive "p")
(let ((fn (or (and (> prefix 1)
(alist-get (read-key "Key: ") pocket-reader-mode-map))
#'pocket-reader-open-url)))
(pocket-reader--with-pocket-reader-buffer
(cl-loop do (progn
(goto-char (random (buffer-size)))
(beginning-of-line))
while (not (pocket-reader--item-visible-p))
finally do (funcall fn)))))
(defun pocket-reader--column-beginning (column)
"Return the position of the beginning of the column named COLUMN, in the current line.
Return nil if not found."
(save-excursion
(beginning-of-line)
(let ((prop 'tabulated-list-column-name)
(end (line-end-position)))
(while (and (< (point) end)
(not (equal (get-text-property (point) prop) column)))
(goto-char (next-single-property-change (point) prop nil end)))
(and (< (point) end) (point)))))
(defun pocket-reader-excerpt ()
"Show excerpt for marked or current items."
(interactive)
(pocket-reader--at-marked-or-current-items
(let ((excerpt (pocket-reader--get-property 'excerpt)))
(unless (s-blank-str? excerpt)
(let* ((start-col (save-excursion
(goto-char (pocket-reader--column-beginning "Title"))
(current-column)))
(prefix (s-repeat start-col " "))
(width (- (window-text-width) start-col))
(left-margin start-col)
(string (concat prefix (s-trim (propertize (pocket-reader--wrap-string excerpt width)
'face 'default)) "\n")))
;; Hide or show excerpt
(unless (cl-loop for ov in (ov-forwards)
when (equal string (ov-val ov 'before-string))
do (ov-reset ov)
and return t)
;; Excerpt not found; show it
(ov (1+ (line-end-position)) (1+ (line-end-position))
'before-string string)))))))
(defun pocket-reader-excerpt-all ()
"Show all excerpts."
(interactive)
(save-excursion
(goto-char (point-min))
(let ((first-excerpt (cl-loop while (not (eobp))
for excerpt = (pocket-reader--get-property 'excerpt)
when excerpt
return excerpt
do (forward-line 1)
finally do (error "No excerpts found"))))
;; Search for overlay showing this excerpt
(if (cl-loop for ov in (ov-forwards)
thereis (equal (ov-val ov 'before-string) first-excerpt))
;; Already shown; hide all excerpts
(cl-loop initially do (goto-char (point-min))
for ov in (ov-forwards)
when (not (equal (ov-val ov 'before-string) "\n"))
do (ov-reset ov))
;; Not shown; show all excerpts
(goto-char (point-min))
(while (not (eobp))
(pocket-reader-excerpt)
(forward-line 1))))))
;;;;;; Marking
(defun pocket-reader-toggle-mark ()
"Toggle mark on current item."
(interactive)
;; Make sure item is visible
(unless (pocket-reader--item-visible-p)
(error "toggle-mark called on invisible item: %s" (tabulated-list-get-id)))
(if (pocket-reader--item-marked-p)
;; Marked; unmark
(pocket-reader--unmark-item (tabulated-list-get-id))
;; Unmarked; mark
(pocket-reader--mark-current-item))
(forward-line 1))
(defun pocket-reader-mark-all ()
"Mark all visible items."
(interactive)
(pocket-reader--with-pocket-reader-buffer
(save-excursion
(goto-char (point-min))
(cl-loop while (not (eobp))
when (pocket-reader--item-visible-p)
do (pocket-reader--mark-current-item)
do (forward-line 1)))))
(defun pocket-reader-unmark-all ()
"Unmark all items."
(interactive)
(cl-loop for (id . ov) in pocket-reader-mark-overlays
do (pocket-reader--unmark-item id)))
;;;;;; Tags
(defun pocket-reader-tag-search (tag)
"Search for items with TAG.
This is a plain, simple tag search, not intended to be used with
other special keywords."
;; MAYBE: Maybe add support for special keywords, but that might
;; make it more complicated to use than it is worth, because it
;; would mean making every plain word an implied tag keyword.
(interactive (list (completing-read "Tag: " (cons "_untagged_" (pocket-reader--all-tags)))))
(let ((query (concat ":t:" tag)))
(pocket-reader-search query)))
(defun pocket-reader-add-tags (tags)
"Add TAGS to current item."
(interactive (list (completing-read "Tags: " (pocket-reader--all-tags))))
(let* ((new-tags (s-split (rx (or space ",")) tags 'omit-nulls))
(new-tags-string (s-join "," new-tags)))
(when (and new-tags-string
(apply #'pocket-lib--tags-action 'tags_add new-tags-string
(pocket-reader--marked-or-current-items)))
;; Tags added successfully
(pocket-reader--at-marked-or-current-items
(pocket-reader--add-tags new-tags)))))
(defun pocket-reader-remove-tags (tags)
"Remove TAGS from current item."
;; FIXME: Get all tags with a function.
(interactive (list (completing-read "Tags: " (let (tags)
(pocket-reader--at-marked-or-current-items
(setq tags (append (pocket-reader--get-property 'tags) tags)))
(-sort #'string< (-uniq tags))))))
(let* ((tags (s-split (rx (or space ",")) tags 'omit-nulls))
(remove-tags-string (s-join "," tags)))
(when (and remove-tags-string
(apply #'pocket-lib--tags-action 'tags_remove remove-tags-string
(pocket-reader--marked-or-current-items)))
;; Tags removed successfully
(pocket-reader--at-marked-or-current-items
(pocket-reader--remove-tags tags)))))
(defun pocket-reader-set-tags (tags)
"Set TAGS of current item."
(interactive (list (completing-read "Tags: " (pocket-reader--all-tags))))
(pocket-reader--with-pocket-reader-buffer
(let* ((tags (s-split (rx (or space ",")) tags 'omit-nulls))
(tags-string (s-join "," tags)))
(when (apply #'pocket-lib--tags-action 'tags_replace tags-string (pocket-reader--marked-or-current-items))
;; Tags replaced successfully
(pocket-reader--at-marked-or-current-items
(pocket-reader--set-tags tags))))))
;;;;;; URL-opening
(cl-defun pocket-reader-open-url (&optional &key fn)
"Open URL of current item with default function."
(interactive)
(pocket-reader--at-marked-or-current-items
(let* ((id (tabulated-list-get-id))
(item (ht-get pocket-reader-items id))
(url (pocket-reader--get-url item))
(fn (or fn (pocket-reader--map-url-open-fn url))))
(when (funcall fn url)
;; Item opened successfully
(when pocket-reader-archive-on-open
(pocket-reader--with-pocket-reader-buffer
(pocket-reader--archive-items (pocket-reader--current-item))))))))
(defun pocket-reader-pop-to-url ()
"Open URL of current item with default pop-to function."
(interactive)
(pocket-reader-open-url :fn pocket-reader-pop-to-url-default-function))
(defun pocket-reader-open-in-external-browser ()
"Open marked or current items in external browser.
The `browse-url-default-browser' function is used."
(interactive)
(pocket-reader-open-url
:fn (lambda (&rest args)
(apply #'browse-url-default-browser args)
;; Return t because the browsing function may not return non-nil
;; when it succeeds, preventing the item from being archived
t)))
(defun pocket-reader-copy-url ()
"Copy URL of current item to kill-ring/clipboard."
(interactive)
(when-let ((id (tabulated-list-get-id))
(item (ht-get pocket-reader-items id))
(url (pocket-reader--get-url item)))
(kill-new url)
(message url)))
;;;;;; Other
(defun pocket-reader-delete ()
"Delete current or marked items (with confirmation)."
(interactive)
(when (yes-or-no-p "Delete item(s)?")
(apply #'pocket-reader--delete-items (pocket-reader--marked-or-current-items))))
(defun pocket-reader-resort ()
"Re-sort list."
(interactive)
(tabulated-list-sort 0)
(tabulated-list-sort 0))
(defun pocket-reader-toggle-favorite ()
"Toggle current or marked items' favorite status."
(interactive)
(cl-loop for item in (pocket-reader--marked-or-current-items)
if (pocket-reader--at-item item
(pocket-reader--is-favorite))
collect item into unfavorites
else collect item into favorites
finally do (when favorites
(apply #'pocket-reader--favorite-items favorites))
finally do (when unfavorites
(apply #'pocket-reader--unfavorite-items unfavorites))))
(defun pocket-reader-toggle-archived ()
"Toggle current or marked items' archived/unread status."
(interactive)
(cl-loop for item in (pocket-reader--marked-or-current-items)
if (pocket-reader--at-item item
(pocket-reader--is-archived))
collect item into readds
else collect item into archives
finally do (when readds
(apply #'pocket-reader--readd-items readds))
finally do (when archives
(apply #'pocket-reader--archive-items archives))))
;;;;; Helpers
(defun pocket-reader--get-url (item &optional &key first)
"Return URL for ITEM.
If FIRST is non-nil, return the first URL found, not the best
one. ITEM should be a hash-table with the appropriate keys, one
of which is chosen as configured by
`pocket-reader-url-priorities'."
(or (when-let ((prioritized-url
(cl-loop for key in pocket-reader-url-priorities
for url = (ht-get item key) ; Gets the URL
when (s-present? url)
return url)))
(if first
prioritized-url
(if-let ((domain (pocket-reader--url-domain prioritized-url))
(key (cl-loop for (key . vals) in pocket-reader-domain-url-type-map
when (member domain vals)
return key))
(domain-preferred-url (ht-get item key)))
domain-preferred-url
prioritized-url)))
(progn
(display-warning 'pocket-reader (format "No URLs found for item: %S." item))
;; HACK: Several places call this function, all of which expect
;; a URL. It seems like a bug on Pocket's end that some items
;; can be missing URLs (nowadays; it wasn't a problem in the
;; past), so rather than return nil or signal an error here, we
;; return a URL that can at least point to the problem.
"https://example.com/?error=item-had-no-URL")))
(defun pocket-reader--item-visible-p ()
"Return non-nil if current item is visible (i.e. not hidden by an overlay)."
(cl-loop for ov in (overlays-at (line-beginning-position))
never (string= "" (ov-val ov 'display))))
(defun pocket-reader--add-items (items)
"Add ITEMS to `pocket-reader-items' and update display."
(--each items
(let* ((item (ht<-alist (cdr it) #'eq))
(id (string-to-number (ht-get item 'item_id)))
(domain (pocket-reader--url-domain (pocket-reader--get-url item)))
(tags (pocket-lib--process-tags (ht-get item 'tags))))
(ht-set item 'domain domain)
(ht-set item 'tags tags)
(ht-set pocket-reader-items id item)))
(pocket-reader--set-tabulated-list-format)
;; Use a copy of the list. Otherwise, when the tabulated list is sorted, `pocket-reader-items'
;; gets rearranged when `tabulated-list-entries' gets sorted, and that somehow causes the apparent
;; length of `pocket-reader-items' to change, and that causes items to disappear from the list
;; when `pocket-reader-more' is called. This is a very strange bug, but it's basically caused by
;; `sort' modifying lists by side effects. Making `tabulated-list-entries' a copy avoids this
;; problem while allowing them to share the underlying items, which aren't changed.
(setq tabulated-list-entries (pocket-reader--items-to-tabulated-list-entries pocket-reader-items))
(tabulated-list-init-header)
(tabulated-list-print 'remember-pos)
(pocket-reader--finalize))
(defun pocket-reader--items-to-tabulated-list-entries (items)
"Convert ITEMS to a list of vectors of lists.
Suitable for `tabulated-list-entries'."
;; NOTE: From Emacs docs:
;; This buffer-local variable specifies the entries displayed in the
;; Tabulated List buffer. Its value should be either a list, or a
;; function.
;;
;; If the value is a list, each list element corresponds to one entry,
;; and should have the form ‘(ID CONTENTS)’, where
;;
;; • ID is either ‘nil’, or a Lisp object that identifies the
;; entry. If the latter, the cursor stays on the same entry when
;; re-sorting entries. Comparison is done with ‘equal’.
;;
;; • CONTENTS is a vector with the same number of elements as
;; ‘tabulated-list-format’. Each vector element is either a
;; string, which is inserted into the buffer as-is, or a list
;; ‘(LABEL . PROPERTIES)’, which means to insert a text button by
;; calling ‘insert-text-button’ with LABEL and PROPERTIES as
;; arguments (*note Making Buttons::).
;;
;; There should be no newlines in any of these strings.
(cl-loop for it being the hash-values of items
collect (let ((id (string-to-number (ht-get it 'item_id)))
(added (pocket-reader--format-timestamp (string-to-number (ht-get it 'time_added))))
(favorite (pocket-reader--favorite-string (ht-get it 'favorite)))
(title (pocket-reader--not-empty-string (pocket-reader--or-string-not-blank
(ht-get it 'resolved_title)
(ht-get it 'given_title)
"[untitled]")))
(domain (pocket-reader--url-domain
;; Don't use --get-url here, because, e.g. we don't want an "amp." to be shown in the list
(pocket-reader--or-string-not-blank (ht-get it 'resolved_url)
(ht-get it 'given_url))))
(tags (pocket-reader--not-empty-string (s-join "," (ht-get it 'tags)))))
(list id (vector added favorite title domain tags)))))
(defun pocket-reader--delete-items (&rest items)
"Delete ITEMS.
Items should be a list of items as returned by
`pocket-reader--marked-or-current-items'."
(when (apply #'pocket-lib-delete items)
(cl-loop for item in items
for id = (alist-get 'item_id item)
do (progn
(ht-remove! pocket-reader-items id)
(pocket-reader--unmark-item id)
(pocket-reader--at-item id
(tabulated-list-delete-entry))))
;; Do this once, at the end, not for each item
;; TODO: Is this even necessary? If so, should we just use
;; `cl-delete' instead of rebuilding it from scratch? Or is it
;; better, safer, to do this?
(setq tabulated-list-entries (pocket-reader--items-to-tabulated-list-entries pocket-reader-items))))
(defun pocket-reader--finalize (&rest _)
"Finalize the buffer after adding or sorting items."
;; Because we have to add this function as advice to
;; `tabulated-list--sort-by-column-name', causing it to run in every
;; tabulated-list buffer, we must make sure it's the pocket-reader
;; buffer.
(when (string= "*pocket-reader*" (buffer-name))
(run-hooks 'pocket-reader-finalize-hook)))
(defun pocket-reader--parse-query (query)
"Return plist representing parsed QUERY string."
(let (parsed)
(with-temp-buffer
(insert query)
(goto-char (point-min))
(with-peg-rules
((query (+ term))
(term (and (opt (* [blank]))
(or favorite archive unread all count tag plain-term)))
(favorite (or ":*" ":favorite")
`(_ -- (setf (plist-get parsed :favorite) t)))
(archive ":archive"
`(_ -- (setf (plist-get parsed :archive) t)))
(unread ":unread"
`(_ -- (setf (plist-get parsed :unread) t)))
(all ":all"
`(_ -- (setf (plist-get parsed :all) t)))
(count ":" (substring (+ [0-9]))
`(num -- (setf (plist-get parsed :count) (string-to-number num))))
(tag (and (or ":t:" "t:") (or quoted-tag unquoted-tag)))
(quoted-tag (and "\"" (substring (+ word (opt (* [blank])))) "\"")
`(tag -- (setf (plist-get parsed :tag) tag)))
(unquoted-tag (substring word)
`(tag -- (setf (plist-get parsed :tag) tag)))
(word (+ (or "_" (syntax-class word))))
(plain-term (substring word)
`(word -- (push word (plist-get parsed :words)))))
(peg-run (peg query))))
parsed))
(defun pocket-reader--get-items (&optional query)
"Return Pocket items for QUERY.
QUERY is a string which may contain certain keywords:
:*, :favorite Return only favorited items.
:archive Return only archived items.
:unread Return only unread items (default).
:all Return all items.
:COUNT Return at most COUNT (a number) items.
:t:TAG, t:TAG Return items with TAG (only one tag may be searched for)."
;; NOTE: ht version
(let* ((query (or query ""))
(parsed (pocket-reader--parse-query query))
(states (remq nil
(list (when (plist-get parsed :archive)
"archive")
(when (plist-get parsed :all)
"all")
(when (plist-get parsed :unread)
"unread"))))
(state (progn
(when states
(unless (= 1 (length states))
(user-error "Only one of :archive, :all, or :unread may be used")))
(car states)))
(favorite (when (plist-get parsed :favorite)
1))
(count (setq pocket-reader-show-count (or (plist-get parsed :count) pocket-reader-show-count)))
(tag (plist-get parsed :tag))
(query-string (s-join " " (plist-get parsed :words)))
(items (alist-get 'list (pocket-lib-get :detail-type "complete" :count count :offset pocket-reader-offset
:search query-string :state state :favorite favorite :tag tag))))
(when (> (length items) 0)
;; Empty results return an empty vector, which evaluates non-nil, which isn't useful, so in that case we return nil instead.
items)))
(defun pocket-reader--action (action &rest _)
"Execute ACTION on marked or current items.
ACTION should be a string or symbol which is the name of an
action in the Pocket API."
;; MAYBE: Not currently using this, may not need it.
(pocket-reader--with-pocket-reader-buffer
(apply #'pocket-lib--action action (pocket-reader--marked-or-current-items))))
(defun pocket-reader--marked-or-current-items ()
"Return marked or current items, suitable for passing to `pocket-lib' functions."
(or (cl-loop for (id . ov) in pocket-reader-mark-overlays
collect (list (cons 'item_id id)))
(list (pocket-reader--current-item))))
(defun pocket-reader--set-tabulated-list-format ()
"Set `tabulated-list-format'.
Sets according to the maximum width of items about to be
displayed."
(when-let ((domain-width (cl-loop for item being the hash-values of pocket-reader-items
maximizing (length (ht-get item 'domain))))
(title-width (- (window-text-width) 11 2 domain-width 10 1)))
(when (> domain-width pocket-reader-site-column-max-width)
(setq domain-width pocket-reader-site-column-max-width))
(setq tabulated-list-format (vector (list "Added" 10 pocket-reader-added-column-sort-function)
(list "*" 1 t)
(list "Title" title-width t)
(list "Site" domain-width t)
(list "Tags" 10 t)))))
(defun pocket-reader--map-url-open-fn (url)
"Return function to use to open URL.
Checks `pocket-reader-url-open-fn-map' for a function to use. If
none is found, returns `pocket-reader-open-url-default-function'."
(or (car (cl-rassoc url pocket-reader-url-open-fn-map
:test (lambda (url regexp)
(string-match (rx-to-string `(seq "http" (optional "s") "://"
(regexp ,(car regexp))
(or "/" eos)))
url))))
pocket-reader-open-url-default-function))
(defun pocket-reader--current-item ()
"Return list containing cons of current item's ID.
Suitable for passing to pocket-lib."
(let* ((id (tabulated-list-get-id)))
(list (cons 'item_id id))))
(defun pocket-reader--get-property (property)
"Return value of PROPERTY for current item."
(let ((id (tabulated-list-get-id)))
(ht-get* pocket-reader-items id property)))
(defun pocket-reader--set-property (property value)
"Set current item's PROPERTY to VALUE."
(pocket-reader--with-pocket-reader-buffer
(let* ((id (tabulated-list-get-id))
(item (ht-get pocket-reader-items id)))
(ht-set! item property value))))
(defun pocket-reader--url-domain (url)
"Return domain for URL.
Common prefixes like www are removed."
(replace-regexp-in-string (rx bos (and (or "www" "amp") ".")) ""
(url-host (url-generic-parse-url url))))
(defun pocket-reader--format-timestamp (timestamp)
"Format TIMESTAMP."
(format-time-string "%Y-%m-%d" timestamp))
(cl-defun pocket-reader--add-spacers (&key (min-group-size 2))
"Insert overlay spacers where the current sort column's values change.
For example, if sorted by date, a spacer will be inserted where
the date changes. If no group has at least MIN-GROUP-SIZE items,
no spacers will be inserted. "
;; TODO: Use column-specific functions so that, e.g. date column could be grouped by month/year
(let ((sort-column (seq-position tabulated-list-format tabulated-list-sort-key
(lambda (seq elt)
(string= (car seq) (car elt))))))
;; Clear existing spacers
(ov-clear)
(save-excursion
(goto-char (point-min))
(cl-loop with largest-group-size = 1
with prev-data = (elt (tabulated-list-get-entry) sort-column)
while (not (eobp))
do (forward-line 1)
for current-data = (elt (tabulated-list-get-entry) sort-column)
if (not (equal current-data prev-data))
do (progn
(ov (line-beginning-position) (line-beginning-position) 'before-string "\n")
(setq prev-data current-data))
else do (cl-incf largest-group-size)
finally do (when (< largest-group-size min-group-size)
(ov-clear))))))
;;;;;; Archived/readd
(defun pocket-reader--archive-items (&rest items)
"Mark ITEMS as archived."
(when (apply #'pocket-lib-archive items)
(--map (pocket-reader--at-item it
(pocket-reader--set-property 'status "1")
(pocket-reader--apply-faces-to-line))
items)))
(defun pocket-reader--readd-items (&rest items)
"Readd ITEMS."
(when (apply #'pocket-lib-readd items)
(--map (pocket-reader--at-item it
(pocket-reader--set-property 'status "0")
(pocket-reader--apply-faces-to-line))
items)))
(defun pocket-reader--is-archived ()
"Return non-nil if current item is archived."
(string= "1" (pocket-reader--get-property 'status)))
;;;;;; Favorites
(defun pocket-reader--favorite-items (&rest items)
"Mark ITEMS as favorites."
(when (apply #'pocket-lib-favorite items)
(--map (pocket-reader--at-item it
(pocket-reader--set-property 'favorite "1")
(pocket-reader--update-favorite-display t))
items)))
(defun pocket-reader--unfavorite-items (&rest items)
"Unmark ITEMS as favorites."
(when (apply #'pocket-lib-unfavorite items)
(--map (pocket-reader--at-item it
(pocket-reader--set-property 'favorite "0")
(pocket-reader--update-favorite-display nil))
items)))
(defun pocket-reader--is-favorite ()
"Return non-nil if current item is a favorite."
(string= "1" (pocket-reader--get-property 'favorite)))