;;; deft.el --- quickly browse, filter, and edit plain text notes ;; Copyright (C) 2011 Jason R. Blevins ;; All rights reserved. ;; Redistribution and use in source and binary forms, with or without ;; modification, are permitted provided that the following conditions are met: ;; 1. Redistributions of source code must retain the above copyright ;; notice, this list of conditions and the following disclaimer. ;; 2. Redistributions in binary form must reproduce the above copyright ;; notice, this list of conditions and the following disclaimer in the ;; documentation and/or other materials provided with the distribution. ;; 3. Neither the names of the copyright holders nor the names of any ;; contributors may be used to endorse or promote products derived from ;; this software without specific prior written permission. ;; THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" ;; AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE ;; IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ;; ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE ;; LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR ;; CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF ;; SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS ;; INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN ;; CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ;; ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE ;; POSSIBILITY OF SUCH DAMAGE. ;; Version: 0.3 ;; Author: Jason R. Blevins ;; Keywords: plain text, notes, Simplenote, Notational Velocity ;; URL: http://jblevins.org/projects/deft/ ;; This file is not part of GNU Emacs. ;;; Commentary: ;; Deft is an Emacs mode for quickly browsing, filtering, and editing ;; directories of plain text notes, inspired by Notational Velocity. ;; It was designed for increased productivity when writing and taking ;; notes by making it fast and simple to find the right file at the ;; right time and by automating many of the usual tasks such as ;; creating new files and saving files. ;; Deft is open source software and may be freely distributed and ;; modified under the BSD license. Version 0.3 is the latest stable ;; version, released on September 11, 2011. You may download it ;; directly here: ;; * [deft.el](http://jblevins.org/projects/deft/deft.el) ;; To follow or contribute to Deft development, you can either ;; [browse](http://jblevins.org/git/deft.git) or clone the Git ;; repository: ;; git clone git://jblevins.org/git/deft.git ;; ![File Browser](http://jblevins.org/projects/deft/browser.png) ;; The Deft buffer is simply a file browser which lists the titles of ;; all text files in the Deft directory followed by short summaries ;; and last modified times. The title is taken to be the first line ;; of the file and the summary is extracted from the text that ;; follows. Files are sorted in terms of the last modified date, from ;; newest to oldest. ;; All Deft files or notes are simple plain text files where the first ;; line contains a title. As an example, the following directory ;; structure generated the screenshot above. ;; ;; % ls ~/.deft ;; about.txt browser.txt directory.txt operations.txt ;; ack.txt completion.txt extensions.txt text-mode.txt ;; binding.txt creation.txt filtering.txt ;; ;; % cat ~/.deft/about.txt ;; About ;; ;; An Emacs mode for slicing and dicing plain text files. ;; ![Filtering](http://jblevins.org/projects/deft/filter.png) ;; Deft's primary operation is searching and filtering. The list of ;; files can be limited or filtered using a search string, which will ;; match both the title and the body text. To initiate a filter, ;; simply start typing. Filtering happens on the fly. As you type, ;; the file browser is updated to include only files that match the ;; current string. ;; To open the first matching file, simply press `RET`. If no files ;; match your search string, pressing `RET` will create a new file ;; using the string as the title. This is a very fast way to start ;; writing new notes. The filename will be generated automatically. ;; If you prefer to provide a specific filename, use `C-RET` instead. ;; To open files other than the first match, navigate up and down ;; using `C-p` and `C-n` and press `RET` on the file you want to open. ;; Press `C-c C-c` to clear the filter string and display all files ;; and `C-c C-g` to refresh the file browser using the current filter ;; string. ;; Static filtering is also possible by pressing `C-c C-l`. This is ;; sometimes useful on its own, and it may be preferable in some ;; situations, such as over slow connections or on older systems, ;; where interactive filtering performance is poor. ;; Common file operations can also be carried out from within Deft. ;; Files can be renamed using `C-c C-r` or deleted using `C-c C-d`. ;; New files can also be created using `C-c C-n` for quick creation or ;; `C-c C-m` for a filename prompt. You can leave Deft at any time ;; with `C-c C-q`. ;; Files opened with deft are automatically saved after Emacs has been ;; idle for a customizable number of seconds. This value is a floating ;; point number given by `deft-auto-save-interval' (default: 1.0). ;; Getting Started ;; --------------- ;; To start using it, place it somewhere in your Emacs load-path and ;; add the line ;; (require 'deft) ;; in your `.emacs` file. Then run `M-x deft` to start. It is useful ;; to create a global keybinding for the `deft` function (e.g., a ;; function key) to start it quickly (see below for details). ;; When you first run Deft, it will complain that it cannot find the ;; `~/.deft` directory. You can either create a symbolic link to ;; another directory where you keep your notes or run `M-x deft-setup` ;; to create the `~/.deft` directory automatically. ;; One useful way to use Deft is to keep a directory of notes in a ;; Dropbox folder. This can be used with other applications and ;; mobile devices, for example, Notational Velocity or Simplenote ;; on OS X, Elements on iOS, or Epistle on Android. ;; Customization ;; ------------- ;; Customize the `deft` group to change the functionality. ;; By default, Deft looks for notes by searching for files with the ;; extension `.txt` in the `~/.deft` directory. You can customize ;; both the file extension and the Deft directory by running ;; `M-x customize-group` and typing `deft`. Alternatively, you can ;; configure them in your `.emacs` file: ;; (setq deft-extension "txt") ;; (setq deft-directory "~/Dropbox/notes") ;; You can also customize the major mode that Deft uses to edit files, ;; either through `M-x customize-group` or by adding something like ;; the following to your `.emacs` file: ;; (setq deft-text-mode 'markdown-mode) ;; Note that the mode need not be a traditional text mode. If you ;; prefer to write notes as LaTeX fragments, for example, you could ;; set `deft-extension' to "tex" and `deft-text-mode' to `latex-mode'. ;; You can easily set up a global keyboard binding for Deft. For ;; example, to bind it to F8, add the following code to your `.emacs` ;; file: ;; (global-set-key [f8] 'deft) ;; The faces used for highlighting various parts of the screen can ;; also be customized. By default, these faces inherit their ;; properties from the standard font-lock faces defined by your current ;; color theme. ;; Acknowledgments ;; --------------- ;; Thanks to Konstantinos Efstathiou for writing simplnote.el, from ;; which I borrowed liberally, and to Zachary Schneirov for writing ;; Notational Velocity, which I have never had the pleasure of using, ;; but whose functionality and spirit I wanted to bring to other ;; platforms, such as Linux, via Emacs. ;; History ;; ------- ;; Version 0.3 (2011-09-11): ;; * Internationalization: support filtering with multibyte characters. ;; Version 0.2 (2011-08-22): ;; * Match filenames when filtering. ;; * Automatically save opened files (optional). ;; * Address some byte-compilation warnings. ;; Deft was originally written by Jason Blevins. ;; The initial version, 0.1, was released on August 6, 2011. ;;; Code: (require 'widget) ;; Customization (defgroup deft nil "Emacs Deft mode." :group 'local) (defcustom deft-directory (expand-file-name "~/.deft/") "Deft directory." :type 'directory :safe 'stringp :group 'deft) (defcustom deft-extension "txt" "Deft file extension." :type 'string :safe 'stringp :group 'deft) (defcustom deft-text-mode 'text-mode "Default mode used for editing files." :type 'function :group 'deft) (defcustom deft-auto-save-interval 1.0 "Idle time in seconds before automatically saving buffers opened by Deft. Set to zero to disable." :type 'float :group 'deft) ;; Faces (defgroup deft-faces nil "Faces used in Deft mode" :group 'deft :group 'faces) (defface deft-header-face '((t :inherit font-lock-keyword-face :bold t)) "Face for Deft header." :group 'deft-faces) (defface deft-filter-string-face '((t :inherit font-lock-string-face)) "Face for Deft filter string." :group 'deft-faces) (defface deft-title-face '((t :inherit font-lock-function-name-face :bold t)) "Face for Deft file titles." :group 'deft-faces) (defface deft-separator-face '((t :inherit font-lock-comment-delimiter-face)) "Face for Deft separator string." :group 'deft-faces) (defface deft-summary-face '((t :inherit font-lock-comment-face)) "Face for Deft file summary strings." :group 'deft-faces) (defface deft-time-face '((t :inherit font-lock-variable-name-face)) "Face for Deft last modified times." :group 'deft-faces) ;; Constants (defconst deft-version "0.3") (defconst deft-buffer "*Deft*" "Deft buffer name.") (defconst deft-separator " --- " "Text used to separate file titles and summaries.") (defconst deft-line-width 63 "Total width of lines in file browser, not including modified time.") ;; Global variables (defvar deft-mode-hook nil "Hook run when entering Deft mode.") (defvar deft-filter-regexp nil "Current filter regexp used by Deft.") (defvar deft-current-files nil "List of files matching current filter.") (defvar deft-all-files nil "List of files matching current filter.") (defvar deft-hash-contents nil "Hash containing complete cached file contents, keyed by filename.") (defvar deft-hash-mtimes nil "Hash containing cached file modification times, keyed by filename.") (defvar deft-hash-titles nil "Hash containing cached file titles, keyed by filename.") (defvar deft-hash-summaries nil "Hash containing cached file summaries, keyed by filename.") (defvar deft-auto-save-buffers nil "List of buffers that will be automatically saved.") ;; File processing (defun deft-chomp (str) "Trim leading and trailing whitespace from STR." (let ((s str)) (replace-regexp-in-string "\\(^[[:space:]\n]*\\|[[:space:]\n]*$\\)" "" s))) (defun deft-base-filename (file) "Strip the path and extension from filename FILE." (setq file (file-name-nondirectory file)) (setq file (replace-regexp-in-string (concat "\." deft-extension "$") "" file))) (defun deft-find-all-files () "Return a list of all files in the Deft directory." (if (file-exists-p deft-directory) (let (files result) ;; List all files (setq files (directory-files deft-directory t (concat "\." deft-extension "$") t)) ;; Filter out files that are not readable or are directories (dolist (file files) (when (and (file-readable-p file) (not (file-directory-p file))) (setq result (cons file result)))) result))) (defun deft-parse-title (contents) "Parse the given file CONTENTS and determine the title. The title is taken to be the first non-empty line of a file." (let ((begin (string-match "^.+$" contents))) (when begin (substring contents begin (min (match-end 0) (+ begin deft-line-width)))))) (defun deft-parse-summary (contents title) "Parse the file CONTENTS, given the TITLE, and extract a summary. The summary is a string extracted from the contents following the title." (let* ((contents (replace-regexp-in-string "\n" " " contents)) (begin (when title (string-match (regexp-quote title) contents))) (size (- deft-line-width (length deft-separator) (match-end 0)))) (when begin (when (< 0 size) (setq contents (substring contents (match-end 0) (length contents))) (setq contents (deft-chomp contents)) (substring contents 0 (min size (length contents))))))) (defun deft-cache-file (file) "Update file cache if FILE exists." (when (file-exists-p file) (let ((mtime-cache (deft-file-mtime file)) (mtime-file (nth 5 (file-attributes file)))) (if (or (not mtime-cache) (time-less-p mtime-cache mtime-file)) (deft-cache-newer-file file mtime-file))))) (defun deft-cache-newer-file (file mtime) "Update cached information for FILE with given MTIME." ;; Modification time (puthash file mtime deft-hash-mtimes) (let (contents title) ;; Contents (with-current-buffer (get-buffer-create "*Deft temp*") (insert-file-contents file nil nil nil t) (setq contents (concat (buffer-string)))) (puthash file contents deft-hash-contents) ;; Title (setq title (deft-parse-title contents)) (puthash file title deft-hash-titles) ;; Summary (puthash file (deft-parse-summary contents title) deft-hash-summaries)) (kill-buffer "*Deft temp*")) (defun deft-file-newer-p (file1 file2) "Return non-nil if FILE1 was modified since FILE2 and nil otherwise." (let (time1 time2) (setq time1 (deft-file-mtime file1)) (setq time2 (deft-file-mtime file2)) (time-less-p time2 time1))) (defun deft-cache-initialize () "Initialize hash tables for caching files." (setq deft-hash-contents (make-hash-table :test 'equal)) (setq deft-hash-mtimes (make-hash-table :test 'equal)) (setq deft-hash-titles (make-hash-table :test 'equal)) (setq deft-hash-summaries (make-hash-table :test 'equal))) (defun deft-cache-update () "Update cached file information." (setq deft-all-files (deft-find-all-files)) ; List all files (mapc 'deft-cache-file deft-all-files) ; Cache contents (setq deft-all-files (deft-sort-files deft-all-files))) ; Sort by mtime ;; Cache access (defun deft-file-contents (file) "Retrieve complete contents of FILE from cache." (gethash file deft-hash-contents)) (defun deft-file-mtime (file) "Retrieve modified time of FILE from cache." (gethash file deft-hash-mtimes)) (defun deft-file-title (file) "Retrieve title of FILE from cache." (gethash file deft-hash-titles)) (defun deft-file-summary (file) "Retrieve summary of FILE from cache." (gethash file deft-hash-summaries)) ;; File list display (defun deft-print-header () "Prints the *Deft* buffer header." (if deft-filter-regexp (progn (widget-insert (propertize "Deft: " 'face 'deft-header-face)) (widget-insert (propertize deft-filter-regexp 'face 'deft-filter-string-face))) (widget-insert (propertize "Deft" 'face 'deft-header-face))) (widget-insert "\n\n")) (defun deft-buffer-setup () "Render the file browser in the *Deft* buffer." (let ((inhibit-read-only t)) (erase-buffer)) (remove-overlays) (deft-print-header) ;; Print the files list (if (not (file-exists-p deft-directory)) (widget-insert (deft-no-directory-message)) (if deft-current-files (progn (mapc 'deft-file-widget deft-current-files)) (widget-insert (deft-no-files-message)))) (use-local-map deft-mode-map) (widget-setup) (goto-char 1) (forward-line 2)) (defun deft-file-widget (file) "Add a line to the file browser for the given FILE." (when file (let ((key (file-name-nondirectory file)) (text (deft-file-contents file)) (title (deft-file-title file)) (summary (deft-file-summary file)) (mtime (deft-file-mtime file))) (widget-create 'link :button-prefix "" :button-suffix "" :button-face 'deft-title-face :format "%[%v%]" :tag file :help-echo "Edit this file" :notify (lambda (widget &rest ignore) (deft-open-file (widget-get widget :tag))) (or title "[Empty file]")) (when summary (widget-insert (propertize deft-separator 'face 'deft-separator-face)) (widget-insert (propertize summary 'face 'deft-summary-face))) (while (< (current-column) deft-line-width) (widget-insert " ")) (widget-insert (propertize (format-time-string " %Y-%m-%d %H:%M" mtime) 'face 'deft-time-face)) (widget-insert "\n")))) (defun deft-refresh () "Refresh the *Deft* buffer in the background." (interactive) (when (get-buffer deft-buffer) (set-buffer deft-buffer) (deft-cache-update) (deft-filter-update) (deft-buffer-setup))) (defun deft-no-directory-message () "Return a short message to display when the Deft directory does not exist." (concat "Directory " deft-directory " does not exist.\n")) (defun deft-no-files-message () "Return a short message to display if no files are found." (if deft-filter-regexp "No files match the current filter string.\n" "No files found.")) ;; File list file management actions (defun deft-open-file (file) "Open FILE in a new buffer and setting its mode." (prog1 (find-file file) (funcall deft-text-mode) (add-to-list 'deft-auto-save-buffers (buffer-name)) (add-hook 'after-save-hook (lambda () (save-excursion (deft-refresh))) nil t))) (defun deft-find-file (file) "Find FILE interactively using the minibuffer." (interactive "F") (deft-open-file file)) (defun deft-new-file-named (file) "Create a new file named FILE (or interactively prompt for a filename). If the filter string is non-nil, use it as the title." (interactive "sNew filename (without extension): ") (setq file (concat (file-name-as-directory deft-directory) file "." deft-extension)) (if (file-exists-p file) (message (concat "Aborting, file already exists: " file)) (when deft-filter-regexp (write-region deft-filter-regexp nil file nil)) (deft-open-file file))) (defun deft-new-file () "Create a new file quickly, with an automatically generated filename. If the filter string is non-nil, use it as the title." (interactive) (let (fmt filename counter temp-buffer) (setq counter 0) (setq fmt (concat "deft-%d." deft-extension)) (setq filename (concat deft-directory (format fmt counter))) (while (or (file-exists-p filename) (get-file-buffer filename)) (setq counter (1+ counter)) (setq filename (concat deft-directory (format fmt counter)))) (when deft-filter-regexp (write-region (concat deft-filter-regexp "\n\n") nil filename nil)) (deft-open-file filename) (with-current-buffer (get-file-buffer filename) (goto-char (point-max))))) (defun deft-delete-file () "Delete the file represented by the widget at the point. If the point is not on a file widget, do nothing. Prompts before proceeding." (interactive) (let ((filename (widget-get (widget-at) :tag))) (when filename (when (y-or-n-p (concat "Delete file " (file-name-nondirectory filename) "? ")) (delete-file filename) (delq filename deft-current-files) (delq filename deft-all-files) (deft-refresh))))) (defun deft-rename-file () "Rename the file represented by the widget at the point. If the point is not on a file widget, do nothing." (interactive) (let (old-filename new-filename old-name new-name) (setq old-filename (widget-get (widget-at) :tag)) (when old-filename (setq old-name (deft-base-filename old-filename)) (setq new-name (read-string (concat "Rename " old-name " to (without extension): "))) (setq new-filename (concat (file-name-as-directory deft-directory) new-name "." deft-extension)) (rename-file old-filename new-filename) (deft-refresh)))) ;; File list filtering (defun deft-sort-files (files) "Sort FILES in reverse order by modified time." (sort files (lambda (f1 f2) (deft-file-newer-p f1 f2)))) (defun deft-filter-initialize () "Initialize the filter string (nil) and files list (all files)." (interactive) (setq deft-filter-regexp nil) (setq deft-current-files deft-all-files)) (defun deft-filter-update () "Update the filtered files list using the current filter regexp." (if (not deft-filter-regexp) (setq deft-current-files deft-all-files) (setq deft-current-files (mapcar 'deft-filter-match-file deft-all-files)) (setq deft-current-files (delq nil deft-current-files)))) (defun deft-filter-match-file (file) "Return FILE if FILE matches the current filter regexp." (if (or (string-match deft-filter-regexp (deft-file-title file)) (string-match deft-filter-regexp file) (string-match deft-filter-regexp (deft-file-contents file))) file)) ;; Filters that cause a refresh (defun deft-filter-clear () "Clear the current filter string and refresh the file browser." (interactive) (when deft-filter-regexp (setq deft-filter-regexp nil) (setq deft-current-files deft-all-files) (deft-refresh)) (message "Filter cleared.")) (defun deft-filter (str) "Set the filter string to STR and update the file browser." (interactive "sFilter: ") (if (= (length str) 0) (setq deft-filter-regexp nil) (setq deft-filter-regexp str) (setq deft-current-files (mapcar 'deft-filter-match-file deft-all-files)) (setq deft-current-files (delq nil deft-current-files))) (deft-refresh)) (defun deft-filter-increment () "Append character to the filter regexp and update `deft-current-files'." (interactive) (let ((char last-command-event)) (if (= char ?\S-\ ) (setq char ?\s)) (setq char (char-to-string char)) (setq deft-filter-regexp (concat deft-filter-regexp char)) (setq deft-current-files (mapcar 'deft-filter-match-file deft-current-files)) (setq deft-current-files (delq nil deft-current-files))) (deft-refresh)) (defun deft-filter-decrement () "Remove last character from the filter regexp and update `deft-current-files'." (interactive) (if (> (length deft-filter-regexp) 1) (deft-filter (substring deft-filter-regexp 0 -1)) (deft-filter-clear))) (defun deft-complete () "Complete the current action. If there is a widget at the point, press it. If a filter is applied and there is at least one match, open the first matching file. If there is an active filter but there are no matches, quick create a new file using the filter string as the title. Otherwise, quick create a new file." (interactive) (cond ;; Activate widget ((widget-at) (widget-button-press (point))) ;; Active filter string with match ((and deft-filter-regexp deft-current-files) (deft-open-file (car deft-current-files))) ;; Default (t (deft-new-file)))) ;;; Automatic File Saving (defun deft-auto-save () (save-excursion (dolist (buf deft-auto-save-buffers) (if (get-buffer buf) ;; Save open buffers that have been modified. (progn (set-buffer buf) (when (buffer-modified-p) (basic-save-buffer))) ;; If a buffer is no longer open, remove it from auto save list. (delq buf deft-auto-save-buffers))))) ;;; Mode definition (defun deft-show-version () "Show the version number in the minibuffer." (interactive) (message "Deft %s" deft-version)) (defun deft-setup () "Prepare environment by creating the Deft notes directory." (interactive) (when (not (file-exists-p deft-directory)) (make-directory deft-directory t)) (deft-refresh)) (defvar deft-mode-map (let ((i 0) (map (make-keymap))) ;; Make multibyte characters extend the filter string. (set-char-table-range (nth 1 map) (cons #x100 (max-char)) 'deft-filter-increment) ;; Extend the filter string by default. (setq i ?\s) (while (< i 256) (define-key map (vector i) 'deft-filter-increment) (setq i (1+ i))) ;; Handle backspace and delete (define-key map (kbd "DEL") 'deft-filter-decrement) ;; Handle return via completion or opening file (define-key map (kbd "RET") 'deft-complete) ;; Filtering (define-key map (kbd "C-c C-l") 'deft-filter) (define-key map (kbd "C-c C-c") 'deft-filter-clear) ;; File creation (define-key map (kbd "C-c C-n") 'deft-new-file) (define-key map (kbd "C-c C-m") 'deft-new-file-named) (define-key map (kbd "") 'deft-new-file-named) ;; File management (define-key map (kbd "C-c C-d") 'deft-delete-file) (define-key map (kbd "C-c C-r") 'deft-rename-file) (define-key map (kbd "C-c C-f") 'deft-find-file) ;; Miscellaneous (define-key map (kbd "C-c C-g") 'deft-refresh) (define-key map (kbd "C-c C-q") 'quit-window) ;; Widgets (define-key map [down-mouse-1] 'widget-button-click) (define-key map [down-mouse-2] 'widget-button-click) (define-key map (kbd "") 'widget-forward) (define-key map (kbd "") 'widget-backward) (define-key map (kbd "") 'widget-backward) map) "Keymap for Deft mode.") (defun deft-mode () "Major mode for quickly browsing, filtering, and editing plain text notes. Turning on `deft-mode' runs the hook `deft-mode-hook'. \\{deft-mode-map}." (interactive) (kill-all-local-variables) (setq truncate-lines t) (setq buffer-read-only t) (setq default-directory deft-directory) (use-local-map deft-mode-map) (deft-cache-initialize) (deft-cache-update) (deft-filter-initialize) (setq major-mode 'deft-mode) (setq mode-name "Deft") (deft-buffer-setup) (when (> deft-auto-save-interval 0) (run-with-idle-timer deft-auto-save-interval t 'deft-auto-save)) (run-mode-hooks 'deft-mode-hook)) (put 'deft-mode 'mode-class 'special) ;;;###autoload (defun deft () "Switch to *Deft* buffer and load files." (interactive) (switch-to-buffer deft-buffer) (if (not (eq major-mode 'deft-mode)) (deft-mode))) (provide 'deft) ;;; deft.el ends here