;;; ledger.el --- Helper code for use with the "ledger" command-line tool

;; Copyright (C) 2004 John Wiegley (johnw AT gnu DOT org)

;; Emacs Lisp Archive Entry
;; Filename: ledger.el
;; Version: 1.1
;; Date: Thu 02-Apr-2004
;; Keywords: data
;; Author: John Wiegley (johnw AT gnu DOT org)
;; Maintainer: John Wiegley (johnw AT gnu DOT org)
;; Description: Helper code for using my "ledger" command-line tool
;; URL: http://www.newartisans.com/johnw/emacs.html
;; Compatibility: Emacs21

;; This file is not part of GNU Emacs.

;; This 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 2, or (at your option) any later
;; version.
;;
;; This 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; see the file COPYING.  If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston,
;; MA 02111-1307, USA.

;;; Commentary:

;; To use this module: Load this file, open a ledger data file, and
;; type M-x ledger-mode.  Once this is done, you can type:
;;
;;   C-c C-a  add a new entry, based on previous entries
;;   C-c C-y  set default year for entry mode
;;   C-c C-m  set default month for entry mode
;;   C-c C-r  reconcile the entries related to an account
;;
;; In the reconcile buffer, use SPACE to toggle the cleared status of
;; a transaction.

(defvar ledger-version "1.1"
  "The version of ledger.el currently loaded")

