summaryrefslogtreecommitdiff
path: root/lisp/garden-connect.el
blob: 6c6ffeeebc50095131e6f2a88c09c24180f442b8 (plain) (blame)
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
;;; garden-connect.el --- unlinked-reference connector -*- lexical-binding: t; -*-

(require 'garden-core)
(require 'garden-icons)
(require 'garden-fleet)
(require 'vui)
(require 'vui-components)

(declare-function evil-define-key* "evil-core" (state keymap key def &rest bindings))
(declare-function evil-set-initial-state "evil-core" (mode state))

(defcustom garden-connect-min-title-length 3 "Minimum title length for a note to be scanned for mentions." :type 'integer :group 'garden)
(defcustom garden-connect-max-results 80 "Maximum number of unlinked references shown at once." :type 'integer :group 'garden)
(defcustom garden-connect-max-line 1500 "Maximum line length in characters before a line is skipped." :type 'integer :group 'garden)

(defvar-local garden--ref-target nil
  "Cons of (ID . TITLE) of the note shown in the connect buffer.")

(defvar-local garden-connect--dispatch nil
  "Async component actions exposed to `garden-connect-mode' commands.")

(defun garden--long-line-p ()
  "Whether the current line exceeds `garden-connect-max-line'."
  (> (- (line-end-position) (line-beginning-position)) garden-connect-max-line))

(defun garden--in-link-p (pos)
  "Whether POS is inside an Org bracket link."
  (save-excursion
    (goto-char pos)
    (let ((bol (line-beginning-position)) (depth 0))
      (goto-char bol)
      (while (< (point) pos)
        (cond ((looking-at "\\[\\[") (setq depth (1+ depth)) (forward-char 2))
              ((looking-at "\\]\\]") (when (> depth 0) (setq depth (1- depth))) (forward-char 2))
              (t (forward-char 1))))
      (> depth 0))))

(defun garden--in-block-p (pos)
  "Whether POS is inside an Org src or example block."
  (save-excursion
    (save-match-data
      (goto-char pos)
      (let ((case-fold-search t))
        (and (re-search-backward "^[ \t]*#\\+\\(begin\\|end\\)_\\(src\\|example\\)\\>" nil t)
             (string-equal (downcase (match-string 1)) "begin"))))))

(defun garden--denote-link-text (id matched)
  "Return a denote link to ID with MATCHED as the description."
  (format "[[denote:%s][%s]]" id matched))

(defun garden--note-list ()
  "Return all garden notes as a list."
  (let (acc) (maphash (lambda (_ n) (push n acc)) (garden-notes)) acc))

(defun garden-unlinked-references-for (id title)
  "Find notes that mention TITLE without linking to the note ID.
Return a list of plists with the first mention found per note."
  (let ((re (concat "\\_<" (regexp-quote title) "\\_>"))
        (case-fold-search t)
        (results '()))
    (dolist (note (garden--note-list))
      (unless (or (equal (garden-note-id note) id)
                  (member id (garden-note-links note)))
        (with-temp-buffer
          (insert-file-contents (garden-note-file note))
          (goto-char (point-min))
          (catch 'found
            (while (re-search-forward re nil t)
              (let ((mb (match-beginning 0)))
                (if (garden--long-line-p)
                    (goto-char (line-end-position))
                  (save-excursion
                    (goto-char mb)
                    (beginning-of-line)
                    (when (and (not (looking-at-p "[ \t]*\\(#\\+\\|:[A-Za-z]\\)"))
                               (not (garden--in-link-p mb))
                               (not (garden--in-block-p mb)))
                      (push (list :id (garden-note-id note)
                                  :title (garden-note-title note)
                                  :file (garden-note-file note)
                                  :beg mb
                                  :line (line-number-at-pos mb)
                                  :context (string-trim (buffer-substring
                                                         (line-beginning-position)
                                                         (line-end-position))))
                            results)
                      (throw 'found t))))))))))
    (nreverse results)))

(defun garden-linkify (file beg id title)
  "Replace the mention of TITLE at BEG in FILE with a link to ID.
Save FILE afterwards."
  (with-current-buffer (find-file-noselect file)
    (save-excursion
      (goto-char beg)
      (let ((case-fold-search t)
            (re (concat "\\_<" (regexp-quote title) "\\_>")))
        (when (looking-at re)
          (let ((matched (match-string 0)))
            (unless (save-match-data (or (garden--in-link-p beg) (garden--in-block-p beg)))
              (replace-match (garden--denote-link-text id matched) t t))))))
    (save-buffer)))

(defun garden--read-note ()
  "Prompt for a garden note and return its id."
  (let* ((table (let (acc)
                  (maphash (lambda (_id n) (push (cons (garden-note-title n) n) acc)) (garden-notes))
                  acc))
         (annotate (lambda (title)
                     (when-let* ((note (cdr (assoc title table))))
                       (garden-note-annotation note))))
         (choice (completing-read "Find unlinked references to: "
                                  (garden-completion-table (mapcar #'car table) annotate) nil t)))
    (garden-note-id (cdr (assoc choice table)))))

(defun garden--current-note-id ()
  "Return the note id of the current buffer's file, or nil."
  (and (buffer-file-name)
       (let ((id (garden--file-id (buffer-file-name))))
         (and id (garden-note id) id))))

(defun garden-connect--window-width ()
  "Return the body width of the window displaying the connect buffer."
  (if-let* ((window (get-buffer-window (current-buffer) t)))
      (window-body-width window)
    (window-width)))

(defun garden-connect--scan (id title)
  "Return the capped unlinked-reference scan for ID and TITLE."
  (seq-take (garden-unlinked-references-for id title)
            garden-connect-max-results))

(defun garden--ref-card (ref id title on-linked)
  "Render REF as a narrow card for target ID and TITLE.
Call ON-LINKED after successfully adding its link."
  (let ((file (plist-get ref :file))
        (line (plist-get ref :line))
        (beg (plist-get ref :beg)))
    (vui-vstack :spacing 0
      (vui-flex :width 'window :justify :space-between
        (vui-flex-item :grow 1
          (lambda (width)
            (vui-button (plist-get ref :title)
                        :key file :no-decoration t :max-width width
                        :face '(:inherit bold)
                        :on-click (lambda ()
                                    (find-file file)
                                    (goto-char (point-min))
                                    (forward-line (1- line))))))
        (vui-muted (format "line %d" line))
        (vui-button (concat (garden-icon 'link) " link")
          :on-click (lambda ()
                      (garden-linkify file beg id title)
                      (garden-refresh)
                      (garden-fleet--refresh)
                      (funcall on-linked))))
      (vui-box (vui-text (plist-get ref :context) :face 'shadow)
               :width (max 12 (- (garden-connect--window-width) 2))
               :padding-left 2))))

(defun garden-connect--table (refs id title on-linked width)
  "Render REFS as a sticky review table for ID and TITLE at WIDTH."
  (let ((context-width (max 22 (- width 34))))
    (vui-table
     :sticky-header t
     :columns `((:header "Note" :width 20 :grow t :truncate t)
                (:header "Line" :width 6 :align :right)
                (:header "Mention" :width ,context-width :grow t :truncate t)
                (:header "" :width 7))
     :rows
     (mapcar
      (lambda (ref)
        (let ((file (plist-get ref :file))
              (line (plist-get ref :line))
              (beg (plist-get ref :beg)))
          (list
           (vui-button (plist-get ref :title)
                       :key file :no-decoration t :help-echo file
                       :on-click (lambda ()
                                   (find-file file)
                                   (goto-char (point-min))
                                   (forward-line (1- line))))
           (vui-text (number-to-string line) :face 'shadow)
           (vui-text (plist-get ref :context) :face 'shadow)
           (vui-button "link" :face 'success
                       :on-click (lambda ()
                                   (garden-linkify file beg id title)
                                   (garden-refresh)
                                   (garden-fleet--refresh)
                                   (funcall on-linked))))))
      refs))))

(defun garden--linkify-note (file id title)
  "Link the first valid mention of TITLE in FILE to ID.
Return the number of replacements made."
  (let ((re (concat "\\_<" (regexp-quote title) "\\_>"))
        (case-fold-search t)
        (n 0))
    (with-current-buffer (find-file-noselect file)
      (save-excursion
        (goto-char (point-min))
        (catch 'done
          (while (re-search-forward re nil t)
            (let ((mb (match-beginning 0))
                  (matched (match-string 0)))
              (cond
               ((garden--long-line-p) (goto-char (line-end-position)))
               ((save-match-data
                  (save-excursion
                    (goto-char mb)
                    (or (progn (beginning-of-line)
                               (looking-at-p "[ \t]*\\(#\\+\\|:[A-Za-z]\\)"))
                        (garden--in-link-p mb)
                        (garden--in-block-p mb)))))
               (t (replace-match (garden--denote-link-text id matched) t t)
                  (setq n 1)
                  (throw 'done t)))))))
      (when (> n 0) (save-buffer)))
    n))

(defun garden-connect-all (&optional id title)
  "Link every note that mentions the target note, after confirming.
When called interactively, ID and TITLE default to the note shown in
the current connect buffer."
  (interactive)
  (let* ((id (or id (car garden--ref-target)))
         (title (or title (cdr garden--ref-target)))
         (refs (garden-unlinked-references-for id title))
         (files (delete-dups (mapcar (lambda (r) (plist-get r :file)) refs)))
         (n 0))
    (when (and refs
               (yes-or-no-p (format "Connect all %d note(s) that mention “%s”? " (length refs) title)))
      (dolist (f files) (setq n (+ n (garden--linkify-note f id title))))
      (garden-refresh)
      (message "garden: connected %d note(s) to %s" n title))))

(vui-defcomponent garden-references (id title)
  :state ((refs (garden-connect--scan id title))
          (busy nil)
          (status nil))
  :render
  (let* ((width (garden-connect--window-width))
         (rescan (vui-async-callback ()
                   (vui-batch
                    (vui-set-state :busy t)
                    (vui-set-state :status nil))
                   (garden-refresh)
                   (vui-batch
                    (vui-set-state :refs (garden-connect--scan id title))
                    (vui-set-state :busy nil))))
         (linked (vui-async-callback ()
                   (vui-set-state :refs (garden-connect--scan id title)))))
    (setq garden-connect--dispatch (list :refresh rescan))
    (vui-vstack
     :spacing 1 :indent 2
     (vui-flex
      :width 'window :justify :space-between
      (vui-heading-2 (format "%s connect" (garden-icon 'link)))
      (vui-text (format "%d / %d max" (length refs) garden-connect-max-results)
                :face 'shadow))
     (vui-text title :face '(:inherit outline-2 :weight bold))
     (vui-muted "Review first mentions one by one; bulk linking always asks for confirmation.")
     (vui-hstack
      :spacing 2
      (when refs
        (vui-button (concat (garden-icon 'link) " link all")
                    :face 'warning
                    :on-click (lambda ()
                                (garden-connect-all id title)
                                (funcall rescan))))
      (vui-button (concat (garden-icon 'refresh) " rescan")
                  :on-click rescan)
      (vui-button (concat (garden-icon 'star) " another note")
                  :on-click #'garden-connect-pick))
     (cond
      (busy (vui-warning "Scanning the garden…"))
      ((null refs)
       (vui-vstack
        :spacing 1
        (vui-box (vui-text (garden-icon 'sparkle)
                           :face '(:inherit success :height 2.0))
                 :width width :align :center)
        (vui-box (vui-success "All linked up") :width width :align :center)
        (vui-box (vui-muted "Every mention already points back to this note")
                 :width width :align :center)))
      ((>= width 88)
       (garden-connect--table refs id title linked width))
      (t
       (vui-list refs
                 (lambda (ref) (garden--ref-card ref id title linked))
                 (lambda (ref) (plist-get ref :file))
                 :spacing 1)))
     (when status (vui-text (car status) :face (cdr status)))
     (vui-flex :width 'window :justify :space-between
               (vui-muted "j/k move · TAB/S-TAB elements · RET/l activate")
               (vui-muted "g rescan · ? help · h/q close")))))

(defun garden-connect-refresh ()
  "Rescan the current connect target."
  (interactive)
  (if-let* ((fn (plist-get garden-connect--dispatch :refresh)))
      (funcall fn)
    (user-error "Connect view is not ready")))

(defun garden-connect-help ()
  "Show keyboard help for the connect review screen."
  (interactive)
  (message "Garden Connect: j/k move, TAB/S-TAB elements, RET/l activate, g rescan, h/q close"))

(defvar garden-connect-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map vui-mode-map)
    (keymap-set map "j" #'next-line)
    (keymap-set map "k" #'previous-line)
    (keymap-set map "TAB" #'vui-forward)
    (keymap-set map "<backtab>" #'vui-backward)
    (keymap-set map "RET" #'vui-activate)
    (keymap-set map "l" #'vui-activate)
    (keymap-set map "g" #'garden-connect-refresh)
    (keymap-set map "?" #'garden-connect-help)
    (keymap-set map "h" #'vui-quit)
    (keymap-set map "q" #'vui-quit)
    map))

(define-derived-mode garden-connect-mode vui-mode "Garden-Connect"
  "Review unlinked Garden references."
  (hl-line-mode 1))

(with-eval-after-load 'evil
  (evil-set-initial-state 'garden-connect-mode 'normal)
  (evil-define-key* '(normal motion) garden-connect-mode-map
    (kbd "j") #'next-line (kbd "k") #'previous-line
    (kbd "RET") #'vui-activate (kbd "l") #'vui-activate
    (kbd "g") #'garden-connect-refresh (kbd "?") #'garden-connect-help
    (kbd "h") #'vui-quit (kbd "q") #'vui-quit))

(defun garden--connect-show (id)
  "Open the connect buffer for the note with ID."
  (let ((note (garden-note id)))
    (unless note (user-error "Not a garden note"))
    (let ((title (garden-note-title note)))
      (let ((buffer (get-buffer-create "*garden connect*")))
        (with-current-buffer buffer
          (garden-connect-mode)
          (setq-local garden--ref-target (cons id title))
          (vui-mount (vui-component 'garden-references :id id :title title) buffer)
          (vui-rerender-on-resize))
        (switch-to-buffer buffer)))))

;;;###autoload
(defun garden-connect ()
  "Show unlinked references to the current note, or prompt for one."
  (interactive)
  (garden-build)
  (garden--connect-show (or (garden--current-note-id) (garden--read-note))))

;;;###autoload
(defun garden-connect-pick ()
  "Prompt for a note and show its unlinked references."
  (interactive)
  (garden-build)
  (garden--connect-show (garden--read-note)))

(provide 'garden-connect)
;;; garden-connect.el ends here