;;; module-collect.el --- manage document collections ;;; ;;; Commentary: ;;; ;;; This module provides the ability to create, rename, and delete ;;; collections of documents. Each collection is a subdirectory of the ;;; path defined by the variable collect/store-path. ;;; ;;; Code: ;;; collect/store-path is a variable that stores a string defining the path to the directory under which collections are stored. (require 'url) (require 'time-date) (require 'eww) (defvar collect/store-path "~/collections") (defvar collect/supported-mime-types '("text/plain" "text/html" "text/css" "text/javascript" "application/json" "application/xml" "text/xml" "text/markdown" "text/csv") "List of MIME types supported for collection.") (defun collect/delete-collection-internal (collection-name) "Delete an existing collection, moving its contents (except .collection-metadata) into the no-name collection." (let ((source-dir (expand-file-name collection-name collect/store-path)) (target-dir (expand-file-name "no-name" collect/store-path))) (if (file-exists-p source-dir) (progn (unless (file-exists-p target-dir) (make-directory target-dir t)) ;; Move all contents, excluding .collection-metadata (dolist (file (directory-files source-dir t "^\\([^.]\\|\\.\\([^.]\\|\\..\\)\\)")) (let ((file-name (file-name-nondirectory file))) (unless (string= file-name ".collection-metadata") (let ((target-file (expand-file-name file-name target-dir))) (if (file-directory-p file) (copy-directory file target-file t t t) (rename-file file target-file t)))))) ;; Force remove the directory (delete-directory source-dir t) (message "Collection '%s' deleted and contents moved to 'no-name' collection." collection-name)) (error "Collection '%s' does not exist" collection-name)))) (defun collect/delete-collection () "Interactively delete an existing collection." (interactive) (let* ((collections (collect/get-collections)) (choice (completing-read "Select collection to delete: " (mapcar #'car collections) nil t)) (dir-to-delete (cdr (assoc choice collections)))) (when dir-to-delete (collect/delete-collection-internal (file-name-nondirectory dir-to-delete))))) (defun collect/create-collection () "Interactively create a new collection." (interactive) (let ((collection-name (read-string "Enter collection name: "))) (collect/create-collection-internal collection-name))) (defun collect/create-collection-internal (collection-name) "Create a new collection with the given name." (let* ((unix-path (replace-regexp-in-string "[^[:alnum:]_-]" "-" (downcase (string-trim collection-name)))) (full-path (expand-file-name unix-path collect/store-path)) (metadata-file (expand-file-name ".collection-metadata" full-path)) (current-time (format-time-string "%Y-%m-%d %H:%M:%S"))) (cond ((string-empty-p unix-path) (error "Collection name cannot be empty")) ((file-exists-p full-path) (error "Collection '%s' already exists" collection-name)) (t (make-directory full-path t) (with-temp-file metadata-file (insert (format "name: %s\n" collection-name) (format "created: %s\n" current-time) (format "updated: %s\n" current-time))) (message "Collection '%s' created successfully with metadata." collection-name))))) (defun collect/rename-collection-internal (old-name new-name) "Rename collection from OLD-NAME to NEW-NAME." (let ((old-dir (expand-file-name old-name collect/store-path)) (new-dir (expand-file-name (replace-regexp-in-string "[^[:alnum:]_-]" "-" (downcase (string-trim new-name))) collect/store-path))) (when (file-exists-p old-dir) ;; Create new collection (collect/create-collection-internal new-name) ;; Move contents (dolist (file (directory-files old-dir t "^\\([^.]\\|\\.\\([^.]\\|\\..\\)\\)")) (let ((file-name (file-name-nondirectory file))) (unless (string= file-name ".collection-metadata") (let ((target-file (expand-file-name file-name new-dir))) (if (file-directory-p file) (copy-directory file target-file t t t) (rename-file file target-file t)))))) ;; Update metadata file (let ((old-metadata-file (expand-file-name ".collection-metadata" old-dir)) (new-metadata-file (expand-file-name ".collection-metadata" new-dir))) (when (file-exists-p old-metadata-file) (with-temp-file new-metadata-file (insert-file-contents old-metadata-file) (goto-char (point-min)) (when (re-search-forward "^name: .*$" nil t) (replace-match (concat "name: " new-name))) (goto-char (point-min)) (when (re-search-forward "^updated: .*$" nil t) (replace-match (concat "updated: " (format-time-string "%Y-%m-%d %H:%M:%S"))))))) ;; Delete old collection directory (delete-directory old-dir t) (message "Collection '%s' renamed to '%s'." old-name new-name)))) (defun collect/rename-collection () "Interactively rename an existing collection." (interactive) (let* ((collections (collect/get-collections)) (old-name (completing-read "Select collection to rename: " (mapcar #'car collections) nil t)) (new-name (read-string (format "Enter new name for collection '%s': " old-name)))) (when (and old-name new-name) (let ((dir-to-rename (cdr (assoc old-name collections)))) (when dir-to-rename (collect/rename-collection-internal (file-name-nondirectory dir-to-rename) new-name)))))) (defun collect/add-file-to-collection () "Add a file to a specified collection." (interactive) (let* ((file-to-add (read-file-name "Select file to add: ")) (all-files (directory-files collect/store-path t "^[^.]")) (collections (cl-remove-if (lambda (dir) (or (not (file-directory-p dir)) (string= (file-name-nondirectory dir) "no-name"))) all-files)) (collection-names (mapcar (lambda (dir) (let ((metadata-file (expand-file-name ".collection-metadata" dir))) (if (file-exists-p metadata-file) (with-temp-buffer (insert-file-contents metadata-file) (if (re-search-forward "^name: \\(.*\\)$" nil t) (match-string 1) (file-name-nondirectory dir))) (file-name-nondirectory dir)))) collections)) (selected-collection (completing-read "Select collection to add to: " collection-names nil t)) (target-dir (nth (cl-position selected-collection collection-names :test 'string=) collections))) (when (and file-to-add target-dir) (let ((target-file (expand-file-name (file-name-nondirectory file-to-add) target-dir))) (if (file-exists-p target-file) (if (y-or-n-p "File already exists in collection. Overwrite?") (copy-file file-to-add target-file t) (message "File not added to collection.")) (copy-file file-to-add target-file)) (message "File '%s' added to collection '%s'." (file-name-nondirectory file-to-add) selected-collection))))) (defun collect/get-collections () "Get a list of collections with their names and directories." (let ((all-dirs (directory-files collect/store-path t "^[^.]"))) (cl-remove-if #'null (mapcar (lambda (dir) (when (file-directory-p dir) (let* ((metadata-file (expand-file-name ".collection-metadata" dir)) (name (collect/get-collection-name metadata-file))) (when name (cons name dir))))) all-dirs)))) (defun collect/get-collection-name (metadata-file) "Get the collection name from the metadata file." (when (file-exists-p metadata-file) (with-temp-buffer (insert-file-contents metadata-file) (goto-char (point-min)) (when (re-search-forward "^name: \\(.+\\)$" nil t) (match-string 1))))) (defun collect/add-webpage-to-collection () "Add a webpage to a specified collection." (interactive) (let* ((url (read-string "Enter URL to add: ")) (collections (collect/get-collections)) (selected-collection (completing-read "Select collection to add to: " (mapcar #'car collections) nil t)) (target-dir (cdr (assoc selected-collection collections)))) (when (and url (file-directory-p target-dir)) (collect/add-webpage-to-collection-internal url target-dir selected-collection)))) (defun collect/add-webpage-to-collection-internal (url target-dir collection-name) "Internal function to add a webpage to a collection." (let* ((filename (collect/generate-filename url)) (target-file (expand-file-name filename target-dir))) (url-retrieve url (lambda (status target-file filename collection-name) (if (plist-get status :error) (message "Error fetching webpage: %s" (plist-get status :error)) (goto-char (point-min)) (re-search-forward "\n\n") (write-region (point) (point-max) target-file) (message "Webpage '%s' added to collection '%s'." filename collection-name))) (list target-file filename collection-name)))) (defun collect/generate-filename (url) "Generate a filename for the given URL." (let* ((timestamp (format-time-string "%Y%m%dT%H%M%S")) (parsed-url (url-generic-parse-url url)) (host (url-host parsed-url)) (path (url-filename parsed-url)) (path-without-schema (replace-regexp-in-string "^/+" "" path)) (sanitized-path (replace-regexp-in-string "[^a-zA-Z0-9._-]" "_" path-without-schema)) (full-path (concat host "_" sanitized-path)) (extension (if (string-match "\\.[^.]+$" full-path) (match-string 0 full-path) ".html"))) (concat timestamp "_" (file-name-sans-extension full-path) extension))) (defun collect/add-eww-page-to-collection () "Add the current eww page to a specified collection." (interactive) (if (eq major-mode 'eww-mode) (progn (eww-copy-page-url) (let* ((url (read-string "Enter URL to add: " (current-kill 0))) (collections (collect/get-collections)) (selected-collection (completing-read "Select collection to add to: " (mapcar #'car collections) nil t)) (target-dir (cdr (assoc selected-collection collections)))) (if (and url (file-directory-p target-dir)) (progn (message "Adding webpage to collection...") (collect/add-webpage-to-collection-internal url target-dir selected-collection)) (message "Invalid URL or collection directory.")))) (message "This function can only be used in eww-mode."))) (provide 'module-collect)