branch: externals/gnosis
commit 6f74c26da26efaa97a573611804b183cf04e8520
Author: Thanos Apollo <[email protected]>
Commit: Thanos Apollo <[email protected]>

    [Refactor] Optimize deck export/import and add export options.
    
    - Export: batch-fetch themata in 2 queries instead of 2*N,
      insert tags/properties as plain text instead of O(n²) buffer scans.
    - Import: wrap in single transaction with ID cache
    - Add option to exclude suspended themata from export
    - Add #+THEMATA header with exported count
    - Report line numbers on failed thema imports
    - Confirm before importing into existing deck
    - Add export/import test suite
---
 Makefile                           |  14 +-
 gnosis.el                          | 179 ++++++++++++++-----
 tests/gnosis-test-export-import.el | 355 +++++++++++++++++++++++++++++++++++++
 3 files changed, 497 insertions(+), 51 deletions(-)

diff --git a/Makefile b/Makefile
index 1836447ef4..0b39205a49 100644
--- a/Makefile
+++ b/Makefile
@@ -6,7 +6,7 @@ EMACS = emacs
 ORG := doc/gnosis.org
 TEXI := doc/gnosis.texi
 INFO := doc/gnosis.info
-TEST_FILE := tests/gnosis-test-algorithm.el
+TEST_FILES := tests/gnosis-test-algorithm.el tests/gnosis-test-export-import.el
 
 all: doc
 
@@ -20,11 +20,13 @@ doc:        $(ORG)
 
 test:
        rm -f *.elc
-       $(EMACS) --batch \
-       -q \
-       --eval "(add-to-list 'load-path \"$(shell pwd)\")" \
-       --load $(TEST_FILE) \
-       --eval "(ert-run-tests-batch-and-exit)"
+       @for f in $(TEST_FILES); do \
+               echo "Running $$f..."; \
+               $(EMACS) --batch \
+               -q \
+               --eval "(add-to-list 'load-path \"$(shell pwd)\")" \
+               --load $$f; \
+       done
 
 clean:
        rm -f $(TEXI) $(INFO) *.elc *-pkg.el*
diff --git a/gnosis.el b/gnosis.el
index e41ca08f82..dca31a4e81 100644
--- a/gnosis.el
+++ b/gnosis.el
@@ -221,6 +221,10 @@ This is set automatically based on buffer type:
 (defvar gnosis-review-editing-p nil
   "Boolean value to check if user is currently in a review edit.")
 
