;; SPDX-FileCopyrightText: 2026 Andrew Oberstar <andrew@ajoberstar.org>
;; SPDX-License-Identifier: AGPL-3.0-only

(in-package #:org.ajoberstar.quixote.server)

;;; Base

(defvar *app* (make-instance 'ningle:app))

(defvar *handler* nil)

(defvar *media-dir* nil)

(defun stop ()
  (import:stop)
  (when *handler*
    (format t "Stopping app~%")
    (clack:stop *handler*)))

(defun start (&key (address "127.0.0.1") (port 5000) connect-args data-dir static-dir (output *error-output*))
  (when *handler*
    (stop))

  (unless (probe-file data-dir)
    (error "Data dir must exist: ~A" data-dir))
  (setf *media-dir* (merge-pathnames "media/" data-dir))
  (setf covers:*covers-dir* (merge-pathnames "covers/" *media-dir*))

  (format t "Starting app~%")

  (import:start :media-dir *media-dir*)

  (setq *handler*
        (clack:clackup
         (lack:builder
          :accesslog
          (:backtrace :output output
                      :result-on-error `(500 (:content-type "text/plain") ("Internal Server Error")))
          (:static :path (lambda (path)
                           (if (ppcre:scan "^(?:/img/|/css/|/js/|/robots\\.txt$|/favicon.ico$)" path)
                               path))
                   :root static-dir)
          (:static :path (lambda (path)
                           (if (ppcre:scan "^(?:/inbox/|/staging/|/covers/)" path)
                               path))
                   :root *media-dir*
                   :headers '(:cache-control "max-age=3600"))
          ;; wrap with db connection
          (lambda (app)
            (lambda (env)
              (db:with-connection (connect-args)
                (db:with-transaction
                    (funcall app env)))))
          *app*)
         :server :hunchentoot
         :address address
         :port port
         :debug nil
         :use-default-middlewares nil)))

(defun reset ()
  (stop)
  (setf *app* (make-instance 'ningle:app))
  (setf *handler* nil))

(defun dev ()
  (let* ((sysdir (asdf:system-source-directory "org.ajoberstar.quixote"))
         (connect-args (list :sqlite3
                             :database-name (merge-pathnames "quixote.db" sysdir)
                             :busy-timeout 5000)))
    (start :connect-args connect-args
           :data-dir sysdir
           :static-dir (merge-pathnames "static/" sysdir))
    (db:with-connection (connect-args)
      (db:sql-migrate))))

;;; Helpers

(defun refresh-reading (reading)
  (let* ((status (model:reading-status reading))
         (sessions (db:select-reading-sessions reading))
         (session-ats (mapcar #'model:session-at sessions))
         (started (if sessions
                      (reduce (lambda (a b) (if (dates:date< a b) a b)) session-ats)))
         (ended (if sessions
                    (reduce (lambda (a b) (if (dates:date> a b) a b)) session-ats))))
    (when (and (string-equal "not-started" status)
               started)
      (setf (model:reading-status reading) "in-progress")
      (setf status (model:reading-status reading)))
    (setf (model:reading-started-at reading) started)
    (setf (model:reading-ended-at reading)
          (cond
            ((member status (list "not-started" "in-progress" "paused") :test #'equalp)
             nil)
            ((member status (list "skipped" "did-not-finish" "completed") :test #'equalp)
             ended)
            (t
             (error "Invalid status: ~A" status))))

    (db:update-reading reading)))

(defun refresh-readthrough (readthrough)
  (let* ((status (model:readthrough-status readthrough))
         (sessions (db:select-readthrough-sessions readthrough))
         (session-ats (mapcar #'model:session-at sessions))
         (started (if sessions
                      (reduce (lambda (a b) (if (dates:date< a b) a b)) session-ats)))
         (ended (if sessions
                    (reduce (lambda (a b) (if (dates:date> a b) a b)) session-ats))))
    (when (and (string-equal "not-started" status)
               started)
      (setf (model:readthrough-status readthrough) "in-progress")
      (setf status (model:readthrough-status readthrough)))
    (setf (model:readthrough-started-at readthrough) started)
    (setf (model:readthrough-ended-at readthrough)
          (cond
            ((member status (list "not-started" "in-progress" "paused") :test #'equalp)
             nil)
            ((member status (list "skipped" "did-not-finish" "completed") :test #'equalp)
             ended)
            (t
             (error "Invalid status: ~A" status))))

    (db:update-readthrough readthrough)))

;;; Fragments

(defun cover-img (edition &key (side :front) (link-to :cover))
  (multiple-value-bind (exists-p files)
      (covers:cover-exists-p (covers:make-cover (model:edition-id edition)))
    (labels ((url (side thumb-p)
               (let ((path (cdr (assoc (cons side thumb-p) files :test #'equalp))))
                 (when path
                   (format nil "/~A~@[?t=~A~]"
                           (enough-namestring path *media-dir*)
                           (uiop:safe-file-write-date path))))))
      (let ((link (ecase link-to
                    (:cover (if exists-p
                                (or (url side nil) (url side t))
                                "/img/missing-cover.png"))
                    (:edition (format nil "/editions/~D" (model:edition-id edition))))))
        (with-html
          (:div :class :cover-img
                (:a :href link
                    (:img :src (if exists-p
                                   (url side t)
                                   "/img/missing-cover.png")
                          :title (model:edition-full-title edition)))))))))

;;; Templates

(defmacro with-base-template ((&key title query charts-p wide-p) &body body)
  `(let ((*html-style* :tree)
         (*always-quote* t))
     (with-html-string
       (:doctype)
       (:html
        (:head
         (:meta :name "viewport" :content "width=device-width, initial-scale=1.0")
         (:link :rel "stylesheet" :href "/css/main.css")
         (:script :src "/js/search.js" :defer t)
         (:script (:raw (ps:ps*
                         `(progn
                            (defun use-value (source-id target-id)
                              (let* ((source (ps:chain document (get-element-by-id source-id)))
                                     (target (ps:chain document (get-element-by-id target-id))))
                                (when (and source target)
                                  (setf (ps:@ target value) (ps:chain source text-content (trim))))))))))
         (when ,charts-p
           (:script :src "/js/vendor/chart.js")

           (:script (:raw (ps:ps*
                           `(progn
                              (defun css-var (name)
                                (let* ((style (get-computed-style (ps:chain document document-element)))
                                       (value (ps:chain style (get-property-value name))))
                                  (ps:chain value (trim)))))))))
         (:title ,title))
        (:body :class (if ,wide-p "wide" "")
               (:header
                (:nav
                 (:ul
                  (:li (:a :href "/" "Quixote"))
                  (loop for collection in (db:select-root-collections)
                        do (:li (:a :href (format nil "/collections/~D" (model:collection-id collection))
                                    (model:collection-title collection))))))
                (:form :class "search" :method "get" :action "/search"
                       (:input :type "search" :name "q" :autocomplete "off" :value ,query)
                       (:ul :id "search-results" :hidden t)
                       (:button :type "submit" "Search")))
               (:main
                ,@body)
               (:footer
                (:nav
                 (:ul
                  (:li (:a :href "/" "Home"))
                  (:li (:a :href "/authors" "Authors"))
                  (:li (:a :href "/editions" "Editions"))
                  (:li (:a :href "/collections" "Collections")))
                 (:ul
                  (:li (:a :href "/readthroughs" "Readthroughs"))
                  (:li (:a :href "/readings" "Readings"))
                  (:li (:a :href "/sessions" "Sessions")))
                 (:ul
                  (:li (:a :href "/import" "Import Scans"))))))))))

(defun breadcrumbs-template (content)
  (with-html
    (loop for content in (if (typep content 'model:edition)
                             (db:select-edition-parts content)
                             (list content))
          append (loop for crumb in (or (db:select-collection-breadcrumbs content)
                                        (list nil))
                       for path = (getf crumb :path)
                       for item = (getf crumb :item)
                       do (:p (loop with first = t
                                    for node in (reverse path)
                                    until (= (model:entity-id node)
                                             (model:entity-id content))
                                    if first
                                      do (setf first nil)
                                    else
                                      do (:span "/")
                                    do (:a :href (format nil "/collections/~D" (model:collection-id node))
                                           (model:collection-title node)))
                              (when (and item (model:item-position item))
                                (:span (format nil "#~D" (float (/ (model:item-position item) 10))))))))))

(defun paging-template (url items page page-size)
  (let ((join-char (if (find #\? url :test #'equal) "&" "?"))
        (prev-p (not (= 1 page)))
        (next-p (not (< (length items) page-size))))
    (with-html
      (when (or prev-p next-p)
        (:nav :class "paging"
              (:ul
               (if prev-p
                   (:li (:a :href (format nil "~A~Apage=~D" url join-char (1- page))
                            "← Previous Page"))
                   (:li))
               (if next-p
                   (:li (:a :href (format nil "~A~Apage=~D" url join-char (1+ page))
                            "Next Page →"))
                   (:li))))))))

(defun delete-template (page)
  (with-html
    (:section :class "form"
              (:h3 "Delete")
              (:form :name "delete" :method :post :action page
                     (:label :for "confirm1" "Are you sure?")
                     (:input :type "checkbox" :name "confirm1")
                     (:label :for "confirm2" "Are you really sure?")
                     (:input :type "checkbox" :name "confirm2")
                     (:button :class "delete" :type "submit" "Delete")))))

(defun sessions-template (sessions &key (part-p t))
  (with-html
    (:table :class "sessions"
            (:thead
             (:tr
              (:th "Date")
              (when part-p
                (:th "Edition"))
              (:th :class (when part-p "hideable")
                   "Start")
              (:th :class (when part-p "hideable")
                   "End")
              (:th :class (when (not part-p) "hideable")
                   "Pages")))
            (:tbody
             (loop with prev-session = nil
                   for session in sessions
                   for gap-p = (when (and (not part-p) prev-session)
                                 (not
                                  (= (model:session-page-start prev-session)
                                     (1+ (model:session-page-end session)))))
                   do (:tr
                       (:td (:a :href (format nil "/sessions/~d" (model:session-id session))
                                (write-date (model:session-at session))))
                       (when part-p
                         (:td :class "title"
                              (model:edition-title (model:part-edition (model:reading-part (model:session-reading session))))))
                       (:td :class (when part-p "hideable")
                            (model:session-page-start-str session))
                       (:td :class (serapeum:string-join (list (when gap-p "session-gap")
                                                               (when part-p "hideable"))
                                                         " ")
                            (model:session-page-end-str session))
                       (:td :class (when (not part-p) "hideable")
                            (model:session-pages session)))
                   do (setf prev-session session))))))

(defun reading-template (reading &key (mode :standalone) (detach-p nil))
  (let* ((sessions-p (eql mode :full))
         (part-p (member mode (list :standalone :full)))
         (part (model:reading-part reading))
         (sessions (db:select-reading-sessions reading))
         (goal (model:part-pages part))
         (progress (cond
                     (sessions
                      (reduce #'+ (mapcar #'model:session-pages sessions)))
                     ((string-equal "skipped" (model:reading-status reading))
                      goal)
                     (t
                      0)))
         (next-page-start (if sessions
                              (1+ (apply #'max (mapcar #'model:session-page-end sessions)))
                              (model:part-page-start part)))
         (avg (if (eq 0 (length sessions))
                  0
                  (round (/ (reduce #'+ (mapcar #'model:session-pages sessions))
                            (length sessions)))))
         (page-range (when (model:part-partial-p part)
                       (model:part-page-range part))))
    (with-html
      (:section :class (let ((base (list "reading" (model:reading-status reading))))
                         (serapeum:string-join (if part-p
                                                   (cons "covered" base)
                                                   base)
                                               " "))
                (when part-p
                  (:section :class "cover"
                            (cover-img (model:part-edition part))))
                (:section :class "content"
                          (case mode
                            (:full
                             (:header
                              (:h2 "Reading")
                              (when page-range
                                (:p page-range))))
                            (:standalone
                             (if (model:part-title-p part)
                                 (:header
                                  (:h3 (:a :href (format nil "/readings/~d" (model:reading-id reading))
                                           (model:part-title part)))
                                  (:p "from" (:a :href (format nil "/editions/~d" (model:edition-id (model:part-edition part)))
                                                 (model:edition-title (model:part-edition part)))))
                                 (:header
                                  (:h3
                                   (:a :href (format nil "/readings/~d" (model:reading-id reading))
                                       (model:edition-title (model:part-edition part))))
                                  (when page-range
                                    (:p page-range)))))
                            (:simple
                             (:h3
                              (:a :href (format nil "/readings/~d" (model:reading-id reading))
                                  (if (model:part-title-p part)
                                      (model:part-title part)
                                      (write-date (model:reading-started-at reading))))
                              (when page-range (format nil "(~A)" page-range)))))

                          (:progress :value progress :max goal)

                          (:section :class "details"
                                    (:dl
                                     (:dt "Started")
                                     (:dd (write-date (model:reading-started-at reading)))

                                     (:dt "Ended")
                                     (:dd (write-date (model:reading-ended-at reading)))

                                     (:dt "Progress")
                                     (:dd (format nil "~:d / ~:d pages" progress goal))

                                     (:dt "Percent")
                                     (:dd (format nil "~d%"
                                                  (if (< 0 goal)
                                                      (round (* 100 (/ progress goal)))
                                                      0)))

                                     (:dt "Activity")
                                     (:dd ("~d / ~d days"
                                           (length sessions)
                                           (1+ (dates:date-diff (or (model:reading-ended-at reading)
                                                                    (dates:today))
                                                                (or (model:reading-started-at reading)
                                                                    (dates:today))))))

                                     (:dt "Remaining")
                                     (:dd (if (and (null (model:reading-ended-at reading))
                                                   (< 0 goal)
                                                   (< 0 avg))
                                              (format nil " ~d days"
                                                      (round (/ (- goal progress) avg)))
                                              (:em (model:reading-status-friendly reading))))

                                     (:dt "Pace")
                                     (:dd (format nil "~:d pages / day" avg))))

                          (when (and detach-p (model:reading-readthrough reading))
                            (:section :class "form"
                                      (:form :method "post" :action (format nil "/readings/~D" (model:reading-id reading))
                                             (:input :type "hidden" :name "readthrough_id" :value "")
                                             (:button :class "delete" :type "submit" "Detach"))))))

      (when sessions-p
        (:section :class "sessions"
                  (:h2 "Sessions")
                  (sessions-template sessions :part-p nil)))

      (when (and sessions-p (null (model:reading-ended-at reading)))
        (let ((current-session (first sessions)))
          (if (and current-session
                   (dates:date= (dates:today) (model:session-at current-session)))
              (:section :class "form"
                        (:h3 "Update Today's Session")
                        (:form :name "session" :method "post" :action (format nil "/sessions/~d" (model:session-id current-session))
                               (:input :type "hidden" :name "at" :value (write-date (model:session-at current-session)))
                               (:label :for "page_start" "Start Page")
                               (:input :type "number" :name "page_start" :required t :min 1 :value (model:session-page-start current-session))
                               (:label :for "page_end" "End Page")
                               (:input :type "number" :name "page_end" :required t :min 1 :value (model:session-page-end current-session))
                               (:button :type "submit" "Submit")))
              (:section :class "form"
                        (:h3 "Add Session")
                        (:form :name "session" :method :post :action "/sessions"
                               (:input :type "hidden" :name "reading_id" :value (model:reading-id reading))
                               (:label :for "page_start" "Start Page")
                               (:input :type "number" :name "page_start" :required t :min 1 :value next-page-start)
                               (:label :for "page_end" "End Page")
                               (:input :type "number" :name "page_end")
                               (:button :type "submit" "Submit")))))))))

(defun readthrough-template (readthrough &key (mode :standalone))
  (let* ((readings-p (eql mode :full))
         (part-p (member mode (list :standalone :full)))
         (collection (model:readthrough-collection readthrough))
         (tree (db:select-collection-tree collection))
         (part-ids (model:tree-map-parts tree #'model:part-id))
         (readings (db:select-readthrough-readings readthrough))
         (sessions (db:select-readthrough-sessions readthrough))
         (day-pages (stats:daily-pages sessions))
         (days (hash-table-count day-pages))
         (progress (+ (reduce #'+ (mapcar #'model:session-pages sessions))
                      ;; Count skipped pages as "done" for progress purposes
                      (reduce #'+ (mapcar (lambda (reading)
                                            (model:part-pages (model:reading-part reading)))
                                          (remove-if-not (lambda (reading)
                                                           (string-equal "skipped" (model:reading-status reading)))
                                                         readings)))))
         (goal (reduce #'+ (model:tree-map-parts tree #'model:part-pages)))
         (avg (if (eq 0 days)
                  0
                  (round (/ (loop for pages being the hash-values of day-pages
                                  sum pages)
                            days))))
         (part (loop with best = nil
                     for reading in readings
                     when (or (not best)
                              ;; prefer active readings
                              (and (model:reading-active-p reading)
                                   (not (model:reading-active-p best)))
                              ;; prefer later in coll between inactive readings
                              (and (not (model:reading-active-p reading))
                                   (not (model:reading-active-p best))
                                   (< (position (model:part-id (model:reading-part best)) part-ids)
                                      (position (model:part-id (model:reading-part reading)) part-ids))))
                       do (setf best reading)
                     finally (return (when best (model:reading-part best))))))

    (with-html
      (:section :class (let ((base (list "readthrough" (model:readthrough-status readthrough))))
                         (serapeum:string-join (if part-p
                                                   (cons "covered" base)
                                                   base)
                                               " "))
                (when (and part-p part)
                  (:section :class "cover"
                            (cover-img (model:part-edition part))))

                (:section :class "content"

                          (case mode
                            (:full
                             (:header
                              (:h2 "Readthrough")))
                            (:standalone
                             (:header
                              (:h3 (:a :href (format nil "/readthroughs/~D" (model:readthrough-id readthrough))
                                       (model:collection-title collection)))))
                            (:simple
                             (:header
                              (:h3 (:a :href (format nil "/readthroughs/~D" (model:readthrough-id readthrough))
                                       (write-date (model:readthrough-started-at readthrough)))))))
                          (:progress :value progress :max goal)
                          (:section :class "details"
                                    (:dl
                                     (:dt "Started")
                                     (:dd (write-date (model:readthrough-started-at readthrough)))

                                     (:dt "Ended")
                                     (:dd (write-date (model:readthrough-ended-at readthrough)))

                                     (:dt "Progress")
                                     (:dd (format nil "~:d / ~:d pages" progress goal))

                                     (:dt "Percent")
                                     (:dd (format nil "~D%"
                                                  (if (< 0 goal)
                                                      (round (* 100 (/ progress goal)))
                                                      0)))

                                     (:dt "Activity")
                                     (:dd (format nil "~D / ~D days"
                                                  days
                                                  (1+ (dates:date-diff (or (model:readthrough-ended-at readthrough)
                                                                           (dates:today))
                                                                       (or (model:readthrough-started-at readthrough)
                                                                           (dates:today))))))

                                     (:dt "Remaining")
                                     (:dd (if (and (null (model:readthrough-ended-at readthrough))
                                                   (< 0 goal)
                                                   (< 0 avg))
                                              (format nil "~D days"
                                                      (round (/ (- goal progress) avg)))
                                              (:em (model:readthrough-status-friendly readthrough))))

                                     (:dt "Pace")
                                     (:dd (format nil "~D pages / day" avg))))))

      (when readings-p
        (:section :class "readings"
                  (:h2 "Readings")
                  (labels ((recurse-tree (node)
                             (with-html
                               (if (typep node 'model:item)
                                   (let ((part (model:item-item node))
                                         (reading (find-if (lambda (r)
                                                             (eql (model:part-id (model:reading-part r))
                                                                  (model:part-id node)))
                                                           readings)))
                                     (if reading
                                         (reading-template reading :detach-p t)
                                         (:section :class "reading covered"
                                                   (:section :class "cover"
                                                             (cover-img (model:part-edition part)))
                                                   (:section :class "content"
                                                             (:header
                                                              (:h3 (model:part-title part)))
                                                             (:section :class "form"
                                                                       (:form :method "post" :action "/readings"
                                                                              (:input :type "hidden" :name "readthrough_id" :value (model:readthrough-id readthrough))
                                                                              (:input :type "hidden" :name "part_id" :value (model:part-id part))
                                                                              (:button :type "submit" "Start Reading")
                                                                              (:button :class "delete" :type "submit" :name "status" :value "skipped" "Skip Reading")))
                                                             (let ((orphans (db:select-part-orphan-readings part)))
                                                               (when orphans
                                                                 (:section :class "form"
                                                                           (:form :method "post" :action (format nil "/readthroughs/~D/attach"
                                                                                                                 (model:readthrough-id readthrough))
                                                                                  (:select :name "reading_id"
                                                                                    (loop for reading in orphans
                                                                                          do (:option :value (model:reading-id reading)
                                                                                                      (format nil "~A (~A)"
                                                                                                              (write-date (model:reading-started-at reading))
                                                                                                              (model:reading-status reading)))))
                                                                                  (:button :type "submit" "Attach Reading")))))))))
                                   (:section :class "node"
                                             (when (not (eql (model:collection-id collection)
                                                             (model:collection-id (model:tree-collection node))))
                                               ;; FIXME different header levels with depth?
                                               (:h3 (model:collection-title (model:tree-collection node))))
                                             (loop for item in (model:tree-children node)
                                                   do (recurse-tree item)))))))
                    (recurse-tree tree)))))))

(defun editions-template (editions &key header-p)
  (with-html
    (:section
     (when header-p
       (:h2 "Editions"))
     (:section :class "editions"
               (loop for edition in editions
                     do (:section :class "edition"
                                  (cover-img edition :link-to :edition)
                                  (:p
                                   (:a :href (format nil "/editions/~D" (model:edition-id edition))
                                       (model:edition-full-title edition)))))))))

(defun author-template (author &key (title "Author"))
  (with-html
    (:section :class "author"
              (:h2 title)
              (:dl
               (:dt "Birth")
               (:dd (write-human-date (model:author-birth author)))

               (:dt "Death")
               (:dd (write-human-date (model:author-death author)))

               (when (model:author-bookbrainz-id author)
                 (:dt "BookBrainz ID")
                 (:dd (:a :href (format nil "https://bookbrainz.org/author/~A" (model:author-bookbrainz-id author))
                          (format nil "~A..."
                                  (subseq (model:author-bookbrainz-id author) 0 8)))))

               (when (model:author-openlibrary-id author)
                 (:dt "OpenLibrary ID")
                 (:dd (:a :href (format nil "https://openlibrary.org/authors/~A" (model:author-openlibrary-id author))
                          (model:author-openlibrary-id author))))

               (when (model:author-wikidata-id author)
                 (:dt "Wikidata ID")
                 (:dd (:a :href (format nil "https://www.wikidata.org/wiki/~A" (model:author-wikidata-id author))
                          (model:author-wikidata-id author))))))))

(defun merge-row-template (name label left right reader)
  (with-html
    (:tr
     (:td (:label :for name label))
     (:td :class "left side"
          (:div
           (:span :id (format nil "left-~A" name)
                  (funcall reader left))
           (:button :type "button" :onclick (format nil "useValue('left-~A', 'merge-~A')" name name)
                    "→")))
     (:td (:input :type "text"
                  :id (format nil "merge-~A" name)
                  :name name
                  :value (or (funcall reader left)
                             (funcall reader right))))
     (:td :class "right side"
          (:div
           (:button :type "button" :onclick (format nil "useValue('right-~A', 'merge-~A')" name name)
                    "←")
           (:span :id (format nil "right-~A" name)
                  (funcall reader right)))))))

(defun week-template (activity week)
  (let ((week-data (find-if (lambda (x) (dates:date= week (getf x :week)))
                            activity)))
    (with-html
      (:tr :class (if (dates:date= week (dates:date-prev (dates:today) :monday))
                      (format nil "~A current" (getf week-data :tier))
                      (getf week-data :tier))
           (:td (dates:date-day-name week))
           (:td (getf week-data :days))
           (:td (getf week-data :pages))))))

(defun month-template (activity month)
  (with-html
    (:section :class "month"
              (:h3 (dates:date-month-name month))
              (:table :class "weeks"
                      (:thead
                       (:th "Week")
                       (:th "Days")
                       (:th "Pages"))
                      (:tbody
                       (loop for week = (if (eq :monday (dates:date-dow month))
                                            month
                                            (dates:date-next month :monday))
                               then (dates:date-next week :monday)
                             while (= (dates:date-month month) (dates:date-month week))
                             do (week-template activity week)))))))

;;; Helpers

(defun getparam (params key)
  (let ((value (cdr (assoc key params :test #'string-equal))))
    (when value
      (string-trim '(#\Space #\Tab #\Newline #\Linefeed #\Return) value))))

(defun write-date (date &optional (default "..."))
  (if date (dates:write-iso-date nil date) default))

(defun write-human-date (date &optional (default "..."))
  (if date (dates:write-human-date nil date) default))

(defun safe-parse-integer (string &optional default)
  (handler-case (parse-integer string)
    (error (c)
      (declare (ignore c))
      default)))

;;; Routes

(setf (ningle:route *app* "/" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (let ((sessions (db:select-sessions)))
            (with-base-template (:title "Quixote - Home" :charts-p t)
              (:h1 "Quixote")

              (:section :class "readings"
                        (:h2 "Active Readings")
                        (loop for reading in (db:select-active-readings)
                              do (reading-template reading :mode :standalone)))

              (:section :class "readthroughs"
                        (:h2 "Active Readthroughs")
                        (loop for readthrough in (db:select-active-readthroughs)
                              do (readthrough-template readthrough :mode :standalone)))

              (:section
               (:header
                (:h2 "Recent Activity")
                (:p (:a :href "/activity" "All Activity")))
               (:div :class "months"
                     (let* ((months (reverse (loop for x from 1 to 3
                                                   for date = (dates:date-start-of-month
                                                               (dates:date-prev (dates:today) :monday))
                                                     then (dates:date-start-of-month (dates:date- date 1))
                                                   do (dates:write-iso-date t date)
                                                   collect date)))
                            (activity (stats:week-activity sessions)))
                       (loop for month in months
                             do (month-template activity month)))))

              (:section
               (:h2 "Charts")
               (:section :class "chart"
                         (:h3 "Pages per Week")
                         (:canvas :id "week_chart"))
               (:section :class "chart"
                         (:h3 "Pages per Quarter")
                         (:canvas :id "quarter_chart"))
               (:section :class "chart"
                         (:h3 "Pages per Year")
                         (:canvas :id "year_chart")))

              (let* ((year-pages (stats:year-pages sessions))
                     (quarter-start (let ((current-quarter (dates:date-start-of-quarter (dates:today))))
                                      (dates:make-date (- (dates:date-year current-quarter) 3)
                                                       (dates:date-month current-quarter)
                                                       (dates:date-day current-quarter))))
                     (quarter-pages (stats:quarter-pages quarter-start
                                                         (remove-if-not (lambda (session)
                                                                          (dates:date< quarter-start (model:session-at session)))
                                                                        sessions)))
                     (week-start (let ((current-week (dates:date-prev (dates:today) :monday)))
                                   (dates:date- current-week (* 13 7))))
                     (week-pages (stats:week-pages week-start
                                                   (remove-if-not (lambda (session)
                                                                    (dates:date< week-start (model:session-at session)))
                                                                  sessions))))
                (:script
                 (:raw (ps:ps*
                        `(progn
                           (let ((ctx (ps:chain document (get-element-by-id "year_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf year-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf year-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-green-1"))))
                                                  :options (ps:create
                                                            :animations ps:f)))))
                           (let ((ctx (ps:chain document (get-element-by-id "quarter_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf quarter-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf quarter-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-red-1"))))
                                                  :options (ps:create
                                                            :animations ps:f)))))
                           (let ((ctx (ps:chain document (get-element-by-id "week_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf week-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf week-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-cyan-1"))))
                                                  :options (ps:create
                                                            :animations ps:f))))))))))))))

;;; Authors

(defun author-form (&key author return-to)
  (with-html
    (:form :name "author" :method "post" :action ""
           (when return-to
             (:input :type "hidden" :name "return_to" :value return-to))

           (when author
             (:input :type "hidden" :name "id" :value (model:author-id author)))

           (:label :for "name" "Name")
           (:input :type "text" :name "name" :value (when author (model:author-name author)))

           (:label :for "sort_name" "Sort Name")
           (:input :type "text" :name "sort_name" :value (when author (model:author-sort-name author)))

           (:label :for "birth" "Birth Date")
           (:input :type "text" :name "birth" :value (when author
                                                       (write-date (model:author-birth author) nil)))

           (:label :for "death" "Death Date")
           (:input :type "text" :name "death" :value (when author
                                                         (write-date (model:author-death author) nil)))

           (:label :for "bookbrainz_author_id" "Bookbrainz Author ID")
           (:input :type "text" :name "bookbrainz_author_id" :value (when author (model:author-bookbrainz-id author)))

           (:label :for "openlibrary_author_id" "OpenLibrary Author ID")
           (:input :type "text" :name "openlibrary_author_id" :value (when author (model:author-openlibrary-id author)))

           (:label :for "wikidata_id" "Wikidata ID")
           (:input :type "text" :name "wikidata_id" :value (when author (model:author-wikidata-id author)))

           (:button :type "submit" "Submit"))))

(setf (ningle:route *app* "/authors" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (with-base-template (:title "Quixote - Authors")
            (:header
             (:h1 "Authors")
             (:p (:a :href "/authors/new" "New Author")))
            (:section :class "authors"
                      (:ul
                       (loop for author in (db:select-authors)
                             do (:li (:a :href (format nil "/authors/~D" (model:author-id author))
                                         (if (model:author-birth author)
                                             (format nil "~A (~A - ~A)"
                                                     (model:author-name author)
                                                     (dates:date-year (model:author-birth author))
                                                     (if (model:author-death author)
                                                         (dates:date-year (model:author-death author))
                                                         "..."))
                                             (model:author-name author))))))))))

(setf (ningle:route *app* "/authors/new" :method :get)
      #'(lambda (params)
          (let ((return-to (getparam params "return_to")))
            (with-base-template (:title "Quixote - New Author")
              (:header
               (:h1 "New Author")
               (:p (:a :href "/authors" "View Authors")))
              (author-form :return-to return-to)))))

(setf (ningle:route *app* "/authors" :method :post)
      #'(lambda (params)
          (let* ((name (getparam params "name"))
                 (sort-name (getparam params "sort_name"))
                 (birth (getparam params "birth"))
                 (death (getparam params "death"))
                 (bookbrainz-id (getparam params "bookbrainz_author_id"))
                 (openlibrary-id (getparam params "openlibrary_author_id"))
                 (wikidata-id (getparam params "wikidata_id"))
                 (return-to (getparam params "return_to"))
                 (author (db:insert-author
                          (model:make-author :name name
                                             :sort-name sort-name
                                             :birth (when birth (dates:parse-iso-date birth))
                                             :death (when death (dates:parse-iso-date death))
                                             :bookbrainz-id bookbrainz-id
                                             :openlibrary-id openlibrary-id
                                             :wikidata-id wikidata-id))))
            `(303 (:location ,(or return-to (format nil "/authors/~D" (model:author-id author))))
                  ()))))

(setf (ningle:route *app* "/authors/merge" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params "author_id")))
                 (author (when id (db:select-author id)))
                 (olid (getparam params "openlibrary_id"))
                 (online (when olid (lib:author olid)))
                 (return-to (getparam params "return_to")))
            (if (null author)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:author-name author)) :wide-p t)
                  (:h1 (model:author-name author))

                  (:form :class "merge" :method "post" :action (format nil "/authors/~D" (model:author-id author))
                         (when return-to
                           (:input :type "hidden" :name "return_to" :value return-to))
                         (:table
                          (:thead
                           (:th "Field")
                           (:th "Existing")
                           (:th "Merged")
                           (:th "Online"))
                          (:tbody
                           (merge-row-template "name" "Name" author online #'model:author-name)
                           (merge-row-template "sort_name" "Sort Name" author online #'model:author-sort-name)
                           (merge-row-template "birth" "Birth" author online (lambda (a)
                                                                               (write-date (model:author-birth a) nil)))
                           (merge-row-template "death" "Death" author online (lambda (a)
                                                                               (write-date (model:author-death a) nil)))
                           (merge-row-template "bookbrainz_author_id" "Bookbrainz ID" author online #'model:author-bookbrainz-id)
                           (merge-row-template "openlibrary_author_id" "OpenLibrary ID" author online #'model:author-openlibrary-id)
                           (merge-row-template "wikidata_id" "Wikidata ID" author online #'model:author-wikidata-id)))
                         (:button :type "submit" "Submit")))))))

(setf (ningle:route *app* "/authors/:id" :method :get)
      #'(lambda (params)
          (let ((author (db:select-author (parse-integer (getparam params :id)))))
            (if (null author)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:author-name author)))
                  (:h1 (model:author-name author))
                  (author-template author)
                  (editions-template (db:select-author-editions author) :header-p t)
                  (:section :class "forms"
                            (:h2 "Forms")
                            (:section :class "form"
                                      (:h3 "Update Author")
                                      (author-form :author author))
                            (delete-template (format nil "/authors/~D/delete" (model:author-id author)))))))))

(setf (ningle:route *app* "/authors/:id" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (name (getparam params "name"))
                 (sort-name (getparam params "sort_name"))
                 (birth (getparam params "birth"))
                 (death (getparam params "death"))
                 (bookbrainz-id (getparam params "bookbrainz_author_id"))
                 (openlibrary-id (getparam params "openlibrary_author_id"))
                 (wikidata-id (getparam params "wikidata_id"))
                 (return-to (getparam params "return_to"))
                 (author (db:select-author id)))
            (if (null author)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (setf (model:author-name author) name)
                  (setf (model:author-sort-name author) sort-name)
                  (setf (model:author-birth author)
                        (when birth (dates:parse-iso-date birth)))
                  (setf (model:author-death author)
                        (when death (dates:parse-iso-date death)))
                  (setf (model:author-bookbrainz-id author) bookbrainz-id)
                  (setf (model:author-openlibrary-id author) openlibrary-id)
                  (setf (model:author-wikidata-id author) wikidata-id)
                  (db:update-author author)

                  `(303 (:location ,(or return-to (format nil "/authors/~D" (model:author-id author))))
                        ()))))))

(setf (ningle:route *app* "/authors/:id/delete" :method :post)
      #'(lambda (params)
          (let ((author (db:select-author (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null author)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-author author)
               `(303 (:location "/authors") ()))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

;;; Editions

(defun edition-form (authors &key edition staged-item-id return-to)
  (with-html
    (:form :name "edition" :method "post" :action (if edition
                                                      (format nil "/editions/~D" (model:edition-id edition))
                                                      "/editions")
           (when edition
             (:input :type "hidden" :name "id" :value (model:edition-id edition)))

           (when staged-item-id
             (:input :type "hidden" :name "staged_item_id" :value staged-item-id))

           (when return-to
             (:input :type "hidden" :name "return_to" :value return-to))

           (:label :for "title" "Title")
           (:input :type "text" :name "title" :value (when edition (model:edition-title edition)))

           (:label :for "sort_title" "Sort Title")
           (:input :type "text" :name "sort_title" :value (when edition (model:edition-sort-title edition)))

           (:label :for "disambiguation" "Disambiguation")
           (:input :type "text" :name "disambiguation" :value (when edition (model:edition-disambiguation edition)))

           (:label :for "author" "Author")
           (:select :name "author_id"
             (:option :value "" "None")
             (loop with selected-id = (when (and edition (model:edition-author edition))
                                        (model:author-id (model:edition-author edition)))
                   for author in authors
                   do (:option :value (model:author-id author)
                               :selected (eql selected-id (model:author-id author))
                               (model:author-name author))))

           (:label :for "translator" "Translator")
           (:select :name "translator_id"
             (:option :value "" "None")
             (loop with selected-id = (when (and edition (model:edition-translator edition))
                                        (model:author-id (model:edition-translator edition)))
                   for author in authors
                   do (:option :value (model:author-id author)
                               :selected (eql selected-id (model:author-id author))
                               (model:author-name author))))

           (:label :for "page_start" "Page Start")
           (:input :type "number" :name "page_start" :required t :min 1 :value (when edition (model:edition-page-start edition)))

           (:label :for "page_end" "Page End")
           (:input :type "number" :name "page_end" :required t :min 1 :value (when edition (model:edition-page-end edition)))

           (:label :for "type" "Type")
           (:select :name "type"
             (loop for (type-value . type-desc) in model:+domain-types+
                   do (:option :value type-value :selected (string-equal type-value
                                                                         (when edition
                                                                           (model:edition-type edition)))
                               type-desc)))

           (:label :for "year" "Year")
           (:input :type "text" :name "year" :value (when edition
                                                      (write-date (model:edition-year edition) nil)))

           (:label :for "isbn" "ISBN")
           (:input :type "number" :name "isbn" :value (when edition (model:edition-isbn edition)))

           (:label :for "bookbrainz_edition_id" "Bookbrainz Edition ID")
           (:input :type "text" :name "bookbrainz_edition_id" :value (when edition (model:edition-bookbrainz-id edition)))

           (:label :for "openlibrary_book_id" "OpenLibrary Book ID")
           (:input :type "text" :name "openlibrary_book_id" :value (when edition (model:edition-openlibrary-id edition)))

           (:label :for "wikidata_id" "Wikidata ID")
           (:input :type "text" :name "wikidata_id" :value (when edition (model:edition-wikidata-id edition)))

           (:button :type "submit" "Submit"))))

(setf (ningle:route *app* "/editions" :method :get)
      #'(lambda (params)
          (let* ((page (or (safe-parse-integer (getparam params "page")) 1))
                 (page-size 40)
                 (offset (* page-size (1- page)))
                 (editions (db:select-editions :limit page-size :offset offset)))
            (with-base-template (:title "Quixote - Editions")
              (:header
               (:h1 "Editions")
               (:p (:a :href "/editions/new" "New Edition")))
              (paging-template "/editions" editions page page-size)
              (editions-template editions)
              (paging-template "/editions" editions page page-size)))))

(setf (ningle:route *app* "/editions/new" :method :get)
      #'(lambda (params)
          (let ((staged-item-id (safe-parse-integer (getparam params "staged_item_id")))
                (return-to (getparam params "return_to")))
            (with-base-template (:title "Quixote - New Edition")
              (:header
               (:h1 "New Edition")
               (:p (:a :href "/editions" "View Editions")))
              (edition-form (db:select-authors)
                            :staged-item-id staged-item-id
                            :return-to return-to)))))

(setf (ningle:route *app* "/editions" :method :post)
      #'(lambda (params)
          (let* ((title (getparam params "title"))
                 (sort-title (getparam params "sort_title"))
                 (disambiguation (getparam params "disambiguation"))
                 (author-id (safe-parse-integer (getparam params "author_id")))
                 (translator-id (safe-parse-integer (getparam params "translator_id")))
                 (page-start (safe-parse-integer (getparam params "page_start")))
                 (page-end (safe-parse-integer (getparam params "page_end")))
                 (type (getparam params "type"))
                 (year (getparam params "year"))
                 (isbn (getparam params "isbn"))
                 (bookbrainz-id (getparam params "bookbrainz_edition_id"))
                 (openlibrary-id (getparam params "openlibrary_book_id"))
                 (wikidata-id (getparam params "wikidata_id"))
                 (staged-item-id (safe-parse-integer (getparam params "staged_item_id")))
                 (return-to (getparam params "return_to"))
                 (edition (db:insert-edition
                           (model:make-edition :title title
                                               :sort-title sort-title
                                               :disambiguation disambiguation
                                               :author (when author-id (db:select-author author-id))
                                               :translator (when translator-id (db:select-author translator-id))
                                               :page-start page-start
                                               :page-end page-end
                                               :type type
                                               :year (when year (dates:parse-iso-date year))
                                               :isbn isbn
                                               :bookbrainz-id bookbrainz-id
                                               :openlibrary-id openlibrary-id
                                               :wikidata-id wikidata-id))))

            ;; create a default complete part (user can change later)
            (db:insert-part
             (model:make-part :edition edition
                              :title nil
                              :sort-title nil
                              :segment "body"
                              :page-start page-start
                              :page-end page-end
                              :bookbrainz-id nil
                              :openlibrary-id nil
                              :wikidata-id nil))

            ;; claim staged item for import
            (let ((item (import:staged-item staged-item-id)))
              (when item
                (setf (import:staged-item-edition item) edition)))

            `(303 (:location ,(or return-to (format nil "/editions/~D" (model:edition-id edition))))
                  ()))))

(setf (ningle:route *app* "/editions/merge" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params "edition_id")))
                 (edition (when id (db:select-edition id)))
                 (olid (getparam params "openlibrary_id"))
                 (online (when olid (lib:book olid)))
                 (staged-item-id (safe-parse-integer (getparam params "staged_item_id")))
                 (return-to (getparam params "return_to")))
            (if (null edition)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:edition-title edition)) :wide-p t)
                  (:h1 (model:edition-title edition))
                  (:form :class "merge" :method "post" :action (format nil "/editions/~D" (model:edition-id edition))
                         (when staged-item-id
                           (:input :type "hidden" :name "staged_item_id" :value staged-item-id))
                         (when return-to
                           (:input :type "hidden" :name "return_to" :value return-to))
                         (:input :type "hidden" :name "author_id" :value (when (model:edition-author edition)
                                                                           (model:author-id (model:edition-author edition))))
                         (:input :type "hidden" :name "translator_id" :value (when (model:edition-translator edition)
                                                                               (model:author-id (model:edition-translator edition))))
                         (:input :type "hidden" :name "type" :value (model:edition-type edition))
                         (:table
                          (:thead
                           (:th "Field")
                           (:th "Existing")
                           (:th "Merged")
                           (:th "Online"))
                          (:tbody
                           (merge-row-template "title" "Title" edition online #'model:edition-title)
                           (merge-row-template "sort_title" "Sort Title" edition online #'model:edition-sort-title)
                           (merge-row-template "disambiguation" "Disambiguation" edition online #'model:edition-disambiguation)
                           (merge-row-template "page_start" "Page Start" edition online #'model:edition-page-start)
                           (merge-row-template "page_end" "Page End" edition online #'model:edition-page-end)
                           (merge-row-template "year" "Date" edition online (lambda (e)
                                                                              (write-date (model:edition-year e) nil)))
                           (merge-row-template "isbn" "ISBN" edition online #'model:edition-isbn)
                           (merge-row-template "bookbrainz_edition_id" "Bookbrainz ID" edition online #'model:edition-bookbrainz-id)
                           (merge-row-template "openlibrary_book_id" "OpenLibrary ID" edition online #'model:edition-openlibrary-id)
                           (merge-row-template "wikidata_id" "Wikidata ID" edition online #'model:edition-wikidata-id)))
                         (:button :type "submit" "Submit")))))))

(setf (ningle:route *app* "/editions/:id" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (edition (when id (db:select-edition id))))
            (if (null edition)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:edition-title edition)))
                  (:header
                   (breadcrumbs-template edition)
                   (:h1 (model:edition-title edition))
                   (when (model:edition-disambiguation edition)
                     (:p (:em (model:edition-disambiguation edition))))
                   (:p (:a :href (format nil "/editions/~D/edit" (model:edition-id edition))
                           "Edit Edition")))

                  (:section :class "edition covered"
                            (:section :class "cover"
                                      (cover-img edition)
                                      (:details
                                       (:summary "Back Cover")
                                       (cover-img edition :side :back)))
                            (:section :class "content"
                                      (:h2 "Edition")
                                      (:dl
                                       (when (model:edition-author edition)
                                         (:dt "Author")
                                         (:dd (:a :href (format nil "/authors/~D" (model:author-id (model:edition-author edition)))
                                                  (model:author-name (model:edition-author edition)))))

                                       (when (model:edition-translator edition)
                                         (:dt "Translator")
                                         (:dd (:a :href (format nil "/authors/~D" (model:author-id (model:edition-translator edition)))
                                                  (model:author-name (model:edition-translator edition)))))

                                       (:dt "Date")
                                       (:dd (write-human-date (model:edition-year edition)))

                                       (:dt "Pages")
                                       (:dd (model:edition-pages edition))

                                       (when (model:edition-isbn edition)
                                         (:dt "ISBN")
                                         (:dd (model:edition-isbn edition)))

                                       (when (model:edition-bookbrainz-id edition)
                                         (:dt "BookBrainz ID")
                                         (:dd (:a :href (format nil "https://bookbrainz.org/edition/~A" (model:edition-bookbrainz-id edition))
                                                  (format nil "~A..."
                                                          (subseq (model:edition-bookbrainz-id edition) 0 8)))))

                                       (when (model:edition-openlibrary-id edition)
                                         (:dt "OpenLibrary ID")
                                         (:dd (:a :href (format nil "https://openlibrary.org/books/~A" (model:edition-openlibrary-id edition))
                                                  (model:edition-openlibrary-id edition))))

                                       (when (model:edition-wikidata-id edition)
                                         (:dt "Wikidata ID")
                                         (:dd (:a :href (format nil "https://www.wikidata.org/wiki/~A" (model:edition-wikidata-id edition))
                                                  (model:edition-wikidata-id edition)))))))

                  (let ((parts (db:select-edition-parts edition)))
                    (when parts
                      (:section :class "parts"
                                (:h2 "Parts")
                                (:table :class "parts"
                                        (:thead
                                         (:tr
                                          (:th "Title")
                                          (:th "Start")
                                          (:th "End")
                                          (:th "Pages")))
                                        (:tbody
                                         (loop for part in parts
                                               do (:tr
                                                   (:td (:a :href (format nil "/parts/~D" (model:part-id part))
                                                            (model:part-title part)))
                                                   (:td (model:part-page-start-str part))
                                                   (:td (model:part-page-end-str part))
                                                   (:td (model:part-pages part)))))))))

                  (let ((readings (db:select-edition-readings edition)))
                    (when readings
                      (:section :class "readings"
                                (:h2 "Readings")
                                (loop for reading in readings
                                      do (reading-template reading :mode :simple))))))))))

(setf (ningle:route *app* "/editions/:id/edit" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (edition (when id (db:select-edition id))))
            (if (null edition)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:edition-title edition)))
                  (:header
                   (breadcrumbs-template edition)
                   (:h1 (model:edition-title edition))
                   (when (model:edition-disambiguation edition)
                     (:p (:em (model:edition-disambiguation edition))))
                   (:p (:a :href (format nil "/editions/~D" (model:edition-id edition))
                           "View Edition")))

                  (:section :class "forms"
                            (:h2 "Forms")
                            (:section :class "form"
                                      (:h3 "Add Part")
                                      (part-form edition))
                            (:section :class "form"
                                      (:h3 "Update Edition")
                                      (edition-form (db:select-authors) :edition edition))
                            (:section :class "form"
                                      (:h3 "Remove Cover")
                                      (:form :name "delete-cover" :method "post" :action (format nil "/editions/~d/cover/delete" (model:edition-id edition))
                                             (:button :type "submit" "Submit")))
                            (delete-template (format nil "/editions/~D/delete" (model:edition-id edition)))))))))


(setf (ningle:route *app* "/editions/:id" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (title (getparam params "title"))
                 (sort-title (getparam params "sort_title"))
                 (disambiguation (getparam params "disambiguation"))
                 (author-id (safe-parse-integer (getparam params "author_id")))
                 (translator-id (safe-parse-integer (getparam params "translator_id")))
                 (page-start (safe-parse-integer (getparam params "page_start")))
                 (page-end (safe-parse-integer (getparam params "page_end")))
                 (type (getparam params "type"))
                 (year (getparam params "year"))
                 (isbn (getparam params "isbn"))
                 (bookbrainz-id (getparam params "bookbrainz_edition_id"))
                 (openlibrary-id (getparam params "openlibrary_book_id"))
                 (wikidata-id (getparam params "wikidata_id"))
                 (staged-item-id (safe-parse-integer (getparam params "staged_item_id")))
                 (return-to (getparam params "return_to"))
                 (edition (db:select-edition id))
                 (old-page-start (when edition (model:edition-page-start edition)))
                 (old-page-end (when edition (model:edition-page-end edition))))
            (if (null edition)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (setf (model:edition-title edition) title)
                  (setf (model:edition-sort-title edition) sort-title)
                  (setf (model:edition-disambiguation edition) disambiguation)
                  (setf (model:edition-author edition) (when author-id (db:select-author author-id)))
                  (setf (model:edition-translator edition) (when translator-id (db:select-author translator-id)))
                  (setf (model:edition-page-start edition) page-start)
                  (setf (model:edition-page-end edition) page-end)
                  (setf (model:edition-type edition) type)
                  (setf (model:edition-year edition)
                        (when year (dates:parse-iso-date year)))
                  (setf (model:edition-isbn edition) isbn)
                  (setf (model:edition-bookbrainz-id edition) bookbrainz-id)
                  (setf (model:edition-openlibrary-id edition) openlibrary-id)
                  (setf (model:edition-wikidata-id edition) wikidata-id)
                  (db:update-edition edition)

                  ;; correct complete part pages
                  (when (or (not (= page-start old-page-start))
                            (not (= page-end old-page-end)))
                    (dolist (part (db:select-edition-parts edition))
                      (when (and (= old-page-start (model:part-page-start part))
                                 (= old-page-end (model:part-page-end part)))
                        (setf (model:part-page-start part) page-start)
                        (setf (model:part-page-end part) page-end)
                        (db:update-part part))))

                  ;; claim staged item for import
                  (let ((item (import:staged-item staged-item-id)))
                    (when item
                      (setf (import:staged-item-edition item) edition)))

                  `(303 (:location ,(or return-to (format nil "/editions/~D" (model:edition-id edition))))
                        ()))))))

(setf (ningle:route *app* "/editions/:id/delete" :method :post)
      #'(lambda (params)
          (let ((edition (db:select-edition (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null edition)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-edition edition)
               '(303 (:location "/editions")))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

;;; Edition Covers

(setf (ningle:route *app* "/editions/:id/cover/delete" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (confirm1 (getparam params "confirm1"))
                 (confirm2 (getparam params "confirm2")))
            (if (and id confirm1 confirm2)
              (progn
                (covers:ensure-cover-empty (covers:make-cover id))
                `(303 (:location ,(format nil "/editions/~D" id)) ()))))))

;;; Edition Parts

(defun part-form (edition &optional part)
  (with-html
    (:form :name "part" :method "post" :action (if part
                                                   (format nil "/parts/~D" (model:part-id part))
                                                   "/parts")
           (when part
             (:input :type "hidden" :name "id" :value (model:part-id part)))

           (:input :type "hidden" :name "edition_id" :value (model:edition-id (if part
                                                                                  (model:part-edition part)
                                                                                  edition)))

           (:label :for "title" "Title")
           (:input :type "text" :name "title" :value (when (and part (model:part-title-p part))
                                                       (model:part-title part))
                   :placeholder (model:edition-title edition))

           (:label :for "sort_title" "Sort Title")
           (:input :type "text" :name "sort_title" :value (when part (model:part-sort-title part)))

           (:label :for "segment" "Segment")
           (:select :name "segment"
             (:option :value "body" :selected (when part (string-equal "body" (model:part-segment part))) "Body")
             (:option :value "frontmatter" :selected (when part (string-equal "frontmatter" (model:part-segment part))) "Frontmatter"))

           (:label :for "page_start" "Page Start")
           (:input :type "number" :name "page_start" :required t :min 1 :value (when part (model:part-page-start part)))

           (:label :for "page_end" "Page End")
           (:input :type "number" :name "page_end" :required t :min 1 :value (when part (model:part-page-end part)))

           (:label :for "bookbrainz_work_id" "Bookbrainz Work ID")
           (:input :type "text" :name "bookbrainz_work_id" :value (when part (model:part-bookbrainz-id part)))

           (:label :for "openlibrary_work_id" "OpenLibrary Work ID")
           (:input :type "text" :name "openlibrary_work_id" :value (when part (model:part-openlibrary-id part)))

           (:label :for "wikidata_id" "Wikidata ID")
           (:input :type "text" :name "wikidata_id" :value (when part (model:part-wikidata-id part)))

           (:button :type "submit" "Submit"))))

(setf (ningle:route *app* "/parts" :method :post)
      #'(lambda (params)
          (let* ((edition-id (safe-parse-integer (getparam params "edition_id")))
                 (title (getparam params "title"))
                 (sort-title (getparam params "sort_title"))
                 (segment (getparam params "segment"))
                 (page-start (safe-parse-integer (getparam params "page_start")))
                 (page-end (safe-parse-integer (getparam params "page_end")))
                 (bookbrainz-id (getparam params "bookbrainz_work_id"))
                 (openlibrary-id (getparam params "openlibrary_work_id"))
                 (wikidata-id (getparam params "wikidata_id")))
            (db:insert-part
             (model:make-part :edition (db:select-edition edition-id)
                              :title title
                              :sort-title sort-title
                              :segment segment
                              :page-start page-start
                              :page-end page-end
                              :bookbrainz-id bookbrainz-id
                              :openlibrary-id openlibrary-id
                              :wikidata-id wikidata-id))
            `(303 (:location ,(format nil "/editions/~D" edition-id)) ()))))

(setf (ningle:route *app* "/parts/:id" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (part (when id (db:select-part id))))
            (if (null part)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:part-title part)))
                  (:header
                   (breadcrumbs-template part)
                   (:h1 (model:part-title part))
                   (:p (:a :href (format nil "/parts/~D/edit" (model:part-id part))
                           "Edit Part")
                       "|"
                       "from" (:a :href (format nil "/editions/~d" (model:edition-id (model:part-edition part)))
                                  (model:edition-full-title (model:part-edition part)))))
                  (:section :class "covered part"
                            (:section :class "cover"
                                      (cover-img (model:part-edition part)))
                            (:section :class "content"
                                      (:h2 "Part")
                                      (:dl
                                       (:dt "Start Page")
                                       (:dd (model:part-page-start-str part))

                                       (:dt "End Page")
                                       (:dd (model:part-page-end-str part))

                                       (when (model:part-bookbrainz-id part)
                                         (:dt "BookBrainz ID")
                                         (:dd (:a :href (format nil "https://bookbrainz.org/work/~A" (model:part-bookbrainz-id part))
                                                  (format nil "~A..."
                                                          (subseq (model:part-bookbrainz-id part) 0 8)))))

                                       (when (model:part-openlibrary-id part)
                                         (:dt "OpenLibrary ID")
                                         (:dd (:a :href (format nil "https://openlibrary.org/work/~A" (model:part-openlibrary-id part))
                                                  (model:part-openlibrary-id part))))

                                       (when (model:part-wikidata-id part)
                                         (:dt "Wikidata ID")
                                         (:dd (:a :href (format nil "https://www.wikidata.org/wiki/~A" (model:part-wikidata-id part))
                                                  (model:part-wikidata-id part)))))))

                  (let ((readings (db:select-part-readings part))
                        (active-p nil))
                    (:section :class "readings"
                              (:h2 "Readings")
                              (loop for reading in readings
                                    when (member (model:reading-status reading)
                                                 '("in-progress" "paused")
                                                 :test #'equalp)
                                      do (setf active-p t)
                                    do (reading-template reading :mode :simple))
                              (unless active-p
                                (reading-form nil part)))))))))

(setf (ningle:route *app* "/parts/:id/edit" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (part (when id (db:select-part id))))
            (if (null part)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A" (model:part-title part)))
                  (:header
                   (breadcrumbs-template part)
                   (:h1 (model:part-title part))
                   (:p  (:a :href (format nil "/parts/~D" (model:part-id part))
                           "View Part")
                        "|"
                        "from" (:a :href (format nil "/editions/~d" (model:edition-id (model:part-edition part)))
                                   (model:edition-full-title (model:part-edition part)))))

                  (:section :class "forms"
                            (:h2 "Forms")
                            (:section :class "form"
                                      (:h3 "Update Part")
                                      (part-form (model:part-edition part) part))
                            (delete-template (format nil "/parts/~D/delete" (model:part-id part)))))))))

(setf (ningle:route *app* "/parts/:id" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (edition-id (safe-parse-integer (getparam params "edition_id")))
                 (title (getparam params "title"))
                 (sort-title (getparam params "sort_title"))
                 (segment (getparam params "segment"))
                 (page-start (safe-parse-integer (getparam params "page_start")))
                 (page-end (safe-parse-integer (getparam params "page_end")))
                 (bookbrainz-id (getparam params "bookbrainz_work_id"))
                 (openlibrary-id (getparam params "openlibrary_work_id"))
                 (wikidata-id (getparam params "wikidata_id"))
                 (part (db:select-part id)))

            (if (null part)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (setf (model:part-edition part) (db:select-edition edition-id))
                  (setf (model:part-title part) title)
                  (setf (model:part-sort-title part) sort-title)
                  (setf (model:part-segment part) segment)
                  (setf (model:part-page-start part) page-start)
                  (setf (model:part-page-end part) page-end)
                  (setf (model:part-bookbrainz-id part) bookbrainz-id)
                  (setf (model:part-openlibrary-id part) openlibrary-id)
                  (setf (model:part-wikidata-id part) wikidata-id)
                  (db:update-part part)

                  `(303 (:location ,(format nil "/editions/~D" edition-id)) ()))))))

(setf (ningle:route *app* "/parts/:id/delete" :method :post)
      #'(lambda (params)
          (let ((part (db:select-part (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null part)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-part part)
               `(303 (:location ,(format nil "/editions/~D" (model:edition-id (model:part-edition part)))) ()))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

;;; Collections

(defun collection-form (parent &optional collection)
  (let* ((current-item (when collection
                         (first (db:select-content-items collection))))
         (current-parent-id (when current-item
                              (model:collection-id (model:item-collection current-item)))))
    (with-html
      (:form :name (if parent "subcollection" "collection")
             :method "post"
             :action (if collection
                         (format nil "/collections/~D" (model:collection-id collection))
                         "/collections")
             (when collection
               (:input :type "hidden" :name "id" :value (model:collection-id collection)))

             (if parent
                 (:input :type "hidden" :name "parent_id" :value (model:collection-id parent))
                 (progn
                   (:label :for "parent" "Parent")
                   (:select :name "parent_id"
                     (:option :value "0" "Library")
                     (loop for option in (db:select-collections)
                           for selected = (and collection
                                               (eql (model:collection-id option)
                                                    current-parent-id))
                           when (not (eql (model:collection-id option)
                                          (model:collection-id collection)))
                             do (:option :value (model:collection-id option)
                                         :selected selected
                                         (model:collection-title option))))))

             (:label :for "position" "Position")
             (:input :type "number" :name "position" :value (when current-item (model:item-position current-item)))

             (:label :for "title" "Title")
             (:input :type "text" :name "title" :value (when collection (model:collection-title collection)))

             (if parent
                 (:input :type "hidden" :name "type" :value (model:collection-type parent))
                 (progn
                   (:label :for "type" "Type")
                   (:select :name "type"
                     (loop for (type-value . type-desc) in model:+domain-types+
                           do (:option :value type-value :selected (string-equal type-value
                                                                                 (when collection
                                                                                   (model:collection-type collection)))
                                       type-desc)))))

             (:button :type "submit" "Submit")))))

(setf (ningle:route *app* "/collections" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (with-base-template (:title "Collections")
            (:h1 "Collections")
            (:section :class "collections"
                      (:h2 "Root Collections")
                      (:ul
                       (loop for collection in (db:select-root-collections)
                             do (:li (:a :href (format nil "/collections/~D" (model:collection-id collection))
                                         (model:collection-title collection))))))
            (:section :class "forms"
                      (:h2 "Forms")
                      (:section :class "form"
                                (:h3 "New Collection")
                                (collection-form nil))))))

(setf (ningle:route *app* "/collections" :method :post)
      #'(lambda (params)
          (let* ((title (getparam params "title"))
                 (type (getparam params "type"))
                 (parent-id (safe-parse-integer (getparam params "parent_id")))
                 (position (safe-parse-integer (getparam params "position")))
                 (collection (db:insert-collection
                              (model:make-collection :title title
                                                     :type type))))
            (when (and parent-id (< 0 parent-id))
              (db:insert-collection-item
               (model:make-item :collection (db:select-collection parent-id)
                                :position position
                                :item collection)))

            `(303 (:location ,(format nil "/collections/~D" (model:collection-id collection))) ()))))

(setf (ningle:route *app* "/collections/:id" :method :get)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (collection (db:select-collection id))
                 (items (db:select-collection-items id))
                 (tree (db:select-collection-tree collection))
                 (part-ids (model:tree-map-parts tree #'model:part-id))
                 (sessions (db:select-collection-tree-sessions tree)))
            (with-base-template (:title (format nil "Quixote - ~A"
                                                (model:collection-title collection))
                                 :charts-p t)
              (:header
               (breadcrumbs-template collection)
               (:h1 (model:collection-title collection))
               (:p (:a :href (format nil "/collections/~D/edit" (model:collection-id collection))
                       "Edit Collection")))

              (:section :class "collection"
                        (:h2 "Collection")
                        (:dl
                         (:dt "Type")
                         (:dd (model:collection-type-friendly collection))
                         (:dt "Children")
                         (:dd (length (model:tree-children tree)))

                         (:dt "Total Parts")
                         (:dd (length part-ids))
                         (:dt "Total Pages")
                         (:dd (format nil "~:D" (reduce #'+ (model:tree-map-parts tree #'model:part-pages))))

                         (:dt "Activity")
                         (:dd (format nil "~D days"
                                      (length (alexandria:hash-table-keys (stats:daily-pages sessions)))))
                         (:dt "Pages Read")
                         (:dd (reduce #'+ (mapcar #'model:session-pages sessions)))))

              (:section :class "collection-items"
                        (:h2 "Items")
                        (loop for item in items
                              for content = (model:item-item item)
                              for subtree = (model:tree-find tree content)
                              for editions = (alexandria:hash-table-values
                                              (loop with unique = (make-hash-table)
                                                    for edition in (model:tree-map-parts subtree #'model:part-edition)
                                                    when edition
                                                      do (setf (gethash (model:edition-id edition) unique) edition)
                                                    finally (return unique)))
                              for random-edition = (nth (random (length editions)) editions)
                              do (:section :class "collection-item covered"
                                           (:section :class "cover"
                                                     (if random-edition
                                                         (cover-img random-edition :link-to :edition)
                                                         (:img :src "/img/missing-cover.png")))
                                           (:section :class "content"
                                                     (:h3
                                                      (format nil "~@[~D~]"
                                                              (when (model:item-position item)
                                                                (float (/ (model:item-position item) 10))))
                                                      (etypecase content
                                                        (model:collection
                                                         (:a :href (format nil "/collections/~D" (model:collection-id content))
                                                             (model:collection-title content)))
                                                        (model:part
                                                         (:a :href (format nil "/parts/~D" (model:part-id content))
                                                             (model:part-title content))
                                                         (when (model:part-title-p content)
                                                           (:span "from")
                                                           (:a :href (format nil "/editions/~D" (model:edition-id (model:part-edition content)))
                                                               (model:edition-full-title (model:part-edition content)))))))
                                                     (:dl
                                                      (when (typep content 'model:collection)
                                                        (:dt "Children")
                                                        (:dd (length (model:tree-children subtree)))
                                                        (:dt "Total Parts")
                                                        (:dd (length (model:tree-map-parts subtree #'model:part-id))))
                                                      (:dt (if (typep content 'model:collection)
                                                               "Total Pages"
                                                               "Pages"))
                                                      (:dd (format nil "~:D" (reduce #'+ (model:tree-map-parts subtree #'model:part-pages)))))))))

              (let ((active-readings (db:select-active-parts-readings part-ids)))
                (when active-readings
                  (:section :class "readings"
                            (:h2 "Active Readings")
                            (loop for reading in active-readings
                                  do (reading-template reading :mode :standalone)))))

              (let ((readthroughs (db:select-collection-readthroughs collection))
                    (active-p nil))
                (:section :class "readthroughs"
                          (:h2 "Readthroughs")
                          (loop for readthrough in readthroughs
                                when (member (model:readthrough-status readthrough)
                                             '("in-progress" "paused")
                                             :test #'equalp)
                                  do (setf active-p t)
                                do (readthrough-template readthrough :mode :simple))
                          (unless active-p
                            (readthrough-form collection))))

              (:section
               (:header
                (:h2 "Recent Activity")
                (:p (:a :href (format nil "/collections/~D/activity" (model:collection-id collection))
                        "All Activity")))
               (:div :class "months"
                     (let* ((months (reverse (loop for x from 1 to 3
                                                   for date = (dates:date-start-of-month
                                                               (dates:date-prev (dates:today) :monday))
                                                     then (dates:date-start-of-month (dates:date- date 1))
                                                   do (dates:write-iso-date t date)
                                                   collect date)))
                            (activity (stats:week-activity sessions)))
                       (loop for month in months
                             do (month-template activity month)))))

              (:section
               (:h2 "Charts")
               (:section :class "chart"
                         (:h3 "Pages per Week")
                         (:canvas :id "week_chart"))
               (:section :class "chart"
                         (:h3 "Pages per Quarter")
                         (:canvas :id "quarter_chart"))
               (:section :class "chart"
                         (:h3 "Pages per Year")
                         (:canvas :id "year_chart")))

              (let* ((year-pages (stats:year-pages sessions))
                     (quarter-start (let ((current-quarter (dates:date-start-of-quarter (dates:today))))
                                      (dates:make-date (- (dates:date-year current-quarter) 3)
                                                       (dates:date-month current-quarter)
                                                       (dates:date-day current-quarter))))
                     (quarter-pages (stats:quarter-pages quarter-start
                                                         (remove-if-not (lambda (session)
                                                                          (dates:date< quarter-start (model:session-at session)))
                                                                        sessions)))
                     (week-start (let ((current-week (dates:date-prev (dates:today) :monday)))
                                   (dates:date- current-week (* 13 7))))
                     (week-pages (stats:week-pages week-start
                                                   (remove-if-not (lambda (session)
                                                                    (dates:date< week-start (model:session-at session)))
                                                                  sessions))))
                (:script
                 (:raw (ps:ps*
                        `(progn
                           (let ((ctx (ps:chain document (get-element-by-id "year_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf year-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf year-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-green-1"))))
                                                  :options (ps:create
                                                            :animations ps:f)))))
                           (let ((ctx (ps:chain document (get-element-by-id "quarter_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf quarter-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf quarter-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-red-1"))))
                                                  :options (ps:create
                                                            :animations ps:f)))))
                           (let ((ctx (ps:chain document (get-element-by-id "week_chart"))))
                             (ps:new (-Chart ctx (ps:create
                                                  :type "bar"
                                                  :data (ps:create
                                                         :labels (list ,@(getf week-pages :labels))
                                                         :datasets (list
                                                                    (ps:create :label "Pages"
                                                                               :data (list ,@(getf week-pages :dataset))
                                                                               "backgroundColor" (css-var "--bg-graph-cyan-1"))))
                                                  :options (ps:create
                                                            :animations ps:f))))))))))))))

(setf (ningle:route *app* "/collections/:id/edit" :method :get)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (collection (db:select-collection id))
                 (query (getparam params "q"))
                 (items (db:select-collection-items id))
                 (part-ids (loop with set = (make-hash-table)
                                 for item in items
                                 if (model:part-id item)
                                   do (setf (gethash (model:part-id item) set) t)
                                 finally (return set)))
                 (candidates (remove-if-not (lambda (part)
                                              (not (gethash (model:part-id part) part-ids)))
                                            (cdr (assoc :parts (search:search query :types '(:part) :limit 25))))))
            (with-base-template (:title (format nil "Quixote - ~A"
                                                (model:collection-title collection)))
              (:header
               (breadcrumbs-template collection)
               (:h1 (model:collection-title collection))
               (:p (:a :href (format nil "/collections/~D" (model:collection-id collection))
                       "View Collection")))
              (:section :class "collection"
                        (:form :name "items" :method "post" :action (format nil "/collections/~D/items"
                                                                            (model:collection-id collection))
                               (:table
                                (:thead
                                 (:tr
                                  (:th "Position")
                                  (:th "Item")
                                  (:th "Delete")))
                                (:tbody
                                 (loop for item in items
                                       for content = (model:item-item item)
                                       do (:tr
                                           (:td :class "position"
                                                (:input :type "hidden" :name "ids[]" :value (model:item-id item))
                                                (:input :type "number" :name "positions[]" :value (model:item-position item)))
                                           (:td (etypecase content
                                                  (model:collection
                                                   (:a :href (format nil "/collections/~D" (model:collection-id content))
                                                       (model:collection-title content)))
                                                  (model:part
                                                   (:a :href (format nil "/parts/~D" (model:part-id content))
                                                       (model:part-title content))
                                                   (when (model:part-title-p content)
                                                     (:span "from")
                                                     (:a :href (format nil "/editions/~D" (model:edition-id (model:part-edition content)))
                                                         (model:edition-full-title (model:part-edition content)))))))
                                           (:td :class "delete"
                                                (:input :type "checkbox"  :name "delete[]" :value (model:item-id item)))))))
                               (:button :type "submit" :name "action" :value "update" "Update Positions")
                               (:button :class "delete" :type "submit" :name "action" :value "delete" "Delete Selected")))
              (:section :class "forms"
                        (:h2 "Forms")
                        (:section :class "form"
                                  (:h3 "Search for Parts")
                                  (:form :name "part_search" :method "get" :action ""
                                         (:label :for "q" "Query")
                                         (:input :type "text" :name "q" :value query)
                                         (:button :type "submit" "Search")))
                        (when query
                          (:section :class "form"
                                    (:h3 "Add Part")
                                    (if candidates
                                        (:form :name "part" :method "post" :action "/items"
                                               (:input :type "hidden" :name "collection_id" :value (model:collection-id collection))
                                               (:input :type "hidden" :name "q" :value query)
                                               (:label :for "part_id" "Part")
                                               (:select :name "part_id" :multiple t
                                                 (loop for part in candidates
                                                       do (:option :value (model:part-id part)
                                                                   (if (model:part-title-p part)
                                                                       (format nil "~A: ~A"
                                                                               (model:edition-title (model:part-edition part))
                                                                               (model:part-title part))
                                                                       (model:part-title part)))))
                                               (:button :type "submit" "Add"))
                                        (:p "No candidates matched query."))))
                        (:section :class "form"
                                  (:h3 "New Subcollection")
                                  (collection-form collection))
                        (:section :class "form"
                                  (:h3 "Update Collection")
                                  (collection-form nil collection))
                        (delete-template (format nil "/collections/~D/delete" (model:collection-id collection))))))))

(setf (ningle:route *app* "/collections/:id" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (parent-id (safe-parse-integer (getparam params "parent_id")))
                 (valid-parent (and parent-id (< 0 parent-id)))
                 (title (getparam params "title"))
                 (type (getparam params "type"))
                 (collection (db:select-collection id)))

            (setf (model:collection-title collection) title)
            (setf (model:collection-type collection) type)
            (db:update-collection collection)

            (let* ((items (db:select-content-items collection))
                   (main (first items)))
              ;; delete extra items (single parent
              (loop for item in (if valid-parent
                                    (rest items)
                                    items)
                    do (db:delete-collection-item item))
              (when valid-parent
                (if main
                    (progn
                      (setf (model:item-collection main)
                            (db:select-collection parent-id))
                      (db:update-collection-item main))
                    (db:insert-collection-item
                     (model:make-item :collection (db:select-collection parent-id)
                                      :position nil
                                      :item collection)))))

            `(303 (:location ,(format nil "/collections/~D/edit" (model:collection-id collection))) ()))))

(setf (ningle:route *app* "/collections/:id/delete" :method :post)
      #'(lambda (params)
          (let ((collection (db:select-collection (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null collection)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-collection collection)
               `(303 (:location ,(format nil "/collections"))))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

(setf (ningle:route *app* "/items" :method :post)
      #'(lambda (params)
          (let* ((collection-id (safe-parse-integer (getparam params "collection_id")))
                 (query (getparam params "q"))
                 (part-ids (loop for (key . value) in params
                                 if (string-equal "part_id" key)
                                   collect (safe-parse-integer value)))
                 (collection (db:select-collection collection-id)))

            (loop for part-id in part-ids
                  do (db:insert-collection-item
                      (model:make-item :collection collection
                                       :position nil
                                       :item (db:select-part part-id))))

            `(303 (:location ,(format nil "/collections/~D/edit~@[?q=~A~]"
                                      (model:collection-id collection)
                                      (when query (quri:url-encode query))))
                  ()))))

(setf (ningle:route *app* "/collections/:id/items" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (action (getparam params "action"))
                 (ids (loop for (key . value) in params
                            if (string-equal "ids[]" key)
                              collect (safe-parse-integer value)))
                 (positions (loop for (key . value) in params
                                  if (string-equal "positions[]" key)
                                    collect (safe-parse-integer value)))
                 (deletes (loop for (key . value) in params
                                if (string-equal "delete[]" key)
                                  collect (safe-parse-integer value)))
                 (collection (db:select-collection id)))

            (cond
              ((string-equal "update" action)
               (loop for id in ids
                     for pos in positions
                     for item = (db:select-collection-item id)
                     do (setf (model:item-position item) pos)
                     do (db:update-collection-item item)))
              ((string-equal "delete" action)
               (loop for id in deletes
                     for item = (db:select-collection-item id)
                     do (db:delete-collection-item item))))

            `(303 (:location ,(format nil "/collections/~D/edit" (model:collection-id collection))) ()))))

(setf (ningle:route *app* "/collections/:id/activity" :method :get)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (collection (db:select-collection id))
                 (tree (db:select-collection-tree collection))
                 (sessions (db:select-collection-tree-sessions tree))
                 (base (dates:date-prev (dates:today) :monday))
                 (activity (stats:week-activity sessions))
                 (oldest (stats::oldest sessions)))
            (with-base-template (:title (format nil "Quixote - ~A Activity" (model:collection-title collection)) :charts-p t)
              (:header
               (:h1 "Charts")
               (:p "for" (:a :href (format nil "/collections/~D" (model:collection-id collection))
                             (model:collection-title collection))))
              (:section :class "chart"
                        (:h2 "Pace")
                        (:canvas :id "pace_chart"))

              (loop for year = (dates:date-start-of-year base) then (dates:make-date (1- (dates:date-year year)) 1 1)
                    while (<= (dates:date-year oldest) (dates:date-year year))
                    do (:section :class "year"
                                 (:h2 (format nil "~d" (dates:date-year year)))
                                 (:div :class "months"
                                       (loop for index from 1 to 12
                                             for month = (dates:make-date (dates:date-year year) index 1)
                                             if (dates:date< month base)
                                               do (month-template activity month)))))

              (let* ((pace (stats:pace sessions)))
                (:script
                 (:raw (ps:ps*
                        `(let ((ctx (ps:chain document (get-element-by-id "pace_chart"))))
                           (ps:new (-Chart ctx (ps:create
                                                :type "line"
                                                :data (ps:create
                                                       :labels (list ,@(getf pace :labels))
                                                       :datasets (list
                                                                  (ps:create :label "Quarter Pace"
                                                                             :data (list ,@ (getf pace :quarter-dataset))
                                                                             "backgroundColor" (css-var "--bg-graph-cyan-1")
                                                                             "borderColor" (css-var "--bg-graph-cyan-1"))
                                                                  (ps:create :label "Year Pace"
                                                                             :data (list ,@ (getf pace :year-dataset))
                                                                             "backgroundColor" (css-var "--bg-graph-green-1")
                                                                             "borderColor" (css-var "--bg-graph-green-1"))
                                                                  (ps:create :label "Month Pace"
                                                                             :data (list ,@ (getf pace :month-dataset))
                                                                             "backgroundColor" (css-var "--bg-inactive")
                                                                             "borderColor" (css-var "--bg-inactive"))

                                                                  ))
                                                :options (ps:create
                                                          :animations ps:f
                                                          :datasets (ps:create
                                                                     :line (ps:create
                                                                            "pointStyle" ps:f)))))))))))))))

;;; Readthroughs

(defun readthrough-form (collection &optional readthrough)
  (with-html
    (:form :name "readthrough" :method "post" :action (if readthrough
                                                          (format nil "/readthroughs/~D" (model:readthrough-id readthrough))
                                                          "/readthroughs")

           (if (or collection readthrough)
               (:input :type "hidden" :name "collection_id" :value (model:collection-id
                                                                    (if readthrough
                                                                        (model:readthrough-collection readthrough)
                                                                        collection)))
               (progn
                 (:label :for "collection_id" "Collection")
                 (:select :name "collection_id"
                   (loop for collection in (db:select-collections)
                         do (:option :value (model:collection-id collection)
                                     (model:collection-title collection))))))

           (if readthrough
               (progn
                 (:label :for "status" "Status")
                 (:select :name "status"
                   (loop for (status-value . status-friendly) in model:+statuses+
                         for selected = (when readthrough
                                          (string-equal status-value (model:readthrough-status readthrough)))
                         do (:option :value status-value :selected selected status-friendly))))
               (:input :type "hidden" :name "status" :value "not-started"))

           (:button :type "submit" (if readthrough "Submit" "Start Readthrough")))))

(setf (ningle:route *app* "/readthroughs" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (with-base-template (:title "Quixote - Readthroughs")
            (:h1 "Readthroughs")
            (loop for readthrough in (db:select-readthroughs)
                  do (readthrough-template readthrough)))))

(setf (ningle:route *app* "/readthroughs" :method :post)
      #'(lambda (params)
          (let* ((collection-id (safe-parse-integer (getparam params "collection_id")))
                 (collection (db:select-collection collection-id))
                 (readthrough (db:insert-readthrough (model:make-readthrough :collection collection
                                                                             :status "not-started"
                                                                             :started-at nil
                                                                             :ended-at nil))))
            `(303 (:location ,(format nil "/readthroughs/~D" (model:readthrough-id readthrough))) ()))))

(setf (ningle:route *app* "/readthroughs/:id" :method :get)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (readthrough (db:select-readthrough id))
                 (collection (model:readthrough-collection readthrough)))
            (with-base-template (:title (format nil "Quixote - ~A Readthrough"
                                                (model:collection-title collection)))
              (:h1 (:a :href (format nil "/collections/~D" (model:collection-id collection))
                       (model:collection-title collection)))
              (readthrough-template readthrough :mode :full)

              (:section :class "forms"
                        (:h2 "Forms")
                        (:section :class "form"
                                  (:h3 "Update Readthrough")
                                  (readthrough-form nil readthrough))
                        (delete-template (format nil "/readthroughs/~D/delete" (model:readthrough-id readthrough))))))))

(setf (ningle:route *app* "/readthroughs/:id" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (status (getparam params "status"))
                 (readthrough (db:select-readthrough id)))
            (if (null readthrough)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (setf (model:readthrough-status readthrough) status)
                  (refresh-readthrough readthrough)
                  `(303 (:location ,(format nil "/readthroughs/~D" (model:readthrough-id readthrough))) ()))))))

(setf (ningle:route *app* "/readthroughs/:id/attach" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (readthrough (db:select-readthrough id))
                 (reading-id (safe-parse-integer (getparam params "reading_id")))
                 (reading (db:select-reading reading-id)))
            (cond
              ((null readthrough)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((null reading)
               '(400 (:content-type "text/plain") ("Bad Request")))
              (t
               (setf (model:reading-readthrough reading) readthrough)
               (db:update-reading reading)
               (refresh-readthrough readthrough)
               `(303 (:location ,(format nil "/readthroughs/~D" (model:readthrough-id readthrough))) ()))))))

(setf (ningle:route *app* "/readthroughs/:id/delete" :method :post)
      #'(lambda (params)
          (let ((readthrough (db:select-readthrough (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null readthrough)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-readthrough readthrough)
               '(303 (:location "/readthroughs") ()))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

;;; Readings

(defun reading-form (readthrough part &optional reading)
  (with-html
    (:form :name "reading" :method "post" :action (if reading
                                                      (format nil "/readings/~D" (model:reading-id reading))
                                                      "/readings")

           (:input :type "hidden" :name "readthrough_id" :value (model:readthrough-id
                                                                 (if reading
                                                                     (model:reading-readthrough reading)
                                                                     readthrough)))


           (:input :type "hidden" :name "part_id" :value (model:part-id
                                                          (if reading
                                                              (model:reading-part reading)
                                                              part)))


           (if reading
               (progn
                 (:label :for "status" "Status")
                 (:select :name "status"
                   (loop for (status-value . status-friendly) in model:+statuses+
                         for selected = (when reading
                                          (string-equal status-value (model:reading-status reading)))
                         do (:option :value status-value :selected selected status-friendly))))
               (:input :type "hidden" :name "status" :value "not-started"))

           (:button :type "submit" (if reading "Submit" "Start Reading")))))

(setf (ningle:route *app* "/readings" :method :get)
      #'(lambda (params)
          (let* ((page (or (safe-parse-integer (getparam params "page")) 1))
                 (page-size 10)
                 (offset (* page-size (1- page)))
                 (readings (db:select-readings :limit page-size :offset offset)))
            (with-base-template (:title "Quixote - Readings")
              (:h1 "Readings")
              (paging-template "/readings" readings page page-size)
              (loop for reading in readings
                    do (reading-template reading :mode :standalone))
              (paging-template "/readings" readings page page-size)))))

(setf (ningle:route *app* "/readings" :method :post)
      #'(lambda (params)
          (let* ((readthrough-id (safe-parse-integer (getparam params "readthrough_id")))
                 (readthrough (db:select-readthrough readthrough-id))
                 (part-id (safe-parse-integer (getparam params "part_id")))
                 (part (db:select-part part-id))
                 (status (getparam params "status"))
                 (reading (db:insert-reading (model:make-reading :readthrough readthrough
                                                                 :part part
                                                                 :status (or status "not-started")
                                                                 :started-at nil
                                                                 :ended-at nil))))
            `(303 (:location ,(format nil "/readings/~d" (model:reading-id reading))) ()))))

(setf (ningle:route *app* "/readings/:id" :method :get)
      #'(lambda (params)
          (let* ((reading (db:select-reading (parse-integer (getparam params :id))))
                 (part (model:reading-part reading))
                 (readthrough (model:reading-readthrough reading))
                 (edition (model:part-edition part)))
            (if (null reading)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A Reading" (model:edition-title edition)) :charts-p t)
                  (:header
                   (when readthrough
                     (:p (:a :href (format nil "/readthroughs/~D" (model:readthrough-id readthrough))
                             (format nil "~A (~A)"
                                     (model:collection-title (model:readthrough-collection readthrough))
                                     (write-date (model:readthrough-started-at readthrough))))))
                   (:h1 (:a :href (format nil "/parts/~d" (model:part-id part))
                            (model:part-title part)))
                   (:p "from" (:a :href (format nil "/editions/~d" (model:edition-id edition))
                                  (model:edition-title edition))))
                  (reading-template reading :mode :full)

                  (:section
                   (:h2 "Charts")
                   (:section :class "chart"
                             (:h3 "Progress")
                             (:canvas :id "progress_chart")))

                  (:section
                   (:h2 "Forms")
                   (:section :class "form"
                             (:h3 "Update Reading")
                             (reading-form nil nil reading)))
                  (delete-template (format nil "/readings/~d/delete" (model:entity-id reading)))

                  (let* ((sessions (db:select-reading-sessions reading))
                         (progress (stats:progress sessions)))
                    (:script
                     (:raw (ps:ps*
                            `(let ((ctx (ps:chain document (get-element-by-id "progress_chart"))))
                               (ps:new (-Chart ctx (ps:create
                                                    :type "line"
                                                    :data (ps:create
                                                           :labels (list ,@(getf progress :labels))
                                                           :datasets (list
                                                                      (ps:create :label "Pages"
                                                                                 :data (list ,@ (getf progress :dataset))
                                                                                 "backgroundColor" (css-var "--bg-graph-cyan-1")
                                                                                 "borderColor" (css-var "--bg-graph-cyan-1"))))
                                                    :options (ps:create
                                                              :animations ps:f
                                                              :datasets (ps:create
                                                                         :line (ps:create
                                                                                :stepped ps:f
                                                                                "pointStyle" ps:f))
                                                              :scales (ps:create
                                                                       :y (ps:create
                                                                           "suggestedMin" ,(model:part-page-start part)
                                                                           "suggestedMax" ,(model:part-page-end part)))))))))))))))))

(setf (ningle:route *app* "/readings/:id" :method :post)
      #'(lambda (params)
          (let* ((id (parse-integer (getparam params :id)))
                 (readthrough-id (safe-parse-integer (getparam params "readthrough_id")))
                 (part-id (safe-parse-integer (getparam params "part_id")))
                 (status (getparam params "status"))
                 (reading (db:select-reading id)))
            (cond
              ((null reading)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and part-id (not (eql part-id (model:part-id (model:reading-part reading)))))
               '(400 (:content-type "text/plain") ("Bad Request")))
              (t
               (let* ((old-readthrough (model:reading-readthrough reading))
                      (readthrough-change-p (not (eql readthrough-id
                                                      (model:readthrough-id old-readthrough))))
                      (new-readthrough (when readthrough-change-p
                                     (db:select-readthrough readthrough-id))))
                 (when readthrough-change-p
                   (setf (model:reading-readthrough reading) new-readthrough))
                 (when status
                   (setf (model:reading-status reading) status))
                 (refresh-reading reading)
                 (when readthrough-change-p
                   (when old-readthrough
                     (refresh-readthrough old-readthrough))
                   (when new-readthrough
                     (refresh-readthrough new-readthrough))))
               `(303 (:location ,(format nil "/readings/~d" id)) ()))))))

(setf (ningle:route *app* "/readings/:id/delete" :method :post)
      #'(lambda (params)
          (let ((reading (db:select-reading (parse-integer (getparam params :id))))
                (confirm1 (getparam params "confirm1"))
                (confirm2 (getparam params "confirm2")))
            (cond
              ((null reading)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-reading reading)
               `(303 (:location ,(format nil "/parts/~d" (model:part-id (model:reading-part reading))))))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

(setf (ningle:route *app* "/sessions" :method :get)
      #'(lambda (params)
          (let* ((page (or (safe-parse-integer (getparam params "page")) 1))
                 (page-size 100)
                 (offset (* page-size (1- page)))
                 (sessions (db:select-sessions :limit page-size :offset offset)))
            (with-base-template (:title "Quixote - Sessions")
              (:h1 "Sessions")
              (paging-template "/sessions" sessions page page-size)
              (sessions-template sessions)
              (paging-template "/sessions" sessions page page-size)))))

(setf (ningle:route *app* "/sessions" :method :post)
      #'(lambda (params)
          (let* ((reading (db:select-reading (parse-integer (getparam params "reading_id"))))
                 (readthrough (model:reading-readthrough reading))
                 (start (safe-parse-integer (getparam params "page_start")))
                 (end (safe-parse-integer (getparam params "page_end"))))
            (db:insert-session (model:make-session :reading reading
                                                   :at (dates:today)
                                                   :page-start start
                                                   :page-end end))
            (refresh-reading reading)
            (when readthrough
              (refresh-readthrough readthrough))
            `(303 (:location ,(format nil "/readings/~d" (model:reading-id reading))) ()))))

(setf (ningle:route *app* "/sessions/:id" :method :get)
      #'(lambda (params)
          (let ((session (db:select-session (parse-integer (getparam params :id)))))
            (if (null session)
                '(404 (:content-type "text/plain") ("Not Found"))
                (with-base-template (:title (format nil "Quixote - ~A ~A Session"
                                                    (model:edition-title (model:part-edition (model:reading-part (model:session-reading session))))
                                                    (dates:write-iso-date nil (model:session-at session))))
                  (let* ((reading (model:session-reading session))
                         (part (model:reading-part reading))
                         (edition (model:part-edition part)))
                    (if (model:part-title-p part)
                        (:header
                         (:h1 (:a :href (format nil "/readings/~d" (model:reading-id reading))
                                  (model:part-title part)))
                         (:p "from" (:a :href (format nil "/editions/~d" (model:edition-id edition))
                                        (model:edition-title edition))))
                        (:h1 (:a :href (format nil "/readings/~d" (model:reading-id reading))
                                 (model:edition-title edition)))))
                  (:section
                   (:h2 "Forms")
                   (:section :class "form"
                             (:h3 "Update Session")
                             (:form :name "session" :method "post" :action ""
                                    (:label :for "at" "Date")
                                    (:input :type "date" :name "at" :value (write-date (model:session-at session)))
                                    (:label :for "page_start" "Start Page")
                                    (:input :type "number" :name "page_start" :required t :min 1 :value (model:session-page-start session))
                                    (:label :for "page_end" "End Page")
                                    (:input :type "number" :name "page_end" :required t :min 1 :value (model:session-page-end session))
                                    (:button :type "submit" "Submit")))
                   (delete-template (format nil "/sessions/~d/delete" (model:session-id session)))))))))

(setf (ningle:route *app* "/sessions/:id" :method :post)
      #'(lambda (params)
          (let* ((session (db:select-session (parse-integer (getparam params :id))))
                 (reading (model:session-reading session))
                 (readthrough (model:reading-readthrough reading))
                 (at (dates:parse-iso-date (getparam params "at")))
                 (start (safe-parse-integer (getparam params "page_start")))
                 (end (safe-parse-integer (getparam params "page_end"))))
            (if (null session)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (setf (model:session-at session) at)
                  (setf (model:session-page-start session) start)
                  (setf (model:session-page-end session) end)
                  (db:update-session session)

                  (refresh-reading reading)
                  (when readthrough
                    (refresh-readthrough readthrough))

                  `(303 (:location ,(format nil "/readings/~d" (model:reading-id (model:session-reading session))))))))))

