fedora-csb-system-manager
1;;; journelly-batch-functions.el --- Batch functions for Journelly journal entries -*- lexical-binding: t; -*-
2
3;; Copyright (C) 2025 Vincent Demeester
4
5;; Author: Vincent Demeester <vincent@demeester.fr>
6;; Keywords: org-mode, journelly, batch
7;; Version: 1.0.0
8
9;;; Commentary:
10
11;; Emacs batch mode functions for manipulating Journelly.org journal files.
12;; Journelly is an iOS app that stores journal entries in org-mode format.
13;;
14;; Format:
15;; - Single file with entries in reverse chronological order (newest first)
16;; - Each entry is a top-level heading: * [YYYY-MM-DD Day HH:MM] @ Location
17;; - Optional PROPERTIES drawer with GPS/weather metadata
18;; - Free-form org-mode content
19;;
20;; Functions:
21;; - journelly-batch-create-entry: Create new journal entry
22;; - journelly-batch-create-entry-auto: Create entry with automatic location/weather
23;; - journelly-batch-append-to-today: Append to today's entry
24;; - journelly-batch-list-entries: List recent entries
25;; - journelly-batch-search: Search entry content
26;; - journelly-batch-get-entry: Get specific entry by date/time
27;;
28;; Usage:
29;; emacs --batch \
30;; --load journelly-batch-functions.el \
31;; --eval "(journelly-batch-create-entry \
32;; \"~/desktop/org/Journelly.org\" \
33;; \"Home\" \
34;; \"Entry content\")"
35
36;;; Code:
37
38(require 'org)
39(require 'org-element)
40(require 'json)
41
42;; Load location/weather functions if available
43(let ((location-weather-file
44 (expand-file-name "journelly-location-weather.el"
45 (file-name-directory (or load-file-name buffer-file-name)))))
46 (when (file-exists-p location-weather-file)
47 (load location-weather-file)))
48
49;; Declare functions from journelly-location-weather.el (loaded conditionally above)
50(declare-function journelly-get-location "journelly-location-weather")
51(declare-function journelly-get-weather "journelly-location-weather")
52
53;;; Utility functions
54
55(defun journelly--format-timestamp ()
56 "Generate org-mode timestamp for current time: [YYYY-MM-DD Day HH:MM]."
57 (format-time-string "[%Y-%m-%d %a %H:%M]"))
58
59(defun journelly--format-date-only ()
60 "Generate date only: YYYY-MM-DD."
61 (format-time-string "%Y-%m-%d"))
62
63(defun journelly--parse-timestamp (heading)
64 "Extract timestamp from HEADING.
65Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
66Returns the timestamp string or nil."
67 (when (string-match "\\[\\([0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\) \\([A-Z][a-z][a-z]\\) \\([0-9]\\{2\\}:[0-9]\\{2\\}\\)\\]" heading)
68 (match-string 0 heading)))
69
70(defun journelly--parse-location (heading)
71 "Extract location from HEADING.
72Expected format: * [YYYY-MM-DD Day HH:MM] @ Location
73Returns the location string or nil."
74 (when (string-match "@ \\(.+\\)$" heading)
75 (match-string 1 heading)))
76
77(defun journelly--make-heading (location)
78 "Create journal entry heading with current timestamp and LOCATION."
79 (format "* %s @ %s" (journelly--format-timestamp) location))
80
81(defun journelly--make-properties (latitude longitude temperature condition symbol)
82 "Create PROPERTIES drawer with GPS and weather data.
83LATITUDE, LONGITUDE, TEMPERATURE, CONDITION, SYMBOL are optional strings.
84Returns nil if no properties provided."
85 (let ((props '()))
86 (when latitude
87 (push (format ":LATITUDE: %s" latitude) props))
88 (when longitude
89 (push (format ":LONGITUDE: %s" longitude) props))
90 (when temperature
91 (push (format ":WEATHER_TEMPERATURE: %s" temperature) props))
92 (when condition
93 (push (format ":WEATHER_CONDITION: %s" condition) props))
94 (when symbol
95 (push (format ":WEATHER_SYMBOL: %s" symbol) props))
96 (when props
97 (concat ":PROPERTIES:\n"
98 (mapconcat 'identity (nreverse props) "\n")
99 "\n:END:\n"))))
100
101(defun journelly--find-header-end (buffer)
102 "Find the end of the Journelly header in BUFFER.
103Returns the position after the :end: line, or nil if not found."
104 (with-current-buffer buffer
105 (goto-char (point-min))
106 (when (re-search-forward "^:end:$" nil t)
107 (forward-line 1)
108 (point))))
109
110(defun journelly--json-response (success data &optional message)
111 "Create JSON response object.
112SUCCESS is boolean, DATA is any JSON-serializable value.
113MESSAGE is optional error/success message."
114 (let ((response `((success . ,success)
115 (data . ,data))))
116 (when message
117 (push `(message . ,message) response))
118 (json-encode response)))
119
120(defun journelly--output-json (success data &optional message)
121 "Output JSON response to stdout.
122SUCCESS is boolean, DATA is the response data, MESSAGE is optional."
123 (princ (journelly--json-response success data message))
124 (terpri))
125
126;;; Main functions
127
128(defun journelly-batch-create-entry (file location content &optional latitude longitude temperature condition symbol content-file)
129 "Create new journal entry in FILE.
130
131Arguments:
132 FILE: Path to Journelly.org file
133 LOCATION: Location string (e.g., \"Home\", \"Kyushu\")
134 CONTENT: Entry content (can be empty string)
135 LATITUDE: Optional GPS latitude
136 LONGITUDE: Optional GPS longitude
137 TEMPERATURE: Optional temperature (e.g., \"15,2°C\")
138 CONDITION: Optional weather condition (e.g., \"Cloudy\")
139 SYMBOL: Optional weather symbol (e.g., \"cloud\")
140 CONTENT-FILE: Optional path to file containing content
141
142If CONTENT-FILE is provided, reads content from file instead of CONTENT arg.
143
144Returns JSON with success status and entry details."
145 (condition-case err
146 (let ((actual-content (if content-file
147 (with-temp-buffer
148 (insert-file-contents content-file)
149 (buffer-string))
150 content)))
151 (with-temp-buffer
152 (insert-file-contents file)
153
154 ;; Find where to insert (after header)
155 (let ((insert-pos (journelly--find-header-end (current-buffer))))
156 (unless insert-pos
157 (error "Could not find Journelly header end marker (:end:)"))
158
159 (goto-char insert-pos)
160
161 ;; Build entry
162 (let ((heading (journelly--make-heading location))
163 (properties (journelly--make-properties
164 latitude longitude temperature condition symbol))
165 (timestamp (journelly--format-timestamp)))
166
167 ;; Insert entry
168 (insert heading "\n")
169 (when properties
170 (insert properties))
171 (when (and actual-content (not (string-empty-p actual-content)))
172 (insert actual-content)
173 (unless (string-suffix-p "\n" actual-content)
174 (insert "\n")))
175 (insert "\n") ;; Blank line after entry
176
177 ;; Write back to file
178 (write-region (point-min) (point-max) file)
179
180 ;; Return success
181 (journelly--output-json
182 t
183 `((timestamp . ,timestamp)
184 (location . ,location)
185 (has-properties . ,(if properties t :json-false))
186 (file . ,file))
187 "Journal entry created successfully")))))
188 (error
189 (journelly--output-json nil nil (error-message-string err)))))
190
191(defun journelly-batch-append-to-today (file content &optional content-file)
192 "Append CONTENT to today's journal entry in FILE.
193
194Arguments:
195 FILE: Path to Journelly.org file
196 CONTENT: Content to append
197 CONTENT-FILE: Optional path to file containing content
198
199If no entry exists for today, returns error.
200Returns JSON with success status."
201 (condition-case err
202 (let ((actual-content (if content-file
203 (with-temp-buffer
204 (insert-file-contents content-file)
205 (buffer-string))
206 content))
207 (today-date (journelly--format-date-only)))
208 (with-temp-buffer
209 (insert-file-contents file)
210 (goto-char (point-min))
211
212 ;; Find today's entry
213 (let ((found nil)
214 (search-pattern (format "^\\* \\[%s " today-date)))
215 (while (and (not found)
216 (re-search-forward search-pattern nil t))
217 (setq found t))
218
219 (unless found
220 (error "No journal entry found for today (%s)" today-date))
221
222 ;; Move to end of this entry (before next heading or end of file)
223 (forward-line 1)
224 (if (re-search-forward "^\\* \\[" nil t)
225 (progn
226 (beginning-of-line)
227 (backward-char 1)) ;; Before the newline
228 (goto-char (point-max)))
229
230 ;; Insert content
231 (insert "\n" actual-content)
232 (unless (string-suffix-p "\n" actual-content)
233 (insert "\n"))
234
235 ;; Write back
236 (write-region (point-min) (point-max) file)
237
238 ;; Return success
239 (journelly--output-json
240 t
241 `((date . ,today-date)
242 (file . ,file))
243 "Content appended to today's entry"))))
244 (error
245 (journelly--output-json nil nil (error-message-string err)))))
246
247(defun journelly-batch-list-entries (file &optional limit)
248 "List recent journal entries from FILE.
249
250Arguments:
251 FILE: Path to Journelly.org file
252 LIMIT: Optional number of entries to return (default 10)
253
254Returns JSON with list of entries."
255 (condition-case err
256 (let ((max-entries (or (and limit (string-to-number limit)) 10))
257 (entries '()))
258 (with-temp-buffer
259 (insert-file-contents file)
260 (goto-char (point-min))
261
262 ;; Skip header
263 (journelly--find-header-end (current-buffer))
264
265 ;; Parse entries
266 (while (and (< (length entries) max-entries)
267 (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t))
268 (let ((timestamp (match-string 1))
269 (location (match-string 2))
270 (has-properties nil)
271 (content-preview ""))
272
273 ;; Check for properties
274 (save-excursion
275 (forward-line 1)
276 (when (looking-at "^:PROPERTIES:")
277 (setq has-properties t)))
278
279 ;; Get content preview (first 100 chars)
280 (save-excursion
281 (forward-line 1)
282 (when has-properties
283 (re-search-forward "^:END:$" nil t)
284 (forward-line 1))
285 (let ((content-start (point)))
286 (if (re-search-forward "^\\* \\[" nil t)
287 (beginning-of-line)
288 (goto-char (point-max)))
289 (setq content-preview
290 (string-trim
291 (buffer-substring-no-properties content-start (point))))
292 (when (> (length content-preview) 100)
293 (setq content-preview
294 (concat (substring content-preview 0 100) "...")))))
295
296 (push `((timestamp . ,timestamp)
297 (location . ,location)
298 (has-properties . ,(if has-properties t :json-false))
299 (preview . ,content-preview))
300 entries)))
301
302 ;; Return results (already in reverse chronological from file)
303 (journelly--output-json t (nreverse entries))))
304 (error
305 (journelly--output-json nil nil (error-message-string err)))))
306
307(defun journelly-batch-search (file query)
308 "Search journal entries in FILE for QUERY.
309
310Arguments:
311 FILE: Path to Journelly.org file
312 QUERY: Search string (case-insensitive)
313
314Returns JSON with matching entries."
315 (condition-case err
316 (let ((matches '())
317 (query-lower (downcase query)))
318 (with-temp-buffer
319 (insert-file-contents file)
320 (goto-char (point-min))
321
322 ;; Skip header
323 (journelly--find-header-end (current-buffer))
324
325 ;; Search entries
326 (while (re-search-forward "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$" nil t)
327 (let ((timestamp (match-string 1))
328 (location (match-string 2))
329 (entry-start (point))
330 (entry-end nil)
331 (entry-content ""))
332
333 ;; Find entry end
334 (save-excursion
335 (if (re-search-forward "^\\* \\[" nil t)
336 (setq entry-end (match-beginning 0))
337 (setq entry-end (point-max))))
338
339 ;; Get entry content
340 (setq entry-content
341 (buffer-substring-no-properties entry-start entry-end))
342
343 ;; Check if query matches
344 (when (string-match-p query-lower (downcase entry-content))
345 (push `((timestamp . ,timestamp)
346 (location . ,location)
347 (content . ,(string-trim entry-content)))
348 matches))))
349
350 ;; Return results
351 (journelly--output-json
352 t
353 (nreverse matches)
354 (format "Found %d matching entries" (length matches)))))
355 (error
356 (journelly--output-json nil nil (error-message-string err)))))
357
358(defun journelly-batch-get-entry (file date &optional time)
359 "Get specific journal entry from FILE by DATE and optional TIME.
360
361Arguments:
362 FILE: Path to Journelly.org file
363 DATE: Date string (YYYY-MM-DD)
364 TIME: Optional time string (HH:MM)
365
366Returns JSON with entry details or error if not found."
367 (condition-case err
368 (let ((search-pattern (if time
369 (format "^\\* \\[%s .* %s\\]" date time)
370 (format "^\\* \\[%s " date)))
371 (found nil))
372 (with-temp-buffer
373 (insert-file-contents file)
374 (goto-char (point-min))
375
376 ;; Skip header
377 (journelly--find-header-end (current-buffer))
378
379 ;; Search for entry
380 (when (re-search-forward search-pattern nil t)
381 (beginning-of-line)
382 (when (looking-at "^\\* \\(\\[.*?\\]\\) @ \\(.+\\)$")
383 (let ((timestamp (match-string 1))
384 (location (match-string 2))
385 (entry-end nil)
386 (has-properties nil)
387 (properties nil)
388 (content ""))
389
390 (forward-line 1)
391
392 ;; Check for properties
393 (when (looking-at "^:PROPERTIES:")
394 (setq has-properties t)
395 (let ((props-start (point)))
396 (re-search-forward "^:END:$" nil t)
397 (setq properties
398 (buffer-substring-no-properties props-start (point)))
399 (forward-line 1)))
400
401 ;; Get content
402 (let ((content-start (point)))
403 (if (re-search-forward "^\\* \\[" nil t)
404 (setq entry-end (match-beginning 0))
405 (setq entry-end (point-max)))
406 (setq content
407 (string-trim
408 (buffer-substring-no-properties content-start entry-end))))
409
410 (setq found `((timestamp . ,timestamp)
411 (location . ,location)
412 (has-properties . ,(if has-properties t :json-false))
413 (properties . ,(or properties ""))
414 (content . ,content))))))
415
416 (if found
417 (journelly--output-json t found)
418 (journelly--output-json
419 nil
420 nil
421 (format "No entry found for %s%s"
422 date
423 (if time (format " at %s" time) ""))))))
424 (error
425 (journelly--output-json nil nil (error-message-string err)))))
426
427(defun journelly-batch-create-entry-auto (file content &optional content-file use-location use-weather)
428 "Create journal entry with automatic location and/or weather detection.
429
430Arguments:
431 FILE: Path to Journelly.org file
432 CONTENT: Entry content
433 CONTENT-FILE: Optional path to file containing content
434 USE-LOCATION: If non-nil, automatically detect location
435 USE-WEATHER: If non-nil, automatically detect weather
436
437Requires journelly-location-weather.el to be loaded.
438
439Returns JSON with success status and entry details."
440 (condition-case err
441 (progn
442 (unless (fboundp 'journelly-get-location)
443 (error "Location/weather functions not available. Load journelly-location-weather.el"))
444
445 (let ((location-data (when use-location (journelly-get-location)))
446 (weather-data (when use-weather (journelly-get-weather)))
447 (actual-content (if content-file
448 (with-temp-buffer
449 (insert-file-contents content-file)
450 (buffer-string))
451 content)))
452
453 ;; Extract data
454 (let ((city (when location-data (cdr (assoc 'city location-data))))
455 (lat (when location-data (cdr (assoc 'lat location-data))))
456 (lon (when location-data (cdr (assoc 'lon location-data))))
457 (temp (when weather-data (cdr (assoc 'temperature weather-data))))
458 (cond (when weather-data (cdr (assoc 'condition weather-data))))
459 (symbol (when weather-data (cdr (assoc 'symbol weather-data)))))
460
461 ;; Create entry
462 (journelly-batch-create-entry
463 file
464 (or city "Unknown")
465 actual-content
466 lat lon temp cond symbol nil))))
467 (error
468 (journelly--output-json nil nil (error-message-string err)))))
469
470;;; Provide
471
472(provide 'journelly-batch-functions)
473
474;;; journelly-batch-functions.el ends here