diff options
Diffstat (limited to 'lisp')
-rw-r--r-- | lisp/seam-export.el | 321 | ||||
-rw-r--r-- | lisp/seam-html.el | 366 | ||||
-rw-r--r-- | lisp/seam-test.el | 453 | ||||
-rw-r--r-- | lisp/seam.el | 615 |
4 files changed, 1755 insertions, 0 deletions
diff --git a/lisp/seam-export.el b/lisp/seam-export.el new file mode 100644 index 0000000..7fa6c2d --- /dev/null +++ b/lisp/seam-export.el @@ -0,0 +1,321 @@ +;;; seam-export.el --- Seam HTML exporter -*- lexical-binding: t -*- + +;; Copyright (C) 2025 Spencer Williams + +;; Author: Spencer Williams <spnw@plexwave.org> + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Frontend for Seam's HTML exporter. + +;;; Code: + +(require 'cl-lib) +(require 'seam-html) + +(defvar seam-export--types nil) +(defvar seam-export--template nil) +(defvar seam-export--root-path nil) +(defvar seam-export--no-extension nil) +(defvar seam-export--internal-link-class nil) +(defvar seam-export--options nil) + +(defgroup seam-export nil + "Options for Seam exporter." + :tag "Seam Export" + :group 'seam) + +(defcustom seam-export-alist nil + "Association list used by Seam to determine how to export notes. + +The car of each element is an HTML directory to which Seam will export a +subset of notes. The cdr is a plist containing any number of these +properties: + + `:types' + + List of note types to export to this directory. Required. + + `:template-file' + + The HTML template file to be used by the exporter. If this is + missing, falls back to :template-string, `seam-export-template-file', + or `seam-export-template-string' in that order. + + `:template-string' + + The HTML template string to be used by the exporter. If this is + missing, falls back to :template-file, `seam-export-template-file', + or `seam-export-template-string' in that order. + + `:root-path' + + The root path used for rendering internal links. Defaults to \"\", + which means all paths are relative. + + `:no-extension' + + Whether to drop the \".html\" file extension in links. Defaults to + nil. + + `:internal-link-class' + + CSS class name for internal links. Defaults to the value of + `seam-export-internal-link-class'. + + `:backend-options' + + A plist of extra options passed to the Org HTML backend. This can be + used to override any of the defaults set in + `seam-export-backend-options'." + :group 'seam-export + :type '(alist :key-type string :value-type plist)) + +(defcustom seam-export-template-file nil + "The HTML template file to be used by the exporter. The template format +is documented at `seam-export-default-template-string'. + +See `seam-export-alist' for more information about specifying templates." + :group 'seam-export + :type '(choice file (const nil))) + +(defvar seam-export-default-template-string + "<!doctype html> +<html lang=\"en\"> +<head> +<meta charset=\"utf-8\" /> +<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /> +<title>{{title}}</title> +</head> +<body> +<main> +<header> +<h1>{{title}}</h1> +<p class=\"modified\">Last modified: <time datetime=\"{{modified-dt}}\">{{modified}}</time></p> +</header> +{{contents}} +<section class=\"backlinks\"> +<h1>Backlinks</h1> +{{backlinks}} +</section> +</main> +</body> +</html>" + "The default HTML template string if no other template is specified. + +It should be plain HTML5. Several variables are defined which can be +interpolated using the {{variable}} syntax: + + `contents' + + The full HTML contents of the note, sans the title header. + + `title' + + The note's title (HTML-escaped). + + `backlinks' + + A list (<ul>) of notes that link to the given note. + + `modified' + + The human-readable date that the note was last modified. See + `seam-export-time-format'. + + `modified-dt' + + The machine-readable date that the note was last modified. See + `seam-export-time-format-datetime'.") + +(defcustom seam-export-template-string seam-export-default-template-string + "The HTML template string to be used by the exporter. The template +format is documented at `seam-export-default-template-string'. + +See `seam-export-alist' for more information about specifying templates." + :group 'seam-export + :type '(choice string (const nil))) + +(defcustom seam-export-time-format "%e %B %Y" + "Human-readable format for template time strings. Passed to +`format-time-string'." + :group 'seam-export + :type 'string) + +(defcustom seam-export-time-format-datetime "%Y-%m-%d" + "Machine-readable format for template time strings. Meant to be used in +the datetime attribute of <time>. Passed to `format-time-string'." + :group 'seam-export + :type 'string) + +(defcustom seam-export-time-zone t + "Time zone used for template time strings. Passed to +`format-time-string'." + :group 'seam-export + :type 'sexp) + +(defcustom seam-export-internal-link-class nil + "CSS class name to use for internal links (i.e., links to other Seam +notes)." + :group 'seam-export + :type 'string) + +(defvar seam-export-backend-options + (list + :html-container "article" + :html-doctype "html5" + :html-html5-fancy t + :html-text-markup-alist + '((bold . "<strong>%s</strong>") + (code . "<code>%s</code>") + (italic . "<em>%s</em>") + (strike-through . "<s>%s</s>") + (underline . "<span class=\"underline\">%s</span>") + (verbatim . "<code>%s</code>")) + :html-toplevel-hlevel 1 + :html-use-infojs nil + :section-numbers nil + :time-stamp-file nil + :with-smart-quotes t + :with-toc nil)) + +(defmacro seam-export--to-string (&rest body) + (declare (indent 0)) + (let ((buf (gensym))) + `(let ((,buf (generate-new-buffer " *seam-export*"))) + (unwind-protect + (progn (with-temp-buffer + ,@body + ;; This let prevents Org from popping up a window. + (let ((org-export-show-temporary-export-buffer nil)) + (org-export-to-buffer 'seam ,buf nil nil nil t seam-export--options nil))) + (with-current-buffer ,buf + (buffer-string))) + (kill-buffer ,buf))))) + +;;; Some HACK-ery to get fully escaped and smartquote-ized string. +(defun seam-export--escape-string (s) + (string-remove-prefix + "<p>\n" + (string-remove-suffix + "</p>\n" + (seam-export--to-string + (insert s))))) + +(defun seam-export--replace-variable (var replacement) + (goto-char 1) + (while (re-search-forward (format "{{%s}}" var) nil t) + (replace-match replacement t t))) + +(defun seam-export--generate-backlinks (file) + (seam-export--to-string + (let ((files (sort + (let ((seam--subset seam-export--types)) + (cl-loop for x in (seam-get-links-to-file file) + collect (cons (seam-get-title-from-file x) x))) + :key #'car + :lessp #'string<))) + (when files + (cl-loop for (title . file) in files + do (insert (format "- [[seam:%s][%s]]\n" (file-name-base file) title))))))) + +(defun seam-export--note-to-html (note-file html-directory) + (seam-ensure-directory-exists html-directory) + (let ((html-file (file-name-concat html-directory + (concat (file-name-base note-file) ".html"))) + (modified (file-attribute-modification-time + (file-attributes note-file)))) + (with-temp-buffer + (insert seam-export--template) + (seam-export--replace-variable + "title" + (seam-export--escape-string + (seam-get-title-from-file note-file))) + (seam-export--replace-variable + "modified" + (format-time-string + seam-export-time-format + modified + seam-export-time-zone)) + (seam-export--replace-variable + "modified-dt" + (format-time-string + seam-export-time-format-datetime + modified + seam-export-time-zone)) + (seam-export--replace-variable + "contents" + (seam-export--to-string + (insert-file-contents note-file) + (re-search-forward (org-headline-re 1)) + (org-mode) ;Needed for `org-set-property'. + (org-set-property "seam-title-p" "t"))) + (seam-export--replace-variable + "backlinks" + (seam-export--generate-backlinks note-file)) + (write-file html-file)))) + +(defun seam-export--file-string (file) + (with-temp-buffer + (insert-file-contents file) + (buffer-string))) + +(defun seam-export-note (file) + (let ((type (seam-get-note-type file))) + (cl-loop for (dir . plist) in seam-export-alist + do + (let ((types (plist-get plist :types)) + (template-file (plist-get plist :template-file)) + (template-string (plist-get plist :template-string))) + (unless types + (error "You must specify :types for export")) + (let ((template + (cond + (template-file (seam-export--file-string template-file)) + (template-string template-string) + (seam-export-template-file (seam-export--file-string + seam-export-template-file)) + (seam-export-template-string seam-export-template-string) + (t (error "You must specify a template for export (see `seam-export-alist')"))))) + (when (member type types) + (let ((seam-export--types types) + (seam-export--root-path (or (plist-get plist :root-path) "")) + (seam-export--no-extension (plist-get plist :no-extension)) + (seam-export--template template) + (seam-export--internal-link-class + (or (plist-get plist :internal-link-class) + seam-export-internal-link-class)) + (seam-export--options (org-combine-plists + seam-export-backend-options + (plist-get plist :backend-options)))) + (seam-export--note-to-html file dir)))))))) + +(defun seam-export-all-notes () + "Export all note files as HTML." + (interactive) + (unless seam-export-alist + (error "Nothing to export. Please configure `seam-export-alist'.")) + (dolist (dir (seam-note-subdirectories)) + (dolist (file (directory-files dir t seam-note-file-regexp)) + (seam-export-note file)))) + +(provide 'seam-export) + +;;; seam-export.el ends here diff --git a/lisp/seam-html.el b/lisp/seam-html.el new file mode 100644 index 0000000..018e56f --- /dev/null +++ b/lisp/seam-html.el @@ -0,0 +1,366 @@ +;;; seam-html.el --- Seam HTML exporter -*- lexical-binding: t -*- + +;; Copyright (C) 2025 Spencer Williams + +;; Author: Spencer Williams <spnw@plexwave.org> + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Seam's HTML exporter. +;; +;; This was blithely hacked together using large chunks of code lifted +;; straight from ox-html.el, and could do with much improvement. +;; +;; Original ox-html code is licensed under GPL v3+. Copyright (c) +;; 2011-2025 Free Software Foundation, Inc. Original authors: Carsten +;; Dominik <carsten.dominik@gmail.com> and Jambunathan K <kjambunathan +;; at gmail dot com>. + +;;; Code: + +(require 'ox-html) + +;;; NOTE: This function does not respect `:headline-levels' or +;;; `:html-self-link-headlines'. +(defun seam-html-headline (headline contents info) + "Transcode a HEADLINE element from Org to HTML. +CONTENTS holds the contents of the headline. INFO is a plist +holding contextual information." + (cond + ((org-element-property :SEAM-TITLE-P headline) + contents) + ((org-element-property :footnote-section-p headline) + nil) + (t + (let* ((level (+ (org-export-get-relative-level headline info) + (1- (plist-get info :html-toplevel-hlevel)))) + (text (org-export-data (org-element-property :title headline) info)) + (contents (or contents "")) + (id (org-html--reference headline info)) + (todo (and (plist-get info :with-todo-keywords) + (let ((todo (org-element-property :todo-keyword headline))) + (and todo (org-export-data todo info))))) + (todo-type (and todo (org-element-property :todo-type headline))) + (priority (and (plist-get info :with-priority) + (org-element-property :priority headline))) + (tags (and (plist-get info :with-tags) + (org-export-get-tags headline info))) + (full-text (funcall (plist-get info :html-format-headline-function) + todo todo-type priority text tags info))) + (let ((headline-class + (org-element-property :HTML_HEADLINE_CLASS headline))) + (format "%s%s\n" + (format "<h%d id=\"%s\"%s>%s</h%d>\n" + level + id + (if (not headline-class) "" + (format " class=\"%s\"" headline-class)) + full-text + level) + contents)))))) + +(defun seam-html-section (_section contents _info) + "Transcode a SECTION element from Org to HTML. +CONTENTS holds the contents of the section." + contents) + +(defvar seam-html-standalone-image-predicate) +(defun seam-html-standalone-image-p (element info) + "Non-nil if ELEMENT is a standalone image. + +INFO is a plist holding contextual information. + +An element or object is a standalone image when + + - its type is `paragraph' and its sole content, save for white + spaces, is a link that qualifies as an inline image; + + - its type is `link' and its containing paragraph has no other + content save white spaces. + +Bind `seam-html-standalone-image-predicate' to constrain paragraph +further. For example, to check for only captioned standalone +images, set it to: + + (lambda (paragraph) (org-element-property :caption paragraph))" + (let ((paragraph (pcase (org-element-type element) + (`paragraph element) + (`link (org-element-parent element))))) + (and (org-element-type-p paragraph 'paragraph) + (or (not (and (boundp 'seam-html-standalone-image-predicate) + (fboundp seam-html-standalone-image-predicate))) + (funcall seam-html-standalone-image-predicate paragraph)) + (catch 'exit + (let ((link-count 0)) + (org-element-map (org-element-contents paragraph) + (cons 'plain-text org-element-all-objects) + (lambda (obj) + (when (pcase (org-element-type obj) + (`plain-text (org-string-nw-p obj)) + (`link (or (> (cl-incf link-count) 1) + (not (org-html-inline-image-p obj info)))) + (_ t)) + (throw 'exit nil))) + info nil 'link) + (= link-count 1)))))) + +(defun seam-html-link (link desc info) + "Transcode a LINK object from Org to HTML. +DESC is the description part of the link, or the empty string. +INFO is a plist holding contextual information. See +`org-export-data'." + (let* ((html-ext (plist-get info :html-extension)) + (dot (when (> (length html-ext) 0) ".")) + (link-org-files-as-html-maybe + (lambda (raw-path info) + ;; Treat links to `file.org' as links to `file.html', if + ;; needed. See `org-html-link-org-files-as-html'. + (save-match-data + (cond + ((and (plist-get info :html-link-org-files-as-html) + (let ((case-fold-search t)) + (string-match "\\(.+\\)\\.org\\(?:\\.gpg\\)?$" raw-path))) + (concat (match-string 1 raw-path) dot html-ext)) + (t raw-path))))) + (link-type (org-element-property :type link)) + (raw-path (org-element-property :path link)) + ;; Ensure DESC really exists, or set it to nil. + (desc (org-string-nw-p desc)) + (path + (cond + ((string= "seam" link-type) + (let ((slug raw-path)) + (when-let ((file (seam-lookup-slug slug))) + (let ((type (seam-get-note-type file))) + (when (and (member type seam-export--types) + (file-exists-p (seam-make-file-name slug type))) + (concat seam-export--root-path + slug + (if seam-export--no-extension "" ".html"))))))) + ((string= "file" link-type) + ;; During publishing, turn absolute file names belonging + ;; to base directory into relative file names. Otherwise, + ;; append "file" protocol to absolute file name. + (setq raw-path + (org-export-file-uri + (org-publish-file-relative-name raw-path info))) + ;; Possibly append `:html-link-home' to relative file + ;; name. + (let ((home (and (plist-get info :html-link-home) + (org-trim (plist-get info :html-link-home))))) + (when (and home + (plist-get info :html-link-use-abs-url) + (not (file-name-absolute-p raw-path))) + (setq raw-path (concat (file-name-as-directory home) raw-path)))) + ;; Maybe turn ".org" into ".html". + (setq raw-path (funcall link-org-files-as-html-maybe raw-path info)) + ;; Add search option, if any. A search option can be + ;; relative to a custom-id, a headline title, a name or + ;; a target. + (let ((option (org-element-property :search-option link))) + (if (not option) raw-path + (let ((path (org-element-property :path link))) + (concat raw-path + "#" + (org-publish-resolve-external-link option path t)))))) + (t (url-encode-url (concat link-type ":" raw-path))))) + (attributes-plist + (org-combine-plists + ;; Extract attributes from parent's paragraph. HACK: Only + ;; do this for the first link in parent (inner image link + ;; for inline images). This is needed as long as + ;; attributes cannot be set on a per link basis. + (let* ((parent (org-element-parent-element link)) + (link (let ((container (org-element-parent link))) + (if (and (org-element-type-p container 'link) + (org-html-inline-image-p link info)) + container + link)))) + (and (eq link (org-element-map parent 'link #'identity info t)) + (org-export-read-attribute :attr_html parent))) + ;; Add Seam internal link class if appropriate. + (when (and seam-export--internal-link-class (string= "seam" link-type)) + (list :class seam-export--internal-link-class)) + ;; Also add attributes from link itself. Currently, those + ;; need to be added programmatically before `org-html-link' + ;; is invoked, for example, by backends building upon HTML + ;; export. + (org-export-read-attribute :attr_html link))) + (attributes + (let ((attr (org-html--make-attribute-string attributes-plist))) + (if (org-string-nw-p attr) (concat " " attr) "")))) + (cond + ;; Link type is handled by a special function. + ((org-export-custom-protocol-maybe link desc 'html info)) + ;; Image file. + ((and (plist-get info :html-inline-images) + (org-export-inline-image-p + link (plist-get info :html-inline-image-rules))) + (org-html--format-image path attributes-plist info)) + ;; Radio target: Transcode target's contents and use them as + ;; link's description. + ((string= link-type "radio") + (let ((destination (org-export-resolve-radio-link link info))) + (if (not destination) desc + (format "<a href=\"#%s\"%s>%s</a>" + (org-export-get-reference destination info) + attributes + desc)))) + ;; Links pointing to a headline: Find destination and build + ;; appropriate referencing command. + ((member link-type '("custom-id" "fuzzy" "id")) + (let ((destination (if (string= link-type "fuzzy") + (org-export-resolve-fuzzy-link link info) + (org-export-resolve-id-link link info)))) + (pcase (org-element-type destination) + ;; ID link points to an external file. + (`plain-text + (let ((fragment (concat org-html--id-attr-prefix raw-path)) + ;; Treat links to ".org" files as ".html", if needed. + (path (funcall link-org-files-as-html-maybe + destination info))) + (format "<a href=\"%s#%s\"%s>%s</a>" + path fragment attributes (or desc destination)))) + ;; Fuzzy link points nowhere. + (`nil + (;; format "<i>%s</i>" + identity + (or desc + (org-export-data + (org-element-property :raw-link link) info)))) + ;; Link points to a headline. + (`headline + (let ((href (org-html--reference destination info)) + ;; What description to use? + (desc + ;; Case 1: Headline is numbered and LINK has no + ;; description. Display section number. + (if (and (org-export-numbered-headline-p destination info) + (not desc)) + (mapconcat #'number-to-string + (org-export-get-headline-number + destination info) ".") + ;; Case 2: Either the headline is un-numbered or + ;; LINK has a custom description. Display LINK's + ;; description or headline's title. + (or desc + (org-export-data + (org-element-property :title destination) info))))) + (format "<a href=\"#%s\"%s>%s</a>" href attributes desc))) + ;; Fuzzy link points to a target or an element. + (_ + (if (and destination + (memq (plist-get info :with-latex) '(mathjax t)) + (org-element-type-p destination 'latex-environment) + (eq 'math (org-latex--environment-type destination))) + ;; Caption and labels are introduced within LaTeX + ;; environment. Use "ref" or "eqref" macro, depending on user + ;; preference to refer to those in the document. + (format (plist-get info :html-equation-reference-format) + (org-html--reference destination info)) + (let* ((ref (org-html--reference destination info)) + (seam-html-standalone-image-predicate + #'org-html--has-caption-p) + (counter-predicate + (if (org-element-type-p destination 'latex-environment) + #'org-html--math-environment-p + #'org-html--has-caption-p)) + (number + (cond + (desc nil) + ((seam-html-standalone-image-p destination info) + (org-export-get-ordinal + (org-element-map destination 'link #'identity info t) + info '(link) 'seam-html-standalone-image-p)) + (t (org-export-get-ordinal + destination info nil counter-predicate)))) + (desc + (cond (desc) + ((not number) "No description for this link") + ((numberp number) (number-to-string number)) + (t (mapconcat #'number-to-string number "."))))) + (format "<a href=\"#%s\"%s>%s</a>" ref attributes desc))))))) + ;; Coderef: replace link with the reference name or the + ;; equivalent line number. + ((string= link-type "coderef") + (let ((fragment (concat "coderef-" (org-html-encode-plain-text raw-path)))) + (format "<a href=\"#%s\" %s%s>%s</a>" + fragment + (format "class=\"coderef\" onmouseover=\"CodeHighlightOn(this, \ +'%s');\" onmouseout=\"CodeHighlightOff(this, '%s');\"" + fragment fragment) + attributes + (format (org-export-get-coderef-format raw-path desc) + (org-export-resolve-coderef raw-path info))))) + ;; External link with a description part. + ((and path desc) + (format "<a href=\"%s\"%s>%s</a>" + (org-html-encode-plain-text path) + attributes + desc)) + ;; External link without a description part. + (path + (let ((path (org-html-encode-plain-text path))) + (format "<a href=\"%s\"%s>%s</a>" path attributes path))) + ;; No path, only description. + (t desc)))) + + +(defun seam-html-src-block (src-block _contents info) + "Transcode a SRC-BLOCK element from Org to HTML. +CONTENTS holds the contents of the item. INFO is a plist holding +contextual information." + (if (org-export-read-attribute :attr_html src-block :textarea) + (org-html--textarea-block src-block) + (let* ((lang (org-element-property :language src-block)) + (code (org-html-format-code src-block info)) + (label (let ((lbl (org-html--reference src-block info t))) + (if lbl (format " id=\"%s\"" lbl) "")))) + (format "<pre>%s%s</pre>" + ;; Build caption. + (let ((caption (org-export-get-caption src-block))) + (if (not caption) "" + (let ((listing-number + (format + "<span class=\"listing-number\">%s </span>" + (format + (org-html--translate "Listing %d:" info) + (org-export-get-ordinal + src-block info nil #'org-html--has-caption-p))))) + (format "<label class=\"org-src-name\">%s%s</label>" + listing-number + (org-trim (org-export-data caption info)))))) + ;; Contents. + (format "<code class=\"src src-%s\"%s>%s</code>" + ;; Lang being nil is OK. + lang label code))))) + +(org-export-define-derived-backend + 'seam + 'html + :translate-alist + `((headline . seam-html-headline) + (link . seam-html-link) + (section . seam-html-section) + (src-block . seam-html-src-block))) + +(provide 'seam-html) + +;;; seam-html.el ends here diff --git a/lisp/seam-test.el b/lisp/seam-test.el new file mode 100644 index 0000000..e7658dd --- /dev/null +++ b/lisp/seam-test.el @@ -0,0 +1,453 @@ +;;; seam-test.el --- Tests for Seam -*- lexical-binding: t -*- + +;; Copyright (C) 2025 Spencer Williams + +;; Author: Spencer Williams <spnw@plexwave.org> + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Tests for Seam. + +;;; Code: + +;;; FIXME: Tests can fail if certain buffer names are already in use +;;; on the test runner's system. + +(require 'seam) +(require 'seam-export) +(require 'ert) +(require 'cl-lib) + +(defvar seam-test-directory nil) + +(defmacro seam-test-environment (&rest body) + (declare (indent 0)) + `(let* ((seam-test-directory (make-temp-file "seam-test" t)) + (seam-note-directory seam-test-directory) + (default-directory seam-test-directory) + (seam-note-types '("private" "public")) + (seam-default-note-type "private") + (seam-title-formatter (lambda (title _type) title)) + (seam-export-template-file nil) + (seam-export-template-string seam-export-default-template-string) + (seam-export-internal-link-class nil) + (seam-export-alist + `((,(file-name-concat seam-test-directory "html") + :types ("public") + :root-path "/"))) + ;; Manually install hooks in test directory. + (dir-locals-directory-cache + (cons (list seam-test-directory 'seam-note-directory nil) + dir-locals-directory-cache))) + (unwind-protect + (progn ,@body) + (delete-directory seam-test-directory t)))) + +(defmacro seam-test-with-notes (options varlist &rest body) + (declare (indent 2)) + `(seam-test-environment + (let ,options + (let ,(cl-loop for (name . args) in varlist + collect `(,name (seam-make-note ,@args))) + (unwind-protect (progn ,@body) + (mapcar #'kill-buffer (list ,@(mapcar #'car varlist)))))))) + +(defun seam-test-remove-testdir (filename) + (string-remove-prefix (concat seam-test-directory "/") filename)) + +(defun seam-test-list-files () + (mapcar + #'seam-test-remove-testdir + (directory-files-recursively seam-test-directory ""))) + +(defun seam-test-add-contents (buffer contents) + (with-current-buffer buffer + (insert contents) + (insert "\n") + (save-buffer))) + +(defun seam-test-replace-contents (buffer contents) + (with-current-buffer buffer + (erase-buffer) + (insert contents) + (insert "\n") + (save-buffer))) + +(defun seam-test-link-to-buffer (buffer) + (format "[[seam:%s]]" (file-name-base (buffer-file-name buffer)))) + +(defun seam-test-links-from-html (file) + (with-temp-buffer + (insert-file-contents file) + (delete-dups + (cl-loop for ret = (re-search-forward "<a href=\"/\\(.*\\)?\">" nil t) + while ret collect (match-string 1))))) + +(ert-deftest seam-test-make-note-private () + (should + (equal + '("private/note.org") + (seam-test-with-notes () + ((note "Note")) + (seam-test-list-files))))) + +(ert-deftest seam-test-make-note-public () + (should + (equal + '("html/note.html" "public/note.org") + (seam-test-with-notes () + ((note "Note" "public")) + (seam-test-list-files))))) + +(ert-deftest seam-test-make-note-weird-filename () + (should + (equal + '("./Weird file name!" ("private/weird-file-name.org")) + (seam-test-with-notes () + ((weird "./Weird file name! ")) + (list (buffer-name weird) + (seam-test-list-files)))))) + +(ert-deftest seam-test-get-title-from-buffer () + (seam-test-with-notes () ((note "Note")) + (should + (equal + (buffer-name note) + (seam-get-title-from-buffer note))))) + +(ert-deftest seam-test-get-title-from-buffer-narrowed () + (should + (equal + "foo" + (seam-test-with-notes () + ((foo "foo")) + (with-current-buffer foo + (seam-test-add-contents foo "* My headline") + (forward-line) + (org-narrow-to-subtree) + (seam-get-title-from-buffer)))))) + +(ert-deftest seam-test-get-title-from-file () + (seam-test-with-notes () ((note "Note")) + (should + (equal + (buffer-name note) + (seam-get-title-from-file (buffer-file-name note)))))) + +(ert-deftest seam-test-make-note-invalid-type () + (should-error + (seam-test-environment + (kill-buffer (seam-make-note "Note" "invalid-type"))))) + +(ert-deftest seam-test-make-note-name-conflict () + (should-error + (seam-test-environment + (kill-buffer (seam-make-note " Note 1 ")) + (kill-buffer (seam-make-note "Note_1"))))) + +(ert-deftest seam-test-make-note-name-conflict-different-types () + (should-error + (seam-test-environment + (kill-buffer (seam-make-note "Note")) + (kill-buffer (seam-make-note "Note" "public"))))) + +(ert-deftest seam-test-rename-note () + (should + (equal + '("New name" ("private/new-name.org")) + (seam-test-with-notes () ((note "Note")) + (with-current-buffer note + (erase-buffer) + (insert "* New name\n") + (save-buffer) + (list (buffer-name) (seam-test-list-files))))))) + +(ert-deftest seam-test-rename-conflict () + (should-error + (seam-test-with-notes () + (let ((note1 "Note 1") + (note2 "Note 2")) + (with-current-buffer note2 + (erase-buffer) + (insert "* note_1!\n") + (unwind-protect + (save-buffer) + (set-buffer-modified-p nil))))))) + +(ert-deftest seam-test-buffer-name-format-default () + (should + (equal + "Note (private)" + (seam-test-with-notes ((seam-title-formatter #'seam-format-title-default)) + ((note "Note")) + (buffer-name note))))) + +(ert-deftest seam-test-buffer-name-format-custom () + (should + (equal + "[private] Note" + (seam-test-with-notes ((seam-title-formatter + (lambda (title type) (format "[%s] %s" type title)))) + ((note "Note")) + (buffer-name note))))) + +(ert-deftest seam-test-link-update () + "Test that renaming a note updates its HTML and that of notes which link to it." + (should + (equal '(("qux.html") + ("public/qux.org") + ("html/foo.html" "html/qux.html" "public/foo.org" "public/qux.org")) + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar" "public")) + (progn + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (seam-test-replace-contents bar "* qux") + (list + (seam-test-links-from-html "html/foo.html") + (mapcar #'seam-test-remove-testdir (seam-get-links-from-file (buffer-file-name foo))) + (seam-test-list-files))))))) + +(ert-deftest seam-test-link-update-no-unnecessary-export () + "Test that updating the contents of a note does not unnecessarily +re-export note to which it links." + (should-not + (member + "html/bar.html" + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar" "public")) + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (delete-file "html/bar.html") + (seam-test-add-contents foo "hello") + (seam-test-list-files))))) + +(ert-deftest seam-test-link-to-private () + "Test that a private link does not get exported in HTML." + (should-error + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar")) + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (with-temp-buffer + (insert-file-contents "html/foo.html") + (buffer-string) + (re-search-forward "<a href=\"/bar.html\">"))))) + +(ert-deftest seam-test-link-no-extension () + "Test that the :no-extension option causes links to render without .html +extension." + (should + (identity + (seam-test-with-notes ((seam-export-alist + `((,(file-name-concat seam-test-directory "html") + :types ("public") + :root-path "/" + :no-extension t)))) + ((foo "foo" "public") + (bar "bar" "public")) + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (with-temp-buffer + (insert-file-contents "html/foo.html") + (buffer-string) + (re-search-forward "<a href=\"/bar\">")))))) + +(ert-deftest seam-test-link-internal-class () + "Test that setting `seam-export-internal-link-class' correctly renders +the class." + (should + (identity + (seam-test-with-notes ((seam-export-internal-link-class "internal")) + ((foo "foo" "public") + (bar "bar" "public")) + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (with-temp-buffer + (insert-file-contents "html/foo.html") + (buffer-string) + (re-search-forward "<a href=\"/bar.html\" class=\"internal\">")))))) + +(ert-deftest seam-test-link-getters () + (should + (equal + '(("private/bar.org" "private/qux.org") + ("private/foo.org") + ("private/bar.org" "private/foo.org")) + (seam-test-with-notes () + ((foo "foo") + (bar "bar") + (qux "qux")) + (seam-test-add-contents foo (seam-test-link-to-buffer bar)) + (seam-test-add-contents foo (seam-test-link-to-buffer qux)) + (seam-test-add-contents bar (seam-test-link-to-buffer qux)) + (list + (mapcar #'seam-test-remove-testdir (seam-get-links-from-file (buffer-file-name foo))) + (mapcar #'seam-test-remove-testdir (seam-get-links-to-file (buffer-file-name bar))) + (mapcar #'seam-test-remove-testdir (seam-get-links-to-file (buffer-file-name qux)))))))) + +(ert-deftest seam-test-delete-note () + "Test that deleting a note also deletes its HTML and re-exports linking +notes such that they no longer link to it." + (should + (equal + '(nil ("html/foo.html" "public/foo.org")) + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar" "public")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (let ((delete-by-moving-to-trash nil)) + (seam-delete-note (buffer-file-name bar))) + (list + (seam-test-links-from-html "html/foo.html") + (seam-test-list-files)))))) + +(ert-deftest seam-test-backlinks-public () + "Test that linking to a note from a public note creates a backlink." + (should + (identity + (seam-test-with-notes ((seam-export-template-string "{{backlinks}}")) + ((foo "foo" "public") + (bar "bar" "public")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (with-temp-buffer + (insert-file-contents "html/bar.html") + (re-search-forward "<a href=\"/foo.html\">")))))) + +(ert-deftest seam-test-backlinks-private () + "Test that linking to a note from a private note does not create a +backlink." + (should-error + (seam-test-with-notes ((seam-export-template-string "{{backlinks}}")) + ((foo "foo") + (bar "bar" "public")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (with-temp-buffer + (insert-file-contents "html/bar.html") + (re-search-forward "<a href=\"/foo.html\">"))))) + +(ert-deftest seam-test-backlinks-delete () + "Test that deleting a note removes backlink." + (should-error + (seam-test-with-notes ((seam-export-template-string "{{backlinks}}")) + ((foo "foo" "public") + (bar "bar" "public")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (let ((delete-by-moving-to-trash nil)) + (seam-delete-note (buffer-file-name foo))) + (with-temp-buffer + (insert-file-contents "html/bar.html") + (re-search-forward "<a href=\"/foo.html\">"))))) + +(ert-deftest seam-test-set-type-private () + "Test that setting a public note to private will delete its HTML file and +update linking HTML files such that they no longer link to it." + (should + (equal + '(nil ("html/foo.html" "private/bar.org" "public/foo.org")) + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar" "public")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (seam-set-note-type (buffer-file-name bar) "private") + (list + (seam-test-links-from-html "html/foo.html") + (seam-test-list-files)))))) + +(ert-deftest seam-test-set-type-public () + "Test that setting a private note to public will export its HTML file and +update linking HTML files such that they link to it." + (should + (equal + '(("bar.html") + ("html/bar.html" "html/foo.html" "public/bar.org" "public/foo.org")) + (seam-test-with-notes () + ((foo "foo" "public") + (bar "bar")) + (with-current-buffer foo + (seam-test-add-contents foo (seam-test-link-to-buffer bar))) + (seam-set-note-type (buffer-file-name bar) "public") + (list + (seam-test-links-from-html "html/foo.html") + (seam-test-list-files)))))) + +(ert-deftest seam-test-set-type-invalid () + "Test that setting a note to an invalid type raises an error." + (should-error + (seam-test-with-notes () + ((foo "foo")) + (seam-set-note-type (buffer-file-name foo) "invalid-type")))) + +(ert-deftest seam-test-follow-link-existing () + "Test that following a link to an existing note opens that note." + (should + (equal + "bar" + (seam-test-with-notes () + ((foo "foo") + (bar "bar")) + (with-current-buffer foo + (seam-test-add-contents foo "[[seam:bar]]") + (org-previous-link) + (org-open-at-point) + (buffer-name)))))) + +(ert-deftest seam-test-follow-link-new () + "Test that following a link to an nonexistent note creates and opens that note." + (should + (equal + '("bar" ("private/bar.org" "private/foo.org")) + (seam-test-with-notes () + ((foo "foo")) + (with-current-buffer foo + (seam-test-add-contents foo "[[seam:bar]]") + (goto-char 1) + (org-next-link) + (org-open-at-point) + (unwind-protect + (list + (buffer-name) + (seam-test-list-files)) + (kill-buffer))))))) + +(ert-deftest seam-test-escape-title () + (should + (equal + "“quotes” & <symbols>\n" + (seam-test-with-notes ((seam-export-template-string "{{title}}")) + ((note "\"quotes\" & <symbols>" "public")) + (seam-export--file-string "html/quotes-symbols.html"))))) + +(ert-deftest seam-test-custom-slug () + "Test that setting the SEAM_SLUG property saves and exports accordingly." + (should + (equal + '("html/c-vs-cpp.html" "public/c-vs-cpp.org") + (seam-test-with-notes () + ((note "C vs C++" "public")) + (seam-test-add-contents note ":PROPERTIES:\n:SEAM_SLUG: c-vs-cpp\n:END:") + (seam-test-list-files))))) + +(provide 'seam-test) + +;;; seam-test.el ends here diff --git a/lisp/seam.el b/lisp/seam.el new file mode 100644 index 0000000..cdf3227 --- /dev/null +++ b/lisp/seam.el @@ -0,0 +1,615 @@ +;;; seam.el --- Personal Org mode wiki -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Spencer Williams + +;; Author: Spencer Williams <spnw@plexwave.org> +;; Homepage: https://wiki.plexwave.org/seam +;; Keywords: hypermedia, outlines + +;; Version: 0.1.0 + +;; SPDX-License-Identifier: GPL-3.0-or-later + +;; This file is not part of GNU Emacs. + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Seam is a package that lets you easily create, link, and publish +;; Org notes in the form of a personal wiki. + +;;; Code: + +(require 'seam-export) +(require 'cl-lib) +(require 'grep) + +(defgroup seam nil + "Options for Seam." + :group 'org + :tag "Seam") + +(defcustom seam-note-directory nil + "Seam note directory." + :group 'seam + :type '(choice directory (const nil))) + +(defcustom seam-default-note-type "private" + "Default type for Seam notes." + :group 'seam + :type 'string) + +(defcustom seam-note-types '("private" "public") + "Seam note types." + :group 'seam + :type '(repeat string)) + +(defun seam-format-title-default (title type) + "Default Seam title formatter. Formats like this: \"TITLE (TYPE)\"." + (format "%s %s" title (propertize (format "(%s)" type) 'face 'font-lock-comment-face))) + +(defcustom seam-title-formatter + #'seam-format-title-default + "Function used by Seam to format note titles for completion and buffer +naming. Must be a function taking two arguments: TITLE and TYPE." + :group 'seam + :type 'function) + +(defun seam-html-directories () + (mapcar #'car seam-export-alist)) + +(defun seam-slugify (title) + (downcase (string-join (string-split title "\\W+" t) "-"))) + +(defun seam-lookup-slug (slug) + (cl-dolist (type seam-note-types) + (let ((file (file-name-concat seam-note-directory type (concat slug ".org")))) + (when (file-exists-p file) + (cl-return (expand-file-name file)))))) + +(defun seam--check-conflict (slug) + (when (seam-lookup-slug slug) + (error "A note called `%s.org' already exists" slug))) + +(defun seam-link-open (path _prefix) + (org-mark-ring-push) + (if-let ((file (seam-lookup-slug path))) + (find-file file) + (seam-make-note path nil t)) + (seam-set-buffer-name)) + +(defvar seam-note-file-regexp "\\`[^.].+\\.org\\'") +(defvar seam--subset nil) + +(defcustom seam-completing-read-function #'completing-read + "The completion function used by Seam." + :group 'seam + :type 'function) + +(defun seam-ensure-directory-exists (dir) + (unless (file-directory-p dir) + (make-directory dir t))) + +(defun seam-ensure-note-subdirectories-exist () + (unless seam-note-directory + (error "Please set `seam-note-directory'")) + (cl-dolist (type seam-note-types) + (let ((dir (file-name-concat seam-note-directory type))) + (seam-ensure-directory-exists dir)))) + +(defcustom seam-sort-method 'title + "The method used by Seam to sort notes." + :group 'seam + :type '(choice (const :tag "Sort by title" title) + (const :tag "Sort by modification date" modified))) + +(cl-defgeneric seam-get-all-notes (sort-by)) + +(cl-defmethod seam-get-all-notes ((sort-by (eql 't))) + (ignore sort-by) + (seam-get-all-notes seam-sort-method)) + +(cl-defmethod seam-get-all-notes ((sort-by (eql 'modified))) + (ignore sort-by) + (let ((files (cl-loop for type in (seam--active-subset) + append (directory-files-and-attributes + (file-name-concat seam-note-directory type) + t + seam-note-file-regexp)))) + (cl-loop for (file . _attributes) + in (cl-sort + files + (lambda (f1 f2) + (time-less-p (file-attribute-modification-time f2) + (file-attribute-modification-time f1))) + :key #'cdr) + collect (cons (seam-get-title-from-file file) file)))) + +(cl-defmethod seam-get-all-notes ((sort-by (eql 'title))) + (ignore sort-by) + (let ((files (cl-loop for type in (seam--active-subset) + append (directory-files + (file-name-concat seam-note-directory type) + t + seam-note-file-regexp)))) + (cl-sort + (cl-loop for file in files + collect (cons (seam-get-title-from-file file) file)) + #'string< + :key #'car))) + +(cl-defun seam-get-title-from-buffer (&optional (buffer (current-buffer))) + (with-current-buffer buffer + (save-mark-and-excursion + (save-restriction + (widen) + (goto-char 1) + (ignore-errors + (re-search-forward (org-headline-re 1)) + (let ((start (point))) + (end-of-line) + (let ((title (string-trim (buffer-substring-no-properties start (point))))) + (unless (string-empty-p title) + title)))))))) + +(defun seam-get-title-from-file (file) + (with-temp-buffer + (insert-file-contents file) + (seam-get-title-from-buffer))) + +(cl-defun seam-get-slug-from-buffer (&optional (buffer (current-buffer))) + (or (with-current-buffer buffer + (save-mark-and-excursion + (save-restriction + (widen) + (goto-char 1) + (ignore-errors + (re-search-forward (org-headline-re 1)) + (org-element-property :SEAM_SLUG (org-element-at-point)))))) + (seam-slugify (seam-get-title-from-buffer buffer)))) + +(defun seam-format-title (title type) + (funcall seam-title-formatter title type)) + +(defun seam--completing-read (&rest args) + (let ((completion-ignore-case t)) + (apply seam-completing-read-function args))) + +(defun seam-make-note (title &optional type select) + (unless type + (setq type seam-default-note-type)) + (unless (member type seam-note-types) + (error "`%s' is not a valid Seam note type" type)) + (seam-ensure-note-subdirectories-exist) + (let* ((slug (seam-slugify title)) + (file (file-name-concat seam-note-directory + type + (concat slug ".org")))) + (seam--check-conflict slug) + (let ((buffer (funcall (if select #'find-file #'find-file-noselect) file))) + (with-current-buffer buffer + (insert (format "* %s\n" title)) + (save-buffer) + buffer)))) + +(defun seam-read-title (prompt) + (seam-ensure-note-subdirectories-exist) + (let* ((notes (seam-get-all-notes t)) + (self (cl-find (buffer-file-name) notes :key #'cdr :test #'equal))) + (let ((notes + (append (cl-remove self + notes + :test #'equal) + (and self (list self))))) + (let ((files (cl-loop for (title . file) in notes + collect (cons (seam-format-title title (seam-get-note-type file)) file)))) + (let ((completion (string-trim (seam--completing-read prompt files)))) + (or (assoc completion files) + (cons completion nil))))))) + +(defun seam--read-type (prompt arg &optional choices) + (when arg + (if (listp arg) + (seam--completing-read prompt (or choices seam-note-types) nil t) + (nth (1- arg) seam-note-types)))) + +;;;###autoload +(defun seam-find-note (arg) + "Find Seam note interactively by title, creating it if it does not exist. +`seam-completing-read-function' is used for completion. + +A prefix argument can be used to show only a specific note type (and to +use that type if a new note is created). With a numeric argument N, the +Nth type in `seam-note-types' is chosen (counting from 1). With C-u, a +completion prompt is given to choose the type." + (interactive "P") + (let* ((type (seam--read-type "Type: " arg)) + (seam--subset + (if type (list type) seam-note-types))) + (cl-destructuring-bind (completion . file) + (seam-read-title "Open note: ") + (if file + (with-current-buffer (find-file file) + ;; Ensure buffer name is up to date (e.g. after changing + ;; formatter function) (NOTE: Redundant if buffer wasn't + ;; already open, as `seam-setup-buffer' does this too.) + (seam-set-buffer-name)) + (seam-make-note (string-trim completion) (or type seam-default-note-type) t))))) + +(cl-defun seam-get-note-type (file &optional no-error) + (when (and file (equal "org" (file-name-extension file))) + (let ((type (cadr (nreverse (file-name-split file))))) + (when (member type seam-note-types) + (cl-return-from seam-get-note-type type)))) + (unless no-error + (error "%s is not a Seam note" file))) + +(defun seam-make-file-name (slug type) + (expand-file-name + (file-name-concat + seam-note-directory type + (concat slug ".org")))) + +(defun seam-get-links-to-file (file) + "Return filename of each note which links to FILE." + (remove (expand-file-name file) + (seam-note-files-containing-string (format "[[seam:%s]" (file-name-base file))))) + +(cl-defun seam-get-links-from-buffer (&optional (buffer (current-buffer))) + "Return filename of each existing note which is linked to from BUFFER." + (let ((links (with-current-buffer buffer + (save-mark-and-excursion + (save-restriction + (widen) + (goto-char 1) + (delete-dups + (cl-loop for ret = (re-search-forward "\\[\\[seam:\\(.*?\\)\\]" nil t) + while ret collect (match-string 1)))))))) + (let ((file (buffer-file-name buffer))) + (remove (and file (expand-file-name file)) + (cl-loop for link in links + as f = (seam-lookup-slug link) + when f collect f))))) + +(defun seam-get-links-from-file (file) + "Return filename of each existing note which is linked to from FILE." + (with-temp-buffer + (insert-file-contents file) + (seam-get-links-from-buffer))) + +(defun seam-delete-html-files-for-note (note-file) + (dolist (dir (seam-html-directories)) + (let ((html (file-name-concat dir (concat (file-name-base note-file) ".html")))) + (when (file-exists-p html) + (delete-file html) + (message "Deleted %s" html))))) + +(defun seam-post-save-or-rename (old new &optional previous-links-from-file slug-or-title-changed) + (unless (string= old new) + (seam-update-links old new) + (seam-delete-html-files-for-note old) + (dolist (dir (seam-html-directories)) + (delete-file (file-name-concat dir (concat (file-name-base old) ".html"))))) + (seam-export-note new) + (let* ((current-links (seam-get-links-from-file new)) + (added-links (cl-set-difference current-links + previous-links-from-file + :test #'string=)) + (removed-links (cl-set-difference previous-links-from-file + current-links + :test #'string=))) + (let ((type-changed + (not (string= (seam-get-note-type old) + (seam-get-note-type new))))) + (mapc #'seam-export-note + (delete-dups + (append + removed-links + + ;; Backlinks sections must be updated when either + ;; slug or title changes. + (if slug-or-title-changed + current-links + added-links) + + ;; `seam-update-links' inherently triggers + ;; re-exporting of notes when links change. + ;; However, note type is not encoded in the link, + ;; so we must handle that case manually. + (when type-changed + (seam-get-links-to-file new)))))))) + +(defun seam-save-buffer () + (let* ((old (buffer-file-name)) + (type (seam-get-note-type old t))) + (when type + (unless (seam-get-title-from-buffer) + (error "Note must have a title")) + (let* ((slug (seam-get-slug-from-buffer)) + (new (seam-make-file-name slug type)) + (newly-created-p (not (file-exists-p old))) + (slug-changed-p (not (string= slug (file-name-base old)))) + (title-changed-p (unless newly-created-p + (not (string= (seam-get-title-from-buffer) + (seam-get-title-from-file old)))))) + (unless (string= old new) ;This is valid because + ;`seam-save-buffer' cannot + ;change type. + (seam--check-conflict slug) + (rename-file old new) + (set-visited-file-name new nil t)) + (let ((previous-links-from-file + ;; If we've yet to create the file, don't check it. + (unless newly-created-p + (seam-get-links-from-file new)))) + (let ((write-contents-functions + (remove 'seam-save-buffer write-contents-functions))) + (save-buffer)) + (seam-post-save-or-rename old + new + previous-links-from-file + (or slug-changed-p title-changed-p)) + (seam-set-buffer-name) + t))))) + +(defun seam--set-note-type (file new-type) + (let ((old-type (seam-get-note-type file)) + (new-file (seam-make-file-name (file-name-base file) new-type))) + (if (string= new-type old-type) + file + (rename-file file new-file) + (seam-post-save-or-rename file new-file) + new-file))) + +;;;###autoload +(defun seam-set-note-type (file new-type &optional interactive) + "Set Seam note FILE to NEW-TYPE. Error if file is not a Seam note. + +When called interactively, FILE is the currently visited file. A +numeric argument N chooses the Nth type in `seam-note-types' (counting +from 1). Otherwise a completion prompt is given for the desired type." + (interactive + (let* ((file (buffer-file-name)) + (old-type (seam-get-note-type file))) + (list file + (or (seam--read-type "New type: " + ;; HACK: Treat nil prefix as C-u. + (or current-prefix-arg '(4)) + (remove old-type seam-note-types)) + old-type) + t))) + (let ((new-file (seam--set-note-type file new-type))) + (when interactive + (set-visited-file-name new-file nil t) + (seam-set-buffer-name)))) + +(defun seam-update-links (old new) + (let ((old-slug (file-name-base old)) + (new-slug (file-name-base new))) + (unless (string= old-slug new-slug) + (let ((count (seam-replace-string-in-all-notes + (format "[[seam:%s]" old-slug) + (format "[[seam:%s]" new-slug) + t))) + (unless (zerop count) + (message "Updated links in %d file%s" + count (if (= count 1) "" "s"))))))) + +(defun seam--active-subset () + (or seam--subset seam-note-types)) + +(defun seam-note-subdirectories () + (cl-loop for type in (seam--active-subset) + collect (expand-file-name + (file-name-as-directory + (file-name-concat seam-note-directory type))))) + +(defun seam-note-files-containing-string (string) + "Search all Seam note files for literal STRING. Case-sensitive." + (seam-ensure-note-subdirectories-exist) + (with-temp-buffer + (apply #'call-process find-program + nil t nil + (append + (seam-note-subdirectories) + (list "-type" "f" "-name" "*.org" "-and" "-not" "-name" ".*" + "-exec" grep-program "-F" "-l" "-s" "-e" string "{}" "+"))) + (string-lines (string-trim (buffer-string)) t))) + +;;;###autoload +(defun seam-search (query &optional delimited) + "Search all Seam notes for the regexp QUERY (case-insensitively). If +DELIMITED is non-nil, only search at word boundaries. + +When called interactively, DELIMITED is t if a prefix argument is given. +Otherwise, it's nil." + (interactive (list (read-string (format "Search all notes%s: " + (if current-prefix-arg + " for word" + ""))) + current-prefix-arg)) + (when (eq grep-highlight-matches 'auto-detect) + (grep-compute-defaults)) + (let ((default-directory seam-note-directory)) + (grep + (format "%s %s -type f -name %s -and -not -name %s -exec %s %s -n -i -e %s \\{\\} \\+" + find-program + (string-join (mapcar (lambda (type) + (shell-quote-argument (concat type "/"))) + seam-note-types) + " ") + (shell-quote-argument "*.org") + (shell-quote-argument ".*") + grep-program + (if grep-highlight-matches "--color=always" "") + (shell-quote-argument + (if delimited + (concat "\\b" query "\\b") + query)))))) + +(defun seam-visited-notes () + (let ((subdirs (seam-note-subdirectories))) + (cl-loop for buf in (buffer-list) + as file = (buffer-file-name buf) + when (and file + (member (file-name-directory file) subdirs) + (string-match seam-note-file-regexp file)) + collect file))) + +(defun seam-replace-string-in-all-notes (old new preserve-modtime) + (let ((hash (make-hash-table :test 'equal))) + (dolist (file (seam-note-files-containing-string old)) + (puthash file nil hash)) + (dolist (file (seam-visited-notes)) + (puthash file t hash)) + (let ((update-count 0)) + (maphash + (lambda (file was-open-p) + (with-current-buffer (find-file-noselect file) + (let ((was-modified-p (buffer-modified-p))) + (save-mark-and-excursion + (without-restriction + (goto-char (point-min)) + (let ((updated-p nil)) + (while (search-forward old nil t) + (setq updated-p t) + (replace-match new)) + (when updated-p + (setq update-count (1+ update-count)))))) + (when (and (not was-modified-p) + (buffer-modified-p)) + (if preserve-modtime + (let ((modtime (visited-file-modtime))) + (save-buffer) + (set-file-times file modtime) + (set-visited-file-modtime modtime)) + (save-buffer))) + (unless was-open-p + (kill-buffer))))) + hash) + update-count))) + +(cl-defun seam-set-buffer-name (&optional (buffer (current-buffer))) + (with-current-buffer buffer + (rename-buffer + (seam-format-title (seam-get-title-from-buffer) + (seam-get-note-type (buffer-file-name buffer)))))) + +(defun seam-setup-buffer () + "Setup hooks when loading a Seam file." + (add-hook 'write-contents-functions 'seam-save-buffer nil t) + ;; NOTE: Needed for when note w/o using Seam commands. Redundant otherwise. + (seam-set-buffer-name)) + +(defun seam--watch-note-directory-var (_symbol newval operation _where) + "Install necessary hooks when `seam-note-directory' is set, removing any +old ones." + (when (member operation '(set makunbound)) + (setq dir-locals-directory-cache + (cl-remove 'seam-note-directory dir-locals-directory-cache :key #'cadr)) + (when newval + (dir-locals-set-directory-class newval 'seam-note-directory)))) + +(defun seam--delete-note (file) + (seam-get-note-type file) ;Error if file isn't a Seam note. + (let ((to-update (delete-dups + (append + (seam-get-links-to-file file) + (seam-get-links-from-file file))))) + (delete-file file t) + (seam-delete-html-files-for-note file) + (mapc #'seam-export-note to-update))) + +;;;###autoload +(defun seam-delete-note (file &optional interactive) + "Delete Seam note FILE. Error if file is not a Seam note. +`delete-by-moving-to-trash' is respected. + +When called interactively, FILE is the currently visited file, and the +buffer is killed after deletion." + (interactive + (let ((file (buffer-file-name))) + (seam-get-note-type file) ;Error if file isn't a Seam note. + (list + (let ((incoming (length (seam-get-links-to-file file)))) + (and (yes-or-no-p + (format "Really %s `%s' and kill buffer%s?" + (if delete-by-moving-to-trash + "trash" + "delete") + (seam-get-title-from-buffer) + (if (> incoming 0) + (format " (breaking links from %d note%s)" + incoming + (if (= incoming 1) "" "s")) + ""))) + file)) + t))) + (unless (and interactive (null file)) + (seam--delete-note file) + (when interactive + (kill-buffer)))) + +;;;###autoload +(defun seam-insert-link () + "Interactively insert an Org link at point to the given Seam note, +creating the note if it does not exist. If any text is selected, the +link will replace it." + (interactive) + (cl-destructuring-bind (completion . file) (seam-read-title "Insert note link: ") + (let* ((new-buffer + (unless file + (seam-make-note completion seam-default-note-type nil))) + (selection (when (use-region-p) + (buffer-substring + (region-beginning) + (region-end)))) + (file (if new-buffer + (buffer-file-name new-buffer) + file)) + (slug (file-name-base file)) + (initial (or selection + (seam-get-title-from-file file))) + (desc (read-string "Description: " initial))) + (when selection + (delete-region (region-beginning) (region-end))) + (insert (format "[[seam:%s][%s]]" slug desc)) + (when new-buffer + (pop-to-buffer new-buffer))))) + +(defvar-keymap seam-prefix-map + "f" #'seam-find-note + "k" #'seam-delete-note + "l" #'seam-insert-link + "s" #'seam-search + "t" #'seam-set-note-type) + +(org-link-set-parameters "seam" :follow #'seam-link-open) + +(dir-locals-set-class-variables + 'seam-note-directory + '((org-mode . ((eval . (seam-setup-buffer)))))) + +(add-variable-watcher 'seam-note-directory #'seam--watch-note-directory-var) + +;;; If `seam-note-directory' was set before loading package, ensure +;;; directory class is set up. +(when (and seam-note-directory + (not (cl-find 'seam-note-directory dir-locals-directory-cache :key #'cadr))) + (dir-locals-set-directory-class seam-note-directory 'seam-note-directory)) + +(provide 'seam) + +;;; seam.el ends here |