956 lines
37 KiB
EmacsLisp
956 lines
37 KiB
EmacsLisp
|
;;; xeft.el --- Deft feat. Xapian -*- lexical-binding: t; -*-
|
|||
|
|
|||
|
;; Copyright (C) 2019-2023 Free Software Foundation, Inc.
|
|||
|
|
|||
|
;; Author: Yuan Fu <casouri@gmail.com>
|
|||
|
;; Maintainer: Yuan Fu <casouri@gmail.com>
|
|||
|
;; URL: https://sr.ht/~casouri/xeft
|
|||
|
;; Version: 3.3
|
|||
|
;; Keywords: Applications, Note, Searching
|
|||
|
;; Package-Requires: ((emacs "26.0"))
|
|||
|
|
|||
|
;; This file is part of GNU Emacs.
|
|||
|
|
|||
|
;; GNU Emacs 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.
|
|||
|
|
|||
|
;; GNU Emacs 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 GNU Emacs. If not, see <https://www.gnu.org/licenses/>.
|
|||
|
|
|||
|
;;; Commentary:
|
|||
|
;;
|
|||
|
;; Usage:
|
|||
|
;;
|
|||
|
;; Type M-x xeft RET, and you should see the Xeft buffer. Type in your
|
|||
|
;; search phrase in the first line and the results will show up as you
|
|||
|
;; type. Press C-n and C-p to go through each file. You can preview a
|
|||
|
;; file by pressing SPC when the point is on a file, or click the file
|
|||
|
;; with the mouse. Press RET to open the file in the same window.
|
|||
|
;;
|
|||
|
;; Type C-c C-g to force a refresh. When point is on the search
|
|||
|
;; phrase, press RET to create a file with the search phrase as
|
|||
|
;; the filename and title.
|
|||
|
;;
|
|||
|
;; Note that:
|
|||
|
;;
|
|||
|
;; 1. Xeft ignores search phrases shorter than three characters,
|
|||
|
;; unless they are CJK characters.
|
|||
|
;;
|
|||
|
;; 2. Xeft only looks for first-level files in ‘xeft-directory’. Files
|
|||
|
;; in sub-directories are not searched unless ‘xeft-recursive’ is
|
|||
|
;; non-nil.
|
|||
|
;;
|
|||
|
;; 3. Xeft creates a new file by using the search phrase as the
|
|||
|
;; filename and title. If you want otherwise, redefine
|
|||
|
;; ‘xeft-create-note’ or ‘xeft-filename-fn’.
|
|||
|
;;
|
|||
|
;; 4. Xeft saves the current window configuration before switching to
|
|||
|
;; Xeft buffer. When Xeft buffer is killed, Xeft restores the saved
|
|||
|
;; window configuration.
|
|||
|
;;
|
|||
|
;; On search queries:
|
|||
|
;;
|
|||
|
;; Since Xeft uses Xapian, it supports the query syntax Xapian
|
|||
|
;; supports:
|
|||
|
;;
|
|||
|
;; AND, NOT, OR, XOR and parenthesizes
|
|||
|
;;
|
|||
|
;; +word1 -word2 which matches documents that contains word1 but not
|
|||
|
;; word2.
|
|||
|
;;
|
|||
|
;; word1 NEAR word2 which matches documents in where word1 is near
|
|||
|
;; word2.
|
|||
|
;;
|
|||
|
;; word1 ADJ word2 which matches documents in where word1 is near word2
|
|||
|
;; and word1 comes before word2
|
|||
|
;;
|
|||
|
;; "word1 word2" which matches exactly “word1 word2â€
|
|||
|
;;
|
|||
|
;; Xeft deviates from Xapian in one aspect: consecutive phrases have
|
|||
|
;; implied “AND†between them. So "word1 word2 word3" is actually seen
|
|||
|
;; as "word1 AND word2 AND word3". See ‘xeft--tighten-search-phrase’
|
|||
|
;; for how exactly is it done.
|
|||
|
;;
|
|||
|
;; See https://xapian.org/docs/queryparser.html for Xapian’s official
|
|||
|
;; documentation on query syntax.
|
|||
|
;;
|
|||
|
;; Further customization:
|
|||
|
;;
|
|||
|
;; You can customize the following faces
|
|||
|
|
|||
|
;; - `xeft-selection'
|
|||
|
;; - `xeft-inline-highlight'
|
|||
|
;; - `xeft-preview-highlight'
|
|||
|
;; - `xeft-excerpt-title'
|
|||
|
;; - `xeft-excerpt-body'
|
|||
|
|
|||
|
;; Functions you can customize to alter Xeft’s behavior:
|
|||
|
|
|||
|
;; - `xeft-filename-fn': How does Xeft create new files from search
|
|||
|
;; phrases.
|
|||
|
;;
|
|||
|
;; - `xeft-file-filter': Which files does Xeft include/exclude from
|
|||
|
;; indexing.
|
|||
|
;;
|
|||
|
;; - `xeft-directory-filter': When `xeft-recursive' is t, which
|
|||
|
;; sub-directories does Xeft include/exclude from indexing.
|
|||
|
;;
|
|||
|
;; - `xeft-title-function': How does Xeft find the title of a file.
|
|||
|
;;
|
|||
|
;; - `xeft-file-list-function': If `xeft-file-filter' and
|
|||
|
;; `xeft-directory-filter' are not flexible enough, this function
|
|||
|
;; gives you ultimate control over which files to index.
|
|||
|
|
|||
|
|
|||
|
;;; Code:
|
|||
|
|
|||
|
(require 'cl-lib)
|
|||
|
(require 'subr-x) ; ‘string-trim’
|
|||
|
(declare-function xapian-lite-reindex-file nil
|
|||
|
(path dbpath &optional lang force))
|
|||
|
(declare-function xapian-lite-query-term nil
|
|||
|
(term dbpath offset page-size &optional lang))
|
|||
|
|
|||
|
;;; Customize
|
|||
|
|
|||
|
(defgroup xeft nil
|
|||
|
"Xeft note interface."
|
|||
|
:group 'applications)
|
|||
|
|
|||
|
(defcustom xeft-directory "~/.deft"
|
|||
|
"Directory in where notes are stored. Must be a full path."
|
|||
|
:type 'directory)
|
|||
|
|
|||
|
(defcustom xeft-database "~/.deft/db"
|
|||
|
"The path to the database."
|
|||
|
:type 'directory)
|
|||
|
|
|||
|
(defcustom xeft-find-file-hook nil
|
|||
|
"Hook run when Xeft opens a file."
|
|||
|
:type 'hook)
|
|||
|
|
|||
|
(defface xeft-selection
|
|||
|
'((t . (:inherit region :extend t)))
|
|||
|
"Face for the current selected search result.")
|
|||
|
|
|||
|
(defface xeft-inline-highlight
|
|||
|
'((t . (:inherit underline :extend t)))
|
|||
|
"Face for highlighting the search phrase in excerpts in Xeft buffer.")
|
|||
|
|
|||
|
(defface xeft-preview-highlight
|
|||
|
'((t . (:inherit highlight :extend t)))
|
|||
|
"Face for highlighting the search phrase in the preview buffer.")
|
|||
|
|
|||
|
(defface xeft-excerpt-title
|
|||
|
'((t . (:inherit (bold underline))))
|
|||
|
"Face for the excerpt title.")
|
|||
|
|
|||
|
(defface xeft-excerpt-body
|
|||
|
'((t . (:inherit default)))
|
|||
|
"Face for the excerpt body.")
|
|||
|
|
|||
|
(defcustom xeft-default-extension "txt"
|
|||
|
"The default extension for new files created by xeft."
|
|||
|
:type 'string)
|
|||
|
|
|||
|
(defcustom xeft-filename-fn
|
|||
|
(lambda (search-phrase)
|
|||
|
(concat search-phrase "." xeft-default-extension))
|
|||
|
"A function that takes the search phrase and returns a filename."
|
|||
|
:type 'function)
|
|||
|
|
|||
|
(defcustom xeft-ignore-extension '("iimg")
|
|||
|
"Files with extensions in this list are ignored.
|
|||
|
|
|||
|
To remove the files that you want to ignore but are already
|
|||
|
indexed in the database, simply delete the database and start
|
|||
|
xeft again.
|
|||
|
|
|||
|
If this is not flexible enough, take a look at
|
|||
|
‘xeft-file-filter’.
|
|||
|
|
|||
|
Changing this variable along doesn’t remove already-indexed files
|
|||
|
from the database, you need to delete the database on disk and
|
|||
|
let xeft recreate it."
|
|||
|
:type '(list string))
|
|||
|
|
|||
|
(defcustom xeft-file-filter #'xeft-default-file-filter
|
|||
|
"A filter function that excludes files from indexing.
|
|||
|
|
|||
|
If ‘xeft-ignore-extension’ is not flexible enough, customize this
|
|||
|
function to filter out unwanted files. This function should take
|
|||
|
the absolute path of a file and return t/nil indicating
|
|||
|
keeping/excluding the file from indexing.
|
|||
|
|
|||
|
Changing this variable along doesn’t remove already-indexed files
|
|||
|
from the database, you need to delete the database on disk and
|
|||
|
let xeft recreate it."
|
|||
|
:type 'function)
|
|||
|
|
|||
|
(defcustom xeft-directory-filter #'xeft-default-directory-filter
|
|||
|
"A filter function that excludes directories from indexing.
|
|||
|
|
|||
|
This function is useful when ‘xeft-recursive’ is non-nil, and you
|
|||
|
want to exclude certain directories (and its enclosing files)
|
|||
|
from indexing.
|
|||
|
|
|||
|
Changing this variable along doesn’t remove already-indexed files
|
|||
|
from the database, you need to delete the database on disk and
|
|||
|
let xeft recreate it."
|
|||
|
:type 'function)
|
|||
|
|
|||
|
(defcustom xeft-title-function #'xeft-default-title
|
|||
|
"A function that extracts the title of a file.
|
|||
|
|
|||
|
This function is passed the absolute path of the file, and is
|
|||
|
called in a temporary buffer containing the content of the file,
|
|||
|
where point is at the beginning of the buffer.
|
|||
|
|
|||
|
This function should return the title as a string, and leave
|
|||
|
point at the beginning of body text (ie, end of title)."
|
|||
|
:type 'function)
|
|||
|
|
|||
|
(defcustom xeft-recursive nil
|
|||
|
"If non-nil, xeft searches for files recursively.
|
|||
|
|
|||
|
Xeft doesn’t follow symlinks and ignores inaccessible
|
|||
|
directories. Customize ‘xeft-directory-filter’ to exclude
|
|||
|
subdirectories. Set this variable to ‘follow-symlinks’ to follow
|
|||
|
symlinks, note that this might lead to infinite recursion.
|
|||
|
|
|||
|
Changing this variable along doesn’t remove already-indexed files
|
|||
|
from the database, you need to delete the database on disk and
|
|||
|
let xeft recreate it."
|
|||
|
:type '(choice (const :tag "No" nil)
|
|||
|
(const :tag "Yes" t)
|
|||
|
(const :tag "Yes, and follow symlinks"
|
|||
|
follow-symlinks )))
|
|||
|
|
|||
|
(defcustom xeft-file-list-function #'xeft--file-list
|
|||
|
"A function that returns files that xeft should search from.
|
|||
|
This function takes no arguments and return a list of absolute paths.
|
|||
|
|
|||
|
Changing this variable along doesn’t remove already-indexed files
|
|||
|
from the database, you need to delete the database on disk and
|
|||
|
let xeft recreate it."
|
|||
|
:type 'function)
|
|||
|
|
|||
|
;;; Compile
|
|||
|
|
|||
|
(defun xeft--compile-module ()
|
|||
|
"Compile the dynamic module. Return non-nil if success."
|
|||
|
;; Just following vterm.el here.
|
|||
|
(when (not (executable-find "make"))
|
|||
|
(user-error "Couldn’t compile xeft: cannot find make"))
|
|||
|
(let* ((default-directory
|
|||
|
(file-name-directory
|
|||
|
(locate-library "xeft.el" t)))
|
|||
|
(prefix (concat "PREFIX="
|
|||
|
(read-string "PREFIX (empty by default): ")))
|
|||
|
(buffer (get-buffer-create "*xeft compile*")))
|
|||
|
(if (zerop (let ((inhibit-read-only t))
|
|||
|
(call-process "make" nil buffer t prefix)))
|
|||
|
(progn (message "Successfully compiled the module :-D") t)
|
|||
|
(pop-to-buffer buffer)
|
|||
|
(compilation-mode)
|
|||
|
(message "Failed to compile the module")
|
|||
|
nil)))
|
|||
|
|
|||
|
(defvar xeft--linux-module-url "https://git.sr.ht/~casouri/xapian-lite/refs/download/v2.0.0/xapian-lite-amd64-linux.so"
|
|||
|
"URL for pre-built dynamic module for Linux.")
|
|||
|
|
|||
|
(defvar xeft--mac-module-url "https://git.sr.ht/~casouri/xapian-lite/refs/download/v2.0.0/xapian-lite-amd64-macos.dylib"
|
|||
|
"URL for pre-built dynamic module for Mac.")
|
|||
|
|
|||
|
|
|||
|
(defun xeft--require-xapian-lite ()
|
|||
|
"Require ‘xapian-lite’. If non-existent, print a message."
|
|||
|
(if (require 'xapian-lite nil t)
|
|||
|
t
|
|||
|
(message "Xapian-Lite is not available.")
|
|||
|
nil))
|
|||
|
|
|||
|
(defvar xeft-library-directory
|
|||
|
(expand-file-name "~/.emacs.d/extensions/xeft/")
|
|||
|
"Directory where the xeft library and modules are located.")
|
|||
|
|
|||
|
(defun xeft--download-module ()
|
|||
|
"Download pre-built module from GitHub. Return non-nil if success."
|
|||
|
(when (y-or-n-p "You are about to download binary from Internet without checking checksum, do you want to continue?")
|
|||
|
(let* ((system (car (read-multiple-choice
|
|||
|
"Which prebuilt binary do you want to download? "
|
|||
|
'((?1 "amd64-GNU/Linux"
|
|||
|
"GNU/Linux on Intel/AMD x86_64 CPU")
|
|||
|
(?2 "amd64-macOS"
|
|||
|
"macOS on Intel/AMD x86_64 CPU")
|
|||
|
(?q "quit")))))
|
|||
|
(module-path (expand-file-name
|
|||
|
"xapian-lite.so"
|
|||
|
(file-name-directory
|
|||
|
(locate-library "xeft.el" t))))
|
|||
|
(url (pcase system
|
|||
|
(?1 xeft--linux-module-url)
|
|||
|
(?2 xeft--mac-module-url))))
|
|||
|
(when (and url
|
|||
|
(y-or-n-p (format "Downloading from %s, is that ok?"
|
|||
|
url)))
|
|||
|
(url-copy-file url module-path)))))
|
|||
|
;;; Helpers
|
|||
|
|
|||
|
(defvar xeft--last-window-config nil
|
|||
|
"Window configuration before Xeft starts.")
|
|||
|
|
|||
|
(defun xeft--buffer ()
|
|||
|
"Return the xeft buffer."
|
|||
|
(get-buffer-create "*xeft*"))
|
|||
|
|
|||
|
(defun xeft--work-buffer ()
|
|||
|
"Return the work buffer for Xeft. Used for holding file contents."
|
|||
|
(get-buffer-create " *xeft work*"))
|
|||
|
|
|||
|
(defun xeft--after-save ()
|
|||
|
"Reindex the file."
|
|||
|
(condition-case _
|
|||
|
(xapian-lite-reindex-file (buffer-file-name) xeft-database)
|
|||
|
(xapian-lite-database-lock-error
|
|||
|
(message "The Xeft database is locked (maybe there is another Xeft instance running) so we will skip indexing this file for now"))
|
|||
|
(xapian-lite-database-corrupt-error
|
|||
|
(message "The Xeft database is corrupted! You should delete the database and Xeft will recreate it. Make sure other programs are not messing with Xeft database"))))
|
|||
|
|
|||
|
(defvar xeft-mode-map
|
|||
|
(let ((map (make-sparse-keymap)))
|
|||
|
(define-key map (kbd "RET") #'xeft-create-note)
|
|||
|
(define-key map (kbd "C-c C-g") #'xeft-refresh-full)
|
|||
|
(define-key map (kbd "C-c C-r") #'xeft-full-reindex)
|
|||
|
(define-key map (kbd "C-n") #'xeft-next)
|
|||
|
(define-key map (kbd "C-p") #'xeft-previous)
|
|||
|
map)
|
|||
|
"Mode map for `xeft-mode'.")
|
|||
|
|
|||
|
(defvar xeft--need-refresh)
|
|||
|
(define-derived-mode xeft-mode fundamental-mode
|
|||
|
"Xeft" "Search for notes and display summaries."
|
|||
|
(let ((inhibit-read-only t))
|
|||
|
(visual-line-mode)
|
|||
|
(setq default-directory xeft-directory
|
|||
|
xeft--last-window-config (current-window-configuration))
|
|||
|
;; Hook ‘after-change-functions’ is too primitive, binding to that
|
|||
|
;; will cause problems with electric-pairs.
|
|||
|
(add-hook 'post-command-hook
|
|||
|
(lambda (&rest _)
|
|||
|
(when xeft--need-refresh
|
|||
|
(let ((inhibit-modification-hooks t))
|
|||
|
;; We don’t want ‘after-change-functions’ to run
|
|||
|
;; when we refresh the buffer, because we set
|
|||
|
;; ‘xeft--need-refresh’ in that hook.
|
|||
|
(xeft-refresh))))
|
|||
|
0 t)
|
|||
|
(add-hook 'after-change-functions
|
|||
|
(lambda (&rest _) (setq xeft--need-refresh t)) 0 t)
|
|||
|
(add-hook 'window-size-change-functions
|
|||
|
(lambda (&rest _) (xeft-refresh)) 0 t)
|
|||
|
(add-hook 'kill-buffer-hook
|
|||
|
(lambda ()
|
|||
|
(when xeft--last-window-config
|
|||
|
(set-window-configuration xeft--last-window-config)))
|
|||
|
0 t)
|
|||
|
(erase-buffer)
|
|||
|
(insert "\n\nInsert search phrase and press RET to search.")
|
|||
|
(goto-char (point-min))))
|
|||
|
|
|||
|
|
|||
|
;;; Userland
|
|||
|
|
|||
|
;;;###autoload
|
|||
|
(defun xeft ()
|
|||
|
"Start Xeft."
|
|||
|
(interactive)
|
|||
|
(when (not (file-name-absolute-p xeft-directory))
|
|||
|
(user-error "XEFT-DIRECTORY must be an absolute path"))
|
|||
|
(when (not (file-exists-p xeft-directory))
|
|||
|
(mkdir xeft-directory t))
|
|||
|
(when (not (file-name-absolute-p xeft-database))
|
|||
|
(user-error "XEFT-DATABASE must be an absolute path"))
|
|||
|
(when (not (file-exists-p xeft-database))
|
|||
|
(mkdir xeft-database t))
|
|||
|
(if (not (xeft--require-xapian-lite))
|
|||
|
(message "Cannot start xeft because required dynamic module is missing")
|
|||
|
(setq xeft--last-window-config (current-window-configuration))
|
|||
|
(switch-to-buffer (xeft--buffer))
|
|||
|
(when (not (derived-mode-p 'xeft-mode))
|
|||
|
(xeft-mode))
|
|||
|
;; Reindex all files. We reindex every time M-x xeft is called.
|
|||
|
;; Because sometimes I use other functions to move between files,
|
|||
|
;; edit them, and come back to Xeft buffer to search. By that time
|
|||
|
;; some file are changed without Xeft noticing.
|
|||
|
(xeft-full-reindex)
|
|||
|
;; Also regenerate newest file cache, for the same reason as above.
|
|||
|
(xeft--front-page-cache-refresh)))
|
|||
|
|
|||
|
(defun xeft-create-note ()
|
|||
|
"Create a new note with the current search phrase as the title."
|
|||
|
(interactive)
|
|||
|
(let* ((search-phrase (xeft--get-search-phrase))
|
|||
|
(file-name (funcall xeft-filename-fn search-phrase))
|
|||
|
(file-path (expand-file-name file-name xeft-directory))
|
|||
|
(exists-p (file-exists-p file-path)))
|
|||
|
;; If there is no match, create the file without confirmation,
|
|||
|
;; otherwise prompt for confirmation. NOTE: this is not DRY, but
|
|||
|
;; should be ok.
|
|||
|
(when (or (search-forward "Press RET to create a new note" nil t)
|
|||
|
(y-or-n-p (format "Create file `%s'? " file-name)))
|
|||
|
(find-file file-path)
|
|||
|
(unless exists-p
|
|||
|
(insert search-phrase "\n\n")
|
|||
|
(save-buffer)
|
|||
|
;; This should cover most cases.
|
|||
|
(xeft--front-page-cache-refresh))
|
|||
|
(run-hooks 'xeft-find-file-hook))))
|
|||
|
|
|||
|
(defvar-local xeft--select-overlay nil
|
|||
|
"Overlay used for highlighting selected search result.")
|
|||
|
|
|||
|
(defun xeft--highlight-file-at-point ()
|
|||
|
"Activate (highlight) the file excerpt button at point."
|
|||
|
(when-let ((button (button-at (point))))
|
|||
|
;; Create the overlay if it doesn't exist yet.
|
|||
|
(when (null xeft--select-overlay)
|
|||
|
(setq xeft--select-overlay (make-overlay (button-start button)
|
|||
|
(button-end button)))
|
|||
|
(overlay-put xeft--select-overlay 'evaporate t)
|
|||
|
(overlay-put xeft--select-overlay 'face 'xeft-selection))
|
|||
|
;; Move the overlay over the file.
|
|||
|
(move-overlay xeft--select-overlay
|
|||
|
(button-start button) (button-end button))))
|
|||
|
|
|||
|
(defun xeft-next ()
|
|||
|
"Move to next file excerpt."
|
|||
|
(interactive)
|
|||
|
(when (forward-button 1 nil nil t)
|
|||
|
(xeft--highlight-file-at-point)))
|
|||
|
|
|||
|
(defun xeft-previous ()
|
|||
|
"Move to previous file excerpt."
|
|||
|
(interactive)
|
|||
|
(if (backward-button 1 nil nil t)
|
|||
|
(xeft--highlight-file-at-point)
|
|||
|
;; Go to the end of the search phrase.
|
|||
|
(goto-char (point-min))
|
|||
|
(end-of-line)))
|
|||
|
|
|||
|
(defun xeft-full-reindex ()
|
|||
|
"Do a full reindex of all files.
|
|||
|
|
|||
|
This function only reindex files but doesn’t delete the database
|
|||
|
and recreate it. So if you changed the filter
|
|||
|
functions (‘xeft-file-filter’, etc), and want to remove the
|
|||
|
now-excluded files from the database, you need to manually delete
|
|||
|
the database."
|
|||
|
(interactive)
|
|||
|
(condition-case _
|
|||
|
(dolist (file (funcall xeft-file-list-function))
|
|||
|
(xapian-lite-reindex-file file xeft-database))
|
|||
|
(xapian-lite-database-lock-error
|
|||
|
(message "The Xeft database is locked (maybe there is another Xeft instance running) so we will skip indexing for now"))
|
|||
|
(xapian-lite-database-corrupt-error
|
|||
|
(message "The Xeft database is corrupted! You should delete the database and Xeft will recreate it. Make sure other programs are not messing with Xeft database"))))
|
|||
|
|
|||
|
;;; Draw
|
|||
|
|
|||
|
(defvar xeft--preview-window nil
|
|||
|
"Xeft shows file previews in this window.")
|
|||
|
|
|||
|
(defun xeft--get-search-phrase ()
|
|||
|
"Return the search phrase. Assumes current buffer is a xeft buffer."
|
|||
|
(save-excursion
|
|||
|
(goto-char (point-min))
|
|||
|
(string-trim
|
|||
|
(buffer-substring-no-properties (point) (line-end-position)))))
|
|||
|
|
|||
|
(defun xeft--find-file-at-point ()
|
|||
|
"View file at point."
|
|||
|
(interactive)
|
|||
|
(find-file (button-get (button-at (point)) 'path))
|
|||
|
(run-hooks 'xeft-find-file-hook)
|
|||
|
(add-hook 'after-save-hook #'xeft--after-save 0 t))
|
|||
|
|
|||
|
(defun xeft--preview-file (file &optional select)
|
|||
|
"View FILE in another window.
|
|||
|
If SELECT is non-nil, select the buffer after displaying it."
|
|||
|
(interactive)
|
|||
|
(let* ((buffer (find-file-noselect file))
|
|||
|
(search-phrase (xeft--get-search-phrase))
|
|||
|
(keyword-list (split-string search-phrase)))
|
|||
|
(if (and (window-live-p xeft--preview-window)
|
|||
|
(not (eq xeft--preview-window (selected-window))))
|
|||
|
(with-selected-window xeft--preview-window
|
|||
|
(switch-to-buffer buffer)
|
|||
|
(when keyword-list
|
|||
|
(let ((case-fold-search t))
|
|||
|
(search-forward (car keyword-list) nil t))))
|
|||
|
(setq xeft--preview-window
|
|||
|
(display-buffer
|
|||
|
buffer '((display-buffer-use-some-window
|
|||
|
display-buffer-in-direction
|
|||
|
display-buffer-pop-up-window)
|
|||
|
. ((inhibit-same-window . t)
|
|||
|
(direction . right)
|
|||
|
(window-width
|
|||
|
. (lambda (win)
|
|||
|
(let ((width (window-width)))
|
|||
|
(when (< width 50)
|
|||
|
(window-resize
|
|||
|
win (- 50 width) t))))))))))
|
|||
|
(if select (select-window xeft--preview-window))
|
|||
|
(with-current-buffer buffer
|
|||
|
(xeft--highlight-matched keyword-list)
|
|||
|
(run-hooks 'xeft-find-file-hook))))
|
|||
|
|
|||
|
(define-button-type 'xeft-excerpt
|
|||
|
'action (lambda (button)
|
|||
|
;; If the file is no already highlighted, highlight it
|
|||
|
;; first.
|
|||
|
(when (not (and xeft--select-overlay
|
|||
|
(overlay-buffer xeft--select-overlay)
|
|||
|
(<= (overlay-start xeft--select-overlay)
|
|||
|
(button-start button)
|
|||
|
(overlay-end xeft--select-overlay))))
|
|||
|
(goto-char (button-start button))
|
|||
|
(xeft--highlight-file-at-point))
|
|||
|
(xeft--preview-file (button-get button 'path)))
|
|||
|
'keymap (let ((map (make-sparse-keymap)))
|
|||
|
(set-keymap-parent map button-map)
|
|||
|
(define-key map (kbd "RET") #'xeft--find-file-at-point)
|
|||
|
(define-key map (kbd "SPC") #'push-button)
|
|||
|
map)
|
|||
|
'help-echo "Open this file"
|
|||
|
'follow-link t
|
|||
|
'face 'default
|
|||
|
'mouse-face 'xeft-selection)
|
|||
|
|
|||
|
(defun xeft--highlight-search-phrase ()
|
|||
|
"Highlight search phrases in buffer."
|
|||
|
(let ((keyword-list (cl-remove-if
|
|||
|
(lambda (word)
|
|||
|
(or (member word '("OR" "AND" "XOR" "NOT" "NEAR"))
|
|||
|
(string-prefix-p "ADJ" word)))
|
|||
|
(split-string (xeft--get-search-phrase))))
|
|||
|
(inhibit-read-only t))
|
|||
|
(dolist (keyword keyword-list)
|
|||
|
(when (> (length keyword) 1)
|
|||
|
(goto-char (point-min))
|
|||
|
(forward-line 2)
|
|||
|
;; We use overlay because overlay allows face composition.
|
|||
|
;; So we can have bold + underline.
|
|||
|
(while (search-forward keyword nil t)
|
|||
|
(let ((ov (make-overlay (match-beginning 0)
|
|||
|
(match-end 0))))
|
|||
|
(overlay-put ov 'face 'xeft-inline-highlight)
|
|||
|
(overlay-put ov 'xeft-highlight t)
|
|||
|
(overlay-put ov 'evaporate t)))))))
|
|||
|
|
|||
|
(defvar xeft--ecache nil
|
|||
|
"Cache for finding excerpt for a file.")
|
|||
|
|
|||
|
(defun xeft--ecache-buffer (file)
|
|||
|
"Return a buffer that has the content of FILE.
|
|||
|
Doesn’t check for modification time, and not used."
|
|||
|
(or (alist-get (sxhash file) xeft--ecache)
|
|||
|
(progn
|
|||
|
(let ((buf (get-buffer-create
|
|||
|
(format " *xeft-ecache %s*" file))))
|
|||
|
(with-current-buffer buf
|
|||
|
(setq buffer-undo-list t)
|
|||
|
(insert-file-contents file nil nil nil t))
|
|||
|
(push (cons (sxhash file) buf) xeft--ecache)
|
|||
|
(when (> (length xeft--ecache) 30)
|
|||
|
(kill-buffer (cdr (nth 30 xeft--ecache)))
|
|||
|
(setcdr (nthcdr 29 xeft--ecache) nil))
|
|||
|
buf))))
|
|||
|
|
|||
|
(defun xeft-default-title (file)
|
|||
|
"Return the title of FILE.
|
|||
|
|
|||
|
This is the default value of ‘xeft-title-function’, see its
|
|||
|
docstring for more detail.
|
|||
|
|
|||
|
Return the first line as title, recognize Org Mode’s #+TITLE:
|
|||
|
cookie, if the first line is empty, return the file name as the
|
|||
|
title."
|
|||
|
(re-search-forward (rx "#+TITLE:" (* whitespace)) nil t)
|
|||
|
(let ((bol (point)) title)
|
|||
|
(end-of-line)
|
|||
|
(setq title (buffer-substring-no-properties bol (point)))
|
|||
|
(if (equal title "")
|
|||
|
(file-name-base file)
|
|||
|
title)))
|
|||
|
|
|||
|
(defun xeft--file-excerpt (file search-phrase)
|
|||
|
"Return an excerpt for FILE.
|
|||
|
Return (TITLE EXCERPT FILE). FILE should be an absolute path.
|
|||
|
SEARCH-PHRASE is the search phrase the user typed."
|
|||
|
(let ((excerpt-len (floor (* 2.7 (1- (window-width)))))
|
|||
|
(last-search-term
|
|||
|
(car (last (split-string search-phrase))))
|
|||
|
title excerpt)
|
|||
|
(with-current-buffer (xeft--work-buffer)
|
|||
|
(widen)
|
|||
|
(erase-buffer)
|
|||
|
(setq buffer-undo-list t)
|
|||
|
;; The times saved by caching is not significant enough. So I
|
|||
|
;; choose to not cache, but kept the code just in case. See
|
|||
|
;; ‘xeft--ecache-buffer’.
|
|||
|
(insert-file-contents file nil nil nil t)
|
|||
|
(goto-char (point-min))
|
|||
|
(setq title (funcall xeft-title-function file))
|
|||
|
(narrow-to-region (point) (point-max))
|
|||
|
;; Grab excerpt.
|
|||
|
(setq excerpt (string-trim
|
|||
|
(replace-regexp-in-string
|
|||
|
"[[:space:]]+"
|
|||
|
" "
|
|||
|
(if (and last-search-term
|
|||
|
(search-forward last-search-term nil t))
|
|||
|
(buffer-substring-no-properties
|
|||
|
(max (- (point) (/ excerpt-len 2))
|
|||
|
(point-min))
|
|||
|
(min (+ (point) (/ excerpt-len 2))
|
|||
|
(point-max)))
|
|||
|
(buffer-substring-no-properties
|
|||
|
(point)
|
|||
|
(min (+ (point) excerpt-len)
|
|||
|
(point-max)))))))
|
|||
|
(list title excerpt file))))
|
|||
|
|
|||
|
(defun xeft--insert-file-excerpts (excerpt-list phrase-empty list-clipped)
|
|||
|
"Insert search results into the buffer at point.
|
|||
|
|
|||
|
EXCERPT-LIST is a list of (TITLE EXCERPT FILE), where TITLE is
|
|||
|
the title of the file, EXCERPT is a piece of excerpt from the
|
|||
|
file, and FILE is the absolute path of the file.
|
|||
|
|
|||
|
PHRASE-EMPTY is a boolean indicating whether the search phrase is
|
|||
|
empty. LIST-CLIPPED is a boolean indicating whether the results
|
|||
|
list is truncated (ie, not the full list)."
|
|||
|
(pcase-dolist (`(,title ,excerpt ,file) excerpt-list)
|
|||
|
(let ((start (point)))
|
|||
|
(insert (propertize title 'face 'xeft-excerpt-title) "\n"
|
|||
|
(propertize excerpt 'face 'xeft-excerpt-body) "\n\n")
|
|||
|
;; If we use overlay (with `make-button'), the button's face
|
|||
|
;; will override the bold and light face we specified above.
|
|||
|
(make-text-button start (- (point) 2)
|
|||
|
:type 'xeft-excerpt
|
|||
|
'path file)))
|
|||
|
;; NOTE: this string is referred in ‘xeft-create-note’.
|
|||
|
(if (and (null excerpt-list)
|
|||
|
(not phrase-empty))
|
|||
|
(insert "Press RET to create a new note"))
|
|||
|
(when list-clipped
|
|||
|
(insert
|
|||
|
(format
|
|||
|
"[Only showing the first 15 results, type %s to show all of them]\n"
|
|||
|
(key-description
|
|||
|
(where-is-internal #'xeft-refresh-full xeft-mode-map t))))))
|
|||
|
|
|||
|
(defun xeft--sort-excerpt (excerpt-list search-phrase)
|
|||
|
"Sort EXCERPT-LIST according to SEARCH-PHRASE.
|
|||
|
|
|||
|
EXCERPT-LIST is a list of (TITLE EXCERPT FILE), usually returned
|
|||
|
by ‘xeft--file-excerpt’. SEARCH-PHRASE is the search phrase
|
|||
|
entered by the user."
|
|||
|
(if (equal search-phrase "")
|
|||
|
excerpt-list
|
|||
|
(let* ((phrase-list
|
|||
|
(mapcar #'downcase (string-split search-phrase))))
|
|||
|
(cl-stable-sort
|
|||
|
excerpt-list
|
|||
|
(lambda (ex1 ex2)
|
|||
|
(> (xeft--excerpt-score (car ex1) phrase-list)
|
|||
|
(xeft--excerpt-score (car ex2) phrase-list)))))))
|
|||
|
|
|||
|
(defun xeft--excerpt-score (title search-phrases)
|
|||
|
"The score function used to sort excerpts in ‘xeft--sort-excerpt’.
|
|||
|
|
|||
|
TITLE is the title of the excerpt. SEARCH-PHRASES is the search
|
|||
|
phrase entered by the user, split into a list. We don’t remove
|
|||
|
the logical cookies like \"AND\", because I doubt it’ll make much
|
|||
|
difference. The phrases should be all lowercase.
|
|||
|
|
|||
|
The score is the number of search phrases that appears in TITLE."
|
|||
|
(let ((score 0)
|
|||
|
(case-fold-search t))
|
|||
|
(dolist (phrase search-phrases)
|
|||
|
(when (string-match-p phrase title)
|
|||
|
(cl-incf score)))
|
|||
|
score))
|
|||
|
|
|||
|
;;; Refresh and search
|
|||
|
|
|||
|
(defun xeft-refresh-full ()
|
|||
|
"Refresh and display _all_ results."
|
|||
|
(interactive)
|
|||
|
(xeft-refresh t))
|
|||
|
|
|||
|
(defun xeft--file-name-extension (path)
|
|||
|
"Return the extension part of PATH.
|
|||
|
This differs from ‘file-name-extension’ in that it doesn’t remove
|
|||
|
trailing \"version strings\"."
|
|||
|
(let ((filename (file-name-nondirectory path)))
|
|||
|
(when (string-match (rx (not ".") "." (group (* (not "."))) eos)
|
|||
|
filename)
|
|||
|
(match-string 1 filename))))
|
|||
|
|
|||
|
(defun xeft-default-file-filter (file)
|
|||
|
"Return nil if FILE should be ignored.
|
|||
|
|
|||
|
FILE is an absolute path. This default implementation ignores
|
|||
|
directories, dot files, and files matched by
|
|||
|
‘xeft-ignore-extension’."
|
|||
|
(and (file-regular-p file)
|
|||
|
(not (string-prefix-p
|
|||
|
"." (file-name-base file)))
|
|||
|
(not (member (xeft--file-name-extension file)
|
|||
|
xeft-ignore-extension))))
|
|||
|
|
|||
|
(defun xeft-default-directory-filter (dir)
|
|||
|
"Return nil if DIR shouldn’t be indexed.
|
|||
|
DIR is an absolute path. This default implementation excludes dot
|
|||
|
directories."
|
|||
|
(not (string-prefix-p "." (file-name-base dir))))
|
|||
|
|
|||
|
(defun xeft--file-list ()
|
|||
|
"Default function for ‘xeft-file-list-function’.
|
|||
|
Return a list of all files in ‘xeft-directory’, ignoring dot
|
|||
|
files and directories and check for ‘xeft-ignore-extension’."
|
|||
|
(cl-remove-if-not
|
|||
|
xeft-file-filter
|
|||
|
(if xeft-recursive
|
|||
|
(directory-files-recursively
|
|||
|
xeft-directory "" nil xeft-directory-filter
|
|||
|
(eq xeft-recursive 'follow-symlinks))
|
|||
|
(directory-files
|
|||
|
xeft-directory t nil t))))
|
|||
|
|
|||
|
(defvar-local xeft--need-refresh t
|
|||
|
"If change is made to the buffer, set this to t.
|
|||
|
Once refreshed the buffer, set this to nil.")
|
|||
|
|
|||
|
(defun xeft--tighten-search-phrase (phrase)
|
|||
|
"Basically insert AND between each term in PHRASE."
|
|||
|
(let ((lst (split-string phrase))
|
|||
|
(in-quote nil))
|
|||
|
;; Basically we only insert AND between two normal phrases, and
|
|||
|
;; don’t insert if any of the two is an operator (AND, OR, +/-,
|
|||
|
;; etc), we also don’t insert AND in quoted phrases.
|
|||
|
(string-join
|
|||
|
(append (cl-loop for idx from 0 to (- (length lst) 2)
|
|||
|
for this = (nth idx lst)
|
|||
|
for next = (nth (1+ idx) lst)
|
|||
|
collect this
|
|||
|
if (and (not in-quote) (eq (aref this 0) ?\"))
|
|||
|
do (setq in-quote t)
|
|||
|
if (and in-quote
|
|||
|
(eq (aref this (1- (length this))) ?\"))
|
|||
|
do (setq in-quote nil)
|
|||
|
if (not
|
|||
|
(or in-quote
|
|||
|
(member this '("AND" "NOT" "OR" "XOR" "NEAR"))
|
|||
|
(string-prefix-p "ADJ" this)
|
|||
|
(memq (aref this 0) '(?+ ?-))
|
|||
|
(member next '("AND" "NOT" "OR" "XOR" "NEAR"))
|
|||
|
(string-prefix-p "ADJ" next)
|
|||
|
(memq (aref next 0) '(?+ ?-))))
|
|||
|
collect "AND")
|
|||
|
(last lst))
|
|||
|
" ")))
|
|||
|
|
|||
|
;; This makes the integrative search results much more stable and
|
|||
|
;; experience more fluid. And because we are not showing radically
|
|||
|
;; different results from one key-press to another, the latency goes
|
|||
|
;; down, I’m guessing because caching in CPU or RAM or OS or whatever.
|
|||
|
(defun xeft--ignore-short-phrase (phrase)
|
|||
|
"If the last term in PHRASE is too short, remove it."
|
|||
|
(let* ((lst (or (split-string phrase) '("")))
|
|||
|
(last (car (last lst))))
|
|||
|
(if (and (not (string-match-p (rx (or (category chinese)
|
|||
|
(category japanese)
|
|||
|
(category korean)))
|
|||
|
last))
|
|||
|
(< (length last) 3))
|
|||
|
(string-join (cl-subseq lst 0 (1- (length lst))) " ")
|
|||
|
(string-join lst " "))))
|
|||
|
|
|||
|
;; See comment in ‘xeft-refresh’.
|
|||
|
(defvar xeft--front-page-cache nil
|
|||
|
"Stores the newest 15 or so files.
|
|||
|
This is a list of absolute paths.")
|
|||
|
|
|||
|
(defun xeft--front-page-cache-refresh ()
|
|||
|
"Refresh ‘xeft--front-page-cache’ and return it."
|
|||
|
(setq xeft--front-page-cache
|
|||
|
(cl-sort (funcall xeft-file-list-function)
|
|||
|
#'file-newer-than-file-p)))
|
|||
|
|
|||
|
(defun xeft-refresh (&optional full)
|
|||
|
"Search for notes and display their summaries.
|
|||
|
By default, only display the first 15 results. If FULL is
|
|||
|
non-nil, display all results."
|
|||
|
(interactive)
|
|||
|
(when (derived-mode-p 'xeft-mode)
|
|||
|
(let ((search-phrase (xeft--ignore-short-phrase
|
|||
|
(xeft--get-search-phrase))))
|
|||
|
(let* ((phrase-empty (equal search-phrase ""))
|
|||
|
(file-list nil)
|
|||
|
(list-clipped nil))
|
|||
|
;; 1. Get a list of files to show.
|
|||
|
(setq file-list
|
|||
|
;; If the search phrase is empty (or too short and thus
|
|||
|
;; ignored), we show the newest files.
|
|||
|
(if phrase-empty
|
|||
|
(or xeft--front-page-cache
|
|||
|
;; Why cache? Turns out sorting this list by
|
|||
|
;; modification date is slow enough to be
|
|||
|
;; perceivable.
|
|||
|
(setq xeft--front-page-cache
|
|||
|
(xeft--front-page-cache-refresh)))
|
|||
|
(xapian-lite-query-term
|
|||
|
(xeft--tighten-search-phrase search-phrase)
|
|||
|
xeft-database
|
|||
|
;; 16 is just larger than 15, so we will know it when
|
|||
|
;; there are more results.
|
|||
|
0 (if full 2147483647 16))))
|
|||
|
(when (and (null full) (> (length file-list) 15))
|
|||
|
(setq file-list (cl-subseq file-list 0 15)
|
|||
|
list-clipped t))
|
|||
|
;; 2. Display these files with excerpt. We do a
|
|||
|
;; double-buffering: first insert in a temp buffer, then
|
|||
|
;; insert the whole thing into this buffer.
|
|||
|
(let* ((inhibit-read-only t)
|
|||
|
(orig-point (point))
|
|||
|
(excerpt-list
|
|||
|
(while-no-input
|
|||
|
(xeft--sort-excerpt
|
|||
|
(mapcar (lambda (file)
|
|||
|
(xeft--file-excerpt file search-phrase))
|
|||
|
file-list)
|
|||
|
search-phrase))))
|
|||
|
;; 2.2 Actually insert the content.
|
|||
|
(while-no-input
|
|||
|
(setq buffer-undo-list t)
|
|||
|
(goto-char (point-min))
|
|||
|
(forward-line 2)
|
|||
|
(let ((start (point)))
|
|||
|
(delete-region (point) (point-max))
|
|||
|
|
|||
|
(xeft--insert-file-excerpts
|
|||
|
excerpt-list phrase-empty list-clipped)
|
|||
|
|
|||
|
(put-text-property (- start 2) (point) 'read-only t)
|
|||
|
(xeft--highlight-search-phrase)
|
|||
|
(set-buffer-modified-p nil)
|
|||
|
;; If finished, update this variable.
|
|||
|
(setq xeft--need-refresh nil)
|
|||
|
(buffer-enable-undo)))
|
|||
|
;; Save excursion wouldn’t work since we erased the
|
|||
|
;; buffer and re-inserted contents.
|
|||
|
(goto-char orig-point)
|
|||
|
;; Re-apply highlight.
|
|||
|
(xeft--highlight-file-at-point))))))
|
|||
|
|
|||
|
;;; Highlight matched phrases
|
|||
|
|
|||
|
(defun xeft--highlight-matched (keyword-list)
|
|||
|
"Highlight keywords in KEYWORD-LIST in the current buffer."
|
|||
|
(save-excursion
|
|||
|
;; Add highlight overlays.
|
|||
|
(dolist (keyword keyword-list)
|
|||
|
(when (> (length keyword) 1)
|
|||
|
(goto-char (point-min))
|
|||
|
(while (search-forward keyword nil t)
|
|||
|
(let ((ov (make-overlay (match-beginning 0)
|
|||
|
(match-end 0))))
|
|||
|
(overlay-put ov 'face 'xeft-preview-highlight)
|
|||
|
(overlay-put ov 'xeft-highlight t)))))
|
|||
|
;; Add cleanup hook.
|
|||
|
(add-hook 'window-selection-change-functions
|
|||
|
#'xeft--cleanup-highlight
|
|||
|
0 t)))
|
|||
|
|
|||
|
(defun xeft--cleanup-highlight (window)
|
|||
|
"Cleanup highlights in WINDOW."
|
|||
|
(when (eq window (selected-window))
|
|||
|
(let ((ov-list (overlays-in (point-min)
|
|||
|
(point-max))))
|
|||
|
(dolist (ov ov-list)
|
|||
|
(when (overlay-get ov 'xeft-highlight)
|
|||
|
(delete-overlay ov))))
|
|||
|
(remove-hook 'window-selection-change-functions
|
|||
|
#'xeft--cleanup-highlight
|
|||
|
t)))
|
|||
|
|
|||
|
;;; Inferred links
|
|||
|
|
|||
|
(defun xeft--extract-buffer-words (buffer)
|
|||
|
"Extract words in BUFFER and return in a list.
|
|||
|
Each element looks like (BEG . WORD) where BEG is the buffer
|
|||
|
position of WORD."
|
|||
|
(with-current-buffer buffer
|
|||
|
(goto-char (point-min))
|
|||
|
(let (beg end word-list)
|
|||
|
(while (progn (and (re-search-forward (rx word) nil t)
|
|||
|
(setq beg (match-beginning 0))
|
|||
|
(re-search-forward (rx (not word)) nil t)
|
|||
|
(setq end (match-beginning 0))))
|
|||
|
(push (cons beg (buffer-substring-no-properties beg end))
|
|||
|
word-list))
|
|||
|
(nreverse word-list))))
|
|||
|
|
|||
|
(defun xeft--generate-phrase-list (word-list max-len)
|
|||
|
"Given WORD-LIST, generate all possible phrases up to MAX-LEN long.
|
|||
|
Eg, given WORD-LIST = (a b c), len = 3, return
|
|||
|
|
|||
|
((a) (b) (c) (a b) (b c) (a b c))"
|
|||
|
(cl-loop for len from 1 to max-len
|
|||
|
append (cl-loop
|
|||
|
for idx from 0 to (- (length word-list) len)
|
|||
|
collect (cl-subseq word-list idx (+ idx len)))))
|
|||
|
|
|||
|
(defun xeft--collect-inferred-links
|
|||
|
(buffer max-len lower-bound upper-bound)
|
|||
|
"Collect inferred links in BUFFER.
|
|||
|
MAX-LEN is the same as in ‘xeft--generate-phrase-list’. Only
|
|||
|
phrases with number of results between LOWER-BOUND and
|
|||
|
UPPER-BOUND (inclusive) are collected."
|
|||
|
(let* ((word-list (xeft--extract-buffer-words buffer))
|
|||
|
(phrase-list (xeft--generate-phrase-list
|
|||
|
word-list max-len))
|
|||
|
(query-list (mapcar (lambda (phrase-list)
|
|||
|
(let ((pos (caar phrase-list))
|
|||
|
(words (mapcar #'cdr phrase-list)))
|
|||
|
(cons pos (concat "\""
|
|||
|
(string-join
|
|||
|
words)
|
|||
|
"\""))))
|
|||
|
phrase-list))
|
|||
|
(link-list
|
|||
|
;; QUERY-CONS = (POS . QUERY-TERM)
|
|||
|
(cl-loop for query-cons in query-list
|
|||
|
for file-list = (xapian-lite-query-term
|
|||
|
(cdr query-cons) xeft-database
|
|||
|
0 (1+ upper-bound))
|
|||
|
if (<= lower-bound (length file-list) upper-bound)
|
|||
|
collect (cons (cdr query-cons)
|
|||
|
(length file-list)))))
|
|||
|
link-list))
|
|||
|
|
|||
|
(provide 'xeft)
|
|||
|
|
|||
|
;;; xeft.el ends here
|