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