branch: externals/gnosis
commit 3b4d8c01714b1e7b4e2b4857baa35bf095165dec
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
[New Module] gnosis-journal: Journal module for gnosis.
* Replaces journal entries and todo integration of org-gnosis.
---
gnosis-journal.el | 296 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 296 insertions(+)
diff --git a/gnosis-journal.el b/gnosis-journal.el
new file mode 100644
index 0000000000..6a7645f66a
--- /dev/null
+++ b/gnosis-journal.el
@@ -0,0 +1,296 @@
+;;; gnosis-journal.el --- Journal module for gnosis -*- lexical-binding: t;
-*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Thanos Apollo <[email protected]>
+;; Keywords: extensions
+
+;;; Commentary:
+
+;; Journal entries, TODO integration, and checked item tracking.
+;; Uses gnosis-sqlite for DB and gnosis-org for parsing.
+
+;;; Code:
+
+(require 'cl-lib)
+(require 'org)
+(require 'org-element)
+(require 'gnosis-org)
+
+;; Forward declarations
+(declare-function gnosis-select "gnosis")
+(declare-function gnosis--ensure-db "gnosis")
+(declare-function gnosis-sqlite-with-transaction "gnosis-sqlite")
+(declare-function gnosis-nodes-select "gnosis-nodes")
+(declare-function gnosis-nodes--find "gnosis-nodes")
+(declare-function gnosis-nodes--create-file "gnosis-nodes")
+(declare-function gnosis-nodes-select-template "gnosis-nodes")
+(declare-function gnosis-nodes-find "gnosis-nodes")
+(declare-function gnosis-nodes-mode "gnosis-nodes")
+(declare-function gnosis-nodes-update-file "gnosis-nodes")
+(declare-function gnosis-nodes--file-changed-p "gnosis-nodes")
+
+(defgroup gnosis-journal nil
+ "Gnosis journal."
+ :group 'gnosis)
+
+(defcustom gnosis-journal-dir
+ (expand-file-name "journal" (bound-and-true-p gnosis-nodes-dir))
+ "Gnosis journal directory."
+ :type 'directory)
+
+(defcustom gnosis-journal-file nil
+ "When non-nil, use this file for journal entries as level 1 headings.
+If nil, journal entries are created as separate files in
+`gnosis-journal-dir'."
+ :type '(choice (const :tag "Use separate files" nil)
+ (file :tag "Single journal file")))
+
+(defcustom gnosis-journal-as-gpg nil
+ "When non-nil, create journal entries with a .gpg suffix."
+ :type 'boolean)
+
+(defcustom gnosis-journal-templates
+ '(("Default" (lambda () (format "** Daily Notes\n\n** Goals\n%s"
(gnosis-journal-todos))))
+ ("Empty" (lambda () "")))
+ "Templates for journaling."
+ :type '(repeat (cons (string :tag "Name")
+ (function :tag "Template Function"))))
+
+(defcustom gnosis-journal-todo-files org-agenda-files
+ "TODO files used for the journal entries."
+ :type '(repeat string))
+
+(defcustom gnosis-journal-todo-keywords org-todo-keywords
+ "TODO Keywords used for parsing `gnosis-journal-todo-files'.
+All items after the vertical bar \"|\" will be ignored, for
+compatability with `org-todo-keywords'."
+ :type '(repeat string))
+
+(defcustom gnosis-journal-bullet-point-char "+"
+ "String to indicate a bullet point."
+ :type 'string)
+
+;;; Internal helpers
+
+(defun gnosis-journal--dir ()
+ "Return journal directory, ensuring it exists."
+ (let ((dir (or gnosis-journal-dir
+ (expand-file-name "journal"
+ (bound-and-true-p gnosis-nodes-dir)))))
+ (unless (file-directory-p dir)
+ (make-directory dir t))
+ dir))
+
+(defun gnosis-journal--create-file ()
+ "Create `gnosis-journal-file' when non-nil and file does not exist."
+ (when (and gnosis-journal-file
+ (not (file-exists-p gnosis-journal-file)))
+ (with-current-buffer (find-file-noselect gnosis-journal-file)
+ (insert (format "#+title: %s Journal\n#+filetags: \n" (or user-full-name
"")))
+ (gnosis-nodes-mode)
+ (save-buffer)
+ (message "Created journal file."))))
+
+(defun gnosis-journal--add-entry (title)
+ "Add entry for TITLE to `gnosis-journal-file'."
+ (when gnosis-journal-file
+ (gnosis-journal--create-file)
+ (find-file gnosis-journal-file)
+ (goto-char (point-max))
+ (insert (format "* %s\n" title))
+ (org-id-get-create)
+ (insert (gnosis-nodes-select-template gnosis-journal-templates))))
+
+;;; TODOs
+
+(defun gnosis-journal-get--todos (file)
+ "Get TODO items for FILE."
+ (let ((todos))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (headline)
+ (when (member (org-element-property :todo-keyword headline)
+ (cl-loop for keyword in gnosis-journal-todo-keywords
+ until (and (stringp keyword) (string= keyword
"|"))
+ collect keyword))
+ (let* ((title (org-element-property :raw-value headline))
+ (timestamp (org-element-property
+ :raw-value (org-element-property :scheduled
headline))))
+ (push `(,title ,timestamp ,file) todos))))))
+ (nreverse todos)))
+
+(defun gnosis-journal-get-todos (&optional files)
+ "Get TODO items for FILES."
+ (let ((files (or files gnosis-journal-todo-files))
+ todos)
+ (cl-loop for file in files
+ do (push (gnosis-journal-get--todos file) todos))
+ (nreverse (apply #'append todos))))
+
+(defun gnosis-journal-todos ()
+ "Output todos as checkboxes in a string for current date."
+ (let ((todos (gnosis-journal-get-todos))
+ (current-date (format-time-string "%Y-%m-%d"))
+ todos-string)
+ (cl-loop for todo in todos
+ do
+ (let ((todo-title (car todo))
+ (todo-timestamp (cadr todo)))
+ (when (or
+ (null todo-timestamp)
+ (string-match-p (regexp-quote current-date)
todo-timestamp))
+ (setq todos-string
+ (concat todos-string
+ (format "%s [ ] %s\n"
gnosis-journal-bullet-point-char
+ todo-title))))))
+ (or todos-string "")))
+
+(defun gnosis-journal-get-checked-items (element)
+ "Get checked items for org ELEMENT.
+ELEMENT should be the output of `org-element-parse-buffer'."
+ (let ((checked-items))
+ (org-element-map element 'item
+ (lambda (item)
+ (when (eq (org-element-property :checkbox item) 'on)
+ (push (car (split-string
+ (substring-no-properties
+ (string-trim
+ (org-element-interpret-data
+ (org-element-contents item))))
+ "\n"))
+ checked-items))))
+ (nreverse checked-items)))
+
+(defun gnosis-journal-find-file-with-heading (title files)
+ "Find first org file in FILES containing heading TITLE."
+ (catch 'found
+ (dolist (file files)
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (goto-char (point-min))
+ (when (org-find-exact-headline-in-buffer title)
+ (throw 'found file))))))
+
+(defun gnosis-journal-mark-todo-as-done (todo-title)
+ "Mark scheduled TODO with TODO-TITLE as DONE if not already done today."
+ (let* ((file (gnosis-journal-find-file-with-heading todo-title
gnosis-journal-todo-files))
+ (today (format-time-string "%Y-%m-%d")))
+ (when file
+ (save-current-buffer
+ (with-current-buffer (find-file-noselect file)
+ (let ((found nil))
+ (save-excursion
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (headline)
+ (when (and (not found)
+ (string= (org-element-property :raw-value
headline)
+ todo-title)
+ (string= (org-element-property :todo-keyword
headline)
+ "TODO")
+ (not (org-entry-get (org-element-property :begin
headline)
+ "LAST_DONE_DATE")))
+ (org-with-point-at (org-element-property :begin headline)
+ (org-todo 'done)
+ (org-entry-put nil "LAST_DONE_DATE" today))
+ (setq found t))))))
+ (save-buffer))))))
+
+(defun gnosis-journal--update-todos (file)
+ "Update TODO items from journal FILE."
+ (let* ((today (format-time-string "%Y-%m-%d"))
+ (parsed-buffer (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (org-element-parse-buffer)))
+ (done-todos (if (and gnosis-journal-file
+ (string= (file-name-nondirectory file)
+ (file-name-nondirectory
gnosis-journal-file)))
+ (let ((today-heading
+ (org-element-map parsed-buffer 'headline
+ (lambda (headline)
+ (when (string= (org-element-property
:raw-value headline) today)
+ headline))
+ nil t)))
+ (if today-heading
+ (gnosis-journal-get-checked-items today-heading)
+ nil))
+ (gnosis-journal-get-checked-items parsed-buffer))))
+ (cl-loop for done-todo in done-todos
+ do (gnosis-journal-mark-todo-as-done done-todo))))
+
+;;; Interactive commands
+
+;;;###autoload
+(defun gnosis-journal-find (&optional title)
+ "Find journal entry for TITLE."
+ (interactive)
+ (let* ((title (or title (gnosis-nodes--find
+ "Select journal entry: "
+ (gnosis-nodes-select '[title tags] 'journal)
+ (gnosis-nodes-select 'title 'journal))))
+ (id (car (gnosis-nodes-select 'id 'journal `(= title ,title) t)))
+ (file (car (gnosis-nodes-select 'file 'journal `(= title ,title) t))))
+ (cond
+ ((and id file)
+ (gnosis-nodes-find
+ title file id (gnosis-journal--dir) gnosis-journal-templates))
+ ((and gnosis-journal-file
+ (string= title (format-time-string "%Y-%m-%d")))
+ (gnosis-journal--add-entry title))
+ (t
+ (gnosis-nodes--create-file
+ title (gnosis-journal--dir)
+ (gnosis-nodes-select-template gnosis-journal-templates))))))
+
+;;;###autoload
+(defun gnosis-journal-insert (arg)
+ "Insert journal entry.
+If called with prefix ARG, use custom link description."
+ (interactive "P")
+ (gnosis-nodes-insert arg t))
+
+;;;###autoload
+(defun gnosis-journal ()
+ "Journal for current date."
+ (interactive)
+ (let* ((date (format-time-string "%Y-%m-%d")))
+ (gnosis-journal-find date)))
+
+;;; Sync
+
+(defun gnosis-journal-db-sync (&optional force)
+ "Sync journal entries in database.
+When FORCE, update all files. Otherwise, only update changed files."
+ (let* ((journal-dir (gnosis-journal--dir))
+ (journal-files (cl-remove-if-not
+ (lambda (file)
+ (and (string-match-p "^[0-9]"
+ (file-name-nondirectory file))
+ (not (file-directory-p file))))
+ (directory-files journal-dir t nil t)))
+ (all-files (if (and gnosis-journal-file
+ (file-exists-p gnosis-journal-file))
+ (cons gnosis-journal-file journal-files)
+ journal-files))
+ (files (if force
+ all-files
+ (cl-remove-if-not
+ (lambda (file) (gnosis-nodes--file-changed-p file 'journal))
+ all-files))))
+ (when (> (length files) 0)
+ (let ((progress (make-progress-reporter
+ (format "Processing %d/%d journal files..." (length
files) (length all-files))
+ 0 (length files))))
+ (cl-loop for file in files
+ for i from 0
+ do (progn
+ (gnosis-nodes-update-file file)
+ (progress-reporter-update progress i)))
+ (progress-reporter-done progress)))))
+
+(provide 'gnosis-journal)
+;;; gnosis-journal.el ends here