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