;;; research-shelf.el --- SQLite-backed research library -*- lexical-binding: t; -*- ;;; Commentary: ;; A useful ETAF application: a small reading/research shelf whose records ;; survive unmounts because the Data Controller is backed by etaf-sqlite. ;;; Code: (require 'cl-lib) (require 'subr-x) (require 'etaf) (require 'etaf-ui) (require 'etaf-sqlite) (require 'etaf-playground) (defcustom etaf-research-shelf-database-file (expand-file-name "research-shelf.sqlite" user-emacs-directory) "SQLite file used by the Research Shelf application." :type 'file :group 'etaf) (defconst etaf-research-shelf-seed-records '((:id 1 :title "The Shape of Tools" :author "M. Abramson" :kind "Essay" :status "reading" :progress 64 :priority "High" :starred 1 :note "The best tools make attention feel larger, not more fragmented." :updated "Today") (:id 2 :title "Ways of Seeing" :author "John Berger" :kind "Book" :status "unread" :progress 0 :priority "Medium" :starred 0 :note "Look again at the relationship between image and power." :updated "Yesterday") (:id 3 :title "The Cathedral and the Bazaar" :author "Eric S. Raymond" :kind "Paper" :status "finished" :progress 100 :priority "Low" :starred 1 :note "A useful contrast between closed and open production." :updated "Mon") (:id 4 :title "Designing for Calm" :author "A. Ito" :kind "Notes" :status "reading" :progress 32 :priority "High" :starred 0 :note "Reduce decisions before asking for attention." :updated "Sun") (:id 5 :title "The Craftsman" :author "Richard Sennett" :kind "Book" :status "unread" :progress 0 :priority "Medium" :starred 1 :note "A long-form companion for making and revising." :updated "Sat") (:id 6 :title "How Buildings Learn" :author "Stewart Brand" :kind "Book" :status "reading" :progress 78 :priority "Medium" :starred 0 :note "Change is a property of useful systems." :updated "Fri") (:id 7 :title "The Interface Is the Message" :author "N. Hara" :kind "Essay" :status "finished" :progress 100 :priority "Low" :starred 0 :note "A short note on visible constraints." :updated "Thu") (:id 8 :title "Notes on Attention" :author "Simone Weil" :kind "Notes" :status "unread" :progress 0 :priority "High" :starred 1 :note "Attention is not force; it is patient availability." :updated "Wed")) "Seed records installed into a new Research Shelf database once.") (defun etaf-research-shelf--static-child (form tag) "Return the first child named TAG from static FORM." (cl-find-if (lambda (entry) (and (consp entry) (eq (car entry) tag))) (cdr form))) (defun etaf-research-shelf--static-value (form key default) "Return KEY from static FORM, or DEFAULT." (or (plist-get (cdr form) key) default)) (defun etaf-research-shelf--database () "Return the configured typed Research Shelf database description." (etaf-sqlite-database etaf-research-shelf-database-file (etaf-sqlite-table 'reading_items (list (etaf-sqlite-column :id "id" :type 'integer :primary t) (etaf-sqlite-column :title "title" :type 'text) (etaf-sqlite-column :author "author" :type 'text) (etaf-sqlite-column :kind "kind" :type 'text) (etaf-sqlite-column :status "status" :type 'text) (etaf-sqlite-column :progress "progress" :type 'integer) (etaf-sqlite-column :priority "priority" :type 'text) (etaf-sqlite-column :starred "starred" :type 'integer) (etaf-sqlite-column :note "note" :type 'text) (etaf-sqlite-column :updated "updated" :type 'text)) :id))) (defun etaf-research-shelf--ensure-database () "Initialize the SQLite database and install seed data when empty." (let* ((database (etaf-research-shelf--database)) (source (etaf-sqlite-source database)) (load (plist-get source :load)) (mutate (plist-get source :mutate))) (etaf-sqlite-initialize database) (when (= 0 (plist-get (funcall load nil 1 1) :total)) (dolist (record etaf-research-shelf-seed-records) (funcall mutate 'insert record))) database)) (defun etaf-research-shelf--create-model () "Create the application model and its SQLite-backed Data Controller." (let* ((database (etaf-research-shelf--ensure-database)) (controller (etaf-data-controller (etaf-sqlite-source database) :page-size 4 :auto-load nil :name 'etaf-research-shelf)) (filter (etaf-ref 'all :name 'research-shelf-filter)) (dark (etaf-ref nil :name 'research-shelf-dark-theme)) (toast (etaf-ref "Ready to read." :name 'research-shelf-toast)) (next-id (etaf-ref 100 :name 'research-shelf-next-id))) (list :database database :database-file etaf-research-shelf-database-file :controller controller :filter filter :dark dark :toast toast :next-id next-id))) (defun etaf-research-shelf--controller (model) "Return MODEL's Data Controller." (plist-get model :controller)) (defun etaf-research-shelf--query (filter) "Translate FILTER into an allowlisted SQLite equality query." (pcase filter ('reading '(:status "reading")) ('unread '(:status "unread")) ('finished '(:status "finished")) ('starred '(:starred 1)) (_ nil))) (defun etaf-research-shelf--load (model &optional message) "Load MODEL and expose a visible success/error MESSAGE." (let ((controller (etaf-research-shelf--controller model))) (condition-case err (progn (etaf-data-load controller) (setf (etaf-value (plist-get model :toast)) (or message "✓ Saved locally"))) (error (setf (etaf-value (plist-get model :toast)) (format "⚠ Storage issue: %s" (error-message-string err))))))) (defun etaf-research-shelf--set-filter (model filter) "Set MODEL FILTER and reload the SQLite-backed list." (let ((controller (etaf-research-shelf--controller model))) (setf (etaf-value (plist-get model :filter)) filter) (etaf-data-set-query controller (etaf-research-shelf--query filter)) (etaf-data-set-page controller 1) (etaf-research-shelf--load model (format "Showing %s" (capitalize (symbol-name filter)))))) (defun etaf-research-shelf--items (model) "Return loaded items for MODEL." (etaf-value (etaf-data-items (etaf-research-shelf--controller model)))) (defun etaf-research-shelf--selected (model) "Return the selected item in MODEL, or nil." (let* ((controller (etaf-research-shelf--controller model)) (id (car (etaf-value (etaf-data-selection controller))))) (cl-find id (etaf-research-shelf--items model) :key (lambda (row) (plist-get row :id)) :test #'equal))) (defun etaf-research-shelf--mutate (model operation payload message) "Apply SQLite OPERATION PAYLOAD for MODEL. Show MESSAGE on success, or preserve the resulting error state." (let ((controller (etaf-research-shelf--controller model))) (condition-case err (progn (etaf-data-mutate controller operation payload) (setf (etaf-value (plist-get model :toast)) message)) (error (setf (etaf-value (plist-get model :toast)) (format "⚠ Could not save: %s" (error-message-string err))))))) (defun etaf-research-shelf--update-selected (model fields message) "Update selected MODEL record with FIELDS and show MESSAGE." (when-let ((row (etaf-research-shelf--selected model))) (etaf-research-shelf--mutate model 'update (append (list :id (plist-get row :id)) fields) message))) (defun etaf-research-shelf--theme (model) "Return MODEL's current palette." (if (etaf-value (plist-get model :dark)) '(:ink "#F4F7FF" :paper "#111827" :panel "#182235" :line "#34435A" :muted "#AAB5C8" :accent "#8EA7FF" :mint "#76D6C3" :coral "#FF9A8E" :amber "#F0C36A" :soft "#202C42") '(:ink "#172033" :paper "#F7F3EA" :panel "#FFFDF8" :line "#D9D4C9" :muted "#6D7482" :accent "#3657D6" :mint "#3E9B8F" :coral "#D86B5D" :amber "#C58A3A" :soft "#F1EEE7"))) (defun etaf-research-shelf--color (model key) "Return palette KEY for MODEL." (plist-get (etaf-research-shelf--theme model) key)) (defun etaf-research-shelf--status-label (status) "Return a readable status label for STATUS." (pcase status ("reading" "◷ In progress") ("finished" "✓ Finished") ("archived" "↗ Archived") (_ "○ Unread"))) (defun etaf-research-shelf--filter-label (model form key fallback) "Return MODEL's active-aware label for KEY from static FILTER FORM." (let* ((entry (cl-find-if (lambda (item) (and (consp item) (eq (car item) 'filter) (eq (plist-get (cdr item) :key) key))) (cdr form))) (label (or (and entry (plist-get (cdr entry) :label)) fallback))) (if (eq key (etaf-value (plist-get model :filter))) (concat "● " label) label))) (defun etaf-research-shelf--button-style (palette variant disabled) "Return theme-aware style props for a Research Shelf action button. The public `button' Component owns interaction and pressed state; this small adapter only maps the app palette to its ordinary presentation props so ghost buttons do not fall back to a light-theme paper surface in dark mode." (let ((secondary (eq variant 'secondary))) (list :color (plist-get palette (if disabled :muted (if secondary :paper :ink))) :bgcolor (plist-get palette (if disabled :soft (if secondary :mint :panel))) :border (plist-get palette (if secondary :mint :line)) :face (if (and secondary (not disabled)) 'bold 'normal)))) (etaf-define-component etaf-research-shelf-button (&key theme label ref variant disabled on-press) "Compose the public Button with the Research Shelf theme palette." :setup (lambda () (let* ((theme (etaf-current-prop :theme)) (label (etaf-current-prop :label)) (ref (etaf-current-prop :ref)) (variant (etaf-current-prop :variant)) (disabled (etaf-current-prop :disabled)) (on-press (etaf-current-prop :on-press)) (style (etaf-research-shelf--button-style theme variant disabled))) (etaf-view (button :label label :ref ref :variant variant :disabled disabled :on-press on-press :color (plist-get style :color) :bgcolor (plist-get style :bgcolor) :border (plist-get style :border) :face (plist-get style :face)))))) (defun etaf-research-shelf--filter-view (model form) "Return the filter rail from MODEL and static FILTER FORM." (etaf-view (column :class "research-shelf-filter-rail" :width 'stretch :min-width 0 :padding '(1 1) :border (etaf-research-shelf--color model :line) :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel) (label :text "Library" :face 'bold :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel)) (label :text "VIEWS" :color (etaf-research-shelf--color model :muted) :bgcolor (etaf-research-shelf--color model :panel)) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label (etaf-research-shelf--filter-label model form 'all "All") :ref 'research-shelf-filter-all :variant (if (eq (etaf-value (plist-get model :filter)) 'all) 'secondary 'ghost) :on-press (lambda () (etaf-research-shelf--set-filter model 'all))) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label (etaf-research-shelf--filter-label model form 'reading "In progress") :ref 'research-shelf-filter-reading :variant (if (eq (etaf-value (plist-get model :filter)) 'reading) 'secondary 'ghost) :on-press (lambda () (etaf-research-shelf--set-filter model 'reading))) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label (etaf-research-shelf--filter-label model form 'unread "Unread") :ref 'research-shelf-filter-unread :variant (if (eq (etaf-value (plist-get model :filter)) 'unread) 'secondary 'ghost) :on-press (lambda () (etaf-research-shelf--set-filter model 'unread))) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label (etaf-research-shelf--filter-label model form 'finished "Finished") :ref 'research-shelf-filter-finished :variant (if (eq (etaf-value (plist-get model :filter)) 'finished) 'secondary 'ghost) :on-press (lambda () (etaf-research-shelf--set-filter model 'finished))) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label (etaf-research-shelf--filter-label model form 'starred "★ Starred") :ref 'research-shelf-filter-starred :variant (if (eq (etaf-value (plist-get model :filter)) 'starred) 'secondary 'ghost) :on-press (lambda () (etaf-research-shelf--set-filter model 'starred))) (spacer :height 1) (label :text "STORAGE" :color (etaf-research-shelf--color model :muted) :bgcolor (etaf-research-shelf--color model :panel)) (label :text (file-name-nondirectory (plist-get model :database-file)) :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel)) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label "↻ Reload library" :ref 'research-shelf-reload :variant 'ghost :on-press (lambda () (etaf-research-shelf--load model "✓ Library reloaded")))))) (defun etaf-research-shelf--row-press (model row) "Select ROW as the single current record in MODEL." (etaf-data-select-one (etaf-research-shelf--controller model) (plist-get row :id))) (defun etaf-research-shelf--reading-list-view (model form) "Return the main reading list for MODEL and static MAIN FORM." (let ((controller (etaf-research-shelf--controller model))) (etaf-view (column :class "research-shelf-list" :width 'stretch :min-width 0 :padding '(1 2) :border (etaf-research-shelf--color model :line) :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel) (flex :width 'stretch :align-items 'center :gap '(1 (8)) (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 (label :text (etaf-research-shelf--static-value form :title "Reading queue") :face 'bold :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel)) (label :text (format "%d items · SQLite-backed" (or (etaf-value (etaf-data-total controller)) 0)) :color (etaf-research-shelf--color model :muted) :bgcolor (etaf-research-shelf--color model :panel))) (etaf-research-shelf-button :theme (etaf-research-shelf--theme model) :label "+ Add reading" :ref 'research-shelf-add :variant 'secondary :on-press (lambda () (let ((id (etaf-value (plist-get model :next-id)))) (etaf-research-shelf--mutate model 'insert (list :id id :title (format "New reading %d" id) :author "You" :kind "Notes" :status "unread" :progress 0 :priority "Medium" :starred 0 :note "A new note waiting for your attention." :updated "Just now") "✓ Added to your shelf") (setf (etaf-value (plist-get model :next-id)) (1+ id)))))) (data-grid :controller controller :columns '((:key :title :label "Title" :width 22) (:key :author :label "Author" :width 12) (:key :status :label "Status" :width 11) (:key :progress :label "Progress" :width 9) (:key :kind :label "Kind" :width 8)) :row-key (lambda (row) (plist-get row :id)) :row-ref (lambda (row) (intern (format "research-shelf-row-%s" (plist-get row :id)))) :row-selected-p (lambda (row) (etaf-data-selected-p controller (plist-get row :id))) :on-row-press (lambda (row) (etaf-research-shelf--row-press model row)) :loading-label "◷ Loading library…" :error-label "⚠ Could not read the shelf. Use Reload.") (etaf-pagination :controller controller :previous-ref 'research-shelf-page-previous :next-ref 'research-shelf-page-next :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel)))))) (defun etaf-research-shelf--detail-view (model form) "Return the selected record inspector for MODEL and static FORM." (etaf-view (column :class "research-shelf-detail" :width 'stretch :min-width 0 :padding '(2 2) :border (etaf-research-shelf--color model :line) :color (etaf-research-shelf--color model :ink) :bgcolor (etaf-research-shelf--color model :panel) (label :text (etaf-research-shelf--static-value form :title "Selected item") :color (etaf-research-shelf--color model :accent) :bgcolor (etaf-research-shelf--color model :panel)) (text :face 'bold (expr :value (if-let ((row (etaf-research-shelf--selected model))) (plist-get row :title) "Choose a record"))) (text :color (etaf-research-shelf--color model :muted) (expr :value (if-let ((row (etaf-research-shelf--selected model))) (format "%s · %s" (plist-get row :author) (plist-get row :kind)) "Select one row to inspect it."))) (spacer :height 1) (text :face 'bold (expr :value (if-let ((row (etaf-research-shelf--selected model))) (etaf-research-shelf--status-label (plist-get row :status)) "○ No selection"))) (text (expr :value (if-let ((row (etaf-research-shelf--selected model))) (format "Progress %d%% · Priority %s" (or (plist-get row :progress) 0) (plist-get row :priority)) "Progress — · Priority —"))) (text :color (etaf-research-shelf--color model :accent) (expr :value (let* ((row (etaf-research-shelf--selected model)) (progress (or (and row (plist-get row :progress)) 0)) (filled (max 0 (min 10 (/ progress 10))))) (format "%s %s" (make-string filled ?█) (make-string (- 10 filled) ?░))))) (spacer :height 1) (text :color (etaf-research-shelf--color model :muted) (expr :value (if-let ((row (etaf-research-shelf--selected model))) (concat "“" (or (plist-get row :note) "No note yet.") "”") "Your notes and actions will appear here."))) (spacer :height 1) (etaf-research-shelf-detail-actions :model model)))) (etaf-define-component etaf-research-shelf-detail-actions (&key model) "Render stable selected-record actions for MODEL." :setup (let ((model (etaf-current-prop :model))) (lambda () (let* ((theme (etaf-research-shelf--theme model)) (row (etaf-research-shelf--selected model)) (progress (or (and row (plist-get row :progress)) 0)) (finished (and row (equal (plist-get row :status) "finished"))) (archived (and row (equal (plist-get row :status) "archived")))) (etaf-view (flex :width 'stretch :flex-wrap 'wrap :gap '(1 (6)) (etaf-research-shelf-button :theme theme :label "+ 10%" :ref 'research-shelf-progress :variant 'secondary :disabled (or (null row) archived (>= progress 100)) :on-press (lambda () (when-let* ((current (etaf-research-shelf--selected model)) (value (or (plist-get current :progress) 0))) (etaf-research-shelf--update-selected model (list :progress (min 100 (+ value 10)) :status (if (>= (+ value 10) 100) "finished" "reading") :updated "Just now") "✓ Progress saved")))) (etaf-research-shelf-button :theme theme :label "✓ Finish" :ref 'research-shelf-finish :variant 'secondary :disabled (or (null row) finished archived) :on-press (lambda () (etaf-research-shelf--update-selected model '(:progress 100 :status "finished" :updated "Just now") "✓ Marked finished"))) (etaf-research-shelf-button :theme theme :label "★ Star" :ref 'research-shelf-star :variant 'ghost :disabled (null row) :on-press (lambda () (when-let ((current (etaf-research-shelf--selected model))) (let ((current-starred (= 1 (or (plist-get current :starred) 0)))) (etaf-research-shelf--update-selected model (list :starred (if current-starred 0 1) :updated "Just now") (if current-starred "Removed star" "★ Starred locally")))))) (etaf-research-shelf-button :theme theme :label "Archive" :ref 'research-shelf-archive :variant 'ghost :disabled (or (null row) archived) :on-press (lambda () (etaf-research-shelf--update-selected model '(:status "archived" :updated "Just now") "↗ Archived"))))))))) (etaf-define-component etaf-research-shelf-filter-rail (&key model static-form) "Render the reusable Research Shelf filter rail. The rail is a Component boundary so its button Components remain ordinary structural children instead of leaking through a direct material `expr'." :setup (let ((model (etaf-current-prop :model)) (static-form (etaf-current-prop :static-form))) (lambda () (etaf-research-shelf--filter-view model static-form)))) (etaf-define-component etaf-research-shelf-reading-list (&key model static-form) "Render the reusable Research Shelf reading list." :setup (let ((model (etaf-current-prop :model)) (static-form (etaf-current-prop :static-form))) (lambda () (etaf-research-shelf--reading-list-view model static-form)))) (etaf-define-component etaf-research-shelf-detail-inspector (&key model static-form) "Render the reusable Research Shelf detail inspector." :setup (let ((model (etaf-current-prop :model)) (static-form (etaf-current-prop :static-form))) (lambda () (etaf-research-shelf--detail-view model static-form)))) (etaf-define-component etaf-research-shelf-shell (&key model static-form) "Compose the SQLite-backed Research Shelf application." :styles (styles (".research-shelf-shell" :width stretch) (".research-shelf-header" :width stretch :padding (1 0)) (".research-shelf-filter-rail" :width stretch) (".research-shelf-list" :width stretch) (".research-shelf-detail" :width stretch) (".research-shelf-footer" :width stretch :padding (0 1))) :setup (let ((model (etaf-current-prop :model)) (static-form (etaf-current-prop :static-form))) (etaf-on-unmounted (lambda () (etaf-data-stop (etaf-research-shelf--controller model)))) (lambda () (let* ((header (etaf-research-shelf--static-child static-form 'header)) (filters (etaf-research-shelf--static-child static-form 'filters)) (main (etaf-research-shelf--static-child static-form 'main)) (library (etaf-research-shelf--static-child main 'library)) (detail (etaf-research-shelf--static-child main 'detail)) (dark (etaf-value (plist-get model :dark))) (palette (etaf-research-shelf--theme model))) (etaf-view (column :class "research-shelf-shell" :width '(viewport) :height '(viewport-height) :color (plist-get palette :ink) :bgcolor (plist-get palette :paper) (flex :class "research-shelf-header" :width 'stretch :align-items 'center :gap '(1 (10)) (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 (label :text (etaf-research-shelf--static-value header :eyebrow "Research Shelf") :color (plist-get palette :accent) :bgcolor (plist-get palette :paper)) (label :text (etaf-research-shelf--static-value static-form :title "Research Shelf") :face 'bold :color (plist-get palette :ink) :bgcolor (plist-get palette :paper)) (label :text (etaf-research-shelf--static-value static-form :subtitle "A quiet place for unfinished ideas") :color (plist-get palette :muted) :bgcolor (plist-get palette :paper))) (checkbox :label "Dark" :ref 'research-shelf-theme-toggle :checked dark :on-change (lambda (value) (setf (etaf-value (plist-get model :dark)) value) (setf (etaf-value (plist-get model :toast)) (if value "Dark theme" "Light theme"))))) (grid :class "research-shelf-workspace" :width 'stretch :grid-template-columns '((minmax (0) (fr 2)) (minmax (0) (fr 5)) (minmax (0) (fr 3))) :gap '(1 (6)) :color (plist-get palette :ink) :bgcolor (plist-get palette :paper) (etaf-research-shelf-filter-rail :model model :static-form filters) (etaf-research-shelf-reading-list :model model :static-form library) (etaf-research-shelf-detail-inspector :model model :static-form detail)) (flex :class "research-shelf-footer" :width 'stretch :gap '(1 (8)) (column :flex-grow 1 :flex-shrink 1 :flex-basis '(0) :min-width 0 (label :text (or (etaf-value (plist-get model :toast)) "Ready") :color (plist-get palette :ink) :bgcolor (plist-get palette :paper))) (label :text (file-name-nondirectory (plist-get model :database-file)) :color (plist-get palette :muted) :bgcolor (plist-get palette :paper))))))))) (defun etaf-research-shelf-view (model static-form) "Return the root View for MODEL and validated STATIC-FORM." (etaf-view (research-shelf-shell :model model :static-form static-form))) (defun etaf-research-shelf-root (static-form) "Consume validated STATIC-FORM and return one owned application root View." (unless (eq (car static-form) 'research-shelf-shell) (error "Unsupported Research Shelf static root: %S" static-form)) (let ((model (etaf-research-shelf--create-model))) ;; Load before the first retained generation. A DataGrid that is created ;; empty and populated from an on-mounted callback has no committed ;; material artifact to use as its first incremental anchor. (etaf-research-shelf--load model "✓ SQLite shelf ready") (lambda () (etaf-research-shelf-view model static-form)))) ;;;###autoload (defun etaf-research-shelf-open (&optional buffer-name) "Mount and optionally display the Research Shelf application in BUFFER-NAME." (interactive) (let* ((name (or buffer-name "*ETAF Research Shelf*")) ;; Put the target in its real GUI window before the first Ebox ;; projection so `(viewport)' resolves against the user's frame. (buffer (get-buffer-create name))) (when (called-interactively-p 'interactive) (switch-to-buffer buffer)) (etaf-mount buffer (etaf-research-shelf-root (etaf-playground-read-static "research-shelf"))))) (provide 'etaf-research-shelf) ;;; research-shelf.el ends here