+(defvar gnosis--id-cache nil
+  "Hash table of existing thema IDs, bound during batch import.
+When non-nil, `gnosis-generate-id' and `gnosis-update-thema' use this
+for O(1) lookups instead of querying the database per thema.")
 
 (defun gnosis-select (value table &optional restrictions flatten)
   "Select VALUE from TABLE, optionally with RESTRICTIONS.
@@ -694,16 +698,21 @@ When called with a prefix, unsuspends all themata in 
deck."
   "Generate a unique gnosis ID.
 
 Default to generating a thema id, when DECK-P is t generates a deck id.
+When `gnosis--id-cache' is bound, uses hash table lookup instead of DB query.
 
 LENGTH: length of id, default to a random number between 10-15."
   (let* ((length (or length (+ (random 5) 10)))
          (max-val (expt 10 length))
          (min-val (expt 10 (1- length)))
          (id (+ (random (- max-val min-val)) min-val))
-        (current-ids (if deck-p (gnosis-select 'id 'decks nil t)
-                       (gnosis-select 'id 'themata nil t))))
-    (if (member id current-ids)
+        (exists (if (and gnosis--id-cache (not deck-p))
+                    (gethash id gnosis--id-cache)
+                  (member id (if deck-p (gnosis-select 'id 'decks nil t)
+                               (gnosis-select 'id 'themata nil t))))))
+    (if exists
         (gnosis-generate-id length)
+      (when gnosis--id-cache
+        (puthash id t gnosis--id-cache))
       id)))
 
 (defun gnosis-mcq-answer (id)
@@ -1536,9 +1545,12 @@ LINKS: List of id links."
                              &optional deck-id type)
   "Update thema entry for ID.
 
-If gnosis ID does not exist, create it anew."
+If gnosis ID does not exist, create it anew.
+When `gnosis--id-cache' is bound, uses hash table for existence check."
   (let ((id (if (stringp id) (string-to-number id) id)))
-    (if (member id (gnosis-select 'id 'themata nil t))
+    (if (if gnosis--id-cache
+           (gethash id gnosis--id-cache)
+         (member id (gnosis-select 'id 'themata nil t)))
        (emacsql-with-transaction gnosis-db
          (gnosis-update 'themata `(= keimenon ,keimenon) `(= id ,id))
          (gnosis-update 'themata `(= hypothesis ',hypothesis) `(= id ,id))
@@ -1763,14 +1775,6 @@ LINKS: list of strings."
     (let ((inhibit-read-only t))
       (insert " "))))
 
-(defun gnosis-export-make-read-only (&rest values)
-  "Make the provided VALUES read-only in the whole buffer."
-  (goto-char (point-min))
-  (dolist (value values)
-    (while (search-forward value nil t)
-      (put-text-property (match-beginning 0) (match-end 0) 'read-only t)))
-  (goto-char (point-min)))
-
 (cl-defun gnosis-export--insert-thema (id type &optional keimenon hypothesis
                                      answer parathema tags example)
   "Insert thema for thema ID.
@@ -1786,15 +1790,15 @@ EXAMPLE: Boolean value, if non-nil do not add 
properties for thema."
                       ("** Hypothesis" . ,hypothesis)
                       ("** Answer" . ,answer)
                       ("** Parathema" . ,parathema))))
+    (goto-char (point-max))
     (insert "\n* Thema")
-    (org-set-tags tags)
+    (when tags
+      (insert " :" (mapconcat #'identity tags ":") ":"))
+    (insert "\n")
     (unless example
-      (org-set-property "GNOSIS_ID" id)
-      (org-set-property "GNOSIS_TYPE" type)
-      (gnosis-export-make-read-only ":PROPERTIES:"
-                                (format "GNOSIS_ID: %s" id)
-                                (format "GNOSIS_TYPE: %s" type)
-                                ":END:"))
+      (let ((start (point)))
+        (insert ":PROPERTIES:\n:GNOSIS_ID: " id "\n:GNOSIS_TYPE: " type 
"\n:END:\n")
+        (add-text-properties start (point) '(read-only t))))
     (dolist (comp components)
       (goto-char (point-max))
       (gnosis-export--insert-read-only (car comp))
@@ -1823,7 +1827,9 @@ Split content of Hypothesis and Answer headings using 
SEPARATOR."
                (gnosis-type (org-element-property :GNOSIS_TYPE headline))
                (tags (org-element-property :tags headline)))
           (when (and (= level 1) gnosis-id gnosis-type)
-            (let (entry)
+            (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))
@@ -1846,6 +1852,7 @@ Split content of Hypothesis and Answer headings using 
SEPARATOR."
                            (t child-text))))
                     (push processed-text entry))))
               (push tags entry)
+              (push line entry)
               (push (nreverse entry) results)))))
       nil nil)
     results))
@@ -1876,12 +1883,17 @@ generate new thema id."
          (nth 5 thema-data)
          (nth 4 thema-data))))))
 
-(defun gnosis-export-deck (&optional deck filename new-p)
-  "Export contents of DECK to FILENAME."
+(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? "))))
-  (let* ((deck-name (gnosis--get-deck-name deck))
+                    (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)))
@@ -1891,15 +1903,57 @@ generate new thema id."
       (let ((inhibit-read-only t))
         (org-mode)
         (erase-buffer)
-        (insert (format "#+DECK: %s\n\n" deck-name))
-        (let ((thema-ids (gnosis-select 'id 'themata `(= deck-id ,deck))))
-          (gnosis-export-themata thema-ids new-p)
+        (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-save-thema (thema deck)
-  "Save THEMA for 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))
@@ -1907,45 +1961,80 @@ generate new thema id."
         (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)))))
-    (funcall thema-func id deck type keimenon hypothesis
-            answer parathema tags 0 links)))
+    (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 ((themata (gnosis-export-parse-themata))
-       (deck (gnosis--get-deck-id (gnosis-export-parse--deck-name))))
-    (cl-loop for thema in themata
-            do (gnosis-save-thema thema deck))
-    (gnosis-edit-quit)))
+  (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))
+    (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))))
 
 ;;;###autoload
 (defun gnosis-save-deck (deck-name)
-  "Save themata for deck with 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)))))
-  (let ((themata (gnosis-export-parse-themata))
-       (deck (gnosis-get-deck-id deck-name)))
-    (cl-loop for thema in themata
-            do (gnosis-save-thema thema deck))))
+  (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: ")
-  (with-temp-buffer
-    (insert-file-contents file)
-    (org-mode)
-    (gnosis-save-deck (gnosis-export-parse--deck-name))))
+  (let ((gc-cons-threshold most-positive-fixnum))
+    (with-temp-buffer
+      (insert-file-contents file)
+      (org-mode)
+      (gnosis-save-deck (gnosis-export-parse--deck-name)))))
 
 ;;;###autoload
 (defun gnosis-add-thema (deck type &optional keimenon hypothesis
diff --git a/tests/gnosis-test-export-import.el 
b/tests/gnosis-test-export-import.el
new file mode 100644
index 0000000000..3cb640032f
--- /dev/null
+++ b/tests/gnosis-test-export-import.el
@@ -0,0 +1,355 @@
+;;; gnosis-test-export-import.el --- Export/import tests  -*- lexical-binding: 
t; -*-
+
+;; Copyright (C) 2023-2026  Thanos Apollo
+
+;; Author: Thanos Apollo <[email protected]>
+
+;;; Commentary:
+
+;; Tests for deck export and import functionality.
+;; Uses a temporary SQLite database so the user's real DB is untouched.
+
+;;; Code:
+(require 'ert)
+(require 'gnosis)
+
+(let ((parent-dir (file-name-directory
+                   (directory-file-name
+                    (file-name-directory (or load-file-name 
default-directory))))))
+  (add-to-list 'load-path parent-dir))
+
+;; ──────────────────────────────────────────────────────────
+;; Test helpers
+;; ──────────────────────────────────────────────────────────
+
+(defvar gnosis-test--db-file nil
+  "Path to temporary test database file.")
+
+(defmacro gnosis-test-with-db (&rest body)
+  "Run BODY with a fresh temporary gnosis database.
+Rebinds `gnosis-db' and initialises the schema."
+  (declare (indent 0) (debug t))
+  `(let* ((gnosis-test--db-file (make-temp-file "gnosis-test-" nil ".db"))
+          (gnosis-db (emacsql-sqlite-open gnosis-test--db-file))
+          (gnosis--id-cache nil))
+     (unwind-protect
+         (progn
+           ;; Create all tables
+           (emacsql-with-transaction gnosis-db
+             (pcase-dolist (`(,table ,schema) gnosis-db--schemata)
+               (emacsql gnosis-db [:create-table $i1 $S2] table schema)))
+           ,@body)
+       (emacsql-close gnosis-db)
+       (delete-file gnosis-test--db-file))))
+
+(defun gnosis-test--add-deck (name)
+  "Add a deck with NAME to the test DB.  Return its id."
+  (let ((id (+ (random 90000) 10000)))
+    (gnosis--insert-into 'decks `([,id ,name]))
+    id))
+
+(defun gnosis-test--add-basic-thema (deck-id keimenon answer
+                                     &optional tags parathema thema-id suspend)
+  "Insert a basic thema into the test DB.  Return its id.
+DECK-ID, KEIMENON, ANSWER are required.
+TAGS defaults to (\"test\"), PARATHEMA to \"\".
+SUSPEND: 1 to suspend, 0 or nil for active."
+  (let* ((id (or thema-id (gnosis-generate-id)))
+         (tags (or tags '("test")))
+         (parathema (or parathema ""))
+         (suspend (or suspend 0))
+         (hypothesis '(""))
+         (answer (if (listp answer) answer (list answer))))
+    (emacsql-with-transaction gnosis-db
+      (gnosis--insert-into 'themata `([,id "basic" ,keimenon ,hypothesis
+                                           ,answer ,tags ,deck-id]))
+      (gnosis--insert-into 'review `([,id ,gnosis-algorithm-gnosis-value
+                                          ,gnosis-algorithm-amnesia-value]))
+      (gnosis--insert-into 'review-log `([,id ,(gnosis-algorithm-date)
+                                              ,(gnosis-algorithm-date) 0 0 0 0
+                                              ,suspend 0]))
+      (gnosis--insert-into 'extras `([,id ,parathema ""])))
+    id))
+
+(defun gnosis-test--kill-export-buffer (deck-name)
+  "Kill the export buffer for DECK-NAME if it exists."
+  (let ((buf (get-buffer (format "EXPORT: %s" deck-name))))
+    (when buf (kill-buffer buf))))
+
+;; ──────────────────────────────────────────────────────────
+;; Export tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-export-deck-basic ()
+  "Export a deck and verify the org buffer content."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "test-deck"))
+           (id1 (gnosis-test--add-basic-thema deck-id "What is 2+2?" "4"
+                                              '("math" "basic")))
+           (id2 (gnosis-test--add-basic-thema deck-id "Capital of Greece?" 
"Athens"
+                                              '("geo")))
+           (export-file (concat (make-temp-file "gnosis-export-") ".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file nil)
+            (should (file-exists-p export-file))
+            (with-temp-buffer
+              (insert-file-contents export-file)
+              (let ((content (buffer-string)))
+                ;; Deck header present
+                (should (string-search "#+DECK: test-deck" content))
+                ;; Themata count in header
+                (should (string-search "#+THEMATA: 2" content))
+                ;; Both themata exported
+                (should (string-search (number-to-string id1) content))
+                (should (string-search (number-to-string id2) content))
+                ;; Tags in org format
+                (should (string-search ":math:basic:" content))
+                (should (string-search ":geo:" content))
+                ;; Properties present
+                (should (string-search ":GNOSIS_ID:" content))
+                (should (string-search ":GNOSIS_TYPE: basic" content))
+                ;; Content present
+                (should (string-search "What is 2+2?" content))
+                (should (string-search "Capital of Greece?" content))
+                (should (string-search "4" content))
+                (should (string-search "Athens" content)))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))
+        (gnosis-test--kill-export-buffer "test-deck")))))
+
+(ert-deftest gnosis-test-export-deck-new-ids ()
+  "Export with new-p replaces IDs with NEW."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "new-id-deck"))
+           (id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+           (export-file (concat (make-temp-file "gnosis-export-new-") ".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file t)
+            (with-temp-buffer
+              (insert-file-contents export-file)
+              (let ((content (buffer-string)))
+                (should (string-search ":GNOSIS_ID: NEW" content))
+                (should-not (string-search
+                             (format ":GNOSIS_ID: %d" id1) content)))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))
+        (gnosis-test--kill-export-buffer "new-id-deck")))))
+
+(ert-deftest gnosis-test-export-empty-deck ()
+  "Exporting an empty deck produces a file with just the header."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "empty-deck"))
+           (export-file (concat (make-temp-file "gnosis-export-empty-") 
".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file nil)
+            (with-temp-buffer
+              (insert-file-contents export-file)
+              (let ((content (buffer-string)))
+                (should (string-search "#+DECK: empty-deck" content))
+                (should-not (string-search "* Thema" content)))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))
+        (gnosis-test--kill-export-buffer "empty-deck")))))
+
+(ert-deftest gnosis-test-export-excludes-suspended ()
+  "Export without include-suspended skips suspended themata."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "susp-deck"))
+           (_id1 (gnosis-test--add-basic-thema deck-id "Active Q" "A1" '("a")))
+           (_id2 (gnosis-test--add-basic-thema deck-id "Suspended Q" "A2"
+                                               '("s") nil nil 1))
+           (export-file (concat (make-temp-file "gnosis-export-susp-") 
".org")))
+      (unwind-protect
+          (progn
+            ;; Export without suspended
+            (gnosis-export-deck deck-id export-file nil nil)
+            (with-temp-buffer
+              (insert-file-contents export-file)
+              (let ((content (buffer-string)))
+                (should (string-search "#+THEMATA: 1" content))
+                (should (string-search "Active Q" content))
+                (should-not (string-search "Suspended Q" content)))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))
+        (gnosis-test--kill-export-buffer "susp-deck")))))
+
+(ert-deftest gnosis-test-export-includes-suspended ()
+  "Export with include-suspended includes all themata."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "susp-deck2"))
+           (_id1 (gnosis-test--add-basic-thema deck-id "Active Q" "A1" '("a")))
+           (_id2 (gnosis-test--add-basic-thema deck-id "Suspended Q" "A2"
+                                               '("s") nil nil 1))
+           (export-file (concat (make-temp-file "gnosis-export-susp2-") 
".org")))
+      (unwind-protect
+          (progn
+            ;; Export with suspended
+            (gnosis-export-deck deck-id export-file nil t)
+            (with-temp-buffer
+              (insert-file-contents export-file)
+              (let ((content (buffer-string)))
+                (should (string-search "#+THEMATA: 2" content))
+                (should (string-search "Active Q" content))
+                (should (string-search "Suspended Q" content)))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))
+        (gnosis-test--kill-export-buffer "susp-deck2")))))
+
+;; ──────────────────────────────────────────────────────────
+;; Import tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-import-creates-deck ()
+  "Importing a file creates the deck if it doesn't exist."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "import-deck"))
+           (_id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1" '("tag1")))
+           (export-file (concat (make-temp-file "gnosis-import-") ".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file t)
+            (gnosis-test--kill-export-buffer "import-deck")
+            ;; Import into a fresh DB to check deck creation
+            (let* ((db-file2 (make-temp-file "gnosis-test2-" nil ".db"))
+                   (gnosis-db (emacsql-sqlite-open db-file2))
+                   (gnosis--id-cache nil))
+              (unwind-protect
+                  (progn
+                    (emacsql-with-transaction gnosis-db
+                      (pcase-dolist (`(,table ,schema) gnosis-db--schemata)
+                        (emacsql gnosis-db [:create-table $i1 $S2] table 
schema)))
+                    ;; Deck should not exist yet
+                    (should-not (gnosis-get 'id 'decks '(= name 
"import-deck")))
+                    (gnosis-import-deck export-file)
+                    ;; Deck should now exist
+                    (should (gnosis-get 'id 'decks '(= name "import-deck")))
+                    ;; Thema should exist
+                    (should (= 1 (length (gnosis-select 'id 'themata nil t)))))
+                (emacsql-close gnosis-db)
+                (delete-file db-file2))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-roundtrip ()
+  "Export then import: thema count and content survive the roundtrip."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "roundtrip"))
+           (_id1 (gnosis-test--add-basic-thema deck-id "What is Emacs?" "A 
text editor"
+                                               '("emacs" "editor")))
+           (_id2 (gnosis-test--add-basic-thema deck-id "What is Lisp?" "A 
language"
+                                               '("lisp") "See SICP"))
+           (_id3 (gnosis-test--add-basic-thema deck-id "What is org?" "A mode"
+                                               '("org")))
+           (export-file (concat (make-temp-file "gnosis-roundtrip-") ".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file t)
+            (gnosis-test--kill-export-buffer "roundtrip")
+            ;; Import into a fresh DB
+            (let* ((db-file2 (make-temp-file "gnosis-rt2-" nil ".db"))
+                   (gnosis-db (emacsql-sqlite-open db-file2))
+                   (gnosis--id-cache nil))
+              (unwind-protect
+                  (progn
+                    (emacsql-with-transaction gnosis-db
+                      (pcase-dolist (`(,table ,schema) gnosis-db--schemata)
+                        (emacsql gnosis-db [:create-table $i1 $S2] table 
schema)))
+                    (gnosis-import-deck export-file)
+                    ;; 3 themata imported
+                    (should (= 3 (length (gnosis-select 'id 'themata nil t))))
+                    ;; Content preserved
+                    (let ((all-keimenon (gnosis-select 'keimenon 'themata nil 
t)))
+                      (should (member "What is Emacs?" all-keimenon))
+                      (should (member "What is Lisp?" all-keimenon))
+                      (should (member "What is org?" all-keimenon)))
+                    ;; Parathema preserved
+                    (let* ((thema-id (gnosis-get 'id 'themata
+                                                 '(= keimenon "What is 
Lisp?")))
+                           (parathema (gnosis-get 'parathema 'extras
+                                                  `(= id ,thema-id))))
+                      (should (string-search "See SICP" parathema)))
+                    ;; Tags preserved
+                    (let* ((thema-id (gnosis-get 'id 'themata
+                                                 '(= keimenon "What is 
Emacs?")))
+                           (tags (gnosis-get 'tags 'themata `(= id 
,thema-id))))
+                      (should (member "emacs" tags))
+                      (should (member "editor" tags))))
+                (emacsql-close gnosis-db)
+                (delete-file db-file2))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-updates-existing-thema ()
+  "Importing with existing IDs updates themata rather than duplicating."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "update-deck"))
+           (id1 (gnosis-test--add-basic-thema deck-id "Old question" "Old 
answer"
+                                              '("old")))
+           (export-file (concat (make-temp-file "gnosis-update-") ".org")))
+      (unwind-protect
+          (progn
+            ;; Export with real IDs (not NEW)
+            (gnosis-export-deck deck-id export-file nil)
+            (gnosis-test--kill-export-buffer "update-deck")
+            ;; Modify the exported file: change the answer
+            (with-temp-file export-file
+              (insert-file-contents export-file)
+              (goto-char (point-min))
+              (when (search-forward "Old answer" nil t)
+                (replace-match "New answer")))
+            ;; Import back — should update, not duplicate
+            (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) t)))
+              (gnosis-import-deck export-file))
+            ;; Still just 1 thema
+            (should (= 1 (length (gnosis-select 'id 'themata nil t))))
+            ;; Answer updated
+            (let ((answer (gnosis-get 'answer 'themata `(= id ,id1))))
+              (should (member "New answer" answer))))
+        (when (file-exists-p export-file)
+          (delete-file export-file))))))
+
+(ert-deftest gnosis-test-import-confirms-existing-deck ()
+  "Importing into an existing deck prompts; declining aborts."
+  (gnosis-test-with-db
+    (let* ((deck-id (gnosis-test--add-deck "confirm-deck"))
+           (_id1 (gnosis-test--add-basic-thema deck-id "Q1" "A1"))
+           (export-file (concat (make-temp-file "gnosis-confirm-") ".org")))
+      (unwind-protect
+          (progn
+            (gnosis-export-deck deck-id export-file nil)
+            (gnosis-test--kill-export-buffer "confirm-deck")
+            ;; Decline the prompt — should abort
+            (cl-letf (((symbol-function 'y-or-n-p) (lambda (_prompt) nil)))
+              (should-error (gnosis-import-deck export-file)
+                            :type 'user-error)))
+        (when (file-exists-p export-file)
+          (delete-file export-file))))))
+
+;; ──────────────────────────────────────────────────────────
+;; ID cache tests
+;; ──────────────────────────────────────────────────────────
+
+(ert-deftest gnosis-test-id-cache-generates-unique ()
+  "gnosis-generate-id with cache produces unique IDs and registers them."
+  (let ((gnosis--id-cache (make-hash-table :test 'equal)))
+    ;; Seed cache with one known ID
+    (puthash 12345678901 t gnosis--id-cache)
+    (let ((new-id (gnosis-generate-id 11)))
+      ;; Should not collide
+      (should-not (= new-id 12345678901))
+      ;; Should be registered in cache
+      (should (gethash new-id gnosis--id-cache)))))
+
+(ert-deftest gnosis-test-id-cache-no-db-query ()
+  "gnosis-generate-id with cache works without a DB connection."
+  (let ((gnosis--id-cache (make-hash-table :test 'equal))
+        (gnosis-db nil))
+    ;; Should not error — cache means no DB query needed
+    (let ((id (gnosis-generate-id)))
+      (should (integerp id))
+      (should (gethash id gnosis--id-cache)))))
+
+(ert-run-tests-batch-and-exit)

Reply via email to