main
  1;;; journelly.el --- Smart Journelly capture with location/weather -*- lexical-binding: t; -*-
  2
  3;; Copyright (C) 2026 Vincent Demeester
  4
  5;; Author: Vincent Demeester <vincent@demeester.fr>
  6;; Keywords: org-mode, journelly, journal, capture
  7;; Version: 1.0.0
  8
  9;;; Commentary:
 10
 11;; Smart capture system for Journelly.org journal entries.
 12;;
 13;; Features:
 14;; - Create-or-append behavior: first entry creates, subsequent append with timestamps
 15;; - Automatic location and weather via IP geolocation
 16;; - Separate entries for regular journal and Claude sessions
 17;; - Full org-capture integration
 18;;
 19;; Entry formats:
 20;; - Regular: * [YYYY-MM-DD Day HH:MM] @ Location (hostname)
 21;;            - HH:MM :: entry content #tags (appended entries, tags optional)
 22;; - Claude:  * [YYYY-MM-DD Day HH:MM] @ Claude session
 23;;            - HH:MM :: session summary #auto-tagged (automated only)
 24;;
 25;; Usage:
 26;;   (require 'journelly)
 27;;   ;; Use org-capture: C-c o c then 'j' for journal
 28;;   ;; Or quick functions: M-x journelly-quick-entry
 29;;   ;; Claude sessions: programmatic only via journelly-claude-session
 30
 31;;; Code:
 32
 33(require 'org)
 34(require 'org-capture)
 35
 36;; Load location/weather helpers from site-lisp
 37(require 'journelly-location-weather)
 38
 39;; Declare functions from journelly-location-weather.el
 40(declare-function journelly-get-location "journelly-location-weather")
 41(declare-function journelly-get-weather "journelly-location-weather")
 42
 43;;; Helper Functions
 44
 45(defun journelly--find-todays-entry ()
 46  "Find today's journal entry in Journelly.org.
 47Returns the position of the entry if found, nil otherwise."
 48  (let ((today (format-time-string "%Y-%m-%d")))
 49    (save-excursion
 50      (goto-char (point-min))
 51      ;; Skip the file header
 52      (when (re-search-forward "^:END:" nil t)
 53        (forward-line))
 54      ;; Search for today's regular entry (not Claude session)
 55      (when (re-search-forward
 56             (format "^\\* \\[%s[^]]+\\] @ \\([^C]\\|C[^l]\\)" today) nil t)
 57        (line-beginning-position)))))
 58
 59(defun journelly--find-todays-claude-entry ()
 60  "Find today's Claude session entry in Journelly.org.
 61Returns the position of the entry if found, nil otherwise."
 62  (let ((today (format-time-string "%Y-%m-%d")))
 63    (save-excursion
 64      (goto-char (point-min))
 65      ;; Skip the file header
 66      (when (re-search-forward "^:END:" nil t)
 67        (forward-line))
 68      ;; Search for today's Claude session entry
 69      (when (re-search-forward
 70             (format "^\\* \\[%s.*@ Claude session" today) nil t)
 71        (line-beginning-position)))))
 72
 73(defun journelly--goto-insert-position ()
 74  "Navigate to the correct insert position for new journal entries.
 75Goes after the file header but before existing entries."
 76  (goto-char (point-min))
 77  (if (re-search-forward "^:END:" nil t)
 78      (progn
 79        (forward-line)
 80        (point))
 81    ;; No header found, go to beginning
 82    (goto-char (point-min))
 83    (point)))
 84
 85(defun journelly--create-entry-heading (&optional custom-location)
 86  "Create journal entry heading with timestamp, location, and hostname.
 87Format: * [YYYY-MM-DD Day HH:MM] @ Location (hostname)
 88If CUSTOM-LOCATION is provided, uses it instead of IP geolocation.
 89Returns the heading string."
 90  (let* ((location-data (when (not custom-location) (journelly-get-location)))
 91         (city (or custom-location (cdr (assoc 'city location-data))))
 92         (hostname (system-name))
 93         (timestamp (format-time-string "[%Y-%m-%d %a %H:%M]")))
 94    (if (string= custom-location "Claude session")
 95        ;; Claude session - no location
 96        (format "* %s @ Claude session" timestamp)
 97      ;; Regular entry - location (hostname)
 98      (format "* %s @ %s (%s)" timestamp city hostname))))
 99
100(defun journelly--create-entry-properties (&optional skip-weather)
101  "Create properties drawer with location and weather metadata.
102If SKIP-WEATHER is non-nil, only includes location data.
103Returns the properties string."
104  (let* ((location-data (journelly-get-location))
105         (lat (cdr (assoc 'lat location-data)))
106         (lon (cdr (assoc 'lon location-data)))
107         (weather-data (unless skip-weather (journelly-get-weather)))
108         (temp (when weather-data (cdr (assoc 'temperature weather-data))))
109         (condition (when weather-data (cdr (assoc 'condition weather-data))))
110         (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
111    (concat ":PROPERTIES:\n"
112            (format ":LATITUDE: %s\n" lat)
113            (format ":LONGITUDE: %s\n" lon)
114            (when temp (format ":WEATHER_TEMPERATURE: %s\n" temp))
115            (when condition (format ":WEATHER_CONDITION: %s\n" condition))
116            (when symbol (format ":WEATHER_SYMBOL: %s\n" symbol))
117            ":END:")))
118
119;;; Capture Target Functions
120
121(defun journelly-capture-target ()
122  "Org capture target function for smart Journelly entries.
123Creates new entry if today's doesn't exist, or appends to existing."
124  (let ((entry-pos (journelly--find-todays-entry)))
125    (if entry-pos
126        ;; Entry exists - go to end to append
127        (progn
128          (goto-char entry-pos)
129          (org-end-of-subtree t)
130          ;; Skip back over any trailing blank lines
131          (while (and (not (bobp))
132                      (looking-back "^[ \t]*\n" (line-beginning-position 0)))
133            (forward-line -1))
134          ;; Now at the last non-blank line of content
135          (end-of-line)
136          (insert "\n")
137          (point))
138      ;; Entry doesn't exist - create new one
139      (journelly--goto-insert-position)
140      (insert (journelly--create-entry-heading) "\n")
141      (insert (journelly--create-entry-properties) "\n")
142      ;; Leave point on blank line inside the new entry, not at the
143      ;; start of the next heading.  org-capture checks org-at-heading-p
144      ;; to set :target-entry-p; if point lands on the old first entry's
145      ;; heading, the template text gets appended to that entry instead.
146      (open-line 1)
147      (point))))
148
149(defun journelly-claude-capture-target ()
150  "Org capture target function for Claude session entries.
151Creates new entry if today's Claude session doesn't exist, or appends."
152  (let ((entry-pos (journelly--find-todays-claude-entry)))
153    (if entry-pos
154        ;; Entry exists - go to end to append
155        (progn
156          (goto-char entry-pos)
157          (org-end-of-subtree t)
158          ;; Skip back over any trailing blank lines
159          (while (and (not (bobp))
160                      (looking-back "^[ \t]*\n" (line-beginning-position 0)))
161            (forward-line -1))
162          ;; Now at the last non-blank line of content
163          (end-of-line)
164          (insert "\n")
165          (point))
166      ;; Entry doesn't exist - create new one
167      (journelly--goto-insert-position)
168      (insert (journelly--create-entry-heading "Claude session") "\n")
169      (insert (journelly--create-entry-properties t) "\n")  ;; Skip weather for Claude
170      ;; Leave point on blank line inside the new entry (see
171      ;; journelly-capture-target for detailed explanation).
172      (open-line 1)
173      (point))))
174
175;;; Interactive Functions
176
177(defun journelly-quick-entry (content)
178  "Quick journal entry with CONTENT.
179Location/weather added automatically.
180Creates today's entry if it doesn't exist, or appends to existing entry."
181  (interactive "sJournal: ")
182  (with-current-buffer (find-file-noselect org-journelly-file)
183    (save-excursion
184      (journelly-capture-target)
185      (insert content))
186    (save-buffer))
187  (message "Journal entry added"))
188
189(defun journelly--detect-tags (summary)
190  "Auto-detect tags for Claude session based on context and SUMMARY.
191Returns a list of tag strings (without # prefix)."
192  (let ((tags '()))
193
194    ;; Detect from file extensions (git status)
195    (when (file-exists-p ".git")
196      (let ((changed-files (shell-command-to-string "git status --short 2>/dev/null")))
197        (when (string-match-p "\\.nix" changed-files)
198          (push "nixos" tags))
199        (when (string-match-p "\\.go" changed-files)
200          (push "golang" tags))
201        (when (string-match-p "\\.el" changed-files)
202          (push "emacs" tags))
203        (when (string-match-p "\\.py" changed-files)
204          (push "python" tags))
205        (when (string-match-p "\\.rs" changed-files)
206          (push "rust" tags))
207        (when (string-match-p "Dockerfile\\|docker-compose" changed-files)
208          (push "docker" tags))
209        (when (string-match-p "\\.ya?ml" changed-files)
210          (push "kubernetes" tags))
211        (when (string-match-p "skills/" changed-files)
212          (push "claude-skills" tags))))
213
214    ;; Detect from git repository name
215    (when (file-exists-p ".git")
216      (let* ((remote-url (shell-command-to-string "git remote get-url origin 2>/dev/null"))
217             (repo-name (when (string-match "/\\([^/]+\\)\\.git" remote-url)
218                         (match-string 1 remote-url))))
219        (cond
220         ((string= repo-name "home") (push "homelab" tags))
221         ((string-match-p "tekton" repo-name) (push "tekton" tags))
222         ((string-match-p "pipeline" repo-name) (push "tekton" tags)))))
223
224    ;; Detect from keywords in summary
225    (let ((summary-lower (downcase summary)))
226      ;; Development activities
227      (when (string-match-p "\\(bug\\|fix\\|debug\\)" summary-lower)
228        (push "debugging" tags))
229      (when (string-match-p "\\(feature\\|implement\\)" summary-lower)
230        (push "development" tags))
231      (when (string-match-p "refactor" summary-lower)
232        (push "refactoring" tags))
233      (when (string-match-p "\\(test\\|testing\\)" summary-lower)
234        (push "testing" tags))
235      (when (string-match-p "\\(doc\\|documentation\\)" summary-lower)
236        (push "documentation" tags))
237
238      ;; Infrastructure & tools
239      (when (string-match-p "\\(kubernetes\\|k8s\\)" summary-lower)
240        (push "kubernetes" tags))
241      (when (string-match-p "docker" summary-lower)
242        (push "docker" tags))
243      (when (string-match-p "\\(commit\\|push\\|git\\)" summary-lower)
244        (push "git" tags))
245      (when (string-match-p "\\(capture\\|journal\\)" summary-lower)
246        (push "journelly" tags))
247
248      ;; AI/LLM
249      (when (string-match-p "\\(llm\\|language model\\)" summary-lower)
250        (push "llm" tags))
251      (when (string-match-p "\\(ai\\|artificial intelligence\\)" summary-lower)
252        (push "ai" tags))
253      (when (string-match-p "claude" summary-lower)
254        (push "claude" tags))
255      (when (string-match-p "anthropic" summary-lower)
256        (push "anthropic" tags))
257      (when (string-match-p "gemini" summary-lower)
258        (push "gemini" tags))
259      (when (string-match-p "ollama" summary-lower)
260        (push "ollama" tags))
261      (when (string-match-p "\\(openai\\|chatgpt\\|gpt\\)" summary-lower)
262        (push "openai" tags))
263
264      ;; Cloud providers
265      (when (string-match-p "\\(cloud\\|infrastructure\\)" summary-lower)
266        (push "cloud" tags))
267      (when (string-match-p "\\(digitalocean\\|\\bdo\\b\\)" summary-lower)
268        (push "digitalocean" tags))
269      (when (string-match-p "\\(gcp\\|google cloud\\)" summary-lower)
270        (push "gcp" tags))
271      (when (string-match-p "oracle.*cloud" summary-lower)
272        (push "oracle-cloud" tags))
273      (when (string-match-p "\\(aws\\|amazon web services\\)" summary-lower)
274        (push "aws" tags))
275      (when (string-match-p "azure" summary-lower)
276        (push "azure" tags))
277
278      ;; Architecture
279      (when (string-match-p "\\(arm\\|aarch64\\)" summary-lower)
280        (push "arm" tags))
281      (when (string-match-p "x86.64" summary-lower)
282        (push "x86_64" tags))
283      (when (string-match-p "\\(cross.compile\\|cross compile\\)" summary-lower)
284        (push "cross-compile" tags))
285      (when (string-match-p "riscv" summary-lower)
286        (push "riscv" tags))
287
288      ;; Monitoring/Observability
289      (when (string-match-p "\\(monitoring\\|observability\\)" summary-lower)
290        (push "monitoring" tags))
291      (when (string-match-p "prometheus" summary-lower)
292        (push "prometheus" tags))
293      (when (string-match-p "grafana" summary-lower)
294        (push "grafana" tags))
295      (when (string-match-p "\\(alert\\|alerting\\)" summary-lower)
296        (push "alerting" tags))
297
298      ;; Media
299      (when (string-match-p "media" summary-lower)
300        (push "media" tags))
301      (when (string-match-p "jellyfin" summary-lower)
302        (push "jellyfin" tags))
303      (when (string-match-p "plex" summary-lower)
304        (push "plex" tags))
305
306      ;; Desktop/Window managers
307      (when (string-match-p "niri" summary-lower)
308        (push "niri" tags))
309      (when (string-match-p "sway" summary-lower)
310        (push "sway" tags))
311      (when (string-match-p "wayland" summary-lower)
312        (push "wayland" tags))
313      (when (string-match-p "x11" summary-lower)
314        (push "x11" tags))
315
316      ;; Networking
317      (when (string-match-p "\\(network\\|networking\\)" summary-lower)
318        (push "networking" tags))
319      (when (string-match-p "wireguard" summary-lower)
320        (push "wireguard" tags))
321      (when (string-match-p "\\(vpn\\|virtual private network\\)" summary-lower)
322        (push "vpn" tags))
323      (when (string-match-p "\\(dns\\|domain name\\)" summary-lower)
324        (push "dns" tags))
325
326      ;; Security
327      (when (string-match-p "security" summary-lower)
328        (push "security" tags))
329      (when (string-match-p "\\(auth\\|authentication\\)" summary-lower)
330        (push "auth" tags))
331      (when (string-match-p "\\(encryption\\|encrypt\\)" summary-lower)
332        (push "encryption" tags))
333      (when (string-match-p "\\(secret\\|agenix\\)" summary-lower)
334        (push "secrets" tags))
335      (when (string-match-p "yubikey" summary-lower)
336        (push "yubikey" tags))
337
338      ;; Backup/Storage
339      (when (string-match-p "backup" summary-lower)
340        (push "backup" tags))
341      (when (string-match-p "storage" summary-lower)
342        (push "storage" tags))
343      (when (string-match-p "syncthing" summary-lower)
344        (push "syncthing" tags))
345      (when (string-match-p "restic" summary-lower)
346        (push "restic" tags))
347
348      ;; Communication
349      (when (string-match-p "\\(email\\|mail\\)" summary-lower)
350        (push "email" tags))
351      (when (string-match-p "\\(mu4e\\|notmuch\\)" summary-lower)
352        (push "email" tags))
353      (when (string-match-p "xmpp" summary-lower)
354        (push "xmpp" tags))
355
356      ;; Databases
357      (when (string-match-p "\\(database\\|\\bdb\\b\\)" summary-lower)
358        (push "database" tags))
359      (when (string-match-p "\\(postgres\\|postgresql\\)" summary-lower)
360        (push "postgres" tags))
361      (when (string-match-p "sqlite" summary-lower)
362        (push "sqlite" tags))
363
364      ;; Web/HTTP
365      (when (string-match-p "\\(web\\|http\\|https\\)" summary-lower)
366        (push "web" tags))
367      (when (string-match-p "nginx" summary-lower)
368        (push "nginx" tags))
369      (when (string-match-p "caddy" summary-lower)
370        (push "caddy" tags))
371
372      ;; Hardware
373      (when (string-match-p "hardware" summary-lower)
374        (push "hardware" tags))
375      (when (string-match-p "\\(raspberry.pi\\|rpi\\)" summary-lower)
376        (push "raspberry-pi" tags))
377      (when (string-match-p "keyboard" summary-lower)
378        (push "keyboard" tags))
379
380      ;; Configuration
381      (when (string-match-p "home.manager" summary-lower)
382        (push "home-manager" tags))
383      (when (string-match-p "flake" summary-lower)
384        (push "flakes" tags))
385      (when (string-match-p "\\(deploy\\|deployment\\)" summary-lower)
386        (push "deployment" tags))
387      (when (string-match-p "\\(ci\\|cd\\|pipeline\\)" summary-lower)
388        (push "ci-cd" tags)))
389
390    ;; Remove duplicates and return
391    (delete-dups tags)))
392
393(defun journelly-claude-session (summary)
394  "Add Claude session SUMMARY to today's Claude session entry.
395Auto-detects and appends tags based on context and content.
396Creates entry if it doesn't exist, or appends to existing entry."
397  (interactive "sClaude session: ")
398  (let* ((timestamp (format-time-string "%H:%M"))
399         (tags (journelly--detect-tags summary))
400         (tags-string (if tags
401                          (concat " " (mapconcat (lambda (tag) (concat "#" tag)) tags " "))
402                        ""))
403         (entry (format "- %s :: %s%s\n" timestamp summary tags-string)))
404    (with-current-buffer (find-file-noselect org-journelly-file)
405      (save-excursion
406        (journelly-claude-capture-target)
407        (insert entry))
408      (save-buffer))
409    (message "Claude session logged%s" (if tags (format " with tags: %s" tags-string) ""))))
410
411(defun journelly-open ()
412  "Open Journelly.org file and jump to today's entry or top."
413  (interactive)
414  (find-file org-journelly-file)
415  (let ((entry-pos (journelly--find-todays-entry)))
416    (if entry-pos
417        (goto-char entry-pos)
418      ;; No entry today, go to insert position
419      (journelly--goto-insert-position)))
420  (recenter-top-bottom 0))
421
422;;; Capture Templates Setup
423
424(defun journelly-setup-capture-templates ()
425  "Setup org-capture templates for Journelly.
426Call this after org-capture is loaded and org-journelly-file is defined."
427
428  ;; Remove old journelly templates if they exist
429  (setq org-capture-templates
430        (seq-remove (lambda (x) (member (car x) '("j" "J")))
431                    org-capture-templates))
432
433  ;; Smart default journal entry (creates or appends with timestamp)
434  (add-to-list 'org-capture-templates
435               `("j" "📝 Journal entry" plain
436                 (file+function ,org-journelly-file journelly-capture-target)
437                 "- %(format-time-string \"%H:%M\") :: %?"
438                 :empty-lines 0)
439               t))
440
441;;; Keybindings
442
443(defun journelly-setup-keybindings ()
444  "Setup keybindings for Journelly functions."
445  (global-set-key (kbd "C-c j j") 'journelly-quick-entry)
446  (global-set-key (kbd "C-c j o") 'journelly-open))
447
448;;; Auto-setup
449
450;; Setup keybindings when loaded
451(journelly-setup-keybindings)
452
453;; Setup capture templates after org-capture is loaded
454(with-eval-after-load 'org-capture
455  (journelly-setup-capture-templates))
456
457(provide 'journelly)
458
459;;; journelly.el ends here