(defgroup ledger nil
  "Interface to the Ledger command-line accounting program."
  :group 'data)

(defcustom ledger-binary-path (executable-find "ledger")
  "Path to the ledger executable."
  :type 'file
  :group 'ledger)

(defcustom ledger-data-file (getenv "LEDGER")
  "Path to the ledger data file."
  :type 'file
  :group 'ledger)

(defvar bold 'bold)

(defvar ledger-font-lock-keywords
  '(("^[0-9./]+\\s-+\\(?:([^)]+)\\s-+\\)?\\([^*].+\\)" 1 bold)
    ("^\\s-+.+?\\(  \\|\t\\|\\s-+$\\)" . font-lock-keyword-face))
  "Default expressions to highlight in Ledger mode.")

(defsubst ledger-current-year ()
  (format-time-string "%Y"))

(defsubst ledger-current-month ()
  (format-time-string "%m"))

(defvar ledger-year (ledger-current-year)
  "Start a ledger session with the current year, but make it
customizable to ease retro-entry.")

(defvar ledger-month (ledger-current-month)
  "Start a ledger session with the current month, but make it
customizable to ease retro-entry.")

(defun ledger-iterate-entries (callback)
  (goto-char (point-min))
  (let* ((now (current-time))
	 (current-year (nth 5 (decode-time now))))
    (while (not (eobp))
      (when (looking-at
	     (concat "\\(Y\\s-+\\([0-9]+\\)\\|"
		     "\\([0-9]\\{4\\}+\\)?[./]?"
		     "\\([0-9]+\\)[./]\\([0-9]+\\)\\s-+"
		     "\\(\\*\\s-+\\)?\\(.+\\)\\)"))
	(let ((found (match-string 2)))
	  (if found
	      (setq current-year (string-to-number found))
	    (let ((start (match-beginning 0))
		  (year (match-string 3))
		  (month (string-to-number (match-string 4)))
		  (day (string-to-number (match-string 5)))
		  (mark (match-string 6))
		  (desc (match-string 7)))
	      (if (and year (> (length year) 0))
		  (setq year (string-to-number year)))
	      (funcall callback start
		       (encode-time 0 0 0 day month
				    (or year current-year))
		       mark desc)))))
      (forward-line))))

(defun ledger-time-less-p (t1 t2)
  "Say whether time value T1 is less than time value T2."
  (or (< (car t1) (car t2))
      (and (= (car t1) (car t2))
	   (< (nth 1 t1) (nth 1 t2)))))

(defun ledger-time-subtract (t1 t2)
  "Subtract two time values.
Return the difference in the format of a time value."
  (let ((borrow (< (cadr t1) (cadr t2))))
    (list (- (car t1) (car t2) (if borrow 1 0))
	  (- (+ (if borrow 65536 0) (cadr t1)) (cadr t2)))))

(defun ledger-find-slot (moment)
  (catch 'found
    (ledger-iterate-entries
     (function
      (lambda (start date mark desc)
	(if (ledger-time-less-p moment date)
	    (throw 'found t)))))))

(defun ledger-add-entry (entry-text)
  (interactive
   (list
    (read-string "Entry: " (concat ledger-year "/" ledger-month "/"))))
  (let* ((date (car (split-string entry-text)))
	 (insert-year t) exit-code)
    (if (string-match "\\([0-9]+\\)/\\([0-9]+\\)/\\([0-9]+\\)" date)
	(setq date (encode-time 0 0 0 (string-to-int (match-string 3 date))
				(string-to-int (match-string 2 date))
				(string-to-int (match-string 1 date)))))
    (ledger-find-slot date)
    (save-excursion
      (if (re-search-backward "^Y " nil t)
	  (setq insert-year nil)))
    (save-excursion
      (insert
       (with-temp-buffer
	 (setq exit-code
	       (ledger-run-ledger
		"entry"
		(with-temp-buffer
		  (insert entry-text)
		  (goto-char (point-min))
		  (while (re-search-forward "\\([&$]\\)" nil t)
		    (replace-match "\\\\\\1"))
		  (buffer-string))))
	 (if (= 0 exit-code)
	     (if insert-year
		 (buffer-substring 2 (point-max))
	       (buffer-substring 7 (point-max)))
	   (concat (if insert-year entry-text
		     (substring entry-text 6)) "\n"))) "\n"))))

(defun ledger-expand-entry ()
  (interactive)
  (ledger-add-entry (prog1
			(buffer-substring (line-beginning-position)
					  (line-end-position))
		      (delete-region (line-beginning-position)
				     (1+ (line-end-position))))))

(defun ledger-toggle-current ()
  (interactive)
  (let (clear)
    (save-excursion
      (when (or (looking-at "^[0-9]")
		(re-search-backward "^[0-9]" nil t))
	(skip-chars-forward "0-9./")
	(delete-horizontal-space)
	(if (equal ?\* (char-after))
	    (delete-char 1)
	  (insert " * ")
	  (setq clear t))))
    clear))

(defun ledger-print-result (command)
  (interactive "sLedger command: ")
  (shell-command (format "%s -f %s %s" ledger-binary-path
			 buffer-file-name command)))

(defvar ledger-mode-abbrev-table)

(define-derived-mode ledger-mode text-mode "Ledger"
  "A mode for editing ledger data files."
  (set (make-local-variable 'comment-start) ";")
  (set (make-local-variable 'comment-end) "")
  (set (make-local-variable 'indent-tabs-mode) nil)
  (if (boundp 'font-lock-defaults)
      (set (make-local-variable 'font-lock-defaults)
	   '(ledger-font-lock-keywords nil t)))
  (let ((map (current-local-map)))
    (define-key map [(control ?c) (control ?a)] 'ledger-add-entry)
    (define-key map [(control ?c) (control ?y)] 'ledger-set-year)
    (define-key map [(control ?c) (control ?m)] 'ledger-set-month)
    (define-key map [(control ?c) (control ?c)] 'ledger-toggle-current)
    (define-key map [(control ?c) (control ?p)] 'ledger-print-result)
    (define-key map [(control ?c) (control ?r)] 'ledger-reconcile)))

(defun ledger-parse-entries (account &optional all-p)
  (let (total entries)
    (ledger-iterate-entries
     (function
      (lambda (start date mark desc)
	(when (or all-p (not mark))
	  (forward-line)
	  (setq total 0.0)
	  (while (looking-at
		  (concat "\\s-+\\([A-Za-z_].+?\\)\\(\\s-*$\\|  \\s-*"
			  "\\([^0-9]+\\)\\s-*\\([0-9,.]+\\)\\)?"
			  "\\(\\s-+;.+\\)?$"))
	    (let ((acct (match-string 1))
		  (amt (match-string 4)))
	      (when amt
		(while (string-match "," amt)
		  (setq amt (replace-match "" nil nil amt)))
		(setq amt (string-to-number amt)
		      total (+ total amt)))
	      (if (string= account acct)
		  (setq entries
			(cons (list (copy-marker start)
				    mark date desc (or amt total))
			      entries)
			all-p t)))
	    (forward-line))))))
    entries))

(defvar ledger-reconcile-text "Reconcile")
(defvar ledger-reconcile-mode-abbrev-table)

(define-derived-mode ledger-reconcile-mode text-mode 'ledger-reconcile-text
  "A mode for reconciling ledger entries."
  (let ((map (make-sparse-keymap)))
    (define-key map [? ] 'ledger-reconcile-toggle)
    (define-key map [?q]
      (function
       (lambda ()
	 (interactive)
	 (kill-buffer (current-buffer)))))
    (use-local-map map)))

(add-to-list 'minor-mode-alist
	     '(ledger-reconcile-mode ledger-reconcile-text))

(defvar ledger-buf nil)
(defvar ledger-acct nil)

(defun ledger-update-balance-display ()
  (let ((account ledger-acct))
    (with-temp-buffer
      (let ((exit-code (ledger-run-ledger "-C" "balance" account)))
	(if (/= 0 exit-code)
	    (setq ledger-reconcile-text "Reconcile [ERR]")
	  (goto-char (point-min))
	  (delete-horizontal-space)
	  (skip-syntax-forward "^ ")
	  (setq ledger-reconcile-text
		(concat "Reconcile ["
			(buffer-substring-no-properties (point-min) (point))
			"]"))))))
  (force-mode-line-update))

(defun ledger-reconcile-toggle ()
  (interactive)
  (let ((where (get-text-property (point) 'where))
	(account ledger-acct)
	cleared)
    (with-current-buffer ledger-buf
      (goto-char where)
      (setq cleared (ledger-toggle-current))
      (save-buffer))
    (if cleared
	(add-text-properties (line-beginning-position)
			     (line-end-position)
			     (list 'face 'bold))
      (remove-text-properties (line-beginning-position)
			      (line-end-position)
			      (list 'face)))
    (forward-line)
    (ledger-update-balance-display)))

(defun ledger-reconcile (account)
  (interactive "sAccount to reconcile: ")
  (let* ((items (save-excursion
		  (goto-char (point-min))
		  (ledger-parse-entries account)))
	 (buf (current-buffer)))
    (with-current-buffer
	(pop-to-buffer (generate-new-buffer "*Reconcile*"))
      (ledger-reconcile-mode)
      (set (make-local-variable 'ledger-buf) buf)
      (set (make-local-variable 'ledger-acct) account)
      (ledger-update-balance-display)
      (dolist (item items)
	(let ((beg (point)))
	  (insert (format "%s %-30s %8.2f\n"
			  (format-time-string "%Y/%m/%d" (nth 2 item))
			  (nth 3 item) (nth 4 item)))
	  (if (nth 1 item)
	      (set-text-properties beg (1- (point))
				   (list 'face 'bold
					 'where (nth 0 item)))
	    (set-text-properties beg (1- (point))
				 (list 'where (nth 0 item)))))
	(goto-char (point-min))))))

(defun ledger-align-dollars (&optional column)
  (interactive "p")
  (if (= column 1)
      (setq column 48))
  (while (search-forward "$" nil t)
    (backward-char)
    (let ((col (current-column))
	  (beg (point))
	  target-col len)
      (skip-chars-forward "-$0-9,.")
      (setq len (- (point) beg))
      (setq target-col (- column len))
      (if (< col target-col)
	  (progn
	    (goto-char beg)
	    (insert (make-string (- target-col col) ? )))
	(move-to-column target-col)
	(if (looking-back "  ")
	    (delete-char (- col target-col))
	  (skip-chars-forward "^ \t")
	  (delete-horizontal-space)
	  (insert "  ")))
      (forward-line))))

(defun ledger-run-ledger (&rest args)
  "run ledger with supplied arguments"
  (let ((command
	 (mapconcat 'identity
		    (append (list ledger-binary-path
				  "-f" ledger-data-file) args) " ")))
    (insert (shell-command-to-string command)))
  0)

(defun ledger-set-year (newyear)
  "Set ledger's idea of the current year to the prefix argument."
  (interactive "p")
  (if (= newyear 1)
      (setq ledger-year (read-string "Year: " (ledger-current-year)))
    (setq ledger-year (number-to-string newyear))))

(defun ledger-set-month (newmonth)
  "Set ledger's idea of the current month to the prefix argument."
  (interactive "p")
  (if (= newmonth 1)
      (setq ledger-month (read-string "Month: " (ledger-current-month)))
    (setq ledger-month (format "%02d" newmonth))))

(provide 'ledger)

;;; ledger.el ends here