branch: elpa/gnosis
commit 115e9c64fd6059d415c73b215f736709bf71b202
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>
[New] Add export-import module.
---
gnosis-export-import.el | 518 ++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 518 insertions(+)
diff --git a/gnosis-export-import.el b/gnosis-export-import.el
new file mode 100644
index 0000000000..e880439bbb
--- /dev/null
+++ b/gnosis-export-import.el
@@ -0,0 +1,518 @@
+;;; gnosis-export-import.el --- Export/import for gnosis -*- lexical-binding:
t; -*-
+
+;; Copyright (C) 2026 Free Software Foundation, Inc.
+
+;; Author: Thanos Apollo <[email protected]>
+;; Keywords: extensions
+;; URL: https://thanosapollo.org/projects/gnosis
+
+;; This program is free software; you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Export and import operations for gnosis decks and themata.
+;;
+;; This module handles:
+;; - Exporting decks to org-mode files (`gnosis-export-deck',
+;; `gnosis-export-deck-async')
+;; - Importing decks from org-mode files (`gnosis-import-deck',
+;; `gnosis-import-deck-async')
+;; - Parsing exported org buffers (`gnosis-export-parse-themata',
+;; `gnosis-export-parse--deck-name')
+;; - Saving edited themata (`gnosis-save', `gnosis-save-deck')
+
+;;; Code:
+
+(require 'gnosis)
+(require 'gnosis-algorithm)
+(require 'org)
+(require 'org-element)
+
+(defun gnosis-export--insert-read-only (string)
+ "Insert STRING as read-only."
+ (let ((start (point)))
+ (insert string)
+ ;; Set the just inserted string as read-only
+ (add-text-properties start (point) '(read-only t))
+ ;; Since the space is inserted outside of the read-only region, it's
editable
+ (let ((inhibit-read-only t))
+ (insert " "))))
+
+(cl-defun gnosis-export--insert-thema (id type &optional keimenon hypothesis
+ answer parathema tags example)
+ "Insert thema for thema ID.
+
+TYPE: Thema type, refer to `gnosis-thema-types'
+KEIMENON: Text user is first presented with.
+HYPOTHESIS: Hypothesis for what the ANSWER is
+ANSWER: The revelation after KEIMENON
+PARATHEMA: The text where THEMA is derived from.
+TAGS: List of THEMA tags
+EXAMPLE: Boolean value, if non-nil do not add properties for thema."
+ (let ((components `(("** Keimenon" . ,keimenon)
+ ("** Hypothesis" . ,hypothesis)
+ ("** Answer" . ,answer)
+ ("** Parathema" . ,parathema))))
+ (goto-char (point-max))
+ (insert "\n* Thema")
+ (when tags
+ (insert " :" (mapconcat #'identity tags ":") ":"))
+ (insert "\n")
+ (unless example
+ (let ((start (point)))
+ (insert ":PROPERTIES:\n:GNOSIS_ID: " id "\n:GNOSIS_TYPE: " type
"\n:END:\n")
+ (add-text-properties start (point)
+ '(read-only t rear-nonsticky (read-only)))))
+ (dolist (comp components)
+ (goto-char (point-max))
+ (gnosis-export--insert-read-only (car comp))
+ (insert "\n" (or (cdr comp) "") "\n\n"))))
+
+(defun gnosis-export-parse--deck-name (&optional parsed-data)
+ "Retrieve deck name from PARSED-DATA."
+ (let* ((parsed-data (or parsed-data (org-element-parse-buffer)))
+ (title (org-element-map parsed-data 'keyword
+ (lambda (kw)
+ (when (string= (org-element-property :key kw) "DECK")
+ (org-element-property :value kw)))
+ nil t)))
+ title))
+
+(defun gnosis-export-parse-themata (&optional separator)
+ "Extract content for each level-2 heading for thema headings with a
GNOSIS_ID.
+
+Split content of Hypothesis and Answer headings using SEPARATOR."
+ (let ((sep (or separator gnosis-export-separator))
+ results)
+ (org-element-map (org-element-parse-buffer) 'headline
+ (lambda (headline)
+ (let* ((level (org-element-property :level headline))
+ (gnosis-id (org-element-property :GNOSIS_ID headline))
+ (gnosis-type (org-element-property :GNOSIS_TYPE headline))
+ (tags (org-element-property :tags headline)))
+ (when (and (= level 1) gnosis-id gnosis-type)
+ (let ((line (line-number-at-pos
+ (org-element-property :begin headline)))
+ entry)
+ (push gnosis-id entry)
+ (push gnosis-type entry)
+ (dolist (child (org-element-contents headline))
+ (when (eq 'headline (org-element-type child))
+ (let* ((child-title (org-element-property :raw-value child))
+ (child-text (substring-no-properties
+ (string-trim
+ (org-element-interpret-data
+ (org-element-contents child)))))
+ (processed-text
+ (cond
+ ((and (member child-title '("Hypothesis" "Answer"))
+ (not (string-empty-p child-text)))
+ (mapcar (lambda (s)
+ (string-trim
+ (string-remove-prefix "-"
+ (string-remove-prefix sep s))))
+ (split-string child-text sep t "[ \t\n]+")))
+ ((string-empty-p child-text) nil)
+ (t child-text))))
+ (push processed-text entry))))
+ (push tags entry)
+ (push line entry)
+ (push (nreverse entry) results)))))
+ nil nil)
+ results))
+
+(defun gnosis-export-themata (ids &optional new-p)
+ "Export themata for IDS.
+
+If NEW-P replace the ids of themata with NEW, used for new themata to
+generate new thema id."
+ (cl-assert (listp ids) nil "IDS value must be a list.")
+ ;; Extract just the ID values if they're in a list structure
+ (let ((id-values (mapcar (lambda (id)
+ (if (listp id) (car id) id))
+ ids)))
+ ;; Process each thema
+ (dolist (id id-values)
+ (let ((thema-data (append (gnosis-select '[type keimenon hypothesis
answer tags]
+ 'themata `(= id ,id) t)
+ (gnosis-select 'parathema 'extras `(= id ,id)
t))))
+ (gnosis-export--insert-thema
+ (if new-p "NEW" (number-to-string id))
+ (nth 0 thema-data)
+ (nth 1 thema-data)
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat 'identity (nth 2 thema-data)
gnosis-export-separator))
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat 'identity (nth 3 thema-data)
gnosis-export-separator))
+ (nth 5 thema-data)
+ (nth 4 thema-data))))))
+
+(defun gnosis-export-deck (&optional deck filename new-p include-suspended)
+ "Export contents of DECK to FILENAME.
+
+When NEW-P, replace thema IDs with NEW for fresh import.
+When INCLUDE-SUSPENDED, also export suspended themata."
+ (interactive (list (gnosis--get-deck-id)
+ (read-file-name "Export to file: ")
+ (not (y-or-n-p "Export with current thema ids? "))
+ (y-or-n-p "Include suspended themata? ")))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (deck-name (gnosis--get-deck-name deck))
+ (filename (if (file-directory-p filename)
+ (expand-file-name deck-name filename)
+ filename)))
+ (unless (string-match-p "\\.org$" filename)
+ (setq filename (concat (or filename deck-name) ".org")))
+ (with-current-buffer (get-buffer-create (format "EXPORT: %s" deck-name))
+ (let ((inhibit-read-only t))
+ (buffer-disable-undo)
+ (org-mode)
+ (erase-buffer)
+ (insert (format "#+DECK: %s\n" deck-name))
+ ;; Batch-fetch: 2 queries instead of 2*N
+ (let* ((all-themata (emacsql gnosis-db
+ [:select [id type keimenon hypothesis answer tags]
+ :from themata :where (= deck-id $s1)] deck))
+ (all-ids (mapcar #'car all-themata))
+ (suspended-ids (when (and all-ids (not include-suspended))
+ (mapcar #'car
+ (emacsql gnosis-db
+ [:select id :from review-log
+ :where (and (in id $v1) (= suspend
1))]
+ (vconcat all-ids)))))
+ (all-themata (if suspended-ids
+ (cl-remove-if (lambda (row)
+ (member (car row)
suspended-ids))
+ all-themata)
+ all-themata))
+ (all-ids (mapcar #'car all-themata))
+ (all-extras (when all-ids
+ (emacsql gnosis-db
+ [:select [id parathema] :from extras
+ :where (in id $v1)] (vconcat all-ids))))
+ (extras-ht (let ((ht (make-hash-table :test 'equal
+ :size (length all-ids))))
+ (dolist (row all-extras ht)
+ (puthash (car row) (cadr row) ht)))))
+ (insert (format "#+THEMATA: %d\n\n" (length all-themata)))
+ (dolist (row all-themata)
+ (let* ((id (nth 0 row))
+ (type (nth 1 row))
+ (hypothesis (nth 3 row))
+ (answer (nth 4 row))
+ (tags (nth 5 row))
+ (parathema (gethash id extras-ht "")))
+ (gnosis-export--insert-thema
+ (if new-p "NEW" (number-to-string id))
+ type
+ (nth 2 row)
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat #'identity hypothesis
gnosis-export-separator))
+ (concat (string-remove-prefix "\n" gnosis-export-separator)
+ (mapconcat #'identity answer gnosis-export-separator))
+ parathema
+ tags)))
+ (when filename
+ (write-file filename)
+ (message "Exported deck to %s" filename)))))))
+
+(defun gnosis-export-deck-async (&optional deck filename new-p
include-suspended
+ chunk-size)
+ "Export contents of DECK to FILENAME asynchronously.
+
+Like `gnosis-export-deck' but uses `run-with-timer' between chunks
+so Emacs stays responsive during large exports. CHUNK-SIZE controls
+how many themata to insert per batch (default 500).
+
+When NEW-P, replace thema IDs with NEW for fresh import.
+When INCLUDE-SUSPENDED, also export suspended themata."
+ (interactive (list (gnosis--get-deck-id)
+ (read-file-name "Export to file: ")
+ (not (y-or-n-p "Export with current thema ids? "))
+ (y-or-n-p "Include suspended themata? ")))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (chunk-size (or chunk-size 500))
+ (deck-name (gnosis--get-deck-name deck))
+ (filename (if (file-directory-p filename)
+ (expand-file-name deck-name filename)
+ filename)))
+ (unless (string-match-p "\\.org$" filename)
+ (setq filename (concat (or filename deck-name) ".org")))
+ (let ((buffer (get-buffer-create (format "EXPORT: %s" deck-name))))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (buffer-disable-undo)
+ (org-mode)
+ (erase-buffer)
+ (insert (format "#+DECK: %s\n" deck-name))
+ ;; Batch-fetch: 2 queries instead of 2*N
+ (let* ((all-themata (emacsql gnosis-db
+ [:select [id type keimenon hypothesis answer
tags]
+ :from themata :where (= deck-id $s1)] deck))
+ (all-ids (mapcar #'car all-themata))
+ (suspended-ids (when (and all-ids (not include-suspended))
+ (mapcar #'car
+ (emacsql gnosis-db
+ [:select id :from review-log
+ :where (and (in id $v1) (= suspend
1))]
+ (vconcat all-ids)))))
+ (all-themata (if suspended-ids
+ (cl-remove-if (lambda (row)
+ (member (car row)
suspended-ids))
+ all-themata)
+ all-themata))
+ (all-ids (mapcar #'car all-themata))
+ (all-extras (when all-ids
+ (emacsql gnosis-db
+ [:select [id parathema] :from extras
+ :where (in id $v1)] (vconcat all-ids))))
+ (extras-ht (let ((ht (make-hash-table :test 'equal
+ :size (length
all-ids))))
+ (dolist (row all-extras ht)
+ (puthash (car row) (cadr row) ht))))
+ (total (length all-themata)))
+ (insert (format "#+THEMATA: %d\n\n" total))
+ (message "Exporting %d themata..." total)
+ (cl-labels
+ ((process-next (remaining exported)
+ (if (null remaining)
+ (progn
+ (when filename
+ (with-current-buffer buffer
+ (write-file filename))
+ (message "Exported deck to %s" filename)))
+ (let ((count 0))
+ (with-current-buffer buffer
+ (let ((inhibit-read-only t))
+ (while (and remaining (< count chunk-size))
+ (let* ((row (car remaining))
+ (id (nth 0 row))
+ (type (nth 1 row))
+ (hypothesis (nth 3 row))
+ (answer (nth 4 row))
+ (tags (nth 5 row))
+ (parathema (gethash id extras-ht "")))
+ (gnosis-export--insert-thema
+ (if new-p "NEW" (number-to-string id))
+ type
+ (nth 2 row)
+ (concat (string-remove-prefix "\n"
gnosis-export-separator)
+ (mapconcat #'identity hypothesis
+ gnosis-export-separator))
+ (concat (string-remove-prefix "\n"
gnosis-export-separator)
+ (mapconcat #'identity answer
+ gnosis-export-separator))
+ parathema
+ tags))
+ (setq remaining (cdr remaining))
+ (cl-incf count))))
+ (let ((new-exported (+ exported count)))
+ (message "Exporting... %d/%d themata" new-exported
total)
+ (run-with-timer 0.01 nil
+ #'process-next remaining
new-exported))))))
+ (process-next all-themata 0))))))))
+
+(defun gnosis-save-thema (thema deck)
+ "Save THEMA for DECK.
+Returns nil on success, or an error message string on failure."
+ (let* ((id (nth 0 thema))
+ (type (nth 1 thema))
+ (keimenon (nth 2 thema))
+ (hypothesis (nth 3 thema))
+ (answer (nth 4 thema))
+ (parathema (or (nth 5 thema) ""))
+ (tags (nth 6 thema))
+ (line (nth 7 thema))
+ (links (append (gnosis-extract-id-links parathema)
+ (gnosis-extract-id-links keimenon)))
+ (thema-func (cdr (assoc (downcase type)
+ (mapcar (lambda (pair) (cons (downcase (car
pair))
+ (cdr pair)))
+ gnosis-thema-types)))))
+ (condition-case err
+ (progn
+ (funcall thema-func id deck type keimenon hypothesis
+ answer parathema tags 0 links)
+ nil)
+ (error (format "Line %s (id:%s): %s" (or line "?") id
+ (error-message-string err))))))
+
+(defun gnosis-save ()
+ "Save themata in current buffer."
+ (interactive nil gnosis-edit-mode)
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (themata (gnosis-export-parse-themata))
+ (deck (gnosis--get-deck-id (gnosis-export-parse--deck-name)))
+ (gnosis--id-cache (let ((ht (make-hash-table :test 'equal)))
+ (dolist (id (gnosis-select 'id 'themata nil t) ht)
+ (puthash id t ht))))
+ (errors nil)
+ (edited-id (string-to-number (caar themata))))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for thema in themata
+ for err = (gnosis-save-thema thema deck)
+ when err do (push err errors)))
+ (if errors
+ (user-error "Failed to import %d thema(ta):\n%s"
+ (length errors) (mapconcat #'identity (nreverse errors)
"\n"))
+ (gnosis-edit-quit)
+ (run-hook-with-args 'gnosis-save-hook edited-id))))
+
+;;;###autoload
+(defun gnosis-save-deck (deck-name)
+ "Save themata for deck with DECK-NAME.
+
+If a deck with DECK-NAME already exists, prompt for confirmation
+before importing into it."
+ (interactive
+ (progn
+ (unless (eq major-mode 'org-mode)
+ (user-error "This function can only be used in org-mode buffers"))
+ (list (read-string "Deck name: " (gnosis-export-parse--deck-name)))))
+ (when (and (gnosis-get 'id 'decks `(= name ,deck-name))
+ (not (y-or-n-p (format "Deck '%s' already exists. Import into it?
"
+ deck-name))))
+ (user-error "Aborted"))
+ (let* ((gc-cons-threshold most-positive-fixnum)
+ (themata (gnosis-export-parse-themata))
+ (deck (gnosis-get-deck-id deck-name))
+ (gnosis--id-cache (let ((ht (make-hash-table :test 'equal)))
+ (dolist (id (gnosis-select 'id 'themata nil t) ht)
+ (puthash id t ht))))
+ (errors nil))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for thema in themata
+ for err = (gnosis-save-thema thema deck)
+ when err do (push err errors)))
+ (if errors
+ (user-error "Failed to import %d thema(ta):\n%s"
+ (length errors) (mapconcat #'identity (nreverse errors)
"\n"))
+ (message "Imported %d themata for deck '%s'" (length themata)
deck-name))))
+
+;;;###autoload
+(defun gnosis-import-deck (file)
+ "Save gnosis deck from FILE."
+ (interactive "fFile: ")
+ (let ((gc-cons-threshold most-positive-fixnum))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (org-mode)
+ (gnosis-save-deck (gnosis-export-parse--deck-name)))))
+
+(defun gnosis--import-split-chunks (text chunk-size)
+ "Split org TEXT into chunks of CHUNK-SIZE themata.
+
+Return a list of strings, each containing up to CHUNK-SIZE
+`* Thema' headings."
+ (let ((headings '())
+ (start 0))
+ ;; Find all `* Thema' positions
+ (while (string-match "^\\* Thema" text start)
+ (push (match-beginning 0) headings)
+ (setf start (1+ (match-beginning 0))))
+ (setq headings (nreverse headings))
+ (let ((chunks '())
+ (total (length headings)))
+ (cl-loop for i from 0 below total by chunk-size
+ for beg = (nth i headings)
+ for end-idx = (min (+ i chunk-size) total)
+ for end = (if (< end-idx total)
+ (nth end-idx headings)
+ (length text))
+ do (push (substring text beg end) chunks))
+ (nreverse chunks))))
+
+(defun gnosis--import-chunk (header chunk deck-id id-cache)
+ "Import a single CHUNK of org text.
+
+HEADER is the #+DECK line to prepend. DECK-ID is the resolved
+deck id. ID-CACHE is the shared `gnosis--id-cache' hash table.
+Returns a list of error strings (nil on full success)."
+ (let ((gc-cons-threshold most-positive-fixnum)
+ (gnosis--id-cache id-cache)
+ (errors nil))
+ (with-temp-buffer
+ (insert header "\n" chunk)
+ (org-mode)
+ (let ((themata (gnosis-export-parse-themata)))
+ (emacsql-with-transaction gnosis-db
+ (cl-loop for thema in themata
+ for err = (gnosis-save-thema thema deck-id)
+ when err do (push err errors)))))
+ (nreverse errors)))
+
+;;;###autoload
+(defun gnosis-import-deck-async (file &optional chunk-size)
+ "Import gnosis deck from FILE asynchronously in chunks.
+
+CHUNK-SIZE controls how many themata to process per batch
+\(default 500). Uses `run-with-timer' between chunks so Emacs
+stays responsive. Progress is reported in the echo area."
+ (interactive "fFile: ")
+ (let* ((chunk-size (or chunk-size 500))
+ (text (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string)))
+ ;; Extract header (everything before first `* Thema')
+ (header-end (or (string-match "^\\* Thema" text) 0))
+ (header (string-trim-right (substring text 0 header-end)))
+ (deck-name (with-temp-buffer
+ (insert header)
+ (org-mode)
+ (gnosis-export-parse--deck-name)))
+ (deck-id (progn
+ (when (and (gnosis-get 'id 'decks `(= name ,deck-name))
+ (not (y-or-n-p
+ (format "Deck '%s' already exists.
Import into it? "
+ deck-name))))
+ (user-error "Aborted"))
+ (gnosis-get-deck-id deck-name)))
+ (id-cache (let ((ht (make-hash-table :test 'equal)))
+ (dolist (id (gnosis-select 'id 'themata nil t) ht)
+ (puthash id t ht))))
+ (chunks (gnosis--import-split-chunks text chunk-size))
+ (total-chunks (length chunks))
+ ;; Count total themata from the text
+ (total-themata (with-temp-buffer
+ (insert text)
+ (count-matches "^\\* Thema" (point-min)
(point-max))))
+ (imported 0)
+ (all-errors '()))
+ (message "Importing %d themata in %d chunks..." total-themata total-chunks)
+ (cl-labels
+ ((process-next (remaining chunk-n)
+ (if (null remaining)
+ ;; Done
+ (if all-errors
+ (message "Import complete: %d themata, %d errors"
+ imported (length all-errors))
+ (message "Import complete: %d themata for deck '%s'"
+ imported deck-name))
+ (let* ((chunk (car remaining))
+ (errors (gnosis--import-chunk header chunk deck-id
id-cache))
+ ;; Count headings in this chunk
+ (n (with-temp-buffer
+ (insert chunk)
+ (count-matches "^\\* Thema" (point-min)
(point-max)))))
+ (setq imported (+ imported n))
+ (when errors
+ (setq all-errors (append all-errors errors)))
+ (message "Importing... %d/%d themata (chunk %d/%d)"
+ imported total-themata chunk-n total-chunks)
+ (run-with-timer 0.01 nil
+ #'process-next (cdr remaining) (1+ chunk-n))))))
+ (process-next chunks 1))))
+
+(provide 'gnosis-export-import)
+;;; gnosis-export-import.el ends here