fedora-csb-system-manager
  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      (insert "\n")  ;; Blank line for content
143      (point))))
144
145(defun journelly-claude-capture-target ()
146  "Org capture target function for Claude session entries.
147Creates new entry if today's Claude session doesn't exist, or appends."
148  (let ((entry-pos (journelly--find-todays-claude-entry)))
149    (if entry-pos
150        ;; Entry exists - go to end to append
151        (progn
152          (goto-char entry-pos)
153          (org-end-of-subtree t)
154          ;; Skip back over any trailing blank lines
155          (while (and (not (bobp))
156                      (looking-back "^[ \t]*\n" (line-beginning-position 0)))
157            (forward-line -1))
158          ;; Now at the last non-blank line of content
159          (end-of-line)
160          (insert "\n")
161          (point))
162      ;; Entry doesn't exist - create new one
163      (journelly--goto-insert-position)
164      (insert (journelly--create-entry-heading "Claude session") "\n")
165      (insert (journelly--create-entry-properties t) "\n")  ;; Skip weather for Claude
166      (insert "\n")  ;; Blank line for content
167      (point))))
168
169;;; Interactive Functions
170
171(defun journelly-quick-entry (content)
172  "Quick journal entry with CONTENT.
173Location/weather added automatically.
174Creates today's entry if it doesn't exist, or appends to existing entry."
175  (interactive "sJournal: ")
176  (with-current-buffer (find-file-noselect org-journelly-file)
177    (save-excursion
178      (journelly-capture-target)
179      (insert content))
180    (save-buffer))
181  (message "Journal entry added"))
182
183(defun journelly--detect-tags (summary)
184  "Auto-detect tags for Claude session based on context and SUMMARY.
185Returns a list of tag strings (without # prefix)."
186  (let ((tags '()))
187
188    ;; Detect from file extensions (git status)
189    (when (file-exists-p ".git")
190      (let ((changed-files (shell-command-to-string "git status --short 2>/dev/null")))
191        (when (string-match-p "\\.nix" changed-files)
192          (push "nixos" tags))
193        (when (string-match-p "\\.go" changed-files)
194          (push "golang" tags))
195        (when (string-match-p "\\.el" changed-files)
196          (push "emacs" tags))
197        (when (string-match-p "\\.py" changed-files)
198          (push "python" tags))
199        (when (string-match-p "\\.rs" changed-files)
200          (push "rust" tags))
201        (when (string-match-p "Dockerfile\\|docker-compose" changed-files)
202          (push "docker" tags))
203        (when (string-match-p "\\.ya?ml" changed-files)
204          (push "kubernetes" tags))
205        (when (string-match-p "skills/" changed-files)
206          (push "claude-skills" tags))))
207
208    ;; Detect from git repository name
209    (when (file-exists-p ".git")
210      (let* ((remote-url (shell-command-to-string "git remote get-url origin 2>/dev/null"))
211             (repo-name (when (string-match "/\\([^/]+\\)\\.git" remote-url)
212                         (match-string 1 remote-url))))
213        (cond
214         ((string= repo-name "home") (push "homelab" tags))
215         ((string-match-p "tekton" repo-name) (push "tekton" tags))
216         ((string-match-p "pipeline" repo-name) (push "tekton" tags)))))
217
218    ;; Detect from keywords in summary
219    (let ((summary-lower (downcase summary)))
220      ;; Development activities
221      (when (string-match-p "\\(bug\\|fix\\|debug\\)" summary-lower)
222        (push "debugging" tags))
223      (when (string-match-p "\\(feature\\|implement\\)" summary-lower)
224        (push "development" tags))
225      (when (string-match-p "refactor" summary-lower)
226        (push "refactoring" tags))
227      (when (string-match-p "\\(test\\|testing\\)" summary-lower)
228        (push "testing" tags))
229      (when (string-match-p "\\(doc\\|documentation\\)" summary-lower)
230        (push "documentation" tags))
231
232      ;; Infrastructure & tools
233      (when (string-match-p "\\(kubernetes\\|k8s\\)" summary-lower)
234        (push "kubernetes" tags))
235      (when (string-match-p "docker" summary-lower)
236        (push "docker" tags))
237      (when (string-match-p "\\(commit\\|push\\|git\\)" summary-lower)
238        (push "git" tags))
239      (when (string-match-p "\\(capture\\|journal\\)" summary-lower)
240        (push "journelly" tags))
241
242      ;; AI/LLM
243      (when (string-match-p "\\(llm\\|language model\\)" summary-lower)
244        (push "llm" tags))
245      (when (string-match-p "\\(ai\\|artificial intelligence\\)" summary-lower)
246        (push "ai" tags))
247      (when (string-match-p "claude" summary-lower)
248        (push "claude" tags))
249      (when (string-match-p "anthropic" summary-lower)
250        (push "anthropic" tags))
251      (when (string-match-p "gemini" summary-lower)
252        (push "gemini" tags))
253      (when (string-match-p "ollama" summary-lower)
254        (push "ollama" tags))
255      (when (string-match-p "\\(openai\\|chatgpt\\|gpt\\)" summary-lower)
256        (push "openai" tags))
257
258      ;; Cloud providers
259      (when (string-match-p "\\(cloud\\|infrastructure\\)" summary-lower)
260        (push "cloud" tags))
261      (when (string-match-p "\\(digitalocean\\|\\bdo\\b\\)" summary-lower)
262        (push "digitalocean" tags))
263      (when (string-match-p "\\(gcp\\|google cloud\\)" summary-lower)
264        (push "gcp" tags))
265      (when (string-match-p "oracle.*cloud" summary-lower)
266        (push "oracle-cloud" tags))
267      (when (string-match-p "\\(aws\\|amazon web services\\)" summary-lower)
268        (push "aws" tags))
269      (when (string-match-p "azure" summary-lower)
270        (push "azure" tags))
271
272      ;; Architecture
273      (when (string-match-p "\\(arm\\|aarch64\\)" summary-lower)
274        (push "arm" tags))
275      (when (string-match-p "x86.64" summary-lower)
276        (push "x86_64" tags))
277      (when (string-match-p "\\(cross.compile\\|cross compile\\)" summary-lower)
278        (push "cross-compile" tags))
279      (when (string-match-p "riscv" summary-lower)
280        (push "riscv" tags))
281
282      ;; Monitoring/Observability
283      (when (string-match-p "\\(monitoring\\|observability\\)" summary-lower)
284        (push "monitoring" tags))
285      (when (string-match-p "prometheus" summary-lower)
286        (push "prometheus" tags))
287      (when (string-match-p "grafana" summary-lower)
288        (push "grafana" tags))
289      (when (string-match-p "\\(alert\\|alerting\\)" summary-lower)
290        (push "alerting" tags))
291
292      ;; Media
293      (when (string-match-p "media" summary-lower)
294        (push "media" tags))
295      (when (string-match-p "jellyfin" summary-lower)
296        (push "jellyfin" tags))
297      (when (string-match-p "plex" summary-lower)
298        (push "plex" tags))
299
300      ;; Desktop/Window managers
301      (when (string-match-p "niri" summary-lower)
302        (push "niri" tags))
303      (when (string-match-p "sway" summary-lower)
304        (push "sway" tags))
305      (when (string-match-p "wayland" summary-lower)
306        (push "wayland" tags))
307      (when (string-match-p "x11" summary-lower)
308        (push "x11" tags))
309
310      ;; Networking
311      (when (string-match-p "\\(network\\|networking\\)" summary-lower)
312        (push "networking" tags))
313      (when (string-match-p "wireguard" summary-lower)
314        (push "wireguard" tags))
315      (when (string-match-p "\\(vpn\\|virtual private network\\)" summary-lower)
316        (push "vpn" tags))
317      (when (string-match-p "\\(dns\\|domain name\\)" summary-lower)
318        (push "dns" tags))
319
320      ;; Security
321      (when (string-match-p "security" summary-lower)
322        (push "security" tags))
323      (when (string-match-p "\\(auth\\|authentication\\)" summary-lower)
324        (push "auth" tags))
325      (when (string-match-p "\\(encryption\\|encrypt\\)" summary-lower)
326        (push "encryption" tags))
327      (when (string-match-p "\\(secret\\|agenix\\)" summary-lower)
328        (push "secrets" tags))
329      (when (string-match-p "yubikey" summary-lower)
330        (push "yubikey" tags))
331
332      ;; Backup/Storage
333      (when (string-match-p "backup" summary-lower)
334        (push "backup" tags))
335      (when (string-match-p "storage" summary-lower)
336        (push "storage" tags))
337      (when (string-match-p "syncthing" summary-lower)
338        (push "syncthing" tags))
339      (when (string-match-p "restic" summary-lower)
340        (push "restic" tags))
341
342      ;; Communication
343      (when (string-match-p "\\(email\\|mail\\)" summary-lower)
344        (push "email" tags))
345      (when (string-match-p "\\(mu4e\\|notmuch\\)" summary-lower)
346        (push "email" tags))
347      (when (string-match-p "xmpp" summary-lower)
348        (push "xmpp" tags))
349
350      ;; Databases
351      (when (string-match-p "\\(database\\|\\bdb\\b\\)" summary-lower)
352        (push "database" tags))
353      (when (string-match-p "\\(postgres\\|postgresql\\)" summary-lower)
354        (push "postgres" tags))
355      (when (string-match-p "sqlite" summary-lower)
356        (push "sqlite" tags))
357
358      ;; Web/HTTP
359      (when (string-match-p "\\(web\\|http\\|https\\)" summary-lower)
360        (push "web" tags))
361      (when (string-match-p "nginx" summary-lower)
362        (push "nginx" tags))
363      (when (string-match-p "caddy" summary-lower)
364        (push "caddy" tags))
365
366      ;; Hardware
367      (when (string-match-p "hardware" summary-lower)
368        (push "hardware" tags))
369      (when (string-match-p "\\(raspberry.pi\\|rpi\\)" summary-lower)
370        (push "raspberry-pi" tags))
371      (when (string-match-p "keyboard" summary-lower)
372        (push "keyboard" tags))
373
374      ;; Configuration
375      (when (string-match-p "home.manager" summary-lower)
376        (push "home-manager" tags))
377      (when (string-match-p "flake" summary-lower)
378        (push "flakes" tags))
379      (when (string-match-p "\\(deploy\\|deployment\\)" summary-lower)
380        (push "deployment" tags))
381      (when (string-match-p "\\(ci\\|cd\\|pipeline\\)" summary-lower)
382        (push "ci-cd" tags)))
383
384    ;; Remove duplicates and return
385    (delete-dups tags)))
386
387(defun journelly-claude-session (summary)
388  "Add Claude session SUMMARY to today's Claude session entry.
389Auto-detects and appends tags based on context and content.
390Creates entry if it doesn't exist, or appends to existing entry."
391  (interactive "sClaude session: ")
392  (let* ((timestamp (format-time-string "%H:%M"))
393         (tags (journelly--detect-tags summary))
394         (tags-string (if tags
395                          (concat " " (mapconcat (lambda (tag) (concat "#" tag)) tags " "))
396                        ""))
397         (entry (format "- %s :: %s%s\n" timestamp summary tags-string)))
398    (with-current-buffer (find-file-noselect org-journelly-file)
399      (save-excursion
400        (journelly-claude-capture-target)
401        (insert entry))
402      (save-buffer))
403    (message "Claude session logged%s" (if tags (format " with tags: %s" tags-string) ""))))
404
405(defun journelly-open ()
406  "Open Journelly.org file and jump to today's entry or top."
407  (interactive)
408  (find-file org-journelly-file)
409  (let ((entry-pos (journelly--find-todays-entry)))
410    (if entry-pos
411        (goto-char entry-pos)
412      ;; No entry today, go to insert position
413      (journelly--goto-insert-position)))
414  (recenter-top-bottom 0))
415
416;;; Capture Templates Setup
417
418(defun journelly-setup-capture-templates ()
419  "Setup org-capture templates for Journelly.
420Call this after org-capture is loaded and org-journelly-file is defined."
421
422  ;; Remove old journelly templates if they exist
423  (setq org-capture-templates
424        (seq-remove (lambda (x) (member (car x) '("j" "J")))
425                    org-capture-templates))
426
427  ;; Smart default journal entry (creates or appends with timestamp)
428  (add-to-list 'org-capture-templates
429               `("j" "📝 Journal entry" plain
430                 (file+function ,org-journelly-file journelly-capture-target)
431                 "- %(format-time-string \"%H:%M\") :: %?"
432                 :empty-lines 0)
433               t))
434
435;;; Keybindings
436
437(defun journelly-setup-keybindings ()
438  "Setup keybindings for Journelly functions."
439  (global-set-key (kbd "C-c j j") 'journelly-quick-entry)
440  (global-set-key (kbd "C-c j o") 'journelly-open))
441
442;;; Auto-setup
443
444;; Setup keybindings when loaded
445(journelly-setup-keybindings)
446
447;; Setup capture templates after org-capture is loaded
448(with-eval-after-load 'org-capture
449  (journelly-setup-capture-templates))
450
451(provide 'journelly)
452
453;;; journelly.el ends here