diff --git a/README.org b/README.org index 0682c52..4c3f501 100644 --- a/README.org +++ b/README.org @@ -4,28 +4,15 @@ A comprehensive Pushbullet client for GNU Emacs that allows you to send and mana * Features -- Send Notes: Send text notes to Pushbullet with custom titles -- Region Pushing: Push selected text regions to your devices -- Clipboard Integration: Send clipboard contents to Pushbullet -- Interactive UI: Browse and manage your pushes in a dedicated Emacs buffer -- Modern Interface: Beautiful formatting with icons and proper typography -- Pagination Support: Load multiple pages of pushes efficiently +- *Interactive UI*: Browse and manage your pushes in a dedicated Emacs buffer +- *Send Notes*: Send text notes to Pushbullet with custom titles +- *Region Pushing*: Push selected text regions to your devices +- *Clipboard Integration*: Send clipboard contents to Pushbullet +- *Pagination Support*: Load multiple pages of pushes efficiently +- *Export*: Export Pushes to Org-Mode * Installation -** Manual Installation - -1. Clone this repository: - #+BEGIN_SRC bash - git clone https://github.com/sav/emacs-pushbullet.git - #+END_SRC - -2. Add the following to your =~/.emacs.d/init.el=: - #+BEGIN_SRC elisp - (add-to-list 'load-path "~/path/to/emacs-pushbullet") - (require 'pushbullet) - #+END_SRC - ** Using straight.el #+BEGIN_SRC elisp @@ -39,6 +26,19 @@ A comprehensive Pushbullet client for GNU Emacs that allows you to send and mana (package! pushbullet :recipe (:host github :repo "sav/emacs-pushbullet")) #+END_SRC +** Manual Installation + +1. Clone this repository: + #+BEGIN_SRC bash + git clone https://github.com/sav/emacs-pushbullet.git + #+END_SRC + +2. Add the following to your =~/.emacs.d/init.el=: + #+BEGIN_SRC elisp + (add-to-list 'load-path "~/path/to/emacs-pushbullet") + (require 'pushbullet) + #+END_SRC + * Configuration ** Required Setup @@ -49,54 +49,61 @@ A comprehensive Pushbullet client for GNU Emacs that allows you to send and mana 2. Configure the token in Emacs: #+BEGIN_SRC elisp - (setq pushbullet-token "your-api-token-here") + (setq pushbullet-api-token "your-api-token-here") #+END_SRC - Or use =M-x customize-variable RET pushbullet-token= + Or use =M-x customize-variable RET pushbullet-api-token= 3. Alternatively, just add an entry for `pushbullet.com` (lower-case) in your `.authinfo.gpg`: - #BEGIN_SRC authinfo + #+BEGIN_SRC authinfo machine pushbullet.com password "your-api-token-here" - #END_SRC + #+END_SRC ** Optional Configuration #+BEGIN_SRC elisp -;; Number of pushes to fetch per request (default: 20) -(setq pushbullet-limit 20) +;; Number of pushes to fetch per request +(setq pushbullet-api-limit 50) -;; Default title for pushes (default: "GNU Emacs ") +;; Default title for pushes (setq pushbullet-default-title "My Emacs") -;; Maximum columns for text wrapping (default: 108) -(setq pushbullet-columns 80) +;; Maximum columns for text wrapping +(setq pushbullet-columns 100) ;; Enable debug logging (default: nil) -(setq pushbullet-debug t) +(setq pushbullet-debug t + pushbullet-api-debug t) #+END_SRC * Usage ** Interactive Commands -| Command | Description | Key Binding | -|----------------------------|--------------------------------|-------------| -| =M-x pushbullet= | Open the Pushbullet Buffer | - | -| =M-x pushbullet-send= | Send a note with prompted title and text | - | -| =M-x pushbullet-send-text=| Send a note with prompted text | - | -| =M-x pushbullet-region= | Send the current region | - | -| =M-x pushbullet-yank= | Send clipboard contents | - | -| =M-x pushbullet-update= | Update the Pushbullet buffer | =C-c C-u= | +| Command | Description | +|----------------------------+----------------------------------------------------------------------+ +| =M-x pushbullet= | Opens or switches to the main Pushbullet UI buffer. | +| =M-x pushbullet-send= | Prompts for a title and text to send a new note push. | +| =M-x pushbullet-send-text= | Prompts for text to send a new note push, using a default title. | +| =M-x pushbullet-region= | Sends the active region's content as a note push. | +| =M-x pushbullet-yank= | Sends the latest kill-ring entry (clipboard content) as a note push. | +| =M-x pushbullet-export= | Exports currently fetched pushes to an Org-mode buffer. | ** Key Bindings in Pushbullet Buffer -| Key | Function | -|----------|--------------------| -| =C-c C-c=| Send a new push | -| =C-c C-u=| Update/refresh the buffer | -| =q= | Quit the buffer | +| Key | Function | +|-----------|--------------------------------------------| +| =C-c C-u= | Fetch more pushes. | +| =C-c C-e= | Export pushes to Org-mode. | +| =C-c C-o= | Open a URL at the current cursor position. | +| =q= | Close the Pushbullet buffer. | ** Examples +*** Open the Interactive UI +#+BEGIN_SRC elisp +(pushbullet) ; Opens the main Pushbullet buffer +#+END_SRC + *** Send a Quick Note #+BEGIN_SRC elisp (pushbullet-send "Meeting Reminder" "Don't forget the team meeting at 3 PM") @@ -112,57 +119,60 @@ A comprehensive Pushbullet client for GNU Emacs that allows you to send and mana (pushbullet-yank) ; Sends current kill-ring contents #+END_SRC -*** Open the Interactive UI -#+BEGIN_SRC elisp -(pushbullet) ; Opens the main Pushbullet buffer -#+END_SRC - * API Reference -** Functions +** Customization Variables -*** =pushbullet-send (title body)= -Send a note to Pushbullet with the specified title and body text. +*** =pushbullet-api-token= +Your Pushbullet API access token (required). -*** =pushbullet-send-text (text)= -Send a note with the default title and specified text. +*** =pushbullet-api-limit= +Number of pushes to fetch per request (default: =20=). -*** =pushbullet-region (start end)= -Push the selected region to Pushbullet. The buffer name is used as the title. +*** =pushbullet-default-title= +Default title for pushes (default: ="GNU Emacs "=). -*** =pushbullet-yank ()= -Push the current kill-ring (clipboard) contents to Pushbullet. +*** =pushbullet-columns= +Maximum columns for text wrapping (default: =70=). -*** =pushbullet ()= -Open the interactive Pushbullet UI buffer. +*** =pushbullet-show-send-forms= +Whether to display the send form in the Pushbullet UI (default: =t=). -*** =pushbullet-update ()= -Fetch and display the latest pushes in the current buffer. +*** =pushbullet-debug= +Enable debug logging (default: =nil=). -** Customization Variables +*** =pushbullet-api-debug= +Enable debug logging for the API requests (default: =nil=). -*** =pushbullet-token= -Your Pushbullet API access token (required). -*** =pushbullet-limit= -Number of pushes to fetch per request (default: 50). +** Interactive Functions -*** =pushbullet-default-title= -Default title for pushes (default: "GNU Emacs "). +*** =(pushbullet)= +Open the Pushbullet application buffer. -*** =pushbullet-columns= -Maximum columns for text wrapping (default: 108). +*** =(pushbullet-send title body &optional url)= +Send a note to Pushbullet using the given title, message body, and, optionally, a URL. -*** =pushbullet-debug= -Enable debug logging (default: nil). +*** =(pushbullet-send-text text)= +Send a note with the default title and specified text. + +*** =(pushbullet-region start end)= +Push the selected region to Pushbullet. The buffer name is used as the title. + +*** =(pushbullet-yank)= +Push the current kill-ring (clipboard) contents to Pushbullet. + +*** =(pushbullet-export)= +Export the list of Pushes to Org-Mode. * Dependencies -This package requires the following Emacs packages: +This package requires the following packages: - =emacs= ~(>= 29.1)~ -- =request= ~(>= 0.3.3)~ - =all-the-icons= ~(>= 5.0.0)~ -- =auth-source= ~(>= 23.1)~ +- =request= ~(>= 0.3.3)~ +- =json= ~(>= 1.5)~ +- =auth-source= ~(>= 2.3.1)~ * License @@ -170,10 +180,16 @@ This project is licensed under the GNU General Public License v3.0 - see the [[f * Version -Current version: 1.0.0 +Current version: 1.0.1 * Changelog +** 1.0.1 +- New UI with new features and a form for submitting pushes +- Deletion of a single push or all pushes at once +- Export pushes to Org-Mode +- Show image and files URLs + ** 1.0.0 - Initial release - Basic push sending functionality diff --git a/pushbullet-api.el b/pushbullet-api.el new file mode 100644 index 0000000..5183a08 --- /dev/null +++ b/pushbullet-api.el @@ -0,0 +1,201 @@ +;;; pushbullet-api.el --- Pushbullet client for Emacs -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Savio Sena + +;; Author: Savio Sena +;; Version: 1.0.1 +;; Package-Requires: ((emacs "29.1") (json "1.5") (request "0.3.3")) +;; Keywords: pushbullet, client, tool, internet +;; URL: https://github.com/sav/emacs-pushbullet + +;; 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 . + +;;; Commentary: +;;; This package provides the REST API components for the Emacs +;;; Pushbullet client. +;;; + +;;; Code: + +(require 'auth-source) +(require 'cl-lib) +(require 'json) +(require 'request) + +(defgroup pushbullet-api nil + "Client for the Pushbullet REST API." + :group 'extensions) + +(defcustom pushbullet-api-token nil + "Your personal Pushbullet API access token. +This token is required for authentication with the Pushbullet API. +You can obtain your access token from the Pushbullet account settings page: +`https://www.pushbullet.com/#settings/account`." + :type 'string + :group 'pushbullet-api) + +(defcustom pushbullet-api-limit 20 + "The maximum number of pushes to fetch in a single API request for pagination." + :type 'integer + :group 'pushbullet-api) + +(defcustom pushbullet-api-debug nil + "Enable verbose logging for Pushbullet API operations. +When non-nil, additional debug messages will be printed to the *Messages* buffer." + :type 'boolean + :group 'pushbullet-api) + +(defvar pushbullet-api-url "https://api.pushbullet.com/v2" + "The base URL for all Pushbullet API v2 endpoints.") + +(defvar-local pushbullet-api-cursor nil + "A buffer-local string used for pagination in Pushbullet API requests, indicating the point from which to fetch subsequent pushes.") + +(defmacro pushbullet-api--log (fmt &rest args) + "Logs a debug message with FMT and ARGS when `pushbullet-api-debug' is enabled. +The message is prefixed with '[pushbullet-api]' for identification." + `(when pushbullet-api-debug + (message (concat "[pushbullet-api] " ,fmt) ,@args))) + +(defun pushbullet-api--check-token () + "Ensures that `pushbullet-api-token' is set, either directly or by +retrieving it from `auth-source'. If the token is not found, return `nil'." + (or (stringp pushbullet-api-token) + (setq pushbullet-api-token (auth-source-pick-first-password :host "pushbullet.com")))) + +(defun pushbullet-api-request (method endpoint data callback &optional error-callback) + "Makes an asynchronous HTTP request to the Pushbullet API. + +METHOD is a string representing the HTTP method (e.g., 'GET', 'POST', 'DELETE'). +ENDPOINT is a string specifying the API endpoint relative to `pushbullet-api-url'. +DATA is an optional alist of request data to be sent as JSON. +CALLBACK is a function to be called upon a successful API response, receiving the parsed JSON data. +ERROR-CALLBACK is an optional function to be called if the API request encounters an error. + +This function automatically includes `pushbullet-api-token' for authentication and handles JSON encoding/decoding." + (unless (pushbullet-api--check-token) + (error "Please set your Pushbullet token with M-x customize-variable RET pushbullet-api-token")) + (let ((url (concat pushbullet-api-url endpoint)) + (headers `(("Access-Token" . ,pushbullet-api-token) + ("Content-Type" . "application/json")))) + (request url + :type method + :headers headers + :data (when data (json-encode data)) + :parser 'json-read + :success callback + :error (or error-callback + (cl-function + (lambda (&key error-thrown &allow-other-keys) + (message "Pushbullet API error: %s" error-thrown))))))) + +(defun pushbullet-api--fetch-url (&optional limit) + "Construct API endpoint string for fetching pushes with pagination. + +Returns a string representing the Pushbullet API URL path. + +If `pushbullet-api-cursor' is nil, fetches initial pushes. +Otherwise, uses cursor for pagination with `pushbullet-api-limit'. +Optional LIMIT overrides the default limit. + +API documentation: https://docs.pushbullet.com/#list-pushes + +The endpoint returns a JSON object with structure: +`((cursor . ) + (pushes . [((iden . ) + (type . ) + (title . ) + (body . ) + (created . ) + (modified . ) + (active . )) ...]))" + (apply #'format + (let ((n (or limit pushbullet-api-limit))) + (if pushbullet-api-cursor + `("/pushes?limit=%d&cursor=%s" ,n ,pushbullet-api-cursor) + `("/pushes?limit=%d" ,n))))) + +(defun pushbullet-api-active (push) + "Returns true if PUSH has data and should be displayed; otherwise, returns `nil`." + (let ((active (alist-get 'active push))) + (and (and active (not (eq active :json-false))) + (or (not (string-empty-p (alist-get 'title push))) + (not (string-empty-p (alist-get 'url push))) + (not (string-empty-p (alist-get 'body push))))))) + + +(defun pushbullet-api-fetch (callback &optional limit) + "Fetch Pushbullet pushes from the API and invoke CALLBACK with results. + +Returns nil (asynchronous operation). + +Uses `pushbullet-api-cursor' for pagination to fetch subsequent sets. +Upon successful retrieval, updates `pushbullet-api-cursor' and invokes +CALLBACK with a list of push alists. +Optional LIMIT overrides the default number of items to fetch. + +API documentation: https://docs.pushbullet.com/#list-pushes + +CALLBACK receives a list of push alists with structure: +`(((iden . ) + (type . ) + (title . ) + (body . ) + (created . ) + (modified . ) + (active . )) + ...)" + (pushbullet-api-request + "GET" (pushbullet-api--fetch-url limit) nil + (cl-function + (lambda (&key data &allow-other-keys) + (let ((pushes (alist-get 'pushes data)) + (cursor (alist-get 'cursor data))) + (pushbullet-api--log "Received %S pushes" (length pushes)) + (setq pushbullet-cursor cursor) + (funcall callback (cl-coerce pushes 'list))))))) + +(defun pushbullet-api-delete (push) + "Deletes the specified PUSH (an alist containing at least an 'iden field) from the Pushbullet server. +Upon successful deletion, a debug message is logged, and the Pushbullet UI is implicitly refreshed by `pushbullet` being called." + (let ((id (alist-get 'iden push))) + (pushbullet-api-request + "DELETE" (format "/pushes/%s" id) nil + (cl-function + (lambda (&key data &allow-other-keys) + (pushbullet-api--log "Push deleted: %S" push)))))) + +(defun pushbullet-api-send (title body &optional url) + "Sends a push to the Pushbullet API. + +TITLE is a string specifying the title of the push. +BODY is a string specifying the main content of the push. +URL is an optional string specifying a URL to be included, transforming +the push into a link." + (let ((push `((type . ,"note") + (title . ,title) + (body . ,body)))) + (when url (push '(url . url) push)) + (pushbullet-api--log "Pushing: %S" push) + (pushbullet-api-request + "POST" "/pushes" push + (cl-function + (lambda (&key data &allow-other-keys) + (pushbullet-api--log "Pushed: %s" data)))))) + +(provide 'pushbullet-api) + +;;; pushbullet-api.el ends here diff --git a/pushbullet.el b/pushbullet.el index a7887bb..88c8a9f 100644 --- a/pushbullet.el +++ b/pushbullet.el @@ -3,8 +3,8 @@ ;; Copyright (C) 2025 Savio Sena ;; Author: Savio Sena -;; Version: 1.0.0 -;; Package-Requires: ((emacs "29.1") (request "0.3.3") (all-the-icons "5.0.0")) +;; Version: 1.0.1 +;; Package-Requires: ((emacs "29.1") (all-the-icons "5.0.0")) ;; Keywords: pushbullet, client, tool, internet ;; URL: https://github.com/sav/emacs-pushbullet @@ -25,264 +25,407 @@ ;;; Commentary: -;; This package provides Pushbullet integration for Emacs, allowing you to: -;; - Send notes to Pushbullet -;; - Push text regions and clipboard contents -;; - Browse and manage your Pushbullet pushes in a dedicated UI buffer +;;;; This package provides a comprehensive Emacs client for the Pushbullet service. +;;; It enables users to: +;;; - Send various types of pushes (notes, links) from Emacs. +;;; - Push selected text regions or clipboard contents directly to Pushbullet. +;;; - Browse, add, delete, and send Pushbullet pushes within a dedicated Emacs UI. +;;; - Export pushes to Org-mode format. -;; Usage: +;;; Usage Examples: -;; M-x pushbullet Open the Pushbullet buffer -;; M-x pushbullet-send Send a note with prompted title and text -;; M-x pushbullet-send-text Send a note with prompted text -;; M-x pushbullet-region Send the current region -;; M-x pushbullet-yank Send the current kill-ring contents +;;; `M-x pushbullet` : Opens or switches to the main Pushbullet UI buffer. +;;; `M-x pushbullet-send` : Prompts for a title and text to send a new note push. +;;; `M-x pushbullet-send-text` : Prompts for text to send a new note push, using a default title. +;;; `M-x pushbullet-region` : Sends the active region's content as a note push. +;;; `M-x pushbullet-yank` : Sends the latest kill-ring entry (clipboard content) as a note push. +;;; `M-x pushbullet-export` : Exports currently fetched pushes to an Org-mode buffer. ;;; Code: -(require 'request) -(require 'json) -(require 'button) (require 'all-the-icons) -(require 'auth-source) +(require 'button) +(require 'cl-lib) +(require 'wid-edit) +(require 'widget) +(require 'pushbullet-api) -(defconst pushbullet-version "1.0.0" - "Version of the Pushbullet client") +(defconst pushbullet-version "1.0.1" + "The current version string of the Pushbullet Emacs package.") (defgroup pushbullet nil - "Pushbullet client." - :version pushbullet-version - :prefix "pushbullet-" - :group 'applications) - -(defcustom pushbullet-token nil - "Your Pushbullet API access token. -You can obtain this from https://www.pushbullet.com/#settings/account" - :type 'string - :group 'pushbullet) + "Client for the Pushbullet service, providing integration with Emacs." + :version pushbullet-version + :prefix "pushbullet-" + :group 'applications) -(defcustom pushbullet-limit 20 - "Number of pushes to fetch per request." - :type 'integer +(defcustom pushbullet-debug nil + "Enable verbose logging for Pushbullet operations. +When non-nil, additional debug messages will be printed to the +*Messages* buffer." + :type 'boolean :group 'pushbullet) +(defvar pushbullet-buffer-name "*Pushbullet*" + "The name of the main buffer where the Pushbullet user interface is displayed.") + +(defvar pushbullet-export-buffer-name "*Pushbullet Export*" + "The name of the buffer used for exporting Pushbullet pushes to Org-mode format.") + (defcustom pushbullet-default-title (format "GNU Emacs %s" emacs-version) - "Default title used when sending a push without an explicit title." + "The default title string used for new pushes when no explicit title + is provided. It is formatted to include the current Emacs version." :type 'string :group 'pushbullet) -(defcustom pushbullet-columns 80 - "Maximum number of columns used to wrap lines in Pushbullet message display." +(defcustom pushbullet-columns 70 + "Maximum number of columns for wrapping lines in the Pushbullet UI buffer." :type 'integer - :group 'pushbullet - :initialize 'custom-initialize-default) - -(defcustom pushbullet-debug nil - "Enable verbose logging for Pushbullet operations. -When non-nil, additional debug messages will be printed to the *Messages* buffer." - :type 'boolean - :group 'pushbullet - :initialize 'custom-initialize-default) + :group 'pushbullet) -(defvar pushbullet-api "https://api.pushbullet.com/v2" - "Base URL for Pushbullet API.") +(defcustom pushbullet-left-alignment 8 + "The size of the left alignment padding in the Pushbullet UI." + :type 'integer + :group 'pushbullet) -(defvar pushbullet-buffer "*Pushbullet*" - "Name of the Pushbullet UI buffer.") +(defcustom pushbullet-textfield-width + (truncate + (* (- pushbullet-columns pushbullet-left-alignment) 0.90)) + "The calculated width for editable text fields within the Pushbullet UI." + :type 'integer + :group 'pushbullet) -(defvar-local pushbullet-cursor nil - "Cursor for pagination, returned by and used in the Pushbullet API.") +(defcustom pushbullet-show-send-form t + "Whether to display the send form in the Pushbullet UI." + :type 'boolean + :group 'pushbullet) -(defvar-local pushbullet-content-start-marker nil - "Marker indicating the beginning of the content.") +(defvar pushbullet--buffer nil + "The buffer currently used for rendering the Pushbullet UI. This is a + buffer-local variable.") -(defvar-local pushbullet-prompt-start-marker nil - "Marker indicating the beginning of the message prompt.") +(defvar pushbullet--title nil + "The title string displayed at the top of the Pushbullet UI buffer. + This is a buffer-local variable.") -(defvar-local pushbullet-prompt-end-marker nil - "Marker indicating the end of the message prompt.") +(defvar pushbullet--pushes nil + "A buffer-local list of Pushbullet pushes currently displayed in the + UI, where each push is an alist.") (defmacro pushbullet--log (fmt &rest args) - "Log a debug message with FMT and ARGS when `pushbullet-debug' is enabled. -The message is prefixed with '[pushbullet]' for identification." + "Logs a debug message with FMT and ARGS if `pushbullet-debug' is enabled. +The message is prefixed with '[pushbullet]' for easy identification in +the `*Messages*' buffer." `(when pushbullet-debug (message (concat "[pushbullet] " ,fmt) ,@args))) -(defun pushbullet--check-token () - "Check if the Pushbullet token is configured." - (unless pushbullet-token - (let ((auth-source-token (auth-source-pick-first-password :host "pushbullet.com"))) - (if auth-source-token - (setq pushbullet-token auth-source-token) - (error "Please set your Pushbullet token with M-x customize-variable RET pushbullet-token")))) - pushbullet-token) - -(defun pushbullet--request (method endpoint data callback &optional error-callback) - "Make a request to the Pushbullet API. -METHOD is the HTTP method (GET, POST, etc.). -ENDPOINT is the API endpoint. -DATA is the request data. -CALLBACK is called on success. -ERROR-CALLBACK is called on error." - (pushbullet--check-token) - (let ((url (concat pushbullet-api endpoint)) - (headers `(("Access-Token" . ,pushbullet-token) - ("Content-Type" . "application/json")))) - (request url - :type method - :headers headers - :data (when data (json-encode data)) - :parser 'json-read - :success callback - :error (or error-callback - (cl-function - (lambda (&key error-thrown &allow-other-keys) - (message "Pushbullet API error: %s" error-thrown))))))) - -(defun pushbullet--format-push (item) - "Format a Pushbullet push ITEM for display in the UI buffer. -Returns a formatted string with timestamp, sender info, title, and body." - (let* ((created (seconds-to-time (alist-get 'created item))) - (datetime (propertize (format " ( %s %s %s %s ) %s" - (all-the-icons-faicon "calendar") - (format-time-string "%Y-%b-%d" created) - (all-the-icons-faicon "clock-o") - (format-time-string "%H:%M" created) - (make-string 4 ?━)) - 'face 'shadow)) - (separator1 (propertize (make-string (- pushbullet-columns (length datetime) 3) ?━) - 'face 'shadow)) - (separator2 (propertize (make-string pushbullet-columns ?━) 'face 'shadow)) - (sender-name (alist-get 'sender_name item)) - (sender-email (alist-get 'sender_email item)) - (sender (format "%s %s %s\n" - (propertize " From:" 'face 'shadow) - (all-the-icons-faicon "user") - (propertize (format "%s <%s>" sender-name sender-email) - 'face '(info-emphasis variable-pitch)))) - (title (alist-get 'title item)) - (subject (if title - (format "%s %s %s\n" - (propertize "Subject:" 'face 'shadow) - (all-the-icons-faicon "pencil") - (propertize title 'face '(variable-pitch bold info-header-node))) - "")) - (body (or (alist-get 'body item) "")) - (text (propertize body 'face '(fixed-pitch info-fixed-pitch))) - (url (alist-get 'url item)) - (link (if url (propertize url 'face 'custom-link) ""))) - (format "%s%s\n%s%s%s\n%s%s\n\n" separator1 datetime sender subject separator2 link text))) - -(defun pushbullet--insert-button (label help value action) - (let ((start (point))) - (insert label) - (make-text-button - start (point) - 'value value - 'action `(lambda (btn) - (when (yes-or-no-p "Confirm action?") - (,action (button-get btn 'value)))) - 'follow-link t - 'help-echo help))) - -(defun pushbullet--delete-push (id) - (pushbullet--request - "DELETE" (format "/pushes/%s" id) nil - (cl-function - (lambda (&key data &allow-other-keys) - (pushbullet--log "Push deleted: %s" id) - (pushbullet))))) - -(defun pushbullet--display-push (item) - "Display a single Pushbullet push ITEM in the current buffer. -Only displays the push if it is active and has a non-empty body." - (let ((active (alist-get 'active item)) - (title (alist-get 'title item)) - (body (alist-get 'body item)) - (url (alist-get 'url item))) - (when (and - active - (or - title - (and body (> (length body) 0)) - (and url (> (length url) 0)))) - (insert (pushbullet--format-push item)) - (pushbullet--insert-button - "[delete]" - "Delete push" - (alist-get 'iden item) - 'pushbullet--delete-push) - (insert "\n\n")))) - -(defun pushbullet--display-pushes (data) - "Display multiple Pushbullet pushes from DATA in the UI buffer. -DATA should contain 'pushes' (list of push items) and 'cursor' (pagination cursor). -Updates the buffer-local cursor for pagination and logs debug information." - (let* ((inhibit-read-only t) - (pushes (alist-get 'pushes data)) - (cursor (alist-get 'cursor data))) - (pushbullet--log "Requested %S items, received %S" pushbullet-limit (length pushes)) - (setq pushbullet-cursor cursor) - (with-current-buffer (get-buffer-create pushbullet-buffer) - (mapc (lambda (item) - (goto-char (point-max)) - (pushbullet--display-push item)) - pushes)))) - -(defun pushbullet--format-banner () - "Format the banner header for the Pushbullet UI buffer. -Returns a formatted string with the package name, version, and decorative separator." - (let ((banner (format "Pushbullet %s" pushbullet-version))) - (format "%s\n%s\n\n" banner (make-string (length banner) ?━)))) - -(defun pushbullet--delete-first-occurence (pattern) - "Delete the region corresponding to the first match of PATTERN in the current buffer. -PATTERN should be a regexp string." - (save-excursion - (goto-char (point-min)) - (when (re-search-forward pattern nil t) - (delete-region (match-beginning 0) (match-end 0))))) +(defun pushbullet--align-right (max str) + "Inserts spaces to right-align STR within a field of MAX width in the + current buffer." + (let ((len (length str))) + (when (>= max len) + (widget-insert (make-string (- max (length str)) ?\s))))) + +(defun pushbullet--insert-aligned (str) + "Inserts a newline and then the string STR, right-aligned by + `pushbullet-left-alignment`." + (widget-insert "\n") + (pushbullet--align-right pushbullet-left-alignment str) + (widget-insert str)) + +(defun pushbullet--list-filter (pushes) + "Filters a list of PUSHES, returning only those that are active and + have at least a title, URL, or body." + (seq-filter (lambda (push) (pushbullet-api-active push)) pushes)) + +(defun pushbullet--list-remove (pushes push) + "Removes PUSH from PUSHES where elements in PUSHES match PUSH based on + the `'iden' key-value pairs." + (let ((iden (alist-get 'iden push))) + (seq-remove (lambda (item) (equal (alist-get 'iden item) iden)) pushes))) + +(defun pushbullet--send (title body url) + "Sends a push with TITLE, BODY, and URL, then reloads the UI." + (pushbullet-api-send title body url) + (pushbullet--log "Pushed: (%S, %S, %S)" title body url) + (pushbullet--load-more 1)) + +(defun pushbullet--load-more (&optional limit) + "Fetches additional pushes from the Pushbullet server using the + `pushbullet-api-fetch' function, then re-renders the UI. +If LIMIT is provided, fetches at most LIMIT pushes." + (interactive) + (pushbullet-api-fetch + #'(lambda (pushes) + (setq pushbullet--pushes + (pushbullet--list-filter + (append pushbullet--pushes pushes))) + (pushbullet--log "Loaded more %S pushes. Total: %S" + (length pushes) (length pushbullet--pushes)) + (pushbullet--render) + ;; Move cursor back to its original position when called from + ;; "Load More" button. + (when (not limit) + (goto-char (point-max)) + (search-backward "Load More"))) + limit) + nil) + +(defun pushbullet--delete-all (&rest args) + "Deletes all pushes currently displayed in the UI from the Pushbullet + server using `pushbullet-api-delete', then re-renders the UI." + (dolist (push pushbullet--pushes) + (pushbullet-api-delete push)) + (setq pushbullet--pushes nil) + (pushbullet--render)) + +(defun pushbullet--delete-row (push) + "Deletes a single PUSH from `pushbullet--pushes', invokes + `pushbullet-api-delete', and then re-renders the UI." + (setq pushbullet--pushes + (pushbullet--list-remove pushbullet--pushes push)) + (pushbullet-api-delete push) + (pushbullet--log "Row deleted") + (pushbullet--render)) + +(defun pushbullet--render-top (title) + "Renders the top section of the Pushbullet UI, displaying the + provided TITLE as a banner." + (let ((len (length title))) + (widget-insert + (propertize + (concat + "══ " title " " + (make-string (- pushbullet-columns 4 len) ?═) + "\n") + 'face 'bold)))) + +(defun pushbullet--render-pushes () + "Renders the list of pushes in the UI (`pushbullet--pushes')." + (dolist (push pushbullet--pushes) + (when (pushbullet-api-active push) + (pushbullet--render-push push)))) + +(defun pushbullet--render-layout (layout) + "recursively renders a UI LAYOUT definition. +LAYOUT is a list where each element is a form `(FUNCTION . ARGS)'. +Strings in the list are treated as `(widget-insert STRING)`. +Lambdas are executed directly." + (mapc (lambda (form) + (cond + ((stringp form) + (widget-insert form)) + ((functionp form) + (funcall form)) + ((consp form) + (apply (car form) (cdr form))))) + layout)) + +(defun pushbullet--render-push (push) + "Renders a single PUSH (an alist) as a set of editable widgets in the UI. +Uses a functional data structure to define the visual layout." + (let* ((created (seconds-to-time (alist-get 'created push))) + (fmt-date (lambda (icon fmt) + (concat (all-the-icons-faicon icon) " " + (format-time-string fmt created)))) + (datetime (propertize + (format "%s %s" + (funcall fmt-date "calendar" "%Y-%b-%d") + (funcall fmt-date "clock-o" "%H:%M")) + 'face 'shadow)) + (separator (make-string (- pushbullet-columns + (+ 7 (length datetime))) ?─)) + (get-val (lambda (k) (or (alist-get k push) "")))) + (pushbullet--render-layout + `((widget-insert ,(format "\n ── %s " datetime)) + (widget-insert ,(concat separator "\n")) + (pushbullet--insert-aligned "Title: ") + (widget-create editable-field + :size ,pushbullet-textfield-width + :format "%v" + :value ,(funcall get-val 'title)) + (pushbullet--insert-aligned "URL: ") + (widget-create editable-field + :size ,pushbullet-textfield-width + :format "%v" + :value ,(or (alist-get 'url push) + (alist-get 'image_url push) + (alist-get 'file_url push) + "")) + (pushbullet--insert-aligned "Text: ") + (widget-create text + :size ,pushbullet-textfield-width + :format "%v" + :value ,(funcall get-val 'body)) + (widget-insert "\n\n") + (pushbullet--align-right ,pushbullet-columns "[Delete]") + (widget-create push-button + :notify ,(lambda (&rest _) (pushbullet--delete-row push)) + "Delete") + (widget-insert "\n"))))) + +(defun pushbullet--render-form () + "Renders the 'New Push' form using a functional layout definition. +Captures widget references in a closure for the Push action." + (let ((header (propertize + (concat "\n\n\n══ New Push " + (make-string (- pushbullet-columns 12) ?═) " \n") + 'face 'bold)) + (w-title nil) + (w-url nil) + (w-body nil)) + (pushbullet--render-layout + `((widget-insert ,header) + (pushbullet--insert-aligned "Title: ") + ,(lambda () (setq w-title (widget-create 'editable-field + :size pushbullet-textfield-width + :value ""))) + (pushbullet--insert-aligned "URL: ") + ,(lambda () (setq w-url (widget-create 'editable-field + :size pushbullet-textfield-width + :value ""))) + (pushbullet--insert-aligned "Text: ") + ,(lambda () (setq w-body (widget-create 'editable-field + :size pushbullet-textfield-width + :value ""))) + (widget-insert "\n\n") + (widget-insert ,(concat (make-string pushbullet-columns ?═) "\n")) + (pushbullet--align-right ,pushbullet-columns " Push ") + (widget-create push-button + :notify ,(lambda (&rest _) + (pushbullet--send (widget-value w-title) + (widget-value w-body) + (widget-value w-url))) + "Push") + (widget-insert "\n"))))) + +(defun pushbullet--render-bottom () + "Renders the bottom section of the Pushbullet UI, including action + buttons such as 'Load More', 'Export', 'Delete All', and 'Close'." + (widget-insert "\n" (make-string pushbullet-columns ?═) "\n") + (pushbullet--align-right + pushbullet-columns + "[Load More] [Export] [Delete All] [Close]") + (widget-create 'push-button + :notify (lambda (&rest _) (pushbullet--load-more)) + "Load More") + (widget-insert " ") + (widget-create 'push-button + :notify (lambda (&rest _) (pushbullet-export)) + "Export") + (widget-insert " ") + (widget-create 'push-button + :notify (lambda (&rest _) (pushbullet--delete-all)) + "Delete All") + (widget-insert " ") + (widget-create 'push-button + :notify (lambda (&rest _) (kill-buffer)) + "Close") + (widget-insert "\n")) + +(defun pushbullet--render () + "Renders the complete Pushbullet UI in the buffer specified by + `pushbullet--buffer'. +This involves rendering the top banner, iterating through +`pushbullet--pushes' to display each push, rendering the bottom +action buttons, and optionally rendering the 'New Push' form if +`pushbullet-show-send-form' is non-nil." + (with-current-buffer pushbullet--buffer + (let* ((inhibit-read-only t) + (inhibit-modification-hooks t)) + (remove-overlays) + (erase-buffer) + (goto-address-mode 1) + (pushbullet--render-top pushbullet--title) + ;; cleanup inactive pushes + (setq pushbullet--pushes + (pushbullet--list-filter pushbullet--pushes)) + (pushbullet--render-pushes) + (pushbullet--render-bottom) + (when pushbullet-show-send-form + (pushbullet--render-form)) + (widget-setup) + (use-local-map + (make-composed-keymap + pushbullet-mode-map widget-keymap))))) ;;;###autoload -(defun pushbullet-update () - "Fetch and display Pushbullet pushes in the current buffer. -Uses pagination cursor if available to fetch additional pushes. -This function is called automatically when opening the Pushbullet buffer." +(defun pushbullet-export (&optional pushes) + "Exports a list of PUSHES to a new Org-mode buffer named + `pushbullet-export-buffer-name'. + +Each active push is formatted as an Org-mode heading, including its +title, URL (if present), and body. + +If PUSHES is `nil` or the function is called interactively, it exports +the currently displayed pushes from the UI (`pushbullet--pushes'). + +This function is interactive." (interactive) - (let ((endpoint (if pushbullet-cursor - (format "/pushes?limit=%d&cursor=%s" pushbullet-limit pushbullet-cursor) - (format "/pushes?limit=%d" pushbullet-limit)))) - (pushbullet--request - "GET" endpoint nil - (cl-function - (lambda (&key data &allow-other-keys) - (pushbullet--display-pushes data))))) - t) + (let ((buffer (get-buffer-create pushbullet-export-buffer-name)) + (pushes (or pushes pushbullet--pushes))) + (with-current-buffer buffer + (remove-overlays) + (erase-buffer) + (insert "#+TITLE: Pushbullet Export\n\n") + (mapc + (lambda (push) + (let ((active (alist-get 'active push)) + (title (alist-get 'title push)) + (body (alist-get 'body push)) + (url (alist-get 'url push))) + (when active + (if title (insert (format "* %s\n" title)) + (insert "* ")) + (when url (insert (format "[[%s]]\n" url))) + (if body (insert (format "%s\n" body)) + (insert "\n"))))) + pushes) + (goto-char (point-min)) + (org-mode)) + (switch-to-buffer buffer))) ;;;###autoload -(defun pushbullet-send (title body) - "Send a note to Pushbullet with TITLE and TEXT." +(defun pushbullet-send (title body &optional url) + "Sends a note push to Pushbullet with the given TITLE and BODY. +An optional URL can be provided to create a link push instead of a +simple note. + +TITLE is a string specifying the title of the push. +BODY is a string specifying the main content of the push. +URL is an optional string specifying a URL to be included, transforming +the push into a link. + +This function is interactive, prompting the user for TITLE and BODY if +called without arguments." (interactive "sTitle: \nsText: ") - (let ((data `((type . "note") - (title . ,title) - (body . ,body)))) - (pushbullet--request - "POST" "/pushes" data - (cl-function - (lambda (&key data &allow-other-keys) - (pushbullet--log "Note pushed successfully: %s" title)))))) + (pushbullet-api-send title body url) + (message "Sent: [%s] %s" title body)) ;;;###autoload (defun pushbullet-send-text (text) - "Send a note to Pushbullet with TEXT, using `pushbullet-default-title` as title." + "Sends a note push to Pushbullet with the provided TEXT. +The title for the push is automatically set using the +`pushbullet-default-title' variable. + +TEXT is a string representing the content of the note push. + +This function is interactive, prompting the user for the TEXT content if +called without arguments." (interactive "sText: ") (pushbullet-send pushbullet-default-title text)) ;;;###autoload (defun pushbullet-region (start end) - "Push the selected region to Pushbullet. -START and END define the region boundaries. -The push title is set to the current buffer's name." + "Sends the content of the currently active region to Pushbullet as a + note push. +The text between START and END is used as the body, and the current +buffer's name is used as the title. + +START and END are buffer positions defining the region. + +This function is interactive and requires an active region to be +selected." (interactive "r") (unless (use-region-p) (error "No region selected")) @@ -291,48 +434,58 @@ The push title is set to the current buffer's name." ;;;###autoload (defun pushbullet-yank () - "Push the current kill-ring (clipboard) contents to Pushbullet." + "Sends the latest entry from the Emacs kill-ring (clipboard) to + Pushbullet as a note push. +The kill-ring content is used as the body, and the title is set to +`pushbullet-default-title'. + +This function is interactive and will signal an error if the kill-ring +is empty." (interactive) (let ((text (current-kill 0))) (unless text (error "Kill ring is empty")) (pushbullet-send pushbullet-default-title text))) -(defvar pushbullet-mode-map +(defconst pushbullet-mode-map (let ((map (make-sparse-keymap))) - (define-key map (kbd "C-c C-c") 'pushbullet-send) - (define-key map (kbd "C-c C-u") 'pushbullet-update) - (define-key map (kbd "C-c C-o") 'browse-url-at-point) - (define-key map (kbd "q") 'quit-window) + (define-key map (kbd "C-c C-e") #'pushbullet-export) + (define-key map (kbd "C-c C-u") #'pushbullet--load-more) + (define-key map (kbd "C-c C-o") #'browse-url-at-point) + (define-key map (kbd "q") #'quit-window) map) - "Keymap for `pushbullet-mode'.") + "Keymap for `pushbullet-mode', defining keybindings for interacting + with the Pushbullet UI. -(define-derived-mode pushbullet-mode fundamental-mode "Pushbullet" - "Major mode for Pushbullet UI buffer." - (kill-all-local-variables) - (use-local-map pushbullet-mode-map) - (setq mode-name "pushbullet") - (setq major-mode 'pushbullet-mode) - (goto-address-mode 1) - (setq buffer-read-only t)) +- `C-c C-e': Calls `pushbullet--export-all` to export pushes to Org-mode. +- `C-c C-u': Calls `pushbullet--load-more` to fetch more pushes. +- `C-c C-o': Calls `browse-url-at-point` to open a URL at the current cursor position. +- `q': Calls `quit-window` to close the Pushbullet UI buffer.") ;;;###autoload (defun pushbullet () - "Open the Pushbullet UI buffer." + "Opens or switches to the main Pushbullet UI buffer (`pushbullet-buffer-name'). +This function initializes the Pushbullet UI by setting up buffer-local +variables, configuring `pushbullet-mode', and fetching the latest pushes +from the Pushbullet API. If the buffer already exists, it is +re-initialized and updated to reflect the current state. + +This function is interactive." (interactive) - (let ((buffer (get-buffer-create pushbullet-buffer))) - (with-current-buffer buffer - (pushbullet-mode) - (let ((inhibit-read-only t) - (loading-message "Loading pushes...\n\n")) + (when (null (get-buffer pushbullet-buffer-name)) + (let ((buffer (get-buffer-create pushbullet-buffer-name))) + (with-current-buffer buffer + (kill-all-local-variables) + (remove-overlays) (erase-buffer) - (insert (propertize (pushbullet--format-banner) 'face 'font-lock-function-name-face)) - (setq pushbullet-content-start-marker (point-max-marker)) - (insert (propertize loading-message 'face 'font-lock-comment-face)) - (pushbullet-update) - (pushbullet--delete-first-occurence loading-message))) - (switch-to-buffer buffer)) - t) + (setq + pushbullet--title (format "Pushbullet %s" pushbullet-version) + pushbullet--buffer buffer + pushbullet--pushes nil + pushbullet-api-cursor nil)))) + (pushbullet--load-more) + (switch-to-buffer-other-window (get-buffer pushbullet-buffer-name)) + nil) (provide 'pushbullet)