miniblog

Miniblog: A command-line static blog system in Common Lisp
Log | Files | Refs | README | LICENSE

commit b4f6e20ff3862358a8e106920422b3396b3a8da7
parent 83660baa6068c8bae988a2615d47f43458508c45
Author: Decay <decaydjk@tilde.town>
Date:   Fri,  7 Feb 2020 21:55:47 +0000

Basic blog functionality

Migrating to cl-emb from eco, support for generating basic blog and
archives

Diffstat:
Mminiblog.asd | 8++++----
Asrc/content.lisp | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/db.lisp | 6++++++
Msrc/miniblog.lisp | 89+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Msrc/packages.lisp | 8+++++++-
Dsrc/template.eco | 42------------------------------------------
Asrc/template.lhtml | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 291 insertions(+), 55 deletions(-)

diff --git a/miniblog.asd b/miniblog.asd @@ -5,19 +5,19 @@ :depends-on ("uiop" "cl-fad" "cl-markdown" - "eco" + "cl-emb" "dbd-sqlite3" "sxql" "mito" "local-time" "str" "command-line-arguments") - :defsystem-depends-on (:eco) :pathname "src/" :entry-point "miniblog:entry-point" :components ((:file "packages") (:file "format") (:file "edit") (:file "db") - (:eco-template "template") - (:file "miniblog"))) + (:file "content") + (:file "miniblog") + (:static-file "template.lhtml"))) diff --git a/src/content.lisp b/src/content.lisp @@ -0,0 +1,124 @@ +(in-package :miniblog.content) + +(defun make-generator (&optional title) + (lambda (entries &key year month tz) + (execute-emb + "default-template" + :env (list + :title title + :posts entries + :content-formatter (miniblog.format:make-content-formatter) + :short-date-formatter (miniblog.format:make-short-date-formatter (or tz *default-timezone*)) + :long-date-formatter (miniblog.format:make-long-date-formatter (or tz *default-timezone*)))))) + +(defun init-generator () + (let ((this-file #.(or *compile-file-truename* *load-truename*))) + (register-emb "default-template" + (make-pathname :name "template" :type "lhtml" + :directory (pathname-directory this-file) + :defaults this-file)))) + +(defun year-month-of-entry (entry &key tz) + (if (not entry) + (error "Entry cannot be nil") + (let* ((real-tz (or tz *default-timezone*)) + (entry-created-at (nth 1 entry)) + (entry-year (timestamp-year entry-created-at :timezone real-tz)) + (entry-month (timestamp-month entry-created-at :timezone real-tz))) + (cons entry-year entry-month)))) + +(defun year-month-of-latest-entry (entries &key tz) + (if (not entries) + (error "List of entries cannot be empty") + (year-month-of-entry (car entries) :tz tz))) + +(defun monthcdr (entries year month tz) + (if (not entries) + nil + (let* + ((year-month (year-month-of-latest-entry entries :tz tz)) + (entry-year (car year-month)) + (entry-month (cdr year-month))) + (cond + ((< entry-year year) entries) + ((and (= entry-year year) (<= entry-month month)) entries) + (t (monthcdr (cdr entries) year month tz)))))) + +(defun collect-entries-for-month (entries year month tz) + (if (not entries) + (cons nil entries) + (let* ((year-month (year-month-of-latest-entry entries :tz tz)) + (entry-year (car year-month)) + (entry-month (cdr year-month))) + (if (or (/= entry-year year) (/= entry-month month)) + (cons nil entries) + (let ((collected (collect-entries-for-month + (cdr entries) year month tz))) + (cons (cons (car entries) (car collected)) (cdr collected))))))) + +(defun gen-all (entries &key generator tz) + (labels + ((gen-all-years (entries next-year generator tz) + (let ((year (gen-year entries next-year :generator generator :tz tz))) + (if (cdr year) + (append + (car year) + (gen-all-years + (cdr year) + (car (year-month-of-latest-entry (cdr year) :tz tz)) + generator tz)) + (car year))))) + (cons + (list :index :index (gen-index entries :generator generator :tz tz)) + (gen-all-years + entries (car (year-month-of-latest-entry entries :tz tz)) + generator tz)))) + +(defun gen-index (entries &key generator tz) + (if (not entries) + (funcall (or generator (make-generator)) nil :tz tz) + (let* ((year-month (year-month-of-latest-entry entries :tz tz)) + (entry-year (car year-month)) + (entry-month (cdr year-month)) + (latest-month-entries (collect-entries-for-month entries entry-year entry-month tz))) + (funcall (or generator (make-generator)) + (car latest-month-entries) + :tz tz)))) + +(defun gen-year (entries year &key generator tz) + (labels + ((gen-month-and-earlier (entries year month tz) + (let ((this-month (gen-month entries year month + :generator generator + :tz tz))) + (if (> month 1) + (let ((earlier (gen-month-and-earlier + (cdr this-month) year (- month 1) tz))) + (cons + (if (car this-month) + (cons (car this-month) (car earlier)) + (car earlier)) + (cdr earlier))) + (cons + (if (car this-month) + (cons (car this-month) nil) + nil) + (cdr this-month)))))) + (gen-month-and-earlier entries year 12 tz))) + +(defun gen-month (entries year month &key generator tz) + (let* ((entries-at-month (monthcdr entries year month tz)) + (collected (collect-entries-for-month + entries-at-month year month tz)) + (month-entries (car collected)) + (rest-entries (cdr collected))) + (cons + (if month-entries + (list year month + (funcall (or generator (make-generator)) + month-entries + :year year + :month month + :tz tz)) + nil) + rest-entries))) diff --git a/src/db.lisp b/src/db.lisp @@ -102,3 +102,9 @@ (save-dao entry) entry)) (error "Post ID ~d not found!" id)))) + +(defun delete-entry (id) + "Delete the specified entry from the database. No-op if the id is invalid." + (let ((entry (get-raw-entry id))) + (if entry + (delete-dao entry)))) diff --git a/src/miniblog.lisp b/src/miniblog.lisp @@ -1,9 +1,14 @@ (in-package :miniblog) +(defvar *blog-timezone* *default-timezone*) +(defvar *public-html* (make-pathname :directory '(:relative "public_html"))) +(defvar *generator*) + (defparameter +command-line-spec+ '((("add" #\a) :type boolean :optional t :documentation "Add new post") (("edit" #\e) :type integer :optional t :documentation "Edit a post by ID") (("get" #\g) :type integer :optional t :documentation "Get a post by ID") + (("delete" #\d) :type integer :optional t :documentation "Delete a post by ID") (("list" #\l) :type boolean :optional t :documentation "List posts") (("start" #\s) :type integer :optional t :documentation "When listing posts, first post to list (default 0)") @@ -26,20 +31,81 @@ (defun get-username () (uiop:getenv "USER")) +(defun make-generator (&optional title) + (miniblog.content:init-generator) + (setf *generator* (miniblog.content:make-generator title))) + +(defun get-index-file-for-path (path) + (merge-pathnames + (make-pathname :name "index" :type "html") + path)) + +(defun get-index-path () + (merge-pathnames *public-html* (user-homedir-pathname))) + +(defun get-monthly-path (year month) + (merge-pathnames + (make-pathname :directory + (list :relative (write-to-string year) + (format nil "~2,'0d" month))) + (get-index-path))) + +(defun regenerate-file (entry) + (let* ((year (nth 0 entry)) + (month (nth 1 entry)) + (content (nth 2 entry)) + (path (if (eql year :index) + (get-index-path) + (get-monthly-path year month))) + (description (if (eql year :index) + "index" + (format nil "archive for ~d/~2,'0d" year month)))) + (format t "Regenerating ~a...~%" description) + (ensure-directories-exist path) + (with-open-file (output (get-index-file-for-path path) + :direction :output :if-exists :supersede) + (princ content output) + (fresh-line output)))) + +(defun regenerate-index-and-given-month (entries year month) + (regenerate-file (list :index :index + (miniblog.content:gen-index entries + :generator *generator* + :tz *blog-timezone*))) + (regenerate-file (list year month + (miniblog.content:gen-month entries year month + :generator *generator* + :tz *blog-timezone*)))) + +(defun regenerate-all (entries) + (let ((all (miniblog.content:gen-all entries + :generator *generator* + :tz *blog-timezone*))) + (format t "~a~%" all) + (mapcar #'regenerate-file all))) + (defun add-new (regen) (let ((text (miniblog.edit:edit-text))) (if text (let* ((post (miniblog.edit:get-title-and-content text)) (title (nth 0 post)) - (content (nth 1 post))) - (miniblog.db:add-entry - (or title "Untitled") - content - :username (get-username))) + (content (nth 1 post)) + (new-entry (miniblog.db:add-entry + (or title "Untitled") + content + :username (get-username))) + (year-month (miniblog.content:year-month-of-entry + new-entry :tz *blog-timezone*)) + (year (car year-month)) + (month (cdr year-month)) + (entries (miniblog.db:get-entries))) + (if (not regen) + (regenerate-index-and-given-month entries year month) + (regenerate-all entries))) (format t "Abandoning post...~%")))) (defun date-format (datetime) - (funcall (miniblog.format:make-long-date-formatter *pst*) datetime)) + (funcall (miniblog.format:make-long-date-formatter *blog-timezone*) datetime)) (defun get-post (id) (miniblog.db:with-entry-id entry id @@ -71,6 +137,10 @@ :username (get-username))) (format t "No change, abandoning...~%"))))) +(defun delete-post (id) + (format t "Deleting post ID ~d...~%" id) + (miniblog.db:delete-entry id)) + (defun list-posts (start n) (let* ((entries (miniblog.db:get-entries)) (first (or start 0)) @@ -82,18 +152,21 @@ (defun init-tz () (reread-timezone-repository) - (defvar *pst* (find-timezone-by-location-name "US/Pacific"))) + (setf *blog-timezone* (find-timezone-by-location-name "US/Pacific"))) (defun get-db-filename () (namestring (merge-pathnames (user-homedir-pathname) (make-pathname :name ".miniblog" :type "db")))) -(defun miniblog (&key add get edit list start n regen-all help) +(defun miniblog (&key add get edit delete list start n regen-all help) (init-tz) + (make-generator) (miniblog.db:init :sqlite3 :database-name (get-db-filename)) (cond (add (add-new regen-all)) (get (get-post get)) (edit (edit-post edit regen-all)) + (delete (delete-post delete)) (list (list-posts start n)) + (regen-all (regenerate-all (miniblog.db:get-entries))) (t (show-option-help +command-line-spec+)))) diff --git a/src/packages.lisp b/src/packages.lisp @@ -13,7 +13,13 @@ (defpackage :miniblog.db (:use :cl :mito :sxql) (:export #:init #:add-entry #:get-entry #:with-entry-id - #:get-entries #:update-entry)) + #:get-entries #:update-entry #:delete-entry)) + +(defpackage :miniblog.content + (:use :cl :local-time :cl-emb) + (:export #:make-generator #:init-generator + #:year-month-of-entry #:year-month-of-latest-entry + #:gen-all #:gen-index #:gen-year #:gen-month)) (defpackage :miniblog (:use :cl :command-line-arguments :local-time) diff --git a/src/template.eco b/src/template.eco @@ -1,42 +0,0 @@ -<% deftemplate index (posts content-formatter short-date-formatter long-date-formatter &key title year month start-entry max-entries) (:escape-html nil) %> - <!DOCTYPE html> - <html> - <head> - <title><%= (or title "Miniblog") %></title> - </head> - <body> - <!-- <%= year %><%= month %><%= start-entry %><%= max-entries %> --> - <% if posts %> - <% let ((short-date)) %> - <% loop for (id created-at updated-at title content username last-updated-by) in posts do %> - <% let ((curr-short-date (funcall short-date-formatter created-at))) %> - <% if (string/= short-date curr-short-date) %> - <% setf short-date curr-short-date %><% end %> - <h1><%= short-date %></h1> - <% else %> - <% end %> - <% end %> - <h2><%= title %></h2> - <article> - <%= (funcall content-formatter content) %> - </article> - <p> - <small> - Posted by <%= username %> on - <%= (funcall long-date-formatter created-at) %> - <% if (local-time:timestamp/= created-at updated-at) %> - <br> - Last updated by <%= last-updated-by %> on - <%= (funcall long-date-formatter updated-at) %> - <% else %> - <% end %> - </small> - </p> - <% end %> - <% end %> - <% else %> - No posts found. - <% end %> - </body> - </html> -<% end %> diff --git a/src/template.lhtml b/src/template.lhtml @@ -0,0 +1,69 @@ +<!DOCTYPE html> +<html> + <head> + <title><%= (or (getf env :title) "Miniblog") %></title> + </head> + <body> + <header> + </header> + <% @if posts %> + <% (let ((short-date)) %> + <% (loop for (id created-at updated-at title content username last-updated-by) in (getf env :posts) do %> + <% (let ((curr-short-date (funcall (getf env :short-date-formatter) created-at))) %> + <% (if (string/= short-date curr-short-date) + (progn + (setf short-date curr-short-date) + (format t "<h1>~A</h1>~%" short-date)))) %> + <h2><%= title %></h2> + <article> + <%= (funcall (getf env :content-formatter) content) %> + </article> + <p> + <small> + Posted by <%= username %> on + <%= (funcall (getf env :long-date-formatter) created-at) %> + <% (if (local-time:timestamp/= created-at updated-at) + (format t "<br>~%Last updated by ~A on ~A~%" + last-updated-by + (funcall (getf env :long-date-formatter) updated-at))) %> + </small> + </p> + <% ) %> + <% ) %> + <% @else %> + No posts found. + <% @endif %> + <nav> + <% @if archive-date-list %> + <% (let ((arc (copy-list (getf env :archive-date-list)))) %> + <% (loop while arc do %> + <div class="cal-title"><%= (car (car arc)) %></div> + <% + (let ((month-entries '()) + (month-names '("Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec"))) + (loop for cal-month from 12 to 1 do + (if (= cal-month (cdr (car arc))) + (progn + (push (format nil "~d/~2,'0d/index.html" (car (car arc)) (cdr (car arc))) month-entries) + (pop arc)) + (push nil month-entries))) + (loop for row from 0 to 2 do %> + <div class="cal-row"> + <% (loop for cal-month from (* row 4) to (+ (* row 4) 3) do %> + <span class="cal-cell"> + <% + (if (nth cal-month month-entries) + (format t "<a href=\"~a\">~a</a>" (nth cal-month month-entries) (nth cal-month month-names)) + (format t "~A" (nth cal-month month-names))) + %> + </span> + <% ) %> + </div> + <% ) %> + <% ) %> + <% ) %> + <% ) %> + <% @endif %> + </nav> + </body> +</html>