summaryrefslogtreecommitdiff
path: root/modules/org-webclipper.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /modules/org-webclipper.el
changing repositories
Diffstat (limited to 'modules/org-webclipper.el')
-rw-r--r--modules/org-webclipper.el145
1 files changed, 145 insertions, 0 deletions
diff --git a/modules/org-webclipper.el b/modules/org-webclipper.el
new file mode 100644
index 00000000..c7b80499
--- /dev/null
+++ b/modules/org-webclipper.el
@@ -0,0 +1,145 @@
+;;; org-webclipper.el --- Web Page Clipping Workflow to Org Roam -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;;
+;; Allows saving a copy of the page EWW is visiting for offline reading.
+;; In other words, it's a "Pocket/Instapaper" that collects the articles in an Emacs org-mode file.
+;;
+;; I review the articles, then add the ones I want for future reference by moving it to an
+;; org-roam file.
+;;
+;;; Code:
+
+(require 'user-constants) ;; for location of 'webclipped-file'
+
+;; ---------------------------- Org Webpage Clipper ----------------------------
+
+(defun cj/org-webpage-clipper ()
+ "Capture the current web page for later viewing in an Org file.
+
+Return the yanked content as a string so templates can insert it."
+ (interactive)
+ (let* ((source-buffer (org-capture-get :original-buffer))
+ (source-mode (with-current-buffer source-buffer major-mode)))
+ (cond
+ ((eq source-mode 'w3m-mode)
+ (with-current-buffer source-buffer
+ (org-w3m-copy-for-org-mode)))
+ ((eq source-mode 'eww-mode)
+ (with-current-buffer source-buffer
+ (org-eww-copy-for-org-mode)))
+ (t
+ (error "Not valid -- must be in w3m or eww mode")))
+ ;; extract the webpage content from the kill ring
+ (car kill-ring)))
+
+;; ------------------------------ Capture Template -----------------------------
+
+(with-eval-after-load 'org-capture
+ ;; Ensure org-capture-templates exists before adding to it
+ (unless (boundp 'org-capture-templates)
+ (setq org-capture-templates nil))
+
+ ;; Add the webclipper template to org-capture-templates
+ (add-to-list 'org-capture-templates
+ '("w" "Web Page Clipper" entry
+ (file+headline webclipped-file "Webclipped Inbox")
+ "* %a\nURL: %L\nCaptured On:%U\n%(cj/org-webpage-clipper)\n"
+ :prepend t :immediate-finish t)
+ t))
+
+;; ------------------------ Org-Branch To Org-Roam-Node ------------------------
+
+(defun cj/org-link-get-description (text)
+ "Extract the description from an org link, or return the text unchanged.
+If TEXT contains an org link like [[url][description]], return description.
+If TEXT contains multiple links, only process the first one.
+Otherwise return TEXT unchanged."
+ (if (string-match "\\[\\[\\([^]]+\\)\\]\\(?:\\[\\([^]]+\\)\\]\\)?\\]" text)
+ (let ((description (match-string 2 text))
+ (url (match-string 1 text)))
+ ;; If there's a description, use it; otherwise use the URL
+ (or description url))
+ text))
+
+(defun cj/move-org-branch-to-roam ()
+ "Move the org subtree at point to a new org-roam node.
+The node filename will be timestamp-based with the heading name.
+The heading becomes the node title, and the entire subtree is demoted to level 1.
+If the heading contains a link, extract the description for the title."
+ (interactive)
+ (unless (org-at-heading-p)
+ (user-error "Not at an org heading"))
+
+ (let* ((heading-components (org-heading-components))
+ (current-level (nth 0 heading-components))
+ (raw-title (nth 4 heading-components))
+ ;; Extract clean title from potential link
+ (title (cj/org-link-get-description raw-title))
+ (timestamp (format-time-string "%Y%m%d%H%M%S"))
+ ;; Convert title to filename-safe format
+ (title-slug (replace-regexp-in-string
+ "[^a-zA-Z0-9]+" "-"
+ (downcase title)))
+ ;; Remove leading/trailing hyphens
+ (title-slug (replace-regexp-in-string
+ "^-\\|-$" "" title-slug))
+ (filename (format "%s-%s.org" timestamp title-slug))
+ (filepath (expand-file-name filename org-roam-directory))
+ ;; Generate a unique ID for the node
+ (node-id (org-id-new))
+ ;; Store the subtree in a temporary buffer
+ subtree-content)
+
+ ;; Copy the subtree content
+ (org-copy-subtree)
+ (setq subtree-content (current-kill 0))
+
+ ;; Now cut it to remove from original buffer
+ (org-cut-subtree)
+
+ ;; Process the subtree to demote it to level 1
+ (with-temp-buffer
+ (org-mode)
+ (insert subtree-content)
+ ;; Demote the entire tree so the top level becomes level 1
+ (goto-char (point-min))
+ (when (> current-level 1)
+ (let ((demote-count (- current-level 1)))
+ (while (re-search-forward "^\\*+ " nil t)
+ (beginning-of-line)
+ (dotimes (_ demote-count)
+ (when (looking-at "^\\*\\*")
+ (delete-char 1)))
+ (forward-line))))
+ (setq subtree-content (buffer-string)))
+
+ ;; Create the new org-roam file
+ (with-temp-file filepath
+ ;; Insert the org-roam template with ID at file level
+ (insert ":PROPERTIES:\n")
+ (insert ":ID: " node-id "\n")
+ (insert ":END:\n")
+ (insert "#+TITLE: " title "\n")
+ (insert "#+CATEGORY: " title "\n")
+ (insert "#+FILETAGS: Topic\n\n")
+
+ ;; Insert the demoted subtree content
+ (insert subtree-content))
+
+ ;; Sync the org-roam database
+ (org-roam-db-sync)
+
+ ;; Message to user
+ (message "'%s' added as an org-roam node." title)))
+
+;; ----------------------------- Webclipper Keymap -----------------------------
+
+;; Buffer & file operations prefix and keymap
+(define-prefix-command 'cj/webclipper-map nil
+ "Keymap for weblipper operations.")
+(define-key cj/custom-keymap "w" 'cj/webclipper-map)
+(define-key cj/webclipper-map "N" 'cj/move-org-branch-to-roam) ;; for node
+
+(provide 'org-webclipper)
+;;; org-webclipper.el ends here.