;;; rudel.el --- A collaborative editing framework for Emacs ;; ;; Copyright (C) 2008, 2009 Jan Moringen ;; ;; Author: Jan Moringen ;; Keywords: rudel, collaboration ;; URL: http://rudel.sourceforge.net/ ;; X-RCS: $Id:$ ;; ;; This file is part of Rudel. ;; ;; Rudel 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. ;; ;; Rudel 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 rudel. If not, see . ;;; Commentary: ;; ;; Rudel is a framework for collaborative editing in Emacs. Its ;; architecture allows communication with arbitrary collaborative ;; editors. ;;; History: ;; ;; 0.1 - Initial revision. ;;; Code: ;; (eval-when-compile (require 'cl)) (require 'eieio) (require 'eieio-base) (require 'eieio-speedbar) ;; TODO required for now (require 'rudel-util) (require 'rudel-backend) (require 'rudel-session-initiation) (require 'rudel-operations) (require 'rudel-operators) (require 'rudel-overlay) (require 'rudel-hooks) (require 'rudel-interactive) ;; for `rudel-read-backend', ;; `rudel-read-document', ;; `rudel-read-session' (require 'rudel-icons) (require 'rudel-compat) ;; for `read-color' replacement ;;; Global variables ;; (defconst rudel-version '(0 3) "Version of the Rudel framework.") (defvar rudel-current-session nil "Global object representing the current Rudel session. nil if there is no active session.") (defvar rudel-buffer-document nil "Buffer-local variable which holds the Rudel document associated with the buffer.") (make-variable-buffer-local 'rudel-buffer-document) (put 'rudel-buffer-document 'permanent-local t) (defvar rudel-buffer-change-workaround-data nil "Buffer-local variable which holds change data that could not be accessed otherwise. It would be nice to find another way to do this.") (make-variable-buffer-local 'rudel-buffer-change-workaround-data) (put 'rudel-buffer-change-workaround-data 'permanent-local t) ;;; Customization ;; (defgroup rudel nil "Rudel collaborative editing framework." :group 'applications) (defcustom rudel-allocate-buffer-function 'rudel-allocate-buffer-clear-existing "A function used to find or create buffers to associate to documents. The function is called with the document name as the sole argument and has to return a buffer object which will be attached to the document in question." :group 'rudel :type '(choice (const :tag "Clear content of existing buffer" rudel-allocate-buffer-clear-existing ) (const :tag "Create a new uniquely named buffer" rudel-allocate-buffer-make-unique ) (function :tag "Other function")) :require 'rudel-interactive) (defcustom rudel-default-username (user-login-name) "*" :group 'rudel :type '(string)) ;;; Class rudel-session ;; (defclass rudel-session (rudel-hook-object) ((backend :initarg :backend :type rudel-backend-child :documentation "The backend used by this session.") (users :initarg :users :type list :initform nil :documentation "The list of users participating in this session.") (documents :initarg :documents :type list :initform nil :documentation "This list of documents available in this session.") ;; Hooks (end-hook :initarg :end-hook :type list :initform nil :documentation "") (add-user-hook :initarg :add-user-hook :type list :initform nil :documentation "This hook is run when a user gets added to the session. The arguments are the session and the user object.") (remove-user-hook :initarg :remove-user-hook :type list :initform nil :documentation "This hook is run when a user gets removed from the session. The arguments are the session and the user object.") (add-document-hook :initarg :add-document-hook :type list :initform nil :documentation "This hook is run when a document gets added to the session. The arguments are the session and the document object.") (remove-document-hook :initarg :remove-document-hook :type list :initform nil :documentation "This hook is run when a document gets removed from the session. The arguments are the session and the document object.")) "This class serves as a base class for rudel-client-session and rudel-server-session. Consequently, it consists of slots common to client and server sessions." :abstract t) (defmethod rudel-end ((this rudel-session)) "Terminate THIS session performing all necessary cleanup." ;; Run the hook. (object-run-hook-with-args this 'end-hook)) (defmethod rudel-add-user ((this rudel-session) user) "Add USER to the user list of THIS session. Runs object hook (see `rudel-hook-object') `add-user-hook' with arguments THIS and USER." ;; Add USER to list. (object-add-to-list this :users user) ;; Run the hook. (object-run-hook-with-args this 'add-user-hook user)) (defmethod rudel-remove-user ((this rudel-session) user) "Remove USER from the user list of THIS session. Runs object hook (see `rudel-hook-object') `remove-user-hook' with arguments THIS and USER." ;; Remove USER from list. (object-remove-from-list this :users user) ;; Run the hook. (object-run-hook-with-args this 'remove-user-hook user)) (defmethod rudel-find-user ((this rudel-session) which &optional test key) "Find user WHICH in the user list. WHICH is compared to the result of KEY using TEST." (unless test (setq test #'string=)) (unless key (setq key #'object-name-string)) (with-slots (users) this (find which users :key key :test test)) ) (defmethod rudel-add-document ((this rudel-session) document) "" (unless (slot-boundp document :session) (oset document :session this)) ;; Add DOCUMENT to the list of documents in THIS session. (object-add-to-list this :documents document) ;; Run the hook. (object-run-hook-with-args this 'add-document-hook document)) (defmethod rudel-remove-document ((this rudel-session) document) "Remove DOCUMENT from THIS session, detaching it if necessary." ;; Detach document from its buffer when necessary. (when (rudel-attached-p document) (rudel-detach-from-buffer document)) ;; Remove DOCUMENT from the list of documents in THIS session. (object-remove-from-list this :documents document) ;; Run the hook. (object-run-hook-with-args this 'remove-document-hook document)) (defmethod rudel-find-document ((this rudel-session) which &optional test key) "Find document WHICH in the document list. WHICH is compared to the result of KEY using TEST." (unless test (setq test #'string=)) (unless key (setq key #'object-name-string)) (with-slots (documents) this (find which documents :key key :test test)) ) (defmethod rudel-unsubscribed-documents ((this rudel-session)) "" (unless (slot-boundp this :self) (error "Cannot find unsubscribed documents unless slot self is bound")) (with-slots (documents self) this (remove-if (lambda (document) (with-slots (subscribed) document (memq self subscribed))) documents)) ) ;;; Class rudel-client-session ;; (defclass rudel-client-session (rudel-session) ((connection :initarg :connection :type (or null rudel-connection-child) :initform nil :documentation "The connection used for communication by this session.") (self :initarg :self :type rudel-user-child :documentation "Points into USERS to the user object representing the local user")) "Objects represent a collaborative editing session from a client perspective.") (defmethod rudel-end ((this rudel-client-session)) ;; Clean everything up (with-slots (connection users documents) this ;; Detach all documents from their buffers (mapc #'rudel-detach-from-buffer documents) ;; Terminate the connection (when connection (condition-case nil (rudel-disconnect connection) (error nil)))) ;; (when (next-method-p) (call-next-method)) ) ;;; Class rudel-server-session ;; (defclass rudel-server-session (rudel-session) () "Class rudel-server-session " :abstract t) ;;; Class rudel-connection ;; (defclass rudel-connection () ((session :initarg :session :type rudel-session-child :documentation "")) "This abstract class defines the interface implementations of client protocols have to obey." :abstract t) (defgeneric rudel-disconnect ((this rudel-connection)) "Close the connection.") (defgeneric rudel-change-color- ((this rudel-connection) color) ;; TODO name "") (defgeneric rudel-publish ((this rudel-connection) document) "") (defgeneric rudel-subscribe-to ((this rudel-connection) document) "") (defgeneric rudel-unsubscribe-from ((this rudel-connection) document) ; TODO name should be rudel-unsubscribe "") (defgeneric rudel-local-insert ((this rudel-connection)) "") (defgeneric rudel-local-delete ((this rudel-connection)) "") (defgeneric rudel-remote-insert ((this rudel-connection)) "") (defgeneric rudel-remote-delete ((this rudel-connection)) "") ;;; Class rudel-user ;; (defclass rudel-user (eieio-named eieio-speedbar-file-button rudel-hook-object) ((color :initarg :color :accessor rudel-color :documentation "Color used to indicate ownership or authorship by the user. Examples includes text written by the user or the user name itself.") (change-hook :initarg :change-hook :type list :initform nil :documentation "This hook is run when this user object changes.")) "Objects of this class represent users participating in collaborative editing session. Note that a participating user does not have to be connected to the session at any given time." :abstract t) (defmethod rudel-display-string ((this rudel-user) &optional use-images align) "Return a textual representation of THIS for user interface stuff." (with-slots ((name :object-name) color) this (propertize (concat (when use-images (propertize "*" 'display rudel-icon-person)) name) 'face (list :background color))) ) ;;; Class rudel-document ;; (defclass rudel-document (eieio-named eieio-speedbar-file-button rudel-hook-object) ((session :initarg :session :type rudel-session :documentation "") (buffer :initarg :buffer :type (or null buffer) :initform nil :documentation "") (subscribed :initarg :subscribed :type list :initform nil :documentation "") ;; Hooks (subscribe-hook :initarg :subscribe-hook :type list :initform nil :documentation "This hook is run when a user subscribes to this document object.") (unsubscribe-hook :initarg :unsubscribe-hook :type list :initform nil :documentation "This hook is run when a user unsubscribes from this document object.") (attach-hook :initarg :attach-hook :type list :initform nil :documentation "This hook is run when a buffer is attached to this document object.") (detach-hook :initarg :detach-hook :type list :initform nil :documentation "This hook is run when the attached buffer is detached from this document object.")) "This class represents a document, which participants of a collaborative editing session can subscribe to." :abstract t) (defmethod rudel-unique-name ((this rudel-document)) "Returns a suggested name for the buffer attached to THIS document." (object-name-string this)) (defmethod rudel-suggested-buffer-name ((this rudel-document)) "Returns a suggested name for the buffer attached to THIS document." (rudel-unique-name this)) (defmethod rudel-attached-p ((this rudel-document)) (with-slots (buffer) this buffer)) (defmethod rudel-attach-to-buffer ((this rudel-document) buffer) "Attach THIS document to BUFFER" (with-slots ((doc-buffer :buffer)) this ;; Set buffer slot of THIS to BUFFER and associated THIS with ;; BUFFER. (setq doc-buffer buffer) (rudel-set-buffer-document this buffer) (with-current-buffer doc-buffer ;; Add the handler function for buffer changes to the buffer's ;; change hook. (add-hook 'after-change-functions #'rudel-handle-buffer-change nil t) ;; Store change data before the change a done. This is necessary ;; because the number of bytes (not characters) cannot otherwise ;; be recovered after a deletion. (add-hook 'before-change-functions #'rudel-buffer-change-workaround nil t) ;; Add a handler to the kill-buffer hook to unsubscribe from the ;; document when the buffer gets killed. (add-hook 'kill-buffer-hook #'rudel-unpublish-buffer nil t) ;; (add-hook 'change-major-mode-hook #'rudel-handle-major-mode-change nil t)) ;; Run the hook. (object-run-hook-with-args this 'attach-hook doc-buffer)) ) (defmethod rudel-detach-from-buffer ((this rudel-document)) "Detach document THIS from its buffer. Do nothing, if THIS is not attached to any buffer." (with-slots (buffer) this (let ((buffer-save buffer)) ;; Only try to detach from BUFFER, if it is non-nil. BUFFER can ;; be nil, if the user did not subscribe to the document, or ;; unsubscribed after subscribing. (when buffer (with-current-buffer buffer ;; Remove our handler function from the kill-buffer hook. (remove-hook 'kill-buffer-hook #'rudel-unpublish-buffer t) ;; Remove our handler function from the after-change hook. (remove-hook 'after-change-functions #'rudel-handle-buffer-change t) ;; Remove our handler function from the before-change hook. (remove-hook 'before-change-functions #'rudel-buffer-change-workaround t) ;; Remove all overlays. (rudel-overlays-remove-all) ;; Remove the major mode change handler. (remove-hook 'change-major-mode-hook #'rudel-handle-major-mode-change t)) ;; Unset buffer slot of THIS and delete association of THIS with ;; BUFFER. (rudel-set-buffer-document nil buffer) (setq buffer nil)) ;; Run the hook. (object-run-hook-with-args this 'detach-hook buffer-save))) ) (defmethod rudel-add-user ((this rudel-document) user) "Add USER to the list of subscribed users of THIS. Runs object hook (see `rudel-hook-object') `subscribe-hook' with arguments THIS and USER." ;; Add USER to list. (object-add-to-list this :subscribed user) ;; Run the hook. (object-run-hook-with-args this 'subscribe-hook user)) (defmethod rudel-remove-user ((this rudel-document) user) "Remove USER from the list of subscribed users of THIS. Runs object hook (see `rudel-hook-object') `unsubscribe-hook' with arguments THIS and USER." ;; Remove USER from list. (object-remove-from-list document :subscribed user) ;; Run the hook. (object-run-hook-with-args this 'unsubscribe-hook user)) (defmethod rudel-insert ((this rudel-document) position data) "Insert DATA at POSITION into the buffer attached to THIS. When POSITION is nil `point-max' is used to determine the insertion position. Modification hooks are disabled during the insertion." (with-slots (buffer) this (save-excursion (set-buffer buffer) (unless position (setq position (- (point-max) 1))) (let ((inhibit-modification-hooks t)) (goto-char (+ position 1)) (insert data)))) ) (defmethod rudel-delete ((this rudel-document) position length) "Delete a region of LENGTH character at POSITION from the buffer attached to THIS. Modification hooks are disabled during the insertion." (with-slots (buffer) this (save-excursion (set-buffer buffer) (let ((inhibit-modification-hooks t)) (delete-region (+ position 1) (+ position length 1))))) ) (defmethod rudel-local-operation ((this rudel-document) operation) "Apply the local operation OPERATION to THIS." (with-slots (session buffer) this (with-slots (connection (user :self)) session (dolist (operators (list ;; Update overlays (rudel-overlay-operators "overlay-operators" :document this :user user) ;; Notify connection (rudel-connection-operators "connection-operators" :connection connection :document this))) ;; Apply the operation using each set of operators (rudel-apply operation operators)))) ) (defmethod rudel-remote-operation ((this rudel-document) user operation) "Apply the remote operation OPERATION performed by USER to THIS." (dolist (operators (append ;; Update buffer contents (list (rudel-document-operators "document-operators" :document this)) ;; Update overlays (when user (list (rudel-overlay-operators "overlay-operators" :document this :user user))))) ;; Apply the operation using each set of operators (rudel-apply operation operators)) ) (defmethod rudel-chunks ((this rudel-document)) "Return a list of text chunks of the associated buffer. Each element in the chunk is a list structured like this (START END AUTHOR). START and END are numbers, AUTHOR is of type (or null rudel-user-child)." (with-slots (buffer) this ;; Extract buffer string and a list of chunks partitioning the ;; string according to the respective author (or nil). (with-current-buffer buffer (let ((string (buffer-string)) ;; TODO no-properties? (overlay-chunks (mapcar (lambda (overlay) (list (- (overlay-start overlay) 1) (- (overlay-end overlay) 1) (rudel-overlay-user overlay))) (sort* (rudel-author-overlays) '< :key 'overlay-start))) (last) (augmented-chunks)) ;; Iterate through the list of chunks to find gaps between ;; chunks (also before the first) and insert entries with ;; author nil accordingly. (dolist (chunk overlay-chunks) (when (or (and (not last) (> (nth 0 chunk) 0)) (and last (/= (nth 1 last) (nth 0 chunk)))) (push (list (if last (nth 1 last) 0) (nth 0 chunk) nil) augmented-chunks)) (push chunk augmented-chunks) (setq last chunk)) ;; If there is text after the last chunk, create another one ;; with author nil. If there were no chunks at all, this chunk ;; can also cover the whole buffer string. (when (or (and (not last) (/= (length string) 0)) (and last (/= (nth 1 last) (length string)))) (push (list (if last (nth 1 last) 0) (length string) nil) augmented-chunks)) ;; Sort chunks according to the start position. (sort* augmented-chunks '< :key 'car)))) ) ;;; Buffer-related functions ;; (defun rudel-buffer-has-document-p (&optional buffer) "Return non-nil if a document object is attached to BUFFER. If BUFFER is nil, use the current buffer." (unless buffer (setq buffer (current-buffer))) (buffer-local-value 'rudel-buffer-document buffer)) (defun rudel-buffer-document (&optional buffer) "Return the document object attached to BUFFER. If BUFFER is nil, use the current buffer." (unless buffer (setq buffer (current-buffer))) (buffer-local-value 'rudel-buffer-document buffer)) (defun rudel-set-buffer-document (document &optional buffer) "Associate BUFFER to DOCUMENT. If DOCUMENT is nil, make it not associated to any buffer. If BUFFER is nil, use the current buffer." (unless buffer (setq buffer (current-buffer))) (with-current-buffer buffer (setq rudel-buffer-document document))) (defun rudel-handle-buffer-change (from to length) "Handle buffer change at range FROM to TO with length LENGTH by relaying them to the document object of the buffer. See after-change-functions for more information." (when (rudel-buffer-has-document-p) (let ((document (rudel-buffer-document)) (text)) ; TODO with-rudel-buffer-document? (cond ;; The change was an insert ((and (/= from to) (zerop length)) (with-slots (buffer) document (with-current-buffer buffer (setq text (buffer-substring-no-properties from to))) (rudel-local-operation document (rudel-insert-op "insert" :from (- from 1) :data text)))) ;; The change was a delete ((and (= from to) (not (zerop length))) (rudel-local-operation document (rudel-delete-op "delete" :from (- from 1) :length length))) ;; The operation was neither an insert nor a delete. This seems ;; to mean that the region has changed arbitrarily. The only ;; option we have is sending a delete and corresponding insert ;; message that emulate the change. (t (with-slots (buffer) document (with-current-buffer buffer (setq text (buffer-substring-no-properties from to))) (rudel-local-operation document (rudel-delete-op "delete" :from (- from 1) :length length)) (rudel-local-operation document (rudel-insert-op "insert" :from (- from 1) :data text))))))) ) (defun rudel-buffer-change-workaround (from to) (when (/= from to) (setq rudel-buffer-change-workaround-data (list from to (buffer-substring-no-properties from to))))) ;;; Protection against major mode changes ;; (defvar rudel-mode-changed-buffers nil "List of buffers that may need to be repaired after a major mode change.") (defun rudel-handle-major-mode-change () "Store the current buffer to repair damage done by major mode change. Note: The way this works is inspired by mode-local.el by David Ponce and Eric M. Ludlam." ;; Store the buffer for later repair. (add-to-list 'rudel-mode-changed-buffers (current-buffer)) ;; Schedule `rudel-after-major-mode-change' to run after the ;; command, that caused the major mode change. (add-hook 'post-command-hook #'rudel-after-major-mode-change) ) (defun rudel-after-major-mode-change () "Repair damage done by major mode changes. As a function in `post-command-hook', this is run after there was a `major-mode' change. Note: The way this works is inspired by mode-local.el by David Ponce and Eric M. Ludlam." ;; Remove this function from `post-command-hook'. (remove-hook 'post-command-hook #'rudel-after-major-mode-change) ;; Repair all buffers affected by the major mode change. (dolist (buffer rudel-mode-changed-buffers) (let ((document (buffer-local-value 'rudel-buffer-document buffer))) (rudel-attach-to-buffer document buffer))) ) ;;; Interactive functions ;; ;;;###autoload (defun rudel-join-session (info) "Join the collaborative editing session described by INFO. INFO is a property list that describes the collaborative editing session in terms of properties like :host, :port and :encryption. The particular properties and their respective meanings depend on the used backend. When called interactively, all data required to join a session will be prompted for." (interactive ;; Try the discover method of session initiation backends to find ;; available sessions. (list (let ((info) (session-initiation-backend)) (while (not info) (message "Discovering Sessions ...") (let* ((sessions (rudel-session-initiation-discover session-initiation-backend)) (maybe-info (if (= (length sessions) 1) (car sessions) (rudel-read-session sessions "Choose Session: " 'object)))) (if (rudel-backend-cons-p maybe-info) (setq session-initiation-backend (car maybe-info)) (setq info maybe-info)))) info))) ;; First, create the session object. (let* ((backend (cdr (plist-get info :backend))) (session (rudel-client-session (plist-get info :name) :backend backend)) (connection)) ;; Give the backend a chance to collect remaining connect ;; info. For session initiation methods like Zeroconf, we have the ;; _connection_ info, but are still missing the username and ;; stuff. (setq info (rudel-ask-connect-info backend info)) ;; Add the session object to the connect information. (plist-put info :session session) ;; Ask BACKEND to connect using INFO. Do not catch errors since ;; the error messages are probably the best feedback we can give. (setq connection (rudel-connect backend info)) ;; Set the connection slot of the session object and store it ;; globally. (oset session :connection connection) (setq rudel-current-session session) ;; Reset the global session variable when the session ends. (object-add-hook session 'end-hook (lambda (session) (setq rudel-current-session nil))) ;; Run the hook. (run-hook-with-args 'rudel-session-start-hook session)) ) ;;;###autoload (defun rudel-host-session () "Host a collaborative editing session. All data required to host a session will be prompted for interactively." (interactive) ;; If necessary, ask the user for the backend we should use. (let* ((backend (cdr (rudel-backend-choose 'protocol (lambda (backend) (rudel-capable-of-p backend 'host))))) (info (rudel-ask-host-info backend)) (server)) ;; Try to create the server (condition-case error-data (setq server (rudel-host backend info)) ('error (error "Could not host session using backend `%s' with %s: %s" (object-name-string backend) info (car error-data)))) server)) ;;;###autoload (defun rudel-end-session () "End the current collaborative editing session." (interactive) (unless rudel-current-session (error "No active Rudel session")) ;; Actually end the session. (rudel-end rudel-current-session) ) ;;;###autoload (defun rudel-change-color () "Change the color associated with the local user. Not all backends support this operation." (interactive) ;; Make sure we have a session. (unless rudel-current-session (error "No active Rudel session")) (with-slots (backend connection self) rudel-current-session ;; Make sure the backend can change colors. (unless (rudel-capable-of-p backend 'change-color) (error "Backend `%s' cannot change colors" (object-name-string backend))) (with-slots ((name :object-name) color) self ;; Ask the user for a new color. (setq color (read-color "New Color: " t)) ;; Tell the connection to announce the change and change it in ;; our user object. (rudel-change-color- connection color) ;; Run the change hook. (object-run-hook-with-args self 'change-hook) ;; Update overlay color. (rudel-overlay-set-face-attributes (rudel-overlay-make-face-symbol 'author name) color))) ) ;;;###autoload (defun rudel-subscribe (document) "Subscribe to DOCUMENT offered by a peer in a collaborative editing session. When called interactively, DOCUMENT is prompted for interactively." (interactive (list (progn ;; We have to retrieve the document list from an active ;; session. (unless rudel-current-session (error "No active Rudel session")) ;; Select unsubscribed documents. (let ((documents (rudel-unsubscribed-documents rudel-current-session))) ;; Already subscribed to all documents. This is an error. (when (null documents) (error "No unsubscribed documents")) ;; Read an unsubscribed document. (rudel-read-document documents nil 'object))))) ;; Make sure we have a session. (unless rudel-current-session (error "No active Rudel session")) ;; Create a new buffer and attach the document to it. (let* ((name (rudel-suggested-buffer-name document)) (buffer (funcall rudel-allocate-buffer-function name))) (rudel-attach-to-buffer document buffer) (let ((connection (oref (oref document :session) :connection))) (rudel-subscribe-to connection document)) ;; Show the new buffer. (set-window-buffer nil buffer)) ) ;;;###autoload (defun rudel-publish-buffer (&optional buffer) "Make the BUFFER available for subscription to peers in a collaborative editing session. If BUFFER is nil, the current buffer is used." (interactive (list nil)) ;; Make sure we have a session. (unless rudel-current-session (error "No active Rudel session")) (unless buffer (setq buffer (current-buffer))) (with-current-buffer buffer (when (rudel-buffer-has-document-p) (error "Buffer already published or subscribed"))) ; TODO keep this? ;; (with-slots (backend connection self) rudel-current-session (let ((document (rudel-make-document backend (buffer-name buffer) rudel-current-session))) (rudel-add-document rudel-current-session document) (rudel-attach-to-buffer document buffer) (object-add-to-list document :subscribed self) (rudel-publish connection document))) ) ;;;###autoload (defun rudel-unpublish-buffer (&optional buffer) "Deny peers access to BUFFER in a collaborative editing session. If BUFFER is nil, the current is used." (interactive) ;; Make sure we have a session. (unless rudel-current-session (error "No active Rudel session")) (unless buffer (setq buffer (current-buffer))) (with-current-buffer buffer (unless (rudel-buffer-has-document-p) (error "Buffer is not published"))) ;; (with-slots (connection) rudel-current-session (let ((document (rudel-buffer-document buffer))) (rudel-detach-from-buffer document) (rudel-unsubscribe-from connection document))) ) (provide 'rudel) ;;; rudel.el ends here