initial commit

This commit is contained in:
2025-07-06 21:55:38 +02:00
parent cfbbd544bb
commit 77852f1da3

928
exepub.el Normal file
View File

@ -0,0 +1,928 @@
;;; exepub.el --- OrgMode asset workflow management -*- lexical-binding: t -*-
;; Author: Emmanuele Somma <emmanuele@exedre.org>
;; Maintainer: Emmanuele Somma <emmanuele@exedre.org>
;; Version: 0.1
;; PackageRequires: ((emacs "25.1") (org "9.0"))
;; Keywords: org, publishing, workflow, assets
;; URL: https://git.xed.it/exedre/exepub.el
;; License: GNU GPL 3.0
;;; Commentary:
;; This library implements a complete workflow for creating, drafting,
;; publishing, archiving, and disseminating OrgMode assets.
;;
;; Commands provided include:
;; • exepub/article-create — create a new draft asset with metadata;
;; • exepub/regenerate-slug — recompute #+SLUG from TITLE and KEYWORDS;
;; • exepub/disseminate — atomically move a draft into archive,
;; update STATUS and PUBLISHED headers;
;; • exepub/open-drafts
;; • exepub/open-archive
;; • exepub/open-year
;; • exepub/open-attic — quickly jump to respective directories;
;; • exepub/export-to — generate a formatspecific manifestation
;; for a given correspondent.
;;
;; All operations preserve atomicity (with rollback on failure), enforce
;; a clear directory structure under `exepub/home-directory`, and maintain
;; consistent metadata in Org headers.
;;; Code:
(require 'ox-org)
(require 'uuidgen)
(defgroup exepub nil
"Configuration for the exepub workflow: asset production and dissemination."
:group 'applications)
(defcustom exepub/home-directory
"~/Dropbox/LIFE/10-projects/10.70 exepub/container/"
"Base directory for exepub.
Contains the subdirectories: attic, draft, archive, template."
:type 'directory
:group 'exepub)
(defcustom exepub/log-publishing-project
"net.exedre"
"Base project for publishing log."
:type 'string
:group 'exepub)
(defun exepub--ensure-dirs ()
"Ensure the existence of the required subdirectories under `exepub/home-directory`."
(dolist (sub '("attic" "draft" "archive" "template"))
(let ((dir (expand-file-name sub exepub/home-directory)))
(unless (file-directory-p dir)
(make-directory dir t)))))
(defun exepub/article-create (slug keywords)
"Create a new asset under the `draft/` directory.
SLUG is a string where spaces (including multiple) are replaced by hyphens.
KEYWORDS is a comma-separated list; commas become underscores, spaces become hyphens."
(interactive
(list (read-string "Create new article draft with slug: ") ;
(read-string "Keywords (comma-separated): ")))
(exepub--ensure-dirs)
(let* ((timestamp (format-time-string "%Y%m%dT%H%M%S"))
(clean-slug
(downcase
(replace-regexp-in-string
"[[:space:]]+" "-"
(string-trim slug))))
(kw-list (split-string keywords "," t "[ \t\n]*"))
(clean-kws
(mapconcat
(lambda (kw)
(downcase
(replace-regexp-in-string "[[:space:]]+" "-"
(string-trim kw))))
kw-list "_"))
(dirname (format "%s--%s__%s" timestamp clean-slug clean-kws))
(draft-root (expand-file-name "draft" exepub/home-directory))
(template-dir (expand-file-name "template" exepub/home-directory))
(asset-dir (expand-file-name dirname draft-root))
(org-file (expand-file-name "draft.org" asset-dir)))
(make-directory asset-dir t)
(with-temp-buffer
;; Import shared #+OPTIONS from template/options.org if available
(let ((options-file (expand-file-name "options.org" template-dir)))
(when (file-readable-p options-file)
(insert-file-contents options-file)
(unless (bolp) (insert "\n"))))
;; Insert basic headers
(insert (format "#+TITLE: %s\n"
(replace-regexp-in-string "-" " " clean-slug)))
(insert (format "#+CREATED: %s\n"
(format-time-string "%Y-%m-%d")))
(insert "#+STATUS: draft\n")
(insert (format "#+SLUG: %s\n" clean-slug))
(insert (format "#+KEYWORDS: %s\n\n" keywords))
(write-file org-file))
(find-file org-file)
(message "New asset created in %s" asset-dir)))
(defun exepub/regenerate-slug ()
"Regenerate the #+SLUG: value from #+TITLE: and #+KEYWORDS: in the current buffer."
(interactive)
(save-excursion
(goto-char (point-min))
(let (title keywords slug-base kw-list kw-part final-slug)
(unless (re-search-forward "^#\\+TITLE:[ \t]*\\(.+\\)$" nil t)
(user-error "No #+TITLE: found"))
(setq title (match-string 1))
(when (re-search-forward "^#\\+KEYWORDS:[ \t]*\\(.*\\)$" nil t)
(setq keywords (match-string 1)))
;; Build slug from TITLE
(setq slug-base
(downcase
(replace-regexp-in-string
"[:'[:space:]]+" "-"
(string-trim title))))
;; Append keyword part if present
(when keywords
(setq kw-list (split-string keywords "," t "[ \t\n]*")
kw-part
(mapconcat
(lambda (kw)
(downcase
(replace-regexp-in-string "[[:space:]]+" "-"
(string-trim kw))))
kw-list "_")))
(setq final-slug
(if kw-part
(concat slug-base "__" kw-part)
slug-base))
;; Replace or insert the #+SLUG: header
(goto-char (point-min))
(if (re-search-forward "^#\\+SLUG:[ \t]*\\(.*\\)$" nil t)
(replace-match (concat "#+SLUG: " final-slug) t t)
(when (re-search-forward "^#\\+STATUS:" nil t)
(beginning-of-line)
(insert (format "#+SLUG: %s\n" final-slug))))
(message "Slug updated to: %s" final-slug))))
(defcustom exepub/default-created
(format-time-string "%Y-%m-%d")
"Default value for #+CREATED: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-status
"draft"
"Default value for #+STATUS: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-published
""
"Default value for #+PUBLISHED: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-embargo-start
""
"Default value for #+EMBARGO_START: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-embargo-end
""
"Default value for #+EMBARGO_END: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-keywords
""
"Default value for #+KEYWORDS: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-secs
""
"Default value for #+SECS: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-dissemination
""
"Default value for #+DISSEMINATION: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-type
"article"
"Default value for #+TYPE: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-bibliography
"../../bibliografia.bib"
"Default value for #+BIBLIOGRAPHY: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-slug
""
"Default value for #+SLUG: header."
:type 'string
:group 'exepub)
(defcustom exepub/default-license
"CC BY-NC-ND"
"Default value for :LICENSE: property."
:type 'string
:group 'exepub)
(defcustom exepub/default-channels
""
"Default value for #+CHANNELS: header."
:type 'string
:group 'exepub)
(defun exepub/org-export-insert-default-template (orig-fn &rest args)
"Extend `C-c C-e #` export template with all EXEPUB headers."
(let ((org-export-options-alist
(append org-export-options-alist
`((:created "CREATED" nil ,exepub/default-created nil)
(:status "STATUS" nil ,exepub/default-status nil)
(:published "PUBLISHED" nil ,exepub/default-published nil)
(:embargo_start "EMBARGO_START" nil ,exepub/default-embargo-start nil)
(:embargo_end "EMBARGO_END" nil ,exepub/default-embargo-end nil)
(:keywords "KEYWORDS" nil ,exepub/default-keywords nil)
(:secs "SECS" nil ,exepub/default-secs nil)
(:dissemination "DISSEMINATION" nil ,exepub/default-dissemination nil)
(:type "TYPE" nil ,exepub/default-type nil)
(:bibliography "BIBLIOGRAPHY" nil ,exepub/default-bibliography nil)
(:slug "SLUG" nil ,exepub/default-slug nil)
(:channels "CHANNELS" nil ,exepub/default-channels nil)))))
(apply orig-fn args)))
(advice-add 'org-export-insert-default-template :around
#'exepub/org-export-insert-default-template)
(defun exepub/update-header (key value)
"Set or update the Org header #+KEY: VALUE in the current buffer.
If VALUE is a function, call it with the previous header value (or nil)
and use its return as the new value. Otherwise VALUE must be a string."
(save-excursion
(goto-char (point-min))
(let* ((key-up (upcase key))
(regexp (format "^#\\+%s:[ \t]*\\(.*\\)$" key-up))
prev new-val)
(if (re-search-forward regexp nil t)
(progn
(setq prev (match-string 1))
(setq new-val (if (functionp value)
(funcall value prev)
value))
(replace-match (format "#+%s: %s" key-up new-val) t t))
;; Insert new header at top if none found
(setq prev nil)
(setq new-val (if (functionp value)
(funcall value prev)
value))
(goto-char (point-min))
(if (re-search-forward "^#\\+\\w+:" nil t)
(progn
(beginning-of-line)
(insert (format "#+%s: %s\n" key-up new-val)))
(insert (format "#+%s: %s\n" key-up new-val)))))))
(defun exepub/disseminate ()
"Atomically move the current asset from draft to archive and update metadata.
If any step fails, rollback to the original draft state, and set STATUS to 'dissemination'."
(interactive)
(let* ((orig-file (buffer-file-name))
(draft-root (expand-file-name "draft/" exepub/home-directory)))
(unless (and orig-file
(string-prefix-p draft-root
(file-name-directory orig-file)))
(user-error "This must be called on an asset under %sdraft/"
exepub/home-directory))
;; Save buffer if modified
(when (buffer-modified-p)
(save-buffer))
;; Regenerate slug from title and keywords
(exepub/regenerate-slug)
;; Gather paths and names
(let* ((new-slug (save-excursion
(goto-char (point-min))
(if (re-search-forward
"^#\\+SLUG:[ \t]*\\(.+\\)$" nil t)
(match-string 1)
(user-error "No #+SLUG: header"))))
(orig-dir (directory-file-name
(file-name-directory orig-file)))
(created-name (file-name-nondirectory orig-dir))
(creation-ts (car (split-string created-name "--")))
(new-dir-name (concat creation-ts "--" new-slug))
(timestamp (format-time-string "%Y%m%dT%H%M%S"))
(pub-date (format-time-string "%Y-%m-%d"))
(year (format-time-string "%Y"))
(archive-root (expand-file-name "archive/" exepub/home-directory))
(year-dir (expand-file-name year archive-root))
(new-filename (concat timestamp "--" new-slug ".org"))
(draft-new-dir (expand-file-name new-dir-name draft-root))
(archive-new-dir (expand-file-name new-dir-name year-dir))
(temp-file (expand-file-name new-filename orig-dir))
(final-file (expand-file-name new-filename archive-new-dir))
;; stack of rollback actions
(rollback-actions '())
(success nil))
(kill-buffer)
(condition-case err
(progn
;; 1) rename draft.org → temp-file
(rename-file orig-file temp-file)
(push (lambda () (ignore-errors
(rename-file temp-file orig-file)))
rollback-actions)
;; 2) rename orig-dir → draft-new-dir
(unless (string= orig-dir draft-new-dir)
(rename-file orig-dir draft-new-dir)
(push (lambda () (ignore-errors
(rename-file draft-new-dir orig-dir)))
rollback-actions))
;; 3) ensure year-dir exists
(unless (file-directory-p year-dir)
(make-directory year-dir t)
(push (lambda () (ignore-errors
(delete-directory year-dir)))
rollback-actions))
;; 4) move draft-new-dir → archive-new-dir
(rename-file draft-new-dir archive-new-dir)
(push (lambda () (ignore-errors
(rename-file archive-new-dir draft-new-dir)))
rollback-actions)
;; 5) open final file, update headers, save
(find-file final-file)
(exepub/update-header "PUBLISHED" pub-date)
(exepub/update-header "STATUS" "dissemination")
(save-buffer)
(setq success t)
(message "Asset disseminated to %s" archive-new-dir))
(error
;; rollback on error
(unless success
(dolist (act rollback-actions)
(funcall act)))
(error "Dissemination failed: %s" (error-message-string err)))))))
(defun exepub/open-drafts ()
"Open dired in the `draft` directory of `exepub/home-directory`."
(interactive)
(exepub--ensure-dirs)
(dired (expand-file-name "draft" exepub/home-directory)))
(defun exepub/open-archive ()
"Open dired in the `archive` directory of `exepub/home-directory`."
(interactive)
(exepub--ensure-dirs)
(dired (expand-file-name "archive" exepub/home-directory)))
(defun exepub/open-year (year)
"Open dired in the YEAR subdirectory under `archive`.
Create the YEAR directory if it does not exist."
(interactive
(let* ((archive-root (expand-file-name "archive" exepub/home-directory))
(years (when (file-directory-p archive-root)
(cl-remove-if-not
#'file-directory-p
(directory-files archive-root t "^[0-9]\\{4\\}$"))))
(choices (mapcar #'file-name-nondirectory years))
(default-year (format-time-string "%Y"))
(year (completing-read
(format "Year (default %s): " default-year)
choices nil nil nil nil default-year)))
(list year)))
(exepub--ensure-dirs)
(let ((dir (expand-file-name year
(expand-file-name "archive"
exepub/home-directory))))
(unless (file-directory-p dir)
(make-directory dir t))
(dired dir)))
(defun exepub/open-attic ()
"Open dired in the `attic` directory of `exepub/home-directory`."
(interactive)
(exepub--ensure-dirs)
(dired (expand-file-name "attic" exepub/home-directory)))
;; Keybindings for exepub under prefix C-c q
(defvar exepub-command-map
(let ((map (make-sparse-keymap)))
map)
"Keymap for exepub commands under C-c q.")
;; Bind the prefix
(define-key global-map (kbd "C-c q") exepub-command-map)
;; Primary commands
(define-key exepub-command-map (kbd "c") 'exepub/article-create)
(define-key exepub-command-map (kbd "r") 'exepub/regenerate-slug)
(define-key exepub-command-map (kbd "s") 'exepub/disseminate)
(define-key exepub-command-map (kbd "e") 'exepub/export-to)
;; Directory navigation commands (two-letter suffix)
(define-key exepub-command-map (kbd "od") 'exepub/open-drafts)
(define-key exepub-command-map (kbd "oa") 'exepub/open-archive)
(define-key exepub-command-map (kbd "oy") 'exepub/open-year)
(define-key exepub-command-map (kbd "ot") 'exepub/open-attic)
;; If you use `which-key`, you can add descriptive labels:
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
"C-c q" "exepub"
"C-c q c" "create article"
"C-c q r" "regenerate slug"
"C-c q s" "disseminate asset"
"C-c q e" "export to format"
"C-c q od" "open drafts"
"C-c q oa" "open archive"
"C-c q oy" "open year"
"C-c q ot" "open attic"))
(defun exepub/update-option (option value)
"Set or update OPTION in the #+OPTIONS line to VALUE in the current buffer.
If VALUE is a function, it is called with the previous OPTION value (or nil)
and its return is used as the new setting."
(save-excursion
(goto-char (point-min))
(if (re-search-forward "^#\\+OPTIONS:[ \t]*\\(.*\\)$" nil t)
(let* ((opts (split-string (match-string 1) "[ \t]+" t))
;; find previous value for OPTION
(prev (catch 'found
(dolist (opt opts)
(when (string-prefix-p (concat option ":") opt)
(throw 'found
(substring opt (1+ (length option)))))
)
nil))
;; compute new value
(new-val (if (functionp value)
(funcall value prev)
value))
;; remove any existing OPTION entries
(filtered (cl-remove-if
(lambda (opt)
(string-prefix-p (concat option ":") opt))
opts))
;; append the new OPTION
(new-opts (append filtered (list (concat option ":" new-val))))
(joined (string-join new-opts " ")))
;; replace the entire #+OPTIONS: line
(goto-char (point-min))
(if (re-search-forward "^#\\+OPTIONS:[ \t]*\\(.*\\)$" nil t)
(replace-match (concat "#+OPTIONS: " joined) t t)))
;; no OPTIONS line: insert one
(let ((new-val (if (functionp value) (funcall value nil) value)))
(insert (format "#+OPTIONS: %s:%s\n" option new-val))))))
;; export
(defun exepub--license-info (license)
"Parse LICENSE and return plist (:url URL :icons ICON-LIST).
Supports Creative Commons (CC), GNU GPL and GNU FDL.
LICENSE may omit the version (defaults: CC→4.0, GPL→3.0, FDL→1.3)."
(let* ((s (downcase (string-trim license)))
(parts (split-string s "[ \t]+" t))
(first (car parts)))
(cond
;; Creative Commons
((string= first "cc")
(let* ((rest (cdr parts))
;; if last part is numeric, take it as version
(lasttok (car (last rest)))
(version (if (string-match-p "^[0-9]+\\(?:\\.[0-9]+\\)?$" lasttok)
lasttok "4.0"))
;; scheme tokens exclude version
(scheme-toks (if (string= version lasttok)
(butlast rest) rest))
(scheme (mapconcat 'identity
(mapcar #'downcase scheme-toks) "-"))
(url (format "https://creativecommons.org/licenses/%s/%s/"
scheme version))
;; always CC icon plus each element of scheme
(icons (cons "cc"
(mapcar (lambda (tok) tok)
scheme-toks))))
(list :url url
:icons icons)))
;; GNU licenses
((and (member first '("gnu"))
(>= (length parts) 2))
(let* ((family (nth 1 parts)) ; "gpl" or "fdl"
(rest (nthcdr 2 parts))
(lasttok (car (last rest)))
(defaults '(("gpl" . "3.0") ("fdl" . "1.3")))
(default (or (cdr (assoc family defaults)) ""))
(version (if (and lasttok
(string-match-p "^[0-9]+\\(?:\\.[0-9]+\\)?$"
lasttok))
lasttok default))
(url-base (pcase family
("gpl" "https://www.gnu.org/licenses/gpl-%s.html")
("fdl" "https://www.gnu.org/licenses/fdl-%s.html")
(_ "")))
(url (if (and url-base (not (string-empty-p version)))
(format url-base version)
"")))
(list :url url
:icons nil)))
((string= s "©")
;; Copyright symbol → All Rights Reserved
(list :url "" :icons nil :label "All Rights Reserved"))
;; fallback: no specific license info
(t
(list :url ""
:icons nil)))))
(defun exepub--format-org-ts (time)
"Return org active timestamp for TIME with Italian weekday."
(let* ((dow-num (string-to-number (format-time-string "%w" time)))
(dows ["dom" "lun" "mar" "mer" "gio" "ven" "sab"])
(dow (aref dows dow-num)))
(format "<%s %s>"
(format-time-string "%Y-%m-%d" time)
dow)))
(defun exepub--ensure-manifestations-section ()
"Ensure a top-level * Manifestations :noexport: exists."
(save-excursion
(goto-char (point-min))
(unless (re-search-forward "^\\*+ Manifestations" nil t)
(goto-char (point-max))
(insert "\n* Manifestations :noexport:\n"))))
(defun exepub--find-end-of-subtree ()
"Move to end of current Org subtree and return point."
(save-excursion
(org-end-of-subtree t t)
(point)))
(defun exepub--ensure-format-subsection (fmt corr)
"Under * Manifestations, ensure a ** FMT:CORR heading exists."
(save-excursion
(goto-char (point-min))
(re-search-forward "^\\*+ Manifestations" nil t)
(let* ((limit (exepub--find-end-of-subtree))
(heading (format "** %s:%s" fmt corr)))
(unless (save-excursion
(re-search-forward (concat "^" (regexp-quote heading) "$")
limit t))
(goto-char limit)
(insert (concat "\n" heading "\n\n"))))))
(defun exepub--goto-format-subtree (fmt corr)
"Position point at beginning of ** FMT:CORR subtree."
(goto-char (point-min))
(re-search-forward (format "^\\*+ Manifestations" ) nil t)
(let ((limit (exepub--find-end-of-subtree))
(re (format "^** %s:%s" fmt corr)))
(unless (re-search-forward re limit t)
(error "Subsection %s:%s not found" fmt corr))
(point)))
(defun exepub--update-property (prop value)
"In the PROPERTIES drawer at point, set PROP to VALUE.
If PROP exists, replace its entire line; otherwise insert it before :END:."
(save-excursion
(let ((drawer-beg (re-search-forward "^:PROPERTIES:" nil t))
drawer-end)
(unless drawer-beg
;; create drawer if missing
(goto-char (point-max))
(insert "\n:PROPERTIES:\n:END:\n")
(setq drawer-beg (save-excursion
(re-search-backward "^:PROPERTIES:" nil t))))
(setq drawer-end (save-excursion
(goto-char drawer-beg)
(re-search-forward "^:END:" nil t)))
(goto-char drawer-beg)
(if (re-search-forward (format "^:%s:.*$" prop) drawer-end t)
;; replace existing line
(let ((beg (line-beginning-position))
(end (line-end-position)))
(delete-region beg (1+ end))
(goto-char beg)
(insert (format ":%s: %s\n" prop value)))
;; insert new line before :END:
(goto-char drawer-end)
(beginning-of-line)
(insert (format ":%s: %s\n" prop value))))))
(defun exepub/org-timestamp-to-pretty (ts)
"Restituisce TS (stringa Org like <YYYY-MM-DD dow>) formattata più leggibile."
(let* ((plain (replace-regexp-in-string "<[<>]" "" ts)) ; togli
(time-list (org-parse-time-string plain))
;; sec min hour day month year dow ... zone
(time (apply #'encode-time time-list)))
(format-time-string "%A, %e %B %Y" time)))
(defun exepub--insert-license-footer (uuid slug author title year license licensed-to embargoed-to)
"Append an Org-formatted license footer given SLUG, TITLE, YEAR, LICENSE."
(let* ((info (exepub--license-info license))
(url (plist-get info :url))
(icons (plist-get info :icons)))
;; link to object and author/license text
(insert "#+ATTR_HTML: :class license-footer\n")
(insert (format "[[https://exedre.net/objects/%s][%s]] © %s di \
[[https://exedre.org/about][%s]] " uuid title year author))
(if (string= license "©")
(insert (format "Tutti i diritti riservati %s" (if (null licensed-to) "" (format ", questo contenuto è concesso a %s e non può essere destinato ad altri." licensed-to))))
(insert (format "è concesso%s sotto licenza [[%s][%s]] "
(if (null licensed-to) "" (format " a %s" licensed-to))
url license)))
;; icons as inline Org links
(when icons
(dolist (ic icons)
(insert (format "[[https://mirrors.creativecommons.org/presskit/icons/%s.svg]] "
ic))))
(unless (null embargoed-to)
(insert "\n#+ATTR_HTML: :class embargo-footer\n")
(insert (format "Quest'articolo è coperto da embargo \
fino a %s. Per favore non divulgarlo pubblicamente \
online o in stampa fino a quella data. Dopo quella data potrai trovare
la versione definitiva da distribuire al link https://exedre.net/objects/%s" (exepub/org-timestamp-to-pretty embargoed-to) uuid)))))
(defun exepub--property-exists-p (prop)
"Return non-nil if PROP exists in the current PROPERTIES drawer."
(not (null (exepub--get-property prop))))
(defun exepub--get-bool-property (prop)
"Return the value of PROP in the current PROPERTIES drawer, or nil."
(let ((prop-v (exepub--get-property prop)))
(when prop-v
(string= prop-v "t"))))
(defun exepub--get-property (prop)
"Return the value of PROP in the current PROPERTIES drawer, or nil.
Uses `org-entry-get` instead of manual regexp parsing."
(let ((val (org-entry-get (point) prop)))
(and val (not (string= val "")) val)))
(defun exepub--collect-properties ()
"Restituisce unalist di tutte le proprietà PROPERTIES nel buffer corrente."
(save-excursion
(when (re-search-forward "^:PROPERTIES:" nil t)
(let ((end (re-search-forward "^:END:" nil t))
props)
(goto-char (match-beginning 0))
(while (re-search-forward "^:\\([^:]+\\):[ \t]*\\(.*\\)$" end t)
(push (cons (match-string 1) (match-string 2)) props))
props))))
(defun exepub/export-to (format correspondent)
"Create a manifestation of the current asset in FORMAT for CORRESPONDENT, using LICENSE.
Also update the source Org with a * Manifestations section, update its
** FORMAT:CORRESPONDENT properties and then generate the manifest Org,
disable its title export, set STATUS to \"manifestation\" and append
a dynamic license footer."
(interactive
(list (read-string "Format (e.g. html, latex, md, org): " "html")
(read-string "Correspondent (e.g. agenda-digitale): " "personal")))
(let ((orig-file (buffer-file-name)))
(let ((status
(save-excursion
(goto-char (point-min))
(when (re-search-forward "^#\\+STATUS:[ \t]*\\(.*\\)$" nil t)
(string-trim (match-string 1))))))
(unless (string= status "dissemination")
(user-error "export-to only runs when STATUS is dissemination (current: %s)"
status)))
;; 1) ensure we are in archive/
(unless (and orig-file
(string-prefix-p
(expand-file-name "archive/" exepub/home-directory)
(file-name-directory orig-file)))
(user-error "This must be called on a file in archive/"))
(save-buffer)
;; 2) update source Org's *Manifestations* subtree
(let (license-val licensed-to embargoed-to uuid
(prop-hash (make-hash-table :test 'equal)))
(with-current-buffer (find-file-noselect orig-file)
(exepub--ensure-manifestations-section)
(let* ((fmt-down (downcase format))
(corr-root (car (split-string correspondent "--")))
(corr-clean (replace-regexp-in-string
"[^[:alnum:]-]" "" corr-root))
(corr-safe (downcase
(replace-regexp-in-string
"[[:space:]]+" "-" corr-clean)))
(manifest-id (format "%s-%s" fmt-down corr-safe)))
(exepub--ensure-format-subsection fmt-down corr-safe)
(exepub--goto-format-subtree fmt-down corr-safe)
(let ((lock (exepub--get-property "LOCK")))
(when (string= lock "t")
(user-error "Manifestation %s is locked, aborting export" manifest-id)))
(setq licensed-to
(exepub--get-property "LICENSED-TO"))
(setq license-val
(or (exepub--get-property "LICENSE")
exepub/default-license))
(unless (exepub--property-exists-p "LICENSE")
(exepub--update-property "LICENSE" license-val))
(unless (exepub--property-exists-p "UUID")
(exepub--update-property "UUID" (uuidgen-1)))
(exepub--update-property "DELIVERED"
(exepub--format-org-ts (current-time)))
(exepub--update-property "EMBARGO"
(exepub--format-org-ts
(time-add (current-time)
(days-to-time 22))))
(setq embargoed-to
(when (exepub--get-bool-property "SHOW-EMBARGO")
(exepub--get-property "EMBARGO")))
(setq uuid (exepub--get-property "UUID"))
(exepub--goto-format-subtree fmt-down corr-safe)
(dolist (kv (org-entry-properties))
(unless (member (car kv)
'("CATEGORY" "BLOCKED" "ALLTAGS"
"FILE" "PRIORITY" "ITEM"))
(puthash (car kv) (cdr kv) prop-hash)))
(save-buffer)
;; 3) build paths for the manifest
(let* ((asset-dir (file-name-directory orig-file))
(man-root (expand-file-name "manifestation" asset-dir))
(man-dir (expand-file-name manifest-id man-root))
(slug-full (save-excursion
(find-file-noselect orig-file)
(goto-char (point-min))
(and (re-search-forward
"^#\\+SLUG:[[:space:]]*\\(.+\\)$"
nil t)
(match-string 1))))
(slug-root (car (split-string slug-full "__")))
(org-copy (expand-file-name (concat slug-root ".org") man-dir))
(title (save-excursion
(find-file-noselect orig-file)
(goto-char (point-min))
(and (re-search-forward
"^#\\+TITLE:[[:space:]]*\\(.+\\)$"
nil t)
(match-string 1))))
(author (save-excursion
(find-file-noselect orig-file)
(goto-char (point-min))
(and (re-search-forward
"^#\\+AUTHOR:[[:space:]]*\\(.+\\)$"
nil t)
(match-string 1))))
(year (format-time-string "%Y")))
;; 4) prepare and perform Org→Org export
(make-directory man-dir t)
(condition-case _
(org-export-to-file 'org org-copy nil nil nil nil
'(:exclude-tags ("noexport")))
(error (copy-file orig-file org-copy t)))
;; 5) adjust the manifest Org
(with-current-buffer (find-file-noselect org-copy)
(maphash (lambda (k v)
(exepub/update-header k v))
prop-hash)
(exepub/update-option "title" "nil")
(exepub/update-header "STATUS" "manifestation")
(goto-char (point-max))
(exepub--insert-license-footer uuid slug-root author title year license-val licensed-to embargoed-to)
(save-buffer))
;; 6) final export or placeholder
(let ((buf (find-file-noselect org-copy)))
(unwind-protect
(with-current-buffer buf
(pcase fmt-down
("html" (let ((out (org-html-export-to-html nil nil nil nil nil)))
(browse-url-of-file (expand-file-name out man-dir))))
("md" (org-md-export-to-markdown nil nil nil nil nil))
("latex" (org-latex-export-to-pdf nil nil nil nil nil))
("org" (message "Org manifest dissemination: %s" org-copy))
(_ (with-temp-file
(expand-file-name
(format "need-to-be-exported-to-%s" fmt-down)
man-dir)))))
(kill-buffer buf)))
(message "Manifestation %s created in %s" manifest-id man-dir)))))))
;;; exepub-save-hook.el --- Regenerate slug and rename directory on save
(defun exepub--maybe-regenerate-slug-and-rename-on-save ()
"On save under `exepub/home-directory`, adjust slug and rename.
If STATUS is \"manifestation\", rename only the Org file to <slug>.org.
If STATUS is \"dissemination\", rename both containing directory to <creation>--<slug>
and the Org file to <slug>.org.
Otherwise, rename only the containing directory."
(when (and buffer-file-name
(derived-mode-p 'org-mode)
(string-prefix-p
(file-name-as-directory (expand-file-name exepub/home-directory))
(file-name-as-directory (file-name-directory buffer-file-name))))
;; Regenerate slug
(exepub/regenerate-slug)
;; Read STATUS
(let* ((status (save-excursion
(goto-char (point-min))
(when (re-search-forward
"^#\\+STATUS:[ \t]*\\(.*\\)$" nil t)
(string-trim (match-string 1)))))
(file buffer-file-name)
(dir (file-name-directory file))
(parent (directory-file-name dir))
;; extract creation timestamp from dirname "TS--slug"
(base (file-name-nondirectory parent))
(parts (split-string base "--"))
(creation (car parts))
;; get full slug and root (before __)
(full-slug (save-excursion
(goto-char (point-min))
(when (re-search-forward
"^#\\+SLUG:[ \t]*\\(.+\\)$" nil t)
(match-string 1))))
(slug-root (car (split-string full-slug "__")))
;; new names
(new-dir-name (concat creation "--" full-slug))
(new-parent (expand-file-name new-dir-name
(file-name-directory parent)))
(new-file-name (concat slug-root ".org"))
(new-file-path (expand-file-name new-file-name
(if (member status '("dissemination" "default"))
new-parent
parent))))
(pcase status
("manifestation"
;; only rename file
(unless (string= (file-name-nondirectory file) new-file-name)
(rename-file file new-file-path t)
(set-visited-file-name new-file-path t t)))
("dissemination"
;; rename directory, then rename file inside it
(unless (string= parent new-parent)
(rename-file parent new-parent))
(unless (string= (file-name-nondirectory file) new-file-name)
(rename-file
(expand-file-name (file-name-nondirectory file) new-parent)
new-file-path t)
(set-visited-file-name new-file-path t t)))
(_
;; other: rename directory only
(unless (string= parent new-parent)
(rename-file parent new-parent)
(let ((file-in-new (expand-file-name
(file-name-nondirectory file)
new-parent)))
(set-visited-file-name file-in-new t t))))))))
(add-hook 'before-save-hook
#'exepub--maybe-regenerate-slug-and-rename-on-save)
(defcustom exepub/git-base-url
"https://git.xed.it/org.exedre/exepub-repo/src/branch/main"
"Base URL for links to the Git repository."
:type 'string
:group 'exepub)
(defun exepub/log-publish-manifestation ()
"Create a log entry org-file for the current manifestation.
The entry is saved under the base directory of `exepub/log-publishing-project'.
Filename is TIMESTAMPUUID.org. Fields TITLE, AUTHOR, DATE, LICENSE,
and a Git link are recorded."
(interactive)
(let* ((orig (buffer-file-name))
(proj exepub/log-publishing-project)
;; retrieve project plist
(plist (cdr (assoc proj org-publish-project-alist)))
(base (plist-get plist :base-directory)))
(unless (and orig base (file-directory-p base))
(user-error "Cannot find publishing project %s or its base-dir" proj))
;; gather metadata from buffer
(let* ((uuid (or (org-entry-get nil "ID")
(org-entry-get nil "UUID")
(user-error "No ID/UUID in buffer")))
(ts (format-time-string "%Y%m%dT%H%M%S"))
(fn (format "%s-%s.org" ts uuid))
(dest (expand-file-name fn base))
(title (or (org-entry-get nil "TITLE")
(user-error "No #+TITLE: found")))
(author (or (org-entry-get nil "AUTHOR")
user-full-name))
(license (or (org-entry-get nil "LICENSE")
exepub/default-license))
(reldir (file-relative-name orig exepub/home-directory))
(giturl (concat (file-name-as-directory exepub/git-base-url)
reldir)))
;; write the log file
(with-temp-file dest
;; header options
(insert "#+OPTIONS: toc:nil num:nil html-style:nil\n")
(insert "#+HTML_HEAD: <link rel=\"stylesheet\" href=\"../css/tufte.css\" />\n")
(insert "#+HTML_HEAD_EXTRA: <meta charset=\"utf-8\" />\n\n")
;; metadata
(insert (format "#+TITLE: %s\n" title))
(insert (format "#+AUTHOR: %s\n" author))
(insert (format "#+DATE: %s\n" ts))
(insert (format "#+LICENSE: %s\n\n" license))
;; git link
(insert (format "[[%s][View source in Git]]\n" giturl)))
;; optionally open the new log file
(find-file dest)
(message "Published log entry %s" fn))))
;;; exepub-save-hook.el ends here
(provide 'exepub)
;; End Code