diff options
author | Spencer Williams <spnw@plexwave.org> | 2025-04-07 17:16:05 -0400 |
---|---|---|
committer | Spencer Williams <spnw@plexwave.org> | 2025-04-07 17:16:05 -0400 |
commit | 41e4bce247ad81945aa92b6fd66b1a6d107988aa (patch) | |
tree | 0aa6c2938034c43f3dcf34754381553a2b6b753a /seam.el | |
parent | 5c283f920cca7421737c57e85ce3c9a645c7f2b1 (diff) |
Move code to lisp/ subdirectory
Diffstat (limited to 'seam.el')
-rw-r--r-- | seam.el | 615 |
1 files changed, 0 insertions, 615 deletions
diff --git a/seam.el b/seam.el deleted file mode 100644 index cdf3227..0000000 --- a/seam.el +++ /dev/null @@ -1,615 +0,0 @@ -;;; 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 |