(setf (ningle:route *app* "/sessions/:id/delete" :method :post)
      #'(lambda (params)
          (let* ((session (db:select-session (parse-integer (getparam params :id))))
                 (reading (model:session-reading session))
                 (readthrough (model:reading-readthrough reading))
                 (confirm1 (getparam params "confirm1"))
                 (confirm2 (getparam params "confirm2")))
            (cond
              ((null session)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (db:delete-session session)
               (refresh-reading reading)
               (when readthrough
                 (refresh-readthrough readthrough))
               `(303 (:location ,(format nil "/readings/~D" (model:reading-id (model:session-reading session))))))
              (t
               '(400 (:content-type "text/plain") ("Bad Request")))))))

;;; Search

(setf (ningle:route *app* "/api/search" :method :get)
      #'(lambda (params)
          (let* ((query (getparam params "q"))
                 (page (or (safe-parse-integer (getparam params "page")) 1))
                 (page-size 20)
                 (offset (* page-size (1- page)))
                 (results (search:search query :limit page-size :offset offset)))
            `(200
              (:content-type "application/json")
              (,(labels ((make-result (kind url description)
                           (let ((result (make-hash-table :test #'equalp)))
                             (setf (gethash "kind" result) kind)
                             (setf (gethash "url" result) url)
                             (setf (gethash "description" result) description)
                             result)))
                  (jzon:stringify (loop with array = (make-array 10 :adjustable t :fill-pointer 0)
                                        for section in results
                                        for kind = (car section)
                                        for items = (cdr section)
                                        do (loop for item in items
                                                 for result = (make-result (let ((small (string-downcase kind)))
                                                                             (subseq small 0 (1- (length small))))
                                                                           (format nil "/~A/~D"
                                                                                   (string-downcase kind)
                                                                                   (model:entity-id item))
                                                                           (etypecase item
                                                                             (model:author
                                                                              (model:author-name item))
                                                                             (model:edition
                                                                              (model:edition-full-title item))
                                                                             (model:part
                                                                              (model:part-title item))
                                                                             (model:collection
                                                                              (model:collection-title item))))
                                                 do (vector-push-extend result array))
                                        finally (return array)))))))))

(setf (ningle:route *app* "/search" :method :get)
      #'(lambda (params)
          (let* ((query (getparam params "q"))
                 (page (or (safe-parse-integer (getparam params "page")) 1))
                 (page-size 20)
                 (offset (* page-size (1- page)))
                 (results (search:search query :limit page-size :offset offset))
                 (largest (loop with result = nil
                                for (type . items) in results
                                when (< (length result) (length items))
                                  do (setf result items)
                                finally (return result)))
                 (authors (cdr (assoc :authors results)))
                 (editions (cdr (assoc :editions results)))
                 (parts (cdr (assoc :parts results)))
                 (collections (cdr (assoc :collections results))))
            (with-base-template (:title "Quixote - Search" :query query)
              (:h1 "Results")
              (paging-template (format nil "/search?q=~A" query) largest page page-size)
              (when authors
                (:section :class "results"
                          (:h2 "Authors")
                          (:table
                           (:thead
                            (:th "Name")
                            (:th "Birth")
                            (:th "Death"))
                           (:tbody
                            (loop for author in authors
                                  do (:tr
                                      (:td (:a :href (format nil "/authors/~D" (model:author-id author))
                                               (model:author-name author)))
                                      (:td (write-human-date (model:author-birth author)))
                                      (:td (write-human-date (model:author-death author)))))))))
              (when editions
                (:section :class "results"
                          (:h2 "Editions")
                          (:table
                           (:thead
                            (:th "Edition")
                            (:th "Author")
                            (:th "Translator")
                            (:th "Date"))
                           (:tbody
                            (loop for edition in editions
                                  for author = (model:edition-author edition)
                                  for translator = (model:edition-translator edition)
                                  do (:tr
                                      (:td (:a :href (format nil "/editions/~D" (model:edition-id edition))
                                               (model:edition-full-title edition)))
                                      (:td (when author
                                             (:a :href (format nil "/authors/~D" (model:author-id author))
                                                 (model:author-name author))))
                                      (:td (when translator
                                             (:a :href (format nil "/authors/~D" (model:author-id translator))
                                                 (model:author-name translator))))
                                      (:td (write-human-date (model:edition-year edition)))))))))

              (when parts
                (:section :class "results"
                          (:h2 "Parts")
                          (:table
                           (:thead
                            (:th "Part")
                            (:th "Edition")
                            (:th "Author")
                            (:th "Date"))
                           (:tbody
                            (loop for part in parts
                                  for edition = (model:part-edition part)
                                  for author = (model:edition-author edition)
                                  do (:tr
                                      (:td (:a :href (format nil "/parts/~D" (model:part-id part))
                                               (model:part-title part)))
                                      (:td (:a :href (format nil "/editions/~D" (model:edition-id edition))
                                               (model:edition-full-title edition)))
                                      (:td (when author
                                             (:a :href (format nil "/authors/~D" (model:author-id author))
                                                 (model:author-name author))))
                                      (:td (model:edition-year edition))))))))

              (when collections
                (:section :class "results"
                          (:h2 "Collections")
                          (:ul
                           (loop for collection in collections
                                 do (:li (:a :href (format nil "/collections/~D" (model:collection-id collection))
                                             (model:collection-title collection)))))))
              (paging-template (format nil "/search?q=~A" query) largest page page-size)))))

;;; Stats

(setf (ningle:route *app* "/activity" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (let* ((base (dates:date-prev (dates:today) :monday))
                 (sessions (db:select-sessions))
                 (activity (stats:week-activity sessions))
                 (oldest (stats::oldest sessions)))
            (with-base-template (:title "Quixote - Activity" :charts-p t)
              (:h1 "Charts")
              (:section :class "chart"
                        (:h2 "Pace")
                        (:canvas :id "pace_chart"))

              (loop for year = (dates:date-start-of-year base) then (dates:make-date (1- (dates:date-year year)) 1 1)
                    while (dates:date<= oldest year)
                    do (:section :class "year"
                                 (:h2 (format nil "~d" (dates:date-year year)))
                                 (:div :class "months"
                                       (loop for index from 1 to 12
                                             for month = (dates:make-date (dates:date-year year) index 1)
                                             if (dates:date< month base)
                                               do (month-template activity month)))))

              (let* ((pace (stats:pace sessions)))
                (:script
                 (:raw (ps:ps*
                        `(let ((ctx (ps:chain document (get-element-by-id "pace_chart"))))
                           (ps:new (-Chart ctx (ps:create
                                                :type "line"
                                                :data (ps:create
                                                       :labels (list ,@(getf pace :labels))
                                                       :datasets (list
                                                                  (ps:create :label "Quarter Pace"
                                                                             :data (list ,@ (getf pace :quarter-dataset))
                                                                             "backgroundColor" (css-var "--bg-graph-cyan-1")
                                                                             "borderColor" (css-var "--bg-graph-cyan-1"))
                                                                  (ps:create :label "Year Pace"
                                                                             :data (list ,@ (getf pace :year-dataset))
                                                                             "backgroundColor" (css-var "--bg-graph-green-1")
                                                                             "borderColor" (css-var "--bg-graph-green-1"))
                                                                  (ps:create :label "Month Pace"
                                                                             :data (list ,@ (getf pace :month-dataset))
                                                                             "backgroundColor" (css-var "--bg-inactive")
                                                                             "borderColor" (css-var "--bg-inactive"))

                                                                  ))
                                                :options (ps:create
                                                          :animations ps:f
                                                          :datasets (ps:create
                                                                     :line (ps:create
                                                                            "pointStyle" ps:f)))))))))))))))

;;; Import

(setf (ningle:route *app* "/import" :method :get)
      #'(lambda (params)
          (declare (ignore params))
          (let ((authors (db:select-authors)))
            (labels ((url (pathname)
                       (if pathname
                           (let ((modified (uiop:safe-file-write-date pathname)))
                             (format nil "/~A~@[?t=~A~]" (enough-namestring pathname *media-dir*)
                                     modified))
                           "/img/missing-cover.png"))
                     (image (image)
                       (with-html
                         (if image
                             (:a :href (url (import:image-thumb-path image))
                                 (:img :class "scan" :src (url (import:image-thumb-path image))))
                             (:img :class "scan" :src "/img/missing-cover.png")))))
              (with-base-template (:title "Quixote - Importer")
                (:header
                 (:h1 "Quixote Importer")
                 (:p :class "auto-refresh"
                     (:label :for "auto-refresh" "Auto Refresh?")
                     (:input :type "checkbox" :id "auto-refresh")))

                ;; Resolve Authors
                (loop for author in (import:staged-authors)
                      do (:section :class "forms"
                                   (:h2 (model:author-name author))
                                   (let ((candidates (import:author-matches author)))
                                     (when candidates
                                       (:section :class "form"
                                                 (:h3 "Merge with Existing Author")
                                                 (:form :name (model:author-openlibrary-id author) :method "get" :action "/authors/merge"
                                                        (:input :type "hidden" :name "return_to" :value "/import")
                                                        (:input :type "hidden" :name "openlibrary_id" :value (model:author-openlibrary-id author))
                                                        (:label :for "match" "Match")
                                                        (:select :name "author_id"
                                                          (loop for candidate in candidates
                                                                do (:option :value (model:author-id candidate) (model:author-name candidate))))

                                                        (:button :type "submit" "Merge")))))

                                   (:section :class "form"
                                             (:h3 "Create New Author")
                                             (:form :method "post" :action "/authors"
                                                    (:input :type "hidden" :name "return_to" :value "/import")
                                                    (:input :type "hidden" :name "name" :value (model:author-name author))
                                                    (:input :type "hidden" :name "sort_name" :value (model:author-sort-name author))
                                                    (:input :type "hidden" :name "birth" :value (write-date (model:author-birth author) nil))
                                                    (:input :type "hidden" :name "death" :value (write-date (model:author-death author) nil))
                                                    (:input :type "hidden" :name "bookbrainz_author_id" :value (model:author-bookbrainz-id author))
                                                    (:input :type "hidden" :name "openlibrary_author_id" :value (model:author-openlibrary-id author))
                                                    (:input :type "hidden" :name "wikidata_id" :value (model:author-wikidata-id author))

                                                    (:button :type "submit" "Create")))))

                ;; Failed Items
                (let ((items (import:staged-items :failed)))
                  (when items
                    (:section :class "failed"
                              (:h2 "Failed Items")
                              (loop for item in items
                                    do (:section
                                        (:h3 (import:staged-item-name item) "»" (model:edition-full-title (import:staged-item-edition item)))
                                        (:form :method "post" :action (format nil "/import/items/~D/complete" (import:staged-item-id item))
                                               (:button :type "submit" "Retry Complete"))
                                        (:details
                                         (:summary "Delete")
                                         (delete-template (format nil "/import/items/~D/delete" (import:staged-item-id item)))))))))

                ;; Completing Items
                (let ((items (import:staged-items :completing)))
                  (when items
                    (:section :class "completing"
                              (:h2 "Completing Items")
                              (:p (format nil "~D items are processing..." (length items))))))

                ;; Resolve Staged
                (import:resolve-staged-authors)
                (loop for item in (import:staged-items)
                      for isbn = (import:staged-item-isbn item)
                      for edition = (import:staged-item-edition item)
                      for author = (when edition (model:edition-author edition))
                      do (:section :class "staged"
                                   (:h2 (import:staged-item-name item))
                                   (:dl
                                    (:dt "Edition")
                                    (:dd (when edition
                                           (if (model:edition-id edition)
                                               (:a :href (format nil "/editions/~D" (model:edition-id edition))
                                                   (model:edition-title edition))
                                               (model:edition-title edition))))
                                    (:dt "Author")
                                    (:dd (when author
                                           (if (model:author-id author)
                                               (:a :href (format nil "/authors/~D" (model:author-id author))
                                                   (model:author-name author))
                                               (model:author-name author))))
                                    (:dt "ISBN")
                                    (:dd isbn))
                                   (:section :class "scans"
                                             (:section
                                              (:h3 "Front Scanned")
                                              (image (import:side-original (import:staged-item-front item)))
                                              (:details
                                               (:summary "Use Original")
                                               (:form :method "post" :action (format nil "/import/items/~D/side" (import:staged-item-id item))
                                                      (:input :type "hidden" :name "side" :value "front")
                                                      (:input :type "hidden" :name "preference" :value "original")
                                                      (:button :type "submit" "Use Original"))))

                                             (:section
                                              (:h3 "Front Cropped")
                                              (image (import:side-cropped (import:staged-item-front item)))
                                              (:details
                                               (:summary "Crop")
                                               (:form :method "post" :action (format nil "/import/items/~D/side" (import:staged-item-id item))
                                                      (:input :type "hidden" :name "side" :value "front")
                                                      (:input :type "hidden" :name "preference" :value "crop")
                                                      (:label :for "left" "Left")
                                                      (:input :type "number" :name "left" :value (import:crop-args-left (import:side-crop-args (import:staged-item-front item))))
                                                      (:label :for "right" "Right")
                                                      (:input :type "number" :name "right" :value (import:crop-args-right (import:side-crop-args (import:staged-item-front item))))
                                                      (:label :for "top" "Top")
                                                      (:input :type "number" :name "top" :value (import:crop-args-top (import:side-crop-args (import:staged-item-front item))))
                                                      (:label :for "bottom" "Bottom")
                                                      (:input :type "number" :name "bottom" :value (import:crop-args-bottom (import:side-crop-args (import:staged-item-front item))))
                                                      (:label :for "rotate" "Rotate")
                                                      (:input :type "number" :name "rotate" :min 0 :max 270 :step 90 :value (import:crop-args-rotate (import:side-crop-args (import:staged-item-front item))))
                                                      (:button :type "submit" "Crop"))))
                                             (:section
                                              (:h3 "Back Scanned")
                                              (image (import:side-original (import:staged-item-back item)))
                                              (:details
                                               (:summary "Use Original")
                                               (:form :method "post" :action (format nil "/import/items/~D/side" (import:staged-item-id item))
                                                      (:input :type "hidden" :name "side" :value "back")
                                                      (:input :type "hidden" :name "preference" :value "original")
                                                      (:button :type "submit" "Use Original"))))

                                             (:section
                                              (:h3 "Back Cropped")
                                              (image (import:side-cropped (import:staged-item-back item)))
                                              (:details
                                               (:summary "Crop")
                                               (:form :method "post" :action (format nil "/import/items/~D/side" (import:staged-item-id item))
                                                      (:input :type "hidden" :name "side" :value "back")
                                                      (:input :type "hidden" :name "preference" :value "crop")
                                                      (:label :for "left" "Left")
                                                      (:input :type "number" :name "left" :value (import:crop-args-left (import:side-crop-args (import:staged-item-back item))))
                                                      (:label :for "right" "Right")
                                                      (:input :type "number" :name "right" :value (import:crop-args-right (import:side-crop-args (import:staged-item-back item))))
                                                      (:label :for "top" "Top")
                                                      (:input :type "number" :name "top" :value (import:crop-args-top (import:side-crop-args (import:staged-item-back item))))
                                                      (:label :for "bottom" "Bottom")
                                                      (:input :type "number" :name "bottom" :value (import:crop-args-bottom (import:side-crop-args (import:staged-item-back item))))
                                                      (:label :for "rotate" "Rotate")
                                                      (:input :type "number" :name "rotate" :min 0 :max 270 :step 90 :value (import:crop-args-rotate (import:side-crop-args (import:staged-item-back item))))
                                                      (:button :type "submit" "Crop")))))

                                   (if (not (import:staged-item-paired-p item))
                                       (:p "Waiting for second cover.")
                                       (progn
                                         (unless (and edition (model:edition-id edition))
                                           (:section :class "form"
                                                     (:h3 "Correct ISBN")
                                                     (:form :method "post" :action (format nil "/import/items/~D/lookup" (import:staged-item-id item))
                                                            (:label :for "isbn" "ISBN")
                                                            (:input :type "text" :name "isbn" :value (import:staged-item-isbn item))
                                                            (:button :type "submit" "Submit"))))

                                         (if (null edition)
                                             (progn
                                               (:section :class "form"
                                                         (:h3 "New Edition from Scratch")
                                                         (:form :method "get" :action "/editions/new"
                                                                (:input :type "hidden" :name "staged_item_id" :value (import:staged-item-id item))
                                                                (:input :type "hidden" :name "return_to" :value "/import")
                                                                (:button :type "submit" "Create")))
                                               (:section :class "form"
                                                         (:h3 "Assign Existing Edition")
                                                         (:form :method "post" :action (format nil "/import/items/~D/assign" (import:staged-item-id item))
                                                                (:input :type "number" :name "edition_id")
                                                                (:button :type "submit" "Assign"))))
                                             (if (model:edition-id edition)
                                                 (:section :class "form"
                                                           (:h3 "Complete Import")
                                                           (:form :method "post" :action (format nil "/import/items/~D/complete" (import:staged-item-id item))
                                                                  (when (import:staged-item-cover-exists-p item)
                                                                    (:p :class "warning" "Completing would overwrite existing cover!"))
                                                                  (:button :type "submit" "Complete")))
                                                 (progn
                                                   (let ((edition-match (import:edition-match edition)))
                                                     (when edition-match
                                                       (:section :class "form"
                                                                 (:h3 "Use Matching Edition")
                                                                 (:form :method "post" :action (format nil "/import/items/~D/assign" (import:staged-item-id item))
                                                                        (:p (:a :href (format nil "/editions/~D" (model:edition-id edition-match))
                                                                                (model:edition-full-title edition-match)))
                                                                        (:input :type "hidden" :name "edition_id" :value (model:edition-id edition-match))
                                                                        (:button :type "submit" "Assign")))))

                                                   (let ((candidates (import:edition-matches edition)))
                                                     (when candidates
                                                       (:section :class "form"
                                                                 (:h3 "Merge with Existing Edition")
                                                                 (:form :name (model:edition-openlibrary-id edition) :method "get" :action "/editions/merge"
                                                                        (:input :type "hidden" :name "staged_item_id" :value (import:staged-item-id item))
                                                                        (:input :type "hidden" :name "return_to" :value "/import")
                                                                        (:input :type "hidden" :name "openlibrary_id" :value (model:edition-openlibrary-id edition))

                                                                        (:label :for "match" "Match")
                                                                        (:select :name "edition_id"
                                                                          (loop for candidate in candidates
                                                                                do (:option :value (model:edition-id candidate) (model:edition-full-title candidate))))

                                                                        (:button :type "submit" "Merge")))))
                                                   (:section :class "form"
                                                             (:h3 "Create New Edition")
                                                             (:form :method "post" :action "/editions"
                                                                    (:input :type "hidden" :name "staged_item_id" :value (import:staged-item-id item))
                                                                    (:input :type "hidden" :name "return_to" :value "/import")

                                                                    (:label :for "title" "Title")
                                                                    (:input :type "text" :name "title" :value (model:edition-title edition))
                                                                    ;; intentionally omit sort_title and let it be computed automatically
                                                                    (:input :type "hidden" :name "disambiguation" :value (model:edition-disambiguation edition))

                                                                    (:label :for "author" "Author")
                                                                    (:select :name "author_id"
                                                                      (:option :value "" "None")
                                                                      (loop with selected-id = (let ((match (import:author-match (model:edition-author edition))))
                                                                                                 (when match
                                                                                                   (model:author-id match)))
                                                                            for author in authors
                                                                            do (:option :value (model:author-id author)
                                                                                        :selected (eql selected-id (model:author-id author))
                                                                                        (model:author-name author))))

                                                                    (:label :for "translator" "Translator")
                                                                    (:select :name "translator_id"
                                                                      (:option :value "" "None")
                                                                      (loop with selected-id = (let ((match (import:author-match (model:edition-translator edition))))
                                                                                                 (when match
                                                                                                   (model:author-id match)))
                                                                            for author in authors
                                                                            do (:option :value (model:author-id author)
                                                                                        :selected (eql selected-id (model:author-id author))
                                                                                        (model:author-name author))))

                                                                    (:label :for "page_start" "Page Start")
                                                                    (:input :type "number" :name "page_start" :required t :min 1 :value (model:edition-page-start edition))
                                                                    (:label :for "page_end" "Page End")
                                                                    (:input :type "number" :name "page_end" :required t :min 1 :value (model:edition-page-end edition))
                                                                    (:label :for "type" "Type")
                                                                    (:select :name "type"
                                                                      (loop for (value . friendly) in model:+domain-types+
                                                                            for selected = (string-equal value import:*default-edition-type*)
                                                                            do (:option :value value :selected selected friendly)))

                                                                    (:input :type "hidden" :name "year" :value (write-date (model:edition-year edition) nil))
                                                                    (:input :type "hidden" :name "isbn" :value (model:edition-isbn edition))
                                                                    (:input :type "hidden" :name "bookbrainz_edition_id" :value (model:edition-bookbrainz-id edition))
                                                                    (:input :type "hidden" :name "openlibrary_book_id" :value (model:edition-openlibrary-id edition))
                                                                    (:input :type "hidden" :name "wikidata_id" :value (model:edition-wikidata-id edition))

                                                                    (:button :type "submit" "Create"))))))))

                                   (delete-template (format nil "/import/items/~D/delete" (import:staged-item-id item)))))

                (:section :class "form"
                          (:h2 "Default Edition Type")
                          (:form :method "post" :action "/import/type"
                                 (:label :for "type" "Type")
                                 (:select :name "type"
                                   (loop for (value . friendly) in model:+domain-types+
                                         for selected = (string-equal value import:*default-edition-type*)
                                         do (:option :value value :selected selected friendly)))
                                 (:button :type "submit" "Submit")))

                (:section
                 (:h2 "Recently Updated Editions")
                 (:ul
                  (loop for edition in (db:select-recent-editions)
                        do (:li (:a :href (format nil "/editions/~D" (model:edition-id edition))
                                    (model:edition-full-title edition))))))
                (:script :src "/js/auto-refresh.js" :defer t))))))

(setf (ningle:route *app* "/import/type" :method :post)
      #'(lambda (params)
          (let* ((type (getparam params "type")))
            (if (assoc type model:+domain-types+ :test #'equalp)
                (progn
                  (setf import:*default-edition-type* type)
                  '(303 (:location "/import") ()))
                '(400 (:content-type "text/plain") ("Bad Request: Invalid type"))))))

(setf (ningle:route *app* "/import/items/:id/lookup" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (item (when id (import:staged-item id)))
                 (isbn (getparam params "isbn")))
            (cond
              ((null item)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((or (null isbn) (zerop (length isbn)))
               '(400 (:content-type "text/plain") ("Bad Request: Missing required 'isbn'")))
              (t
               (import:lookup-staged-item item isbn)
               `(303 (:location "/import") ()))))))

(setf (ningle:route *app* "/import/items/:id/assign" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (item (when id (import:staged-item id)))
                 (edition-id (safe-parse-integer (getparam params "edition_id")))
                 (edition (when edition-id (db:select-edition edition-id))))
            (cond
              ((null item)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((null edition)
               '(400 (:content-type "text/plain") ("Bad Request: Must provide valid 'edition_id'")))
              (t
               (setf (import:staged-item-edition item) edition)
               `(303 (:location "/import") ()))))))

(setf (ningle:route *app* "/import/items/:id/complete" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (item (when id (import:staged-item id)))
                 (edition (when item (import:staged-item-edition item))))
            (cond
              ((null item)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((or (null edition) (null (model:edition-id edition)))
               '(400 (:content-type "text/plain") ("Bad Request: No existing edition set on item")))
              (t
                (import:queue-complete-item item)
                '(303 (:location "/import") ()))))))

(setf (ningle:route *app* "/import/items/:id/side" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (item (import:staged-item id))
                 (side (alexandria:make-keyword (string-upcase (getparam params "side"))))
                 (preference (alexandria:make-keyword (string-upcase (getparam params "preference"))))
                 (left (safe-parse-integer (getparam params "left") 0))
                 (right (safe-parse-integer (getparam params "right") 0))
                 (top (safe-parse-integer (getparam params "top") 0))
                 (bottom (safe-parse-integer (getparam params "bottom") 0))
                 (rotate (safe-parse-integer (getparam params "rotate") 0)))
            (if (null item)
                '(404 (:content-type "text/plain") ("Not Found"))
                (progn
                  (import:staged-item-side item side preference
                                           (import:make-crop-args :left left :right right
                                                                  :top top :bottom bottom
                                                                  :rotate rotate))
                  `(303 (:location "/import") ()))))))

(setf (ningle:route *app* "/import/items/:id/delete" :method :post)
      #'(lambda (params)
          (let* ((id (safe-parse-integer (getparam params :id)))
                 (item (when id (import:staged-item id)))
                 (confirm1 (getparam params "confirm1"))
                 (confirm2 (getparam params "confirm2")))
            (cond
              ((null item)
               '(404 (:content-type "text/plain") ("Not Found")))
              ((and confirm1 confirm2)
               (import:delete-staged-item item)
               '(303 (:location "/import") ()))
              (t
               '(400 (:content-type "text/plain") ("Bad Request: Must confirm delete")))))))
