;;; org-projectile.el --- Repository todo management for org-mode -*- lexical-binding: t; -*-

;; Copyright (C) 2014-2016 Ivan Malison

;; Author: Ivan Malison <IvanMalison@gmail.com>
;; Keywords: org projectile todo
;; Package-Version: 20160822.2123
;; URL: https://github.com/IvanMalison/org-projectile
;; Version: 0.2.1
;; Package-Requires: ((projectile "0.11.0") (dash "2.10.0") (emacs "24"))

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:

;; This package aims to provide an easy interface to creating per
;; project org-mode TODO headings.

;;; Code:

(require 'cl-lib)
(require 'org-capture)
(require 'projectile)

(defvar org-projectile:projects-file "~/org/projects.org")
(defvar org-projectile:per-repo-filename "todo.org")

(defvar org-projectile:capture-template "* TODO %?\n")
(defvar org-projectile:linked-capture-template "* TODO %? %A\n")

(defvar org-projectile:force-linked t)
(defvar org-projectile:counts-in-heading t)
(defvar org-projectile:subheading-selection t)

(defvar org-projectile:project-name-to-org-file
  'org-projectile:project-name-to-org-file-one-file)
(defvar org-projectile:project-name-to-location
  'org-projectile:project-name-to-location-one-file)
(defvar org-projectile:todo-files 'org-projectile:default-todo-files)

;; For a single projects file
(defun org-projectile:project-name-to-org-file-one-file (_project-name)
  org-projectile:projects-file)

(defun org-projectile:project-name-to-location-one-file (project-name)
  (org-projectile:project-heading project-name)
  (when org-projectile:subheading-selection
    (org-projectile:prompt-for-subheadings 'tree))
  t)

(defun org-projectile:one-file ()
  "Use org-projectile in one-file mode."
  (interactive)
  (setq org-projectile:todo-files 'org-projectile:default-todo-files)
  (setq org-projectile:project-name-to-org-file
        'org-projectile:project-name-to-org-file-one-file)
  (setq org-projectile:project-name-to-location
        'org-projectile:project-name-to-location-one-file))

;; For repo files in the projectile project path
(defun org-projectile:project-name-to-org-file-per-repo (project-name)
  (concat (org-projectile:project-location-from-name project-name)
          org-projectile:per-repo-filename))

(defun org-projectile:project-name-to-location-per-repo (_project-name)
  (goto-char (point-max))
  nil)

(defun org-projectile:per-repo ()
  "Use org-projectile in per-repo mode."
  (interactive)
  (setq org-projectile:todo-files 'org-projectile:default-todo-files)
  (setq org-projectile:project-name-to-org-file
        'org-projectile:project-name-to-org-file-per-repo)
  (setq org-projectile:project-name-to-location
        'org-projectile:project-name-to-location-per-repo))

;; Hybrid of the two approaches mentioned above
(defvar org-projectile:project-to-approach nil)
(defvar org-projectile:default-approach 'one-file)

(defun org-projectile:get-approach-for-project (project-name)
  (or (cdr (assoc project-name org-projectile:project-to-approach))
      org-projectile:default-approach))

(defun org-projectile:project-name-to-org-file-hybrid (project-name)
  (let ((approach (org-projectile:get-approach-for-project project-name)))
     (cond
     ((equal approach 'one-file)
      (org-projectile:project-name-to-org-file-one-file project-name))
     ((equal approach 'per-repo)
      (org-projectile:project-name-to-org-file-per-repo project-name)))))

(defun org-projectile:project-name-to-location-hybrid (project-name)
  (let ((approach (org-projectile:get-approach-for-project project-name)))
    (cond
     ((equal approach 'one-file)
      (org-projectile:project-name-to-location-one-file project-name))
     ((equal approach 'per-repo)
      (org-projectile:project-name-to-location-per-repo project-name)))))

(defun org-projectile:hybrid ()
  "Use org-projectile in hybrid mode."
  (interactive)
  (setq org-projectile:todo-files 'org-projectile:default-todo-files)
  (setq org-projectile:project-name-to-org-file
        'org-projectile:project-name-to-org-file-hybrid)
  (setq org-projectile:project-name-to-location
        'org-projectile:project-name-to-location-hybrid))

;; Prompt for org file location on a per project basis
(defvar org-projectile:find-org-file-for-project-function nil)
(defvar org-projectile:keep-project-to-org-filepath-in-memory nil)
(defvar org-projectile:project-to-org-filepath 'not-yet-read)
(defvar org-projectile:project-to-org-filepath-filepath
  (concat (file-name-as-directory user-emacs-directory) "project-to-org-filepath"))

(defun org-projectile:write-project-to-org-filepath
    (project-to-org-filepath &optional project-to-org-filepath-filepath)
  (unless project-to-org-filepath-filepath
    (setq project-to-org-filepath-filepath
          org-projectile:project-to-org-filepath-filepath))
  (with-temp-buffer
    (insert (prin1-to-string project-to-org-filepath))
    (write-region (point-min) (point-max) project-to-org-filepath-filepath nil)))

(defun org-projectile:read-project-to-org-filepath
    (&optional project-to-org-filepath-filepath)
  (unless project-to-org-filepath-filepath
    (setq project-to-org-filepath-filepath
          org-projectile:project-to-org-filepath-filepath))
  (when (file-exists-p project-to-org-filepath-filepath)
    (with-temp-buffer
      (insert-file-contents project-to-org-filepath-filepath)
      (read (buffer-string)))))

(defun org-projectile:update-project-to-org-filepath
    (project-name org-file &optional project-to-org-filepath-filepath)
  (let* ((project-to-org-filepath (org-projectile:get-project-to-org-filepath
                                   project-to-org-filepath-filepath))
         (org-file-truename (org-projectile:file-truename org-file))
         (current-value (assoc project-name project-to-org-filepath)))
    (when (or (not (file-exists-p org-file-truename)) (file-directory-p org-file-truename))
      (throw "The provided filepath is invalid" org-file))
    (if current-value
        (setcdr current-value org-file-truename)
      (push (cons project-name org-file-truename)
            project-to-org-filepath))
    (org-projectile:write-project-to-org-filepath
     project-to-org-filepath project-to-org-filepath-filepath)))

(defun org-projectile:get-project-to-org-filepath
    (&optional project-to-org-filepath-filepath)
  (if org-projectile:keep-project-to-org-filepath-in-memory
      (if (eq org-projectile:project-to-org-filepath 'not-yet-read)
          (progn
            (setq org-projectile:project-to-org-filepath
                  (org-projectile:read-project-to-org-filepath
                   project-to-org-filepath-filepath)))
        org-projectile:project-to-org-filepath)
    (org-projectile:read-project-to-org-filepath
     project-to-org-filepath-filepath)))

(defun org-projectile:project-name-to-org-file-prompt
    (project-name &optional project-to-org-filepath-filepath)
  (let ((current (assoc project-name (org-projectile:get-project-to-org-filepath))))
    (if current (cdr current)
      (let ((org-filepath (org-projectile:find-project-in-known-files project-name)))
        (unless org-filepath
          (setq org-filepath
                (org-projectile:no-org-file-for-project project-name
                                                        project-to-org-filepath-filepath)))
        (org-projectile:update-project-to-org-filepath project-name org-filepath) org-filepath))))

(defun org-projectile:no-org-file-for-project
    (project-name &optional project-to-org-filepath-filepath)
  (let ((org-filepath (when org-projectile:find-org-file-for-project-function
                        (funcall
                         org-projectile:find-org-file-for-project-function
                         project-name))))
    (unless org-filepath
      (setq org-filepath (org-projectile:prompt-for-project-name
                          project-name project-to-org-filepath-filepath)))
    org-filepath))

(defun org-projectile:prompt-for-project-name
    (project-name &optional _project-to-org-filepath-filepath)
  (read-file-name (concat "org-mode file for " project-name ": ")
                  (file-name-directory org-projectile:projects-file)))

(defun org-projectile:set-project-file-default
    (&optional project-to-org-filepath-filepath)
  "Set the filepath for any known projects that do not have a filepath.

If PROJECT-TO-ORG-FILEPATH-FILEPATH is provided use that as the
location of the filepath cache."
  (interactive)
  (let ((org-filepath
         (read-file-name "org-mode file: "
                         (file-name-directory org-projectile:projects-file))))
    (cl-loop for project-name being the elements of (org-projectile:known-projects)
             do (org-projectile:update-project-to-org-filepath
                 project-name org-filepath project-to-org-filepath-filepath))
    org-filepath))

(defun org-projectile:find-project-in-known-files (project-name)
  (cl-loop for org-file in (funcall org-projectile:todo-files) when
           (-contains-p
            (org-map-entries (lambda ()
                               (org-projectile:get-link-description
                                (nth 4 (org-heading-components)))) nil
                                (list org-file)
                                (lambda ()
                                  (when (< 1 (nth 1 (org-heading-components)))
                                    (point)))) project-name)
           return org-file))

(fset 'org-projectile:project-name-to-location-prompt
      'org-projectile:project-name-to-location-one-file)

(defun org-projectile:todo-files-project-to-org-filepath ()
  (delete-dups
   (cl-loop for elem in (org-projectile:get-project-to-org-filepath)
            collect (cdr elem))))

(defun org-projectile:set-org-file-for-project ()
  "Set the org file to use for a projectile project."
  (interactive)
  (org-projectile:update-project-to-org-filepath
   (org-projectile:prompt-for-project-name
    (projectile-completing-read "Select project for which to set org file: "
                                (org-projectile:known-projects)))
   (read-file-name "Select an org file: ")))

(defun org-projectile:prompt ()
  "Use the prompt mode of org-projectile."
  (interactive)
  (setq org-projectile:todo-files
        'org-projectile:todo-files-project-to-org-filepath)
  (setq org-projectile:project-name-to-org-file
        'org-projectile:project-name-to-org-file-prompt)
  (setq org-projectile:project-name-to-location
        'org-projectile:project-name-to-location-prompt))

(defun org-projectile:location-for-project (project-name)
  (let* ((filename (funcall org-projectile:project-name-to-org-file project-name)))
    (switch-to-buffer (find-file-noselect filename))
    (funcall org-projectile:project-name-to-location project-name)))

(defun org-projectile:target-subheading-and-return-marker ()
  (org-end-of-line)
  (org-projectile:end-of-properties)
  ;; It sucks that this has to be done, but we have to insert a
  ;; subheading if the entry does not have one in order to convince
  ;; capture to actually insert the template as a subtree of the
  ;; selected entry. We return a marker where the dummy subheading
  ;; was created so that it can be deleted later.
  (when (not (save-excursion (org-goto-first-child)))
    (save-excursion (org-insert-subheading nil) (point-marker))))

(defun org-projectile:file-truename (filepath)
  (when filepath
    (if (find-file-name-handler filepath 'file-truename)
        filepath ;; skip if the file requires special handling
      (file-truename filepath))))

(defun org-projectile:project-root-of-filepath (filepath)
  (org-projectile:file-truename
   (let ((dir (file-name-directory filepath)))
     (--some (let* ((cache-key (format "%s-%s" it dir))
                    (cache-value (gethash
                                  cache-key projectile-project-root-cache)))
               (if cache-value
                   cache-value
                 (let ((value (funcall it (org-projectile:file-truename dir))))
                   (puthash cache-key value projectile-project-root-cache)
                   value)))
             projectile-project-root-files-functions))))

(defun org-projectile:project-todo-entry
    (&optional capture-character capture-template capture-heading
               &rest additional-options)
  (unless capture-template (setq capture-template
                                 org-projectile:capture-template))
  (unless capture-character (setq capture-character "p"))
  (unless capture-heading (setq capture-heading "Project Todo"))
  `(,capture-character ,capture-heading entry
                       (function
                        (lambda () (org-projectile:location-for-project
                                    (org-projectile:project-heading-from-file
                                     (org-capture-get :original-file)))))
    ,capture-template ,@additional-options))

(defun org-projectile:project-heading-from-file (filename)
  (let ((project-root (org-projectile:project-root-of-filepath filename)))
    (when project-root
      (file-name-nondirectory
       (directory-file-name project-root)))))

(defun org-projectile:get-link-description (heading)
  (with-temp-buffer
    (insert heading)
    (goto-char (point-min))
    (if (re-search-forward org-any-link-re nil t)
        (match-string-no-properties 4) heading)))

(defun org-projectile:known-projects ()
  (remove-if
   #'null
   (delete-dups
    `(,@(mapcar #'org-projectile:project-heading-from-file
                (projectile-relevant-known-projects))
      ,@(org-map-entries
         (lambda () (org-projectile:get-link-description
                     (nth 4 (org-heading-components)))) nil
                     (funcall org-projectile:todo-files)
                     (lambda ()
                       (when (< 1 (nth 1 (org-heading-components)))
                         (point))))))))

(defun org-projectile:todo-files ()
  (funcall org-projectile:todo-files))

(defun org-projectile:default-todo-files ()
  (cl-remove-if-not
   #'file-exists-p
   (delete-dups
    (cl-loop for project-name in
             (mapcar #'org-projectile:project-heading-from-file
                     (projectile-relevant-known-projects))
             collect (funcall org-projectile:project-name-to-org-file
                              project-name)))))

(defun org-projectile:project-name-to-location-alist ()
  (cl-loop for project-location in projectile-known-projects
           collect `(,(file-name-nondirectory
                       (directory-file-name project-location)) .
                       ,project-location)))

(defun org-projectile:project-location-from-name (name)
  (cdr (assoc name (org-projectile:project-name-to-location-alist))))

(defvar dired-buffers)

(defun org-projectile:capture-for-project
    (project-name &optional capture-template &rest additional-options)
  (org-capture-set-plist
   (apply #'org-projectile:project-todo-entry
          nil capture-template nil additional-options))
  ;; TODO: super gross that this had to be copied from org-capture,
  ;; Unfortunately, it does not seem to be possible to call into org-capture
  ;; because it makes assumptions that make it impossible to set things up
  ;; properly
  (let ((orig-buf (current-buffer))
        (annotation (if (and (boundp 'org-capture-link-is-already-stored)
                             org-capture-link-is-already-stored)
                        (plist-get org-store-link-plist :annotation)
                      (ignore-errors (org-store-link nil))))
        org-projectile:subheading-cleanup-marker
        org-projectile:do-target-entry)
    (org-capture-put :original-buffer orig-buf
                     :original-file (or (buffer-file-name orig-buf)
                                        (and (featurep 'dired)
                                             (car (rassq orig-buf dired-buffers))))
                     :original-file-nondirectory
                     (and (buffer-file-name orig-buf)
                          (file-name-nondirectory
                           (buffer-file-name orig-buf)))
                     :annotation annotation
                     :initial ""
                     :return-to-wconf (current-window-configuration)
                     :default-time
                     (or org-overriding-default-time
                         (org-current-time)))
    (org-capture-put :template (org-capture-fill-template capture-template))
    (org-capture-set-target-location
     `(function ,(lambda () (setq org-projectile:do-target-entry
                                  (org-projectile:location-for-project project-name)))))
    ;; Apparently this needs to be forced because (org-at-heading-p)
    ;; will not be true and so `org-capture-set-target-location` will
    ;; set this value to nil.
    ;; TODO(@IvanMalison): Perhaps there is a better way to do this?
    ;; Maybe something that would allow us to get rid of the horrible
    ;; subheading-cleanup-marker hack?
    (org-capture-put :target-entry-p org-projectile:do-target-entry)
    (when org-projectile:do-target-entry
      (setq org-projectile:subheading-cleanup-marker
            (org-projectile:target-subheading-and-return-marker)))
    (org-capture-place-template)
    (when org-projectile:subheading-cleanup-marker
      (org-projectile:cleanup-subheading
       org-projectile:subheading-cleanup-marker))))

(defun org-projectile:cleanup-subheading (marker)
  (with-current-buffer (marker-buffer marker)
    (save-excursion (goto-char (marker-position marker))
    (kill-whole-line))))

(defun org-projectile:open-project (name)
  (let* ((name-to-location (org-projectile:project-name-to-location-alist))
         (entry (assoc name name-to-location)))
    (when entry
      (projectile-switch-project-by-name (cdr entry)))))

(defun org-projectile:insert-or-goto-heading (heading)
  (goto-char (point-min))
  (unless (derived-mode-p 'org-mode)
    (error
     "Target buffer \"%s\" for file+headline should be in Org mode"
     (current-buffer)))
  (let ((linked-heading (org-projectile:linked-heading heading)))
    (if (re-search-forward
         (format org-complex-heading-regexp-format
                 (format "%s\\|%s" (regexp-quote linked-heading)
                         (regexp-quote heading)))
         nil t)
        (progn
          (goto-char (point-at-bol))
          (when (and org-projectile:force-linked
                     (looking-at
                      (format org-complex-heading-regexp-format
                              (regexp-quote heading))))
            (re-search-forward heading)
            (org-show-subtree)
            (delete-char (* (length heading) -1))
            (insert linked-heading)
            (goto-char (point-at-bol))))
      (progn
        (goto-char (point-max))
        (or (bolp) (insert "\n"))
        (let ((org-insert-heading-respect-content t))
          (org-insert-heading nil nil t))
        (insert linked-heading)
        (when org-projectile:counts-in-heading (insert " [/]"))))
    (nth 4 (org-heading-components))))

(defun org-projectile:linked-heading (heading)
  (org-make-link-string
   (format "elisp:(org-projectile:open-project \"%s\")" heading) heading))

(defun org-projectile:project-heading (heading)
  (let ((heading-text (org-projectile:insert-or-goto-heading heading)))
    (hide-subtree)
    (org-beginning-of-line)
    (org-set-property "CATEGORY" heading)
    heading-text))

(defun org-projectile:end-of-properties ()
  (let ((end-of-heading (save-excursion (outline-next-heading) (point)))
        (last-match t))
    (while last-match (setq last-match
                            (re-search-forward ":END:" end-of-heading t)))
    (point)))

(defun org-projectile:prompt-for-subheadings (&optional recursive)
  (let ((subheadings-to-point (org-projectile:get-subheadings))
        (point-at-start (save-excursion (org-back-to-heading) (point))))
    (when (> (length subheadings-to-point) 1)
      (org-projectile:prompt-for-and-move-to-subheading subheadings-to-point)
      (when recursive
        (unless (eq point-at-start (save-excursion (org-back-to-heading) (point)))
          (org-projectile:prompt-for-subheadings))))))

;; Only define the following functions if helm is installed
(when (require 'helm-source nil 'noerror)
  (defun org-projectile:prompt-for-and-move-to-subheading (subheadings-to-point)
    (cond ((eq projectile-completion-system 'helm)
           (let ((selection
                  (helm :sources (org-projectile:helm-subheadings-source
                                  subheadings-to-point))))
             (goto-char selection)))))
  (defun org-projectile:helm-subheadings-source (subheadings-to-point)
    (helm-build-sync-source "Choose a subheading:"
      :candidates subheadings-to-point))
  (defun org-projectile:helm-source (&optional capture-template)
    (helm-build-sync-source "Org Capture Options:"
      :candidates (cl-loop for project in (org-projectile:known-projects)
                           collect `(,project . ,project))
      :action `(("Do capture" .
                 ,(lambda (project)
                    (org-projectile:capture-for-project
                     project capture-template)))))))

(defun org-projectile:get-subheadings (&optional scope)
  (unless scope (setq scope 'tree))
  (org-show-subtree)
  (save-excursion
    (org-map-entries (lambda () `(,(org-get-heading) . ,(point))) nil scope
                     (lambda () (when (and (nth 2 (org-heading-components))
                                           (not (org-entry-get nil "ORG-PROJECTILE-SUBHEADINGS")))
                                  (org-end-of-subtree))))))

;;;###autoload
(defun org-projectile:toggle-subheading ()
  "Toggle subheading setting for heading at point.

When a heading is considered a subheading it will appear in
org-projectile search commands."
  (interactive)
  (let ((was-enabled (org-entry-get nil "ORG-PROJECTILE-SUBHEADINGS")))
    (if was-enabled
        (org-delete-property "ORG-PROJECTILE-SUBHEADINGS")
      (org-set-property "ORG-PROJECTILE-SUBHEADINGS" "t"))))

;;;###autoload
(defun org-projectile:template-or-project (&optional arg)
  "Select a project or org capture template and record a TODO.

If ARG is provided use `org-projectile:linked-capture-template'
as the capture template."
  (interactive "P")
  (if (require 'helm-org nil 'noerror)
      (helm :sources
	    (list (helm-source-org-capture-templates)
		  (org-projectile:helm-source
		   (if arg org-projectile:linked-capture-template nil)))
	    :candidate-number-limit 99999
	    :buffer "*helm org capture templates*")
    (user-error "%s" "This command is only available to helm users. Install helm and try again.")))

;;;###autoload
(defun org-projectile:project-todo-completing-read
    (&optional capture-template &rest additional-options)
  "Select a project using a `projectile-completing-read' and record a TODO.

If CAPTURE-TEMPLATE is provided use it as the capture template for the TODO."
  (interactive)
  (apply
   #'org-projectile:capture-for-project
   (projectile-completing-read "Record TODO for project: "
                               (org-projectile:known-projects))
   capture-template additional-options))

;;;###autoload
(defun org-projectile:capture-for-current-project
    (&optional capture-template &rest additional-options)
  "Capture a TODO for the current active projectile project.

If CAPTURE-TEMPLATE is provided use it as the capture template for the TODO."
  (interactive)
  (let ((project-name (projectile-project-name)))
    (if (projectile-project-p)
        (apply #'org-projectile:capture-for-project
               project-name capture-template additional-options)
      (error (format "%s is not a recognized projectile project." project-name)))))

(provide 'org-projectile)
;;; org-projectile.el ends here
