2559 lines
98 KiB
EmacsLisp
2559 lines
98 KiB
EmacsLisp
;;; org-x.el --- Extra Org Commands -*- lexical-binding: t; -*-
|
|
|
|
;; Copyright (C) 2019 Nathan Dwarshuis
|
|
|
|
;; Author: Nathan Dwarshuis <natedwarshuis@gmail.com>
|
|
;; Keywords: org-mode, outlines
|
|
;; Homepage: https://github.com/ndwarshuis/org-x
|
|
;; Package-Requires: ((emacs "27.2") (dash "2.18"))
|
|
;; Version: 0.0.1
|
|
|
|
;; 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:
|
|
|
|
;; Extra org-mode glue code I use to run my life. These are generally bits and
|
|
;; pieces that I deem useful enough to put in their own file separate from my
|
|
;; 'main' config to a) keep me sane b) test things and c) fork off into a
|
|
;; separate package if I think it is worthy (mostly (a)).
|
|
|
|
;;; Code:
|
|
|
|
(require 'org-ml)
|
|
(require 'dash)
|
|
(require 's)
|
|
(require 'ht)
|
|
(require 'org)
|
|
(require 'org-id)
|
|
(require 'org-x-agg)
|
|
(require 'org-x-dag)
|
|
|
|
;;; TODO KEYWORDS
|
|
|
|
(defconst org-x-kw-todo "TODO"
|
|
"Headline todo keyword for open task or project.")
|
|
|
|
(defconst org-x-kw-next "NEXT"
|
|
"Headline todo keyword for next task.")
|
|
|
|
(defconst org-x-kw-wait "WAIT"
|
|
"Headline todo keyword for task that is waiting on something.")
|
|
|
|
(defconst org-x-kw-hold "HOLD"
|
|
"Headline todo keyword for task or project that is held.")
|
|
|
|
(defconst org-x-kw-done "DONE"
|
|
"Headline todo keyword for completed task or project.")
|
|
|
|
(defconst org-x-kw-canc "CANC"
|
|
"Headline todo keyword for canceled task or project.")
|
|
|
|
(defconst org-x-done-keywords `(,org-x-kw-done ,org-x-kw-canc)
|
|
"Headline todo keywords that mark a task as 'complete'.")
|
|
|
|
(defconst org-x-meeting-keywords (cons org-x-kw-todo org-x-done-keywords)
|
|
"Allowed keywords for meetings.")
|
|
|
|
;;; TAGS
|
|
|
|
(defun org-x-prepend-char (char string)
|
|
"Return STRING with CHAR appended to the front."
|
|
(concat (char-to-string char) string))
|
|
|
|
(defconst org-x-tag-location-prefix ?@
|
|
"Prefix character denoting location context tag.")
|
|
|
|
(defconst org-x-tag-resource-prefix ?#
|
|
"Prefix character denoting resource context tag.")
|
|
|
|
(defconst org-x-tag-misc-prefix ?%
|
|
"Prefix character denoting misc tag.")
|
|
|
|
(defconst org-x-tag-category-prefix ?_
|
|
"Prefix character denoting life category tag.")
|
|
|
|
(defconst org-x-exclusive-prefixes (list org-x-tag-category-prefix
|
|
org-x-tag-location-prefix)
|
|
"Tag prefixes which denote mutually exclusive groups.")
|
|
|
|
(defconst org-x-tag-errand
|
|
(org-x-prepend-char org-x-tag-location-prefix "errand")
|
|
"Tag denoting an errand location.")
|
|
|
|
(defconst org-x-tag-home
|
|
(org-x-prepend-char org-x-tag-location-prefix "home")
|
|
"Tag denoting a home location.")
|
|
|
|
(defconst org-x-tag-work
|
|
(org-x-prepend-char org-x-tag-location-prefix "work")
|
|
"Tag denoting a work location.")
|
|
|
|
(defconst org-x-tag-travel
|
|
(org-x-prepend-char org-x-tag-location-prefix "travel")
|
|
"Tag denoting a travel location.")
|
|
|
|
(defconst org-x-tag-laptop
|
|
(org-x-prepend-char org-x-tag-resource-prefix "laptop")
|
|
"Tag denoting a laptop resource.")
|
|
|
|
(defconst org-x-tag-phone
|
|
(org-x-prepend-char org-x-tag-resource-prefix "phone")
|
|
"Tag denoting a phone resource.")
|
|
|
|
(defconst org-x-tag-deep
|
|
(org-x-prepend-char org-x-tag-misc-prefix "deep")
|
|
"Tag denoting deep work.")
|
|
|
|
(defconst org-x-tag-note
|
|
(org-x-prepend-char org-x-tag-misc-prefix "note")
|
|
"Tag denoting a note.")
|
|
|
|
(defconst org-x-tag-incubated
|
|
(org-x-prepend-char org-x-tag-misc-prefix "inc")
|
|
"Tag denoting an incubated task.")
|
|
|
|
(defconst org-x-tag-maybe
|
|
(org-x-prepend-char org-x-tag-misc-prefix "maybe")
|
|
"Tag denoting a maybe task.")
|
|
|
|
(defconst org-x-tag-subdivision
|
|
(org-x-prepend-char org-x-tag-misc-prefix "subdiv")
|
|
"Tag denoting a task awaiting subdivision.")
|
|
|
|
(defconst org-x-tag-flagged
|
|
(org-x-prepend-char org-x-tag-misc-prefix "flag")
|
|
"Tag denoting a flagged task.")
|
|
|
|
(defconst org-x-tag-meeting
|
|
(org-x-prepend-char org-x-tag-misc-prefix "meeting")
|
|
"Tag denoting a meeting.")
|
|
|
|
;; (defconst org-x-tag-environmental
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "env")
|
|
;; "Tag denoting an environmental life category.")
|
|
|
|
;; (defconst org-x-tag-financial
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "fin")
|
|
;; "Tag denoting a financial life category.")
|
|
|
|
;; (defconst org-x-tag-intellectual
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "int")
|
|
;; "Tag denoting an intellectual life category.")
|
|
|
|
;; (defconst org-x-tag-metaphysical
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "met")
|
|
;; "Tag denoting an metaphysical life category.")
|
|
|
|
;; (defconst org-x-tag-physical
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "phy")
|
|
;; "Tag denoting an physical life category.")
|
|
|
|
;; (defconst org-x-tag-professional
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "pro")
|
|
;; "Tag denoting a professional life category.")
|
|
|
|
;; (defconst org-x-tag-recreational
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "rec")
|
|
;; "Tag denoting a recreational life category.")
|
|
|
|
;; (defconst org-x-tag-social
|
|
;; (org-x-prepend-char org-x-tag-category-prefix "soc")
|
|
;; "Tag denoting a social life category.")
|
|
|
|
(defconst org-x-tag-no-agenda "NA"
|
|
"Tag denoting a headlines that shouldn't go in the agenda.")
|
|
|
|
(defconst org-x-tag-no-archive "NRXIV"
|
|
"Tag denoting a headlines that shouldn't go in the archive.")
|
|
|
|
(defconst org-x-tag-refile "REFILE"
|
|
"Tag denoting a headlines that are to be refiled.")
|
|
|
|
(defconst org-x-life-categories
|
|
(->> (list "environmental"
|
|
"financial"
|
|
"intellectual"
|
|
"metaphysical"
|
|
"physical"
|
|
"professional"
|
|
"recreational"
|
|
"social")
|
|
(--map (let* ((abbr (substring it 0 3))
|
|
(key (intern abbr))
|
|
(tag (org-x-prepend-char org-x-tag-category-prefix abbr)))
|
|
(list key :tag tag :desc it))))
|
|
"Alist of life categories.
|
|
The car of each member is a symbol representing the category, the
|
|
cdr is a plist which has entries for :tag and :desc which are the
|
|
org tag and a long name respectively for the category.")
|
|
|
|
(defun org-x-life-category-plist-get (key category-sym)
|
|
(plist-get (alist-get category-sym org-x-life-categories) key))
|
|
|
|
(defun org-x-life-category-tag (category-sym)
|
|
(org-x-life-category-plist-get :tag category-sym))
|
|
|
|
(defun org-x-life-category-desc (category-sym)
|
|
(org-x-life-category-plist-get :desc category-sym))
|
|
|
|
;;; PROPERTIES
|
|
|
|
;; all follow the nomenclature `org-x-prop-PROPNAME' (key) or
|
|
;; `org-x-prop-PROPNAME-VALNAME' (value)
|
|
|
|
(defconst org-x-prop-parent-type "PARENT_TYPE"
|
|
"Property denoting iterator/periodical headline.")
|
|
|
|
(defconst org-x-prop-parent-type-periodical "periodical"
|
|
"Property value for a periodical parent type.")
|
|
|
|
(defconst org-x-prop-parent-type-iterator "iterator"
|
|
"Property value for an iterator parent type.")
|
|
|
|
(defconst org-x-prop-time-shift "TIME_SHIFT"
|
|
"Property denoting time shift when cloning iterator/periodical headlines.")
|
|
|
|
(defconst org-x-prop-location "X-LOCATION"
|
|
"Property denoting location for meetings.")
|
|
|
|
;; TODO this is a WIP
|
|
(defconst org-x-prop-thread "THREAD"
|
|
"Property denoting an email thread to track.")
|
|
|
|
(defconst org-x-prop-routine "X-ROUTINE"
|
|
"Property denoting a routine group.")
|
|
|
|
(defconst org-x-prop-routine-morning "morning"
|
|
"Property value for morning routine.")
|
|
|
|
(defconst org-x-prop-routine-evening "evening"
|
|
"Property value for evening routine.")
|
|
|
|
(defconst org-x-prop-created "CREATED"
|
|
"Property denoting when a headline was created.")
|
|
|
|
(defconst org-x-prop-expire "X-EXPIRE"
|
|
"Property denoting when a headline will expire.")
|
|
|
|
(defconst org-x-prop-days-to-live "X-DAYS_TO_LIVE"
|
|
"Property denoting after how many days a headline will expire.")
|
|
|
|
(defconst org-x-prop-goal "X-GOAL"
|
|
"Property denoting the goal this headline fulfills.")
|
|
|
|
(defconst org-x-prop-allocate "X-ALLOCATE"
|
|
"Property the property denoting intended time allocation.")
|
|
|
|
;;; DRAWERS
|
|
|
|
(defconst org-x-drwr-agenda "AGENDA_ITEMS"
|
|
"Drawer to hold agenda items in meetings.")
|
|
|
|
(defconst org-x-drwr-action "ACTION_ITEMS"
|
|
"Drawer to hold action items in meetings.")
|
|
|
|
(defconst org-x-drwr-categories "X_CATEGORIES"
|
|
"Drawer to hold ranked categories for a quarterly plan.")
|
|
|
|
;;; PUBLIC VARS
|
|
|
|
(defconst org-x-archive-delay 30
|
|
"The number of days to wait before tasks are considered archivable.")
|
|
|
|
(defconst org-x-inert-delay-days 90
|
|
"The number of days to wait before tasks are considered inert.")
|
|
|
|
(defconst org-x-iterator-active-future-offset (* 7 24 60 60)
|
|
"Iterators must have at least one task this far in the future to be active.")
|
|
|
|
(defconst org-x-periodical-active-future-offset
|
|
org-x-iterator-active-future-offset
|
|
"Periodicals must have at least one heading this far in the future to be active.")
|
|
|
|
;; files and directories (all relative to `org-directory')
|
|
|
|
(defvar org-x-action-files nil
|
|
"List of relative paths or globs that hold actions (not incubated).")
|
|
|
|
(defvar org-x-incubator-files nil
|
|
"List of relative paths or globs that hold incubated actions.")
|
|
|
|
(defvar org-x-reference-files nil
|
|
"List of relative paths or globs that hold referenced headlines.")
|
|
|
|
(defvar org-x-capture-file nil
|
|
"Path to capture file.")
|
|
|
|
(defvar org-x-endpoint-goal-file nil
|
|
"Path to endpoint goal file.")
|
|
|
|
(defvar org-x-quarterly-plan-file nil
|
|
"Path to quarterly plan file.")
|
|
|
|
(defvar org-x-weekly-plan-file nil
|
|
"Path to weekly plan file.")
|
|
|
|
(defvar org-x-lifetime-goal-file nil
|
|
"Path to lifetime goal file.")
|
|
|
|
(defvar org-x-daily-plan-file nil
|
|
"Path to daily plan file.")
|
|
|
|
(defvar org-x-meeting-archive-file nil
|
|
"Path to meeting archive file.")
|
|
|
|
;;; INTERNAL CONSTANTS
|
|
|
|
;; TODO ;unscheduled should trump all
|
|
(defconst org-x--iter-statuscodes
|
|
'(:uninit :empt :actv :project-error :unscheduled)
|
|
"Statuscodes for iterators, arranged from high to low precedence.")
|
|
|
|
(defconst org-x--peri-statuscodes
|
|
'(:uninit :empt :actv :unscheduled)
|
|
"Statuscodes for periodicals, arranged from high to low precedence.")
|
|
|
|
(defconst org-x--project-invalid-todostates
|
|
(list org-x-kw-wait org-x-kw-next)
|
|
"Projects cannot have these todostates.")
|
|
|
|
(defconst org-x--project-skip-todostates
|
|
(list org-x-kw-hold org-x-kw-canc)
|
|
"These keywords override all contents within their subtrees.
|
|
Currently used to tell skip functions when they can hop over
|
|
entire subtrees to save time and ignore tasks")
|
|
|
|
(defconst org-x--first-active-ts-pattern
|
|
'(:first :any * (:and timestamp (:or (:type 'active) (:type 'active-range))))
|
|
"Pattern for first active timestamp to be supplied to `org-ml-match' et al.")
|
|
|
|
;; INTERNAL VARS
|
|
|
|
(defvar org-x--agenda-property-filter nil)
|
|
|
|
;; ORG-ELEMENT EXTENSIONS
|
|
|
|
;; TODO this should be in org-ml
|
|
(defun org-x-logbook-config ()
|
|
"Return the logbook config for `org-ml-headline-get-supercontents' et al."
|
|
(list :log-into-drawer org-log-into-drawer
|
|
:clock-into-drawer org-clock-into-drawer
|
|
:clock-out-notes org-log-note-clock-out))
|
|
|
|
(defun org-x-element-first-lb-entry (headline)
|
|
"Return epoch time of most recent logbook item or clock from HEADLINE."
|
|
(let* ((config (org-x-logbook-config))
|
|
(logbook (->> (org-ml-headline-get-supercontents config headline)
|
|
(org-ml-supercontents-get-logbook)))
|
|
(first-item-ut (-some->> (org-ml-logbook-get-items logbook)
|
|
(car)
|
|
(org-ml-logbook-item-get-timestamp)))
|
|
(first-clock-ut (-some->> (org-ml-logbook-get-clocks logbook)
|
|
(car)
|
|
(org-ml-get-property :value)
|
|
(org-ml-timestamp-get-end-time)
|
|
(org-ml-time-to-unixtime))))
|
|
(cond
|
|
((and first-item-ut first-clock-ut (< first-item-ut first-clock-ut))
|
|
first-clock-ut)
|
|
((and first-item-ut first-clock-ut (> first-item-ut first-clock-ut))
|
|
first-item-ut)
|
|
(first-item-ut first-item-ut)
|
|
(first-clock-ut first-clock-ut))))
|
|
|
|
(defun org-x-element-headline-add-created (epoch-time headline)
|
|
"Add the CREATED property to HEADLINE.
|
|
|
|
EPOCH-TIME is an integer/float for the created time. If nil, use
|
|
the current time."
|
|
(let ((ts (->> (or epoch-time (float-time))
|
|
(org-ml-unixtime-to-time-long)
|
|
(org-ml-build-timestamp!)
|
|
(org-ml-to-string))))
|
|
(org-ml-headline-set-node-property org-x-prop-created ts headline)))
|
|
|
|
(defmacro org-x-with-file (path &rest body)
|
|
"Open PATH and execute BODY."
|
|
(declare (indent 1))
|
|
`(with-current-buffer (find-file-noselect ,path)
|
|
(save-excursion
|
|
,@body)))
|
|
|
|
(defun org-x-parse-file-subtrees (path which)
|
|
"Return a list of headlines from file at PATH.
|
|
WHICH is passed to the one argument of `org-ml-parse-subtrees'."
|
|
(org-x-with-file path
|
|
(org-ml-parse-subtrees which)))
|
|
|
|
(defun org-x-parse-file-headlines (path which)
|
|
"Return a list of headlines from file at PATH.
|
|
WHICH is passed to the one argument of `org-ml-parse-headlines'."
|
|
(org-x-with-file path
|
|
(org-ml-parse-headlines which)))
|
|
|
|
;;; ORG FILE LOCATIONS
|
|
|
|
(defun org-x--abs-org-path (path)
|
|
"Return PATH as an absolute path string.
|
|
PATH is a assumed to be a path relative to `org-directory'.
|
|
If PATH is not relative, return nil and print a warning."
|
|
(if (f-relative-p path)
|
|
(f-canonical (f-join org-directory path))
|
|
(message "WARNING: %s is not a relative path" path)))
|
|
|
|
(defun org-x--valid-org-file-p (path)
|
|
"Return t if PATH points to a valid org file.
|
|
Valid means that it exists and ends in '.org'."
|
|
(cond
|
|
((not (f-file-p path))
|
|
(message "WARNING: %s does not exist; ignoring" path)
|
|
nil)
|
|
((not (s-matches-p ".*\\.org" path))
|
|
(message "WARNING: %s does not end with '.org'; ignoring" path)
|
|
nil)
|
|
(t
|
|
t)))
|
|
|
|
(defun org-x--expand-path-list (globs)
|
|
"Return GLOBS as expanded list of paths.
|
|
GLOBS is a list of strings to be consumed by `f-glob'. Only
|
|
expand files that end in '.org' and that exist are returned. All
|
|
members of GLOBS should be relative to `org-directory'."
|
|
(->> (-map #'org-x--abs-org-path globs)
|
|
(-non-nil)
|
|
(-mapcat #'f-glob)
|
|
(-filter #'org-x--valid-org-file-p)
|
|
(-uniq)))
|
|
|
|
(defun org-x--expand-path (path)
|
|
"Return PATH as an expanded path.
|
|
PATH must be relative to `org-directory' and end in '.org'."
|
|
(-when-let (a (org-x--abs-org-path path))
|
|
(when (org-x--valid-org-file-p a)
|
|
a)))
|
|
|
|
(defun org-x-get-endpoint-goal-file ()
|
|
"Return the absolute path of `org-x-endpoint-goal-file'."
|
|
(org-x--expand-path org-x-endpoint-goal-file))
|
|
|
|
(defun org-x-get-lifetime-goal-file ()
|
|
"Return the absolute path of `org-x-lifetime-goal-file'."
|
|
(org-x--expand-path org-x-lifetime-goal-file))
|
|
|
|
(defun org-x-get-capture-file ()
|
|
"Return the absolute path of `org-x-capture-file'."
|
|
(org-x--expand-path org-x-capture-file))
|
|
|
|
(defun org-x-get-action-files ()
|
|
"Return the absolute path of `org-x-action-files'."
|
|
(org-x--expand-path-list org-x-action-files))
|
|
|
|
(defun org-x-get-daily-plan-file ()
|
|
"Return the absolute path of `org-x-daily-plan-file'."
|
|
(org-x--expand-path org-x-daily-plan-file))
|
|
|
|
(defun org-x-get-weekly-plan-file ()
|
|
"Return the absolute path of `org-x-weekly-plan-file'."
|
|
(org-x--expand-path org-x-weekly-plan-file))
|
|
|
|
(defun org-x-qtp-get-file ()
|
|
"Return the absolute path of `org-x-quarterly-plan-file'."
|
|
(org-x--expand-path org-x-quarterly-plan-file))
|
|
|
|
(defun org-x-get-incubator-files ()
|
|
"Return the absolute path of `org-x-incubator-files'."
|
|
(org-x--expand-path-list org-x-incubator-files))
|
|
|
|
(defun org-x-get-reference-files ()
|
|
"Return the absolute path of `org-x-reference-files'."
|
|
(org-x--expand-path-list org-x-reference-files))
|
|
|
|
(defun org-x-get-action-and-incubator-files ()
|
|
"Return combined list of paths for incubator and action files."
|
|
(append (org-x-get-action-files)
|
|
(org-x-get-incubator-files)))
|
|
|
|
;;; STATEFUL BUFFER HEADLINE FUNCTIONS
|
|
|
|
;; All of these functions operate on the current headline
|
|
|
|
;; helper function
|
|
|
|
(defun org-x--forward-stars ()
|
|
"Move point forward until a star is not encountered."
|
|
(forward-char 1)
|
|
(while (= ?* (following-char))
|
|
(forward-char 1)))
|
|
|
|
(defun org-x--headline-get-level ()
|
|
"Return level of the current headline.
|
|
Assumes point is at the start of a headline."
|
|
(save-excursion
|
|
(while (= ?* (following-char)) (forward-char 1))
|
|
(current-column)))
|
|
|
|
(defmacro org-x--while-child-headlines (while-form &rest body)
|
|
"Run BODY for each child headline in the subtree under point.
|
|
Assume point is at the start of a headline. Loop through children
|
|
until WHILE-FORM evals to nil. Note that this only applies BODY
|
|
to the children one level down from the current headline."
|
|
;; Rather than using regular expressions, it is much faster and simpler to
|
|
;; walk down each line and count the number of stars to get the level.
|
|
;;
|
|
;; Algorithm steps:
|
|
;; 1. Count stars on the current headline (move point forward until first
|
|
;; non-star, and use the column number to get level) and add 1 to get
|
|
;; the "target-level" (that is the child level of the current headline)
|
|
;; 2. Move forward one line until a) `while-form' returns nil b) the current
|
|
;; level of the org-tree is less than the target-level or c) the end of
|
|
;; the buffer is reached.
|
|
;; 2.1. If point not on a star, continue looping.
|
|
;; 2.2. Otherwise, get the current level similar to (1) using the column
|
|
;; number. If the current level is equal to the target level, eval
|
|
;; `body', otherwise do nothing since point is too deep in the tree.
|
|
(declare (indent 1))
|
|
`(save-excursion
|
|
(let* ((target-level (1+ (org-x--headline-get-level)))
|
|
(cur-level target-level))
|
|
(while (and ,while-form
|
|
(<= target-level cur-level)
|
|
(= 0 (forward-line 1)))
|
|
(when (= ?* (following-char))
|
|
(org-x--forward-stars)
|
|
(when (= 32 (following-char))
|
|
(setq cur-level (current-column))
|
|
(when (= cur-level target-level)
|
|
,@body)))))))
|
|
|
|
(defun org-x--headline-has-children (test-fun)
|
|
"Return t if heading has a child for whom TEST-FUN is t.
|
|
Assume that point is at the beginning of a headline."
|
|
(let ((has-children nil))
|
|
(org-x--while-child-headlines (not has-children)
|
|
(when (funcall test-fun)
|
|
(setq has-children t)))
|
|
has-children))
|
|
|
|
(defun org-x--headline-has-parent (heading-test)
|
|
"Return t if heading has parent for whom HEADING-TEST is t."
|
|
(save-excursion (and (org-up-heading-safe) (funcall heading-test))))
|
|
|
|
;; timestamp predicates
|
|
|
|
(defun org-x--headline-get-property-epoch-time (timestamp-property)
|
|
"Return TIMESTAMP-PROPERTY of the current headline as an epoch time.
|
|
If TIMESTAMP-PROPERTY is missing, return nil. This will return 0
|
|
if a property is given that returns a string that isn't an org
|
|
timestamp."
|
|
(-some-> (org-entry-get nil timestamp-property) (org-2ft)))
|
|
|
|
(defmacro org-x--headline-compare-timestamp (ref-epoch-time future epoch-time-form)
|
|
"Compare epoch-time to some reference time.
|
|
|
|
EPOCH-TIME-FORM should return an epoch time when called on the
|
|
headline under point. Return t if epoch time is further back in
|
|
time compared to REF-EPOCH-TIME (0 is now, negative is past, and
|
|
positive is future). If the FUTURE flag is t, returns timestamp
|
|
if it is in the future compared to REF-EPOCH-TIME. Return nil if
|
|
no timestamp is found."
|
|
(declare (indent 2))
|
|
(let ((op (if future '> '<=)))
|
|
`(-when-let (epoch-time ,epoch-time-form)
|
|
(when (,op (- epoch-time (float-time)) ,ref-epoch-time)
|
|
epoch-time))))
|
|
|
|
(defun org-x-headline-is-scheduled-p ()
|
|
"Return non-nil if current headline has a scheduled timestamp.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-get-property-epoch-time "SCHEDULED"))
|
|
|
|
(defun org-x-headline-is-deadlined-p ()
|
|
"Return non-nil if current headline has a deadline timestamp.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-get-property-epoch-time "DEADLINE"))
|
|
|
|
(defun org-x-headline-is-closed-p ()
|
|
"Return non-nil if current headline has a closed timestamp.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-get-property-epoch-time "CLOSED"))
|
|
|
|
(defun org-x-headline-is-timestamped-p ()
|
|
"Return non-nil if current headline has an active timestamp.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-get-property-epoch-time "TIMESTAMP"))
|
|
|
|
(defun org-x-headline-is-created-p ()
|
|
"Return non-nil if current headline has a created timestamp.
|
|
Created timestamps are held in the `org-x-prop-created' property.
|
|
Actual returned value is the epoch time of the property."
|
|
(org-x--headline-get-property-epoch-time org-x-prop-created))
|
|
|
|
(defun org-x-headline-is-stale-p ()
|
|
"Return non-nil if current headline is stale.
|
|
'Stale' means the headline has an active timestamp in the past.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-compare-timestamp 0 nil
|
|
(-when-let (ts (org-entry-get nil "TIMESTAMP"))
|
|
(unless (s-matches-p "+[0-9]+[dwmy]" ts)
|
|
(org-2ft ts)))))
|
|
|
|
(defun org-x-headline-is-expired-date-p ()
|
|
"Return non-nil if current headline is expired.
|
|
'Expired' means the headline has an `org-x-prop-expire' property
|
|
that is in the past. Actual returned value is the epoch time of
|
|
the timestamp."
|
|
(org-x--headline-compare-timestamp 0 nil
|
|
(org-x--headline-get-property-epoch-time org-x-prop-expire)))
|
|
|
|
(defun org-x-headline-is-expired-days-to-live ()
|
|
"Return non-nil if current headline is expired.
|
|
'Expired' means the headline has passed its days to live
|
|
according to the `org-x-prop-days-to-live' and
|
|
`org-x-prop-created' properties. Actual returned value is the
|
|
epoch time of the created property plus the days to live."
|
|
(org-x--headline-compare-timestamp 0 nil
|
|
(-when-let (dtl (org-entry-get nil org-x-prop-days-to-live))
|
|
(when (s-matches-p "[0-9]+" dtl)
|
|
(-when-let (et (org-x--headline-get-property-epoch-time org-x-prop-created))
|
|
(+ et (* (string-to-number dtl) 24 60 60)))))))
|
|
|
|
(defun org-x-headline-is-expired-p ()
|
|
"Return non-nil if current headline is expired.
|
|
This will test the current headline using
|
|
`org-x-headline-is-expired-date-p' and
|
|
`org-x-headline-is-expired-days-to-live' (in that order)."
|
|
(or (org-x-headline-is-expired-days-to-live)
|
|
(org-x-headline-is-expired-date-p)))
|
|
|
|
(defun org-x-headline-is-fresh-p ()
|
|
"Return non-nil if current headline is fresh.
|
|
'Fresh' means the headline has an active timestamp in the future.
|
|
Actual returned value is the epoch time of the timestamp."
|
|
(org-x--headline-compare-timestamp 0 t
|
|
(org-x-headline-is-timestamped-p)))
|
|
|
|
(defun org-x-headline-is-archivable-p ()
|
|
"Return non-nil if current headline is fresh.
|
|
'Archivable' means the headline has been closed at least
|
|
`org-x-archive-delay' days in the past. Actual returned value is
|
|
the epoch time of the timestamp."
|
|
(org-x--headline-compare-timestamp (- (* 60 60 24 org-x-archive-delay)) nil
|
|
(org-x-headline-is-closed-p)))
|
|
|
|
(defun org-x-headline-is-created-in-future ()
|
|
"Return non-nil if current headline was 'created' in the future.
|
|
This should not happen and is an error if it does, and the
|
|
headline is tested analogously to `org-x-headline-is-created-p'
|
|
except tests if the timestamp is in the future. Actual returned
|
|
value is the epoch time of the timestamp."
|
|
(org-x--headline-compare-timestamp 0 t
|
|
(org-x-headline-is-created-p)))
|
|
|
|
(defun org-x-headline-is-inert-p ()
|
|
"Return non-nil if current headline is inert.
|
|
|
|
'Inert means that the headline has had no activity in
|
|
`org-x-inert-delay-days' in the past. Activity is assessed using
|
|
logbook entries (clocks or items), and the headline must have
|
|
been created `org-x-inert-delay-days' in the past to be inert.
|
|
Furthermore, headlines with deadline or scheduled timestamps in
|
|
the future cannot be inert.
|
|
|
|
Actual return value is the epoch time of the most recent
|
|
timestamp."
|
|
(let* ((now (float-time))
|
|
(hl (org-ml-parse-this-headline))
|
|
(most-recent-log-ut (-some->> hl (org-x-element-first-lb-entry)))
|
|
(planning (org-ml-headline-get-planning hl))
|
|
(scheduled-ut (-some->> planning
|
|
(org-ml-get-property :scheduled)
|
|
(org-ml-timestamp-get-start-time)
|
|
(org-ml-time-to-unixtime)))
|
|
(deadline-ut (-some->> planning
|
|
(org-ml-get-property :deadline)
|
|
(org-ml-timestamp-get-start-time)
|
|
(org-ml-time-to-unixtime)))
|
|
(created-ut (-some->> (org-ml-headline-get-node-property org-x-prop-created hl)
|
|
(org-2ft))))
|
|
;; not inert if headline is scheduled or deadlined in the future
|
|
(unless (or (-some->> scheduled-ut (- now) (> 0))
|
|
(-some->> deadline-ut (- now) (> 0)))
|
|
(-some--> (or most-recent-log-ut created-ut)
|
|
(- now it)
|
|
(when (> it (* 86400 org-x-inert-delay-days)) it)))))
|
|
|
|
;; keyword testing
|
|
|
|
(defmacro org-x--return-keyword-when (keyword when-form)
|
|
"Return keyword under headline if WHEN-FORM is t.
|
|
If KEYWORD is non-nil, don't look up the keyword but instead
|
|
return KEYWORD if WHEN-FORM is t."
|
|
(declare (indent 1))
|
|
(if keyword `(and ,when-form ,keyword)
|
|
`(-when-let (kw (org-x-headline-is-todoitem-p))
|
|
(and ,when-form kw))))
|
|
|
|
(defalias 'org-x-headline-is-todoitem-p 'org-get-todo-state
|
|
"Return todo keyword if current headline has one.")
|
|
|
|
(defun org-x-headline-has-discontinuous-parent ()
|
|
"Return t if heading has a non-todoitem parent which in turn has a todoitem parent."
|
|
(let ((has-todoitem-parent)
|
|
(has-non-todoitem-parent))
|
|
(save-excursion
|
|
(while (and (not has-todoitem-parent) (org-up-heading-safe))
|
|
(if (org-x-headline-is-todoitem-p)
|
|
(setq has-todoitem-parent t)
|
|
(setq has-non-todoitem-parent t))))
|
|
(and has-todoitem-parent has-non-todoitem-parent)))
|
|
|
|
(defun org-x-headline-has-task-children ()
|
|
"Return todo keyword of first task child under headline if it exists."
|
|
(org-x--headline-has-children #'org-x-headline-is-todoitem-p))
|
|
|
|
(defun org-x-headline-has-task-parent ()
|
|
"Return todo keyword of current headline's if it exists."
|
|
(org-x--headline-has-parent #'org-x-headline-is-todoitem-p))
|
|
|
|
(defmacro org-x-headline-is-project-p (&optional keyword)
|
|
"Return todo keyword if heading has todoitem children.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(org-x--return-keyword-when ,keyword
|
|
(org-x-headline-has-task-children)))
|
|
|
|
(defmacro org-x-headline-is-toplevel-project-p (&optional keyword)
|
|
"Return todo keyword if headline has task children and no task parents.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(org-x--return-keyword-when (org-x-headline-is-project-p ,keyword)
|
|
(not (org-x-headline-has-task-parent))))
|
|
|
|
(defmacro org-x-headline-is-task-p (&optional keyword)
|
|
"Return todo keyword if heading has no todoitem children.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(org-x--return-keyword-when ,keyword
|
|
(not (org-x-headline-has-task-children))))
|
|
|
|
(defmacro org-x-headline-is-project-task-p (&optional keyword)
|
|
"Return todo keyword if heading has todoitem parents.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(org-x--return-keyword-when (org-x-headline-is-task-p ,keyword)
|
|
(org-x-headline-has-task-parent)))
|
|
|
|
(defmacro org-x-headline-is-atomic-task-p (&optional keyword)
|
|
"Return todo keyword if heading has no todoitem parents or children.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(org-x--return-keyword-when (org-x-headline-is-task-p ,keyword)
|
|
(not (org-x-headline-has-task-parent))))
|
|
|
|
;; property testing
|
|
|
|
;; TODO use selective inheritence always? it might be slower
|
|
(defun org-x-headline-has-property (property value &optional inherit)
|
|
"Return t if headline under point has PROPERTY with VALUE.
|
|
INHERIT is passed to `org-entry-get'."
|
|
(equal value (org-entry-get nil property inherit)))
|
|
|
|
(defun org-x-headline-is-periodical-p ()
|
|
"Return t if heading is a periodical."
|
|
(org-x-headline-has-property org-x-prop-parent-type
|
|
org-x-prop-parent-type-periodical t))
|
|
|
|
(defun org-x-headline-is-iterator-p ()
|
|
"Return t if heading is an iterator."
|
|
(org-x-headline-has-property org-x-prop-parent-type
|
|
org-x-prop-parent-type-iterator t))
|
|
|
|
(defun org-x-headline-is-habit-p ()
|
|
"Return t if heading is an iterator."
|
|
(org-x-headline-has-property "STYLE" "habit"))
|
|
|
|
(defun org-x-headline-has-effort-p ()
|
|
"Return t if heading has an effort."
|
|
(org-entry-get nil org-effort-property))
|
|
|
|
;; tag testing
|
|
|
|
(defun org-x-headline-has-context-p ()
|
|
"Return non-nil if heading has a context tag."
|
|
(--any
|
|
(memq (elt it 0) `(,org-x-tag-resource-prefix ,org-x-tag-location-prefix))
|
|
(org-get-tags)))
|
|
|
|
(defun org-x-headline-has-tag-p (tag)
|
|
"Return t if heading has tag TAG."
|
|
(member tag (org-get-tags)))
|
|
|
|
;; compound headline testing
|
|
|
|
(defmacro org-x-headline-get-task-status (&optional keyword)
|
|
"Return the status of the headline under point.
|
|
|
|
See `org-x--return-keyword-when' for meaning of KEYWORD."
|
|
`(-when-let (kw (org-x-headline-is-task-p ,keyword))
|
|
(cond
|
|
((org-x-headline-is-archivable-p)
|
|
:archivable)
|
|
((and (not (member kw org-x-done-keywords)) (org-x-headline-is-expired-p))
|
|
:expired)
|
|
((org-x-headline-is-inert-p)
|
|
:inert)
|
|
((and (member kw org-x-done-keywords) (not (org-x-headline-is-closed-p)))
|
|
:done-unclosed)
|
|
((and (not (member kw org-x-done-keywords)) (org-x-headline-is-closed-p))
|
|
:undone-closed)
|
|
((member kw org-x-done-keywords)
|
|
:complete)
|
|
(t
|
|
:active))))
|
|
|
|
(defun org-x-headline-is-discontinous-project-task-p ()
|
|
"Return t if headline is a task with a discontinous project parent."
|
|
(org-x--return-keyword-when (org-x-headline-is-todoitem-p)
|
|
(org-x-headline-has-discontinuous-parent)))
|
|
|
|
(defun org-x-headline-is-done-unclosed-task-p ()
|
|
"Return t if headline is a done unclosed task.
|
|
'Done unclosed' means it is marked with a done keyword but is
|
|
missing a closed timestamp."
|
|
(and (member (org-get-todo-state) org-x-done-keywords)
|
|
(not (org-x-headline-is-closed-p))
|
|
t))
|
|
|
|
(defun org-x-headline-is-undone-closed-task-p ()
|
|
"Return t if headline is a undone closed task.
|
|
'Undone closed' means it is not marked with a done keyword but
|
|
has closed timestamp."
|
|
(-when-let ((keyword (org-get-todo-state)))
|
|
(and (not (member keyword org-x-done-keywords))
|
|
(org-x-headline-is-closed-p)
|
|
t)))
|
|
|
|
(defun org-x-headline-is-task-without-creation-timestamp-p ()
|
|
"Return t if headline is a task without a creation timestamp.
|
|
Creation timestamps are set using the `org-x-prop-created'
|
|
property."
|
|
(-when-let (keyword (org-x-headline-is-task-p))
|
|
(and (not (member keyword org-x-done-keywords))
|
|
(not (org-x-headline-is-created-p))
|
|
t)))
|
|
|
|
(defun org-x-headline-is-iterator-without-archive-target-p ()
|
|
"Return t if headline is an iterator without an archive target."
|
|
(and (org-x-headline-has-property org-x-prop-parent-type
|
|
org-x-prop-parent-type-iterator)
|
|
(org-x-headline-has-property "ARCHIVE" nil)
|
|
t))
|
|
|
|
(defmacro org-x-headline-is-task-with-p (&rest body)
|
|
"Return t if all of BODY is t on the current headline.
|
|
'it' is bound to the keyword (if any)."
|
|
(declare (indent 0))
|
|
`(-when-let (it (org-x-headline-is-task-p))
|
|
(and ,@body t)))
|
|
|
|
(defun org-x-headline-is-task-with-future-creation-timestamp-p ()
|
|
"Return t if current headline is undone task with missing creation timestamp."
|
|
(org-x-headline-is-task-with-p
|
|
(not (member it org-x-done-keywords))
|
|
(org-x-headline-is-created-in-future)))
|
|
|
|
(defun org-x-headline-is-meeting-p ()
|
|
"Return t if current headline is a meeting."
|
|
(org-x-headline-is-task-with-p
|
|
(member it org-x-meeting-keywords)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)))
|
|
|
|
(defun org-x-headline-is-open-unscheduled-meeting-p ()
|
|
"Return t if current headline is an unscheduled meeting."
|
|
(org-x-headline-is-task-with-p
|
|
(equal it org-x-kw-todo)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(not (org-x-headline-is-scheduled-p))))
|
|
|
|
(defun org-x-headline-is-open-meeting-p ()
|
|
"Return t if current headline is a meeting."
|
|
(org-x-headline-is-task-with-p
|
|
(equal it org-x-kw-todo)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)))
|
|
|
|
(defun org-x-headline-is-open-meeting-without-effort-p ()
|
|
"Return t if current headline is a meeting with no effort property."
|
|
(org-x-headline-is-task-with-p
|
|
(equal it org-x-kw-todo)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(not (org-entry-get nil "Effort" nil))))
|
|
|
|
(defun org-x-headline-is-open-meeting-without-location-p ()
|
|
"Return t if current headline is a meeting without a location."
|
|
(org-x-headline-is-task-with-p
|
|
(equal it org-x-kw-todo)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(not (org-entry-get nil org-x-prop-location t))))
|
|
|
|
(defun org-x-headline-is-open-meeting-with-invalid-keyword-p ()
|
|
"Return t if current headline is a meeting with invalid keywords."
|
|
(org-x-headline-is-task-with-p
|
|
(not (member it org-x-meeting-keywords))
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)))
|
|
|
|
(defun org-x-headline-is-closed-meeting-p ()
|
|
"Return t if current headline is a closed meeting."
|
|
(org-x-headline-is-task-with-p
|
|
(member it org-x-done-keywords)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)))
|
|
|
|
(defun org-x-headline-get-meeting-drawer (drawer-name)
|
|
"Return DRAWER-NAME under current headline.
|
|
If drawer is present but has no children, return 'none'. If
|
|
drawer is present and has a plain-list, return its items as a
|
|
list of nodes. If none of these conditions are true, return nil."
|
|
(-when-let (d (->> (org-ml-parse-this-headline)
|
|
(org-ml-headline-get-section)
|
|
(--find (and (org-ml-is-type 'drawer it)
|
|
(equal (org-ml-get-property :drawer-name it)
|
|
drawer-name)))))
|
|
(-if-let (n (car (org-ml-get-children d)))
|
|
(when (org-ml-is-type 'plain-list n)
|
|
(org-ml-get-children n))
|
|
'none)))
|
|
|
|
(defun org-x-headline-get-meeting-agenda-items ()
|
|
"Return the agenda items for the current headline.
|
|
See `org-x-headline-get-meeting-drawer' for rules on what is
|
|
returned."
|
|
(org-x-headline-get-meeting-drawer org-x-drwr-agenda))
|
|
|
|
(defun org-x-headline-get-meeting-action-items ()
|
|
"Return the action items for the current headline.
|
|
See `org-x-headline-get-meeting-drawer' for rules on what is
|
|
returned."
|
|
(org-x-headline-get-meeting-drawer org-x-drwr-action))
|
|
|
|
(defun org-x-headline-get-meeting-unresolved-agenda-items ()
|
|
"Return unresolved agenda items for current headline."
|
|
(let ((items (org-x-headline-get-meeting-agenda-items)))
|
|
(when (and items (not (eq 'none items)))
|
|
(--remove (eq 'on (org-ml-get-property :checkbox it)) items))))
|
|
|
|
(defun org-x-headline-is-open-meeting-without-agenda-p ()
|
|
"Return t if current headline is a meeting with no agenda."
|
|
(org-x-headline-is-task-with-p
|
|
(not (member it org-x-done-keywords))
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(not (org-x-headline-get-meeting-agenda-items))))
|
|
|
|
(defun org-x-headline-is-closed-meeting-without-action-items-p ()
|
|
"Return t if current headline is a meeting with no action items."
|
|
(org-x-headline-is-task-with-p
|
|
(member it org-x-done-keywords)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(not (org-x-headline-get-meeting-action-items))))
|
|
|
|
(defun org-x-headline-is-closed-meeting-with-unresolved-agenda-p ()
|
|
"Return t if current headline is a meeting with unresolved agenda items."
|
|
(org-x-headline-is-task-with-p
|
|
(and (member it org-x-done-keywords)
|
|
(org-x-headline-has-tag-p org-x-tag-meeting)
|
|
(org-x-headline-get-meeting-unresolved-agenda-items))))
|
|
|
|
;; (defun org-x-is-todo-child (keyword)
|
|
;; "Return t if current headline has a parent (at any level) with todo KEYWORD."
|
|
;; (let ((has-keyword-parent))
|
|
;; (save-excursion
|
|
;; (while (and (not has-keyword-parent) (org-up-heading-safe))
|
|
;; (when (equal keyword (org-x-headline-is-todoitem-p))
|
|
;; (setq has-keyword-parent t))))
|
|
;; has-keyword-parent))
|
|
|
|
;; project level testing
|
|
|
|
(defmacro org-x--compare-statuscodes (sc-list sc1 op sc2)
|
|
"Compare position of statuscodes SC1 and SC2 in SC-LIST using operator OP."
|
|
(declare (indent 1))
|
|
`(,op (cl-position ,sc1 ,sc-list) (cl-position ,sc2 ,sc-list)))
|
|
|
|
(defmacro org-x--descend-into-project (statuscode-tree get-task-status callback-form)
|
|
"Loop through (sub)project and return overall statuscode.
|
|
|
|
The returned statuscode is chosen from list ALLOWED-STATUSCODES where
|
|
later entries in the list trump earlier ones.
|
|
|
|
When a subproject is encountered, this function will obtain the
|
|
statuscode of that project and use TRANS-TBL to translate the
|
|
subproject statuscode to one in ALLOWED-STATUSCODES (if not found an
|
|
error will be raised). TRANS-TBL is given as an alist of two-member
|
|
cons cells where the first member is the subproject statuscode and the
|
|
second is the index in ALLOWED-STATUSCODES to which the subproject
|
|
statuscode will be translated.
|
|
|
|
When a task is encountered, function GET-TASK-STATUS will be applied to
|
|
obtain a statuscode-equivalent of the status of the tasks.
|
|
|
|
CALLBACK-FUN is a function to call once this is finished (which
|
|
should be this function again)."
|
|
;; define "breaker-status" as the last of the allowed-statuscodes
|
|
;; when this is encountered the loop is broken because we are done
|
|
;; (the last entry trumps all others)
|
|
(let* ((allowed-statuscodes (-map #'car statuscode-tree))
|
|
(trans-tbl (->> statuscode-tree
|
|
(--map (-let (((a . bs) it)) (--map (cons it a) bs)))
|
|
(-flatten-n 1)))
|
|
(breaker-status (-last-item allowed-statuscodes))
|
|
(initial-status (car allowed-statuscodes)))
|
|
`(save-excursion
|
|
(let ((project-status ,initial-status)
|
|
(new-status nil)
|
|
(it-kw nil))
|
|
;; loop through tasks one level down until breaker-status found
|
|
(org-x--while-child-headlines (not (eq project-status ,breaker-status))
|
|
(setq it-kw (org-get-todo-state))
|
|
(when it-kw
|
|
(if (org-x--headline-has-children #'org-x-headline-is-todoitem-p)
|
|
(progn
|
|
;; If project returns an allowed status then use that.
|
|
;; Otherwise look up the value in the translation table and
|
|
;; return error if not found.
|
|
(setq new-status ,callback-form)
|
|
(unless (member new-status ',allowed-statuscodes)
|
|
(setq new-status (alist-get new-status ',trans-tbl))))
|
|
;; if tasks then use get-task-status to obtain status
|
|
(setq new-status (nth ,get-task-status ',allowed-statuscodes)))
|
|
(when (org-x--compare-statuscodes ',allowed-statuscodes
|
|
new-status > project-status)
|
|
(setq project-status new-status))))
|
|
project-status))))
|
|
|
|
(defun org-x-headline-get-project-status (&optional kw)
|
|
"Return project heading statuscode (assumes it is indeed a project)."
|
|
;;
|
|
;; these first three are easy because they only require
|
|
;; testing the project headline and nothing underneath
|
|
;;
|
|
;; it does not make sense for projects to be scheduled
|
|
(if (org-x-headline-is-scheduled-p) :scheduled-project
|
|
(-when-let (keyword (or kw (org-get-todo-state)))
|
|
(cond
|
|
;; held projects do not care what is underneath them
|
|
;; only need to test if they are inert
|
|
((equal keyword org-x-kw-hold) (if (org-x-headline-is-inert-p) :inert :held))
|
|
|
|
;; projects with invalid todostates are nonsense
|
|
((member keyword org-x--project-invalid-todostates)
|
|
:invalid-todostate)
|
|
|
|
;; canceled projects can either be archivable or complete
|
|
;; any errors or undone tasks are irrelevant
|
|
((equal keyword org-x-kw-canc) (if (org-x-headline-is-archivable-p) :archivable
|
|
:complete))
|
|
|
|
;;
|
|
;; these require descending into the project subtasks
|
|
;;
|
|
|
|
;; done projects are like canceled projects but can also be incomplete
|
|
((equal keyword org-x-kw-done)
|
|
(org-x--descend-into-project
|
|
((:archivable)
|
|
(:complete)
|
|
(:done-incomplete :stuck :inert :held :wait :active
|
|
:scheduled-project :invalid-todostate
|
|
:undone-complete))
|
|
(if (member it-kw org-x-done-keywords)
|
|
(if (org-x-headline-is-archivable-p) 0 1)
|
|
2)
|
|
(org-x-headline-get-project-status it-kw)))
|
|
|
|
;; project with TODO states could be basically any status
|
|
((equal keyword org-x-kw-todo)
|
|
(org-x--descend-into-project
|
|
((:undone-complete :complete :archivable)
|
|
(:stuck :scheduled-project :invalid-todostate :done-incomplete)
|
|
(:held)
|
|
(:wait)
|
|
(:inert)
|
|
(:active))
|
|
(cond
|
|
((and (not (member it-kw org-x-done-keywords)) (org-x-headline-is-inert-p)) 4)
|
|
((equal it-kw org-x-kw-todo) (if (org-x-headline-is-scheduled-p) 5 1))
|
|
((equal it-kw org-x-kw-hold) 2)
|
|
((equal it-kw org-x-kw-wait) 3)
|
|
((equal it-kw org-x-kw-next) 5)
|
|
(t 0))
|
|
(org-x-headline-get-project-status it-kw)))
|
|
|
|
(t (error (concat "invalid keyword detected: " keyword)))))))
|
|
|
|
;; goals
|
|
|
|
(defvar org-x-agenda-goal-task-ids nil)
|
|
(defvar org-x-agenda-goal-endpoint-ids nil)
|
|
(defvar org-x-agenda-lifetime-ids nil)
|
|
|
|
(defun org-x-get-goal-link-id (&optional inherit)
|
|
(-when-let (g (org-entry-get nil org-x-prop-goal inherit))
|
|
(-if-let (i (org-x-link-get-id g))
|
|
i
|
|
(message "WARNING: invalid id found: %s" i))))
|
|
|
|
(defmacro org-x-with-id-target (id &rest body)
|
|
(declare (indent 1))
|
|
`(-when-let ((it-file . it-point) (org-id-find ,id))
|
|
(org-x-with-file it-file
|
|
(save-excursion
|
|
(goto-char it-point)
|
|
,@body))))
|
|
|
|
(defun org-x-goal-build-link (id)
|
|
(org-x-with-id-target id
|
|
(let ((desc (org-get-heading t t t t)))
|
|
(org-ml-build-link id :type "id" desc))))
|
|
|
|
(defun org-x-resolve-goal-id ()
|
|
(-when-let (i (org-x-get-goal-link-id t))
|
|
(org-x-with-id-target i
|
|
(cons it-file (org-ml-parse-this-headline)))))
|
|
|
|
(defun org-x-link-get-id (s)
|
|
(cadr (s-match "^\\[\\[id:\\(.*\\)\\]\\[.*\\]\\]$" s)))
|
|
|
|
(defun org-x-buffer-get-goal-ids (file)
|
|
(org-x-with-file file
|
|
(let ((acc))
|
|
(cl-flet
|
|
((get-goal
|
|
()
|
|
(-when-let (i (org-x-get-goal-link-id))
|
|
(setq acc (cons i acc)))))
|
|
;; TODO need to return nothing if a file has a toplevel prop drawer with
|
|
;; a goal in it but no TODO headlines
|
|
(goto-char (point-min))
|
|
(get-goal)
|
|
(while (outline-next-heading)
|
|
(get-goal))
|
|
acc))))
|
|
|
|
(defun org-x-get-ids-in-file (file)
|
|
(cl-flet
|
|
((full-path
|
|
(p)
|
|
(f-canonical (f-expand p))))
|
|
(let ((f (full-path file)))
|
|
(->> (ht-to-alist org-id-locations)
|
|
(--filter (equal f (full-path (cdr it))))
|
|
(-map #'car)))))
|
|
|
|
;; TODO this is necessary since this (rather unintuitively) scans the agenda
|
|
;; files, so I need to supply my own files since these are not set
|
|
(defun org-x-update-id-locations ()
|
|
(interactive)
|
|
(let ((files (append (org-x-get-action-and-incubator-files)
|
|
(org-x-get-reference-files)
|
|
(list (org-x-get-endpoint-goal-file)
|
|
(org-x-get-lifetime-goal-file)))))
|
|
(org-id-update-id-locations files)))
|
|
|
|
(defun org-x-update-goal-link-ids ()
|
|
(org-x-update-id-locations)
|
|
(setq org-x-agenda-goal-task-ids
|
|
(-mapcat #'org-x-buffer-get-goal-ids (org-files-list))
|
|
org-x-agenda-goal-endpoint-ids
|
|
(org-x-buffer-get-goal-ids (org-x-get-endpoint-goal-file))
|
|
org-x-agenda-lifetime-ids
|
|
(org-x-get-ids-in-file (org-x-get-lifetime-goal-file))))
|
|
|
|
(defun org-x-buffer-get-id-headlines (file)
|
|
(cl-flet
|
|
((is-leaf
|
|
(headline)
|
|
(and (org-ml-get-property :todo-keyword headline)
|
|
(->> (org-ml-headline-get-subheadlines headline)
|
|
(--none? (org-ml-get-property :todo-keyword it))))))
|
|
(org-x-with-file file
|
|
(->> (org-ml-parse-headlines 'all)
|
|
(-filter #'is-leaf)))))
|
|
|
|
(defun org-x-get-goal-link-property ()
|
|
"Get the goal link under current headline."
|
|
(-some->> (org-entry-get (point) org-x-prop-goal)
|
|
(s-split ";")
|
|
(--map (->> (s-trim it) (org-ml-from-string 'link)))))
|
|
|
|
(defun org-x-set-goal-link-property (ids)
|
|
"Set the goal link property of the current headline to IDS.
|
|
Assumes point is on a valid headline or org mode file."
|
|
(->> (-map #'org-ml-to-trimmed-string ids)
|
|
(s-join "; ")
|
|
(org-set-property org-x-prop-goal)))
|
|
|
|
(defmacro org-x-map-goal-link-property (form)
|
|
(declare (indent 0))
|
|
`(let ((it (org-x-get-goal-link-property)))
|
|
(org-x-set-goal-link-property ,form)))
|
|
|
|
(defun org-x-add-goal-link (id title)
|
|
"Add goal link with ID and TITLE if not under the current headline."
|
|
(org-x-map-goal-link-property
|
|
(let ((cur-ids (--map (org-ml-get-property :path it) it)))
|
|
(if (member id cur-ids) it
|
|
(-> (org-ml-build-link id :type "id" title)
|
|
(org-ml-to-trimmed-string)
|
|
(cons it))))))
|
|
|
|
(defun org-x-remove-goal-link (id)
|
|
"Remove goal link with ID if under the current headline."
|
|
(org-x-map-goal-link-property
|
|
(--remove (equal id (org-ml-get-property :path it)) it)))
|
|
|
|
(defun org-x-get-goal-entries (keep-present? cur-ids files)
|
|
(cl-flet*
|
|
((mk-entry
|
|
(cur-ids path base hl)
|
|
(let* ((title (org-ml-get-property :raw-value hl))
|
|
(id (org-ml-headline-get-node-property "ID" hl))
|
|
(is-present (and id (member id cur-ids) t)))
|
|
(list (format "%s%-10s | %s" (if is-present "*" " ") base title)
|
|
:title title
|
|
:path path
|
|
:id id
|
|
:point (org-ml-get-property :begin hl)
|
|
:is-present is-present)))
|
|
(get-headlines
|
|
(cur-ids path)
|
|
(let ((f (f-base path)))
|
|
(->> (org-x-buffer-get-id-headlines path)
|
|
(--map (mk-entry cur-ids path f it)))))
|
|
(compare-headlines
|
|
(a b)
|
|
(-let (((&plist :title ta :is-present pa) (cdr a))
|
|
((&plist :title tb :is-present pb) (cdr b)))
|
|
(or (and pa (not pb)) (and pa pb (string< ta tb))))))
|
|
(let ((col (->> (--mapcat (get-headlines cur-ids it) files)
|
|
(-sort #'compare-headlines))))
|
|
(if keep-present? col
|
|
(--filter (not (plist-get (cdr it) :is-present)) col)))))
|
|
|
|
(defun org-x-choose-goal (keep-present? cur-ids files)
|
|
(let* ((col (org-x-get-goal-entries keep-present? cur-ids files))
|
|
(res (completing-read "Goal: " col nil t)))
|
|
(alist-get res col nil nil #'equal)))
|
|
|
|
;; TODO use the current rankings by default if desired
|
|
(defun org-x-choose-category ()
|
|
(intern (completing-read "Category: " org-x-life-categories nil t)))
|
|
|
|
(defun org-x-set-goal-link ()
|
|
(interactive)
|
|
;; TODO also add a sanity check for if we are in a goals file or not
|
|
(ignore-errors
|
|
(org-back-to-heading t))
|
|
(-let* ((cur-ids (->> (org-x-get-goal-link-property)
|
|
(--map (org-ml-get-property :path it))))
|
|
(files (list (org-x-get-endpoint-goal-file)
|
|
(org-x-get-lifetime-goal-file)))
|
|
((&plist :title :path :id :point :is-present)
|
|
(org-x-choose-goal t cur-ids files)))
|
|
(if is-present
|
|
(progn
|
|
(org-x-remove-goal-link id)
|
|
(message "removed id for '%s'" title))
|
|
(let ((target-id (if id id
|
|
(org-x-with-file path
|
|
(goto-char point)
|
|
(message "ID not present. Creating.")
|
|
(org-id-get-create)))))
|
|
(org-x-add-goal-link target-id title)))))
|
|
|
|
(defun org-x-headline-get-category-tag ()
|
|
(--find (s-prefix-p "_" it) (org-get-tags)))
|
|
|
|
(defun org-x-get-category-score ()
|
|
(-when-let (c (org-x-headline-get-category-tag))
|
|
(alist-get c org-x--qtp-weighted-categories nil nil #'equal)))
|
|
|
|
(defun org-x-lifetime-goal-get-score ()
|
|
(let* ((p (aref (org-entry-get nil "PRIORITY") 0))
|
|
(priority-score (if (= org-priority-highest p) 1 -1)))
|
|
(-when-let (cat-score (org-x-get-category-score))
|
|
(* cat-score priority-score))))
|
|
|
|
(defun org-x-endpoint-goal-get-score ()
|
|
(unless org-x--qtp-weighted-categories
|
|
(error "`org-x--qtp-weighted-categories' is not set"))
|
|
(cl-flet
|
|
((get-link-score
|
|
(link)
|
|
(let ((id (org-ml-get-property :path link)))
|
|
(org-x-with-id-target id
|
|
(org-x-lifetime-goal-get-score)))))
|
|
(-some->> (org-x-get-goal-link-property)
|
|
(-map #'get-link-score)
|
|
(-sum))))
|
|
|
|
;;; QUARTERLY PLANNING (QTP)
|
|
|
|
;; qtp state
|
|
;;
|
|
;; define a data structure to hold a "quarter" (which is just a year and a digit
|
|
;; from 1-4)
|
|
|
|
(defvar org-x--current-quarter nil
|
|
"The currently selected quarter as a list like (YEAR QTR).")
|
|
|
|
(defun org-x-qtp-is-valid-quarter-p (quarter)
|
|
"Return t if QUARTER is a valid quarter data structure.
|
|
Valid means it is a list like (YEAR QUARTER) where YEAR is an
|
|
integer 1970 or greater and QUARTER is an integer 1-4."
|
|
(pcase quarter
|
|
(`(,(and (pred integerp) (pred (lambda (x) (<= 1970 x)))) ,(or 1 2 3 4)) t)
|
|
(_ nil)))
|
|
|
|
(defun org-x-qtp-validate-quarter (quarter)
|
|
"Raise error if QUARTER is invalid."
|
|
(unless (org-x-qtp-is-valid-quarter-p quarter)
|
|
(error "Invalid quarter: %s" quarter)))
|
|
|
|
(defun org-x-qtp-time-to-quarter (time)
|
|
"Return quarter for TIME.
|
|
TIME is anything consumed by `decode-time' (eg an integer for the
|
|
epoch time or a list of integers as returned by `current-time')."
|
|
(-let* (((_ _ _ _ month year) (decode-time time))
|
|
(quarter (1+ (/ (1- month) 3))))
|
|
(list year quarter)))
|
|
|
|
(defun org-x-qtp-read-current-quarter ()
|
|
"Return the current quarter."
|
|
(org-x-qtp-time-to-quarter (float-time)))
|
|
|
|
(defun org-x-qtp-set-quarter (&optional quarter)
|
|
(when quarter
|
|
(org-x-qtp-validate-quarter quarter))
|
|
(setq org-x--current-quarter (or quarter (org-x-qtp-read-current-quarter))))
|
|
|
|
;; quarter plan buffer
|
|
;;
|
|
;; ASSUME the plan buffer has the following structure
|
|
;; - level 1: year
|
|
;; - level 2: quarter (eg "Q1")
|
|
;; - level 3: categories
|
|
;; - level 4: specific goals under each category
|
|
;;
|
|
;; there is also a drawer under level 3 for holding the weighted category
|
|
;; rankings for quarter
|
|
;;
|
|
;; define a data structure that holds the category rankings/weights and the
|
|
;; goals as a plist with :categories and :goals keys
|
|
|
|
(defun org-x--qtp-headline-get-year (headline)
|
|
(let ((rt (org-ml-get-property :raw-value headline)))
|
|
(if (s-matches-p "[0-9]\\{4\\}" rt) (string-to-number rt)
|
|
(error "Invalid year headline in quarterly plan: %s" rt))))
|
|
|
|
(defun org-x--qtp-headline-get-quarter (headline)
|
|
(let ((rt (org-ml-get-property :raw-value headline)))
|
|
(-if-let ((_ qt) (s-match "Q\\([0-9]\\)" rt)) (string-to-number qt)
|
|
(error "Invalid quarter headline in quarterly plan: %s" rt))))
|
|
|
|
(defun org-x--qtp-headline-find-year (year headlines)
|
|
(--find (= year (org-x--qtp-headline-get-year it)) headlines))
|
|
|
|
(defun org-x--qtp-headline-find-quarter (quarter headlines)
|
|
(--find (= quarter (org-x--qtp-headline-get-quarter it)) headlines))
|
|
|
|
(defun org-x-qtp-drawer-to-categories (drawer)
|
|
(->> (org-ml-get-children drawer)
|
|
(org-ml-match '(plain-list item paragraph))
|
|
(--map (->> (org-ml-get-children it)
|
|
(-map #'org-ml-to-string)
|
|
(s-join "")
|
|
(s-trim)
|
|
(intern)))))
|
|
|
|
(defun org-x--qtp-from-children (children)
|
|
;; ignore properties, planning, etc
|
|
(-let* (((sec goals) (if (org-ml-is-type 'section (car children))
|
|
`(,(car children) ,(cdr children))
|
|
`(nil ,children)))
|
|
(cats (-some->> sec
|
|
(--find (org-x--is-drawer-with-name org-x-drwr-categories it))
|
|
(org-x-qtp-drawer-to-categories))))
|
|
(list :categories cats :goals goals)))
|
|
|
|
(defun org-x--qtp-to-children (qt-plan)
|
|
(-let* (((&plist :categories :goals) qt-plan)
|
|
;; TODO what happens if there are no categories?
|
|
(sec (-some->> categories
|
|
(--map-indexed (org-ml-build-item!
|
|
:bullet it-index
|
|
:paragraph (symbol-name it)))
|
|
(apply #'org-ml-build-plain-list)
|
|
(org-ml-build-drawer org-x-drwr-categories)
|
|
(org-ml-build-section))))
|
|
(if sec (cons sec goals) goals)))
|
|
|
|
(defun org-x-qtp-get (quarter)
|
|
(org-x-with-file (org-x-qtp-get-file)
|
|
(-let (((year qnum) quarter))
|
|
(->> (org-ml-parse-subtrees 'all)
|
|
(org-x--qtp-headline-find-year year)
|
|
(org-ml-headline-get-subheadlines)
|
|
(org-x--qtp-headline-find-quarter qnum)
|
|
(org-ml-get-children)
|
|
(org-x--qtp-from-children)))))
|
|
|
|
(defun org-x-qtp-set (quarter qt-plan)
|
|
(cl-flet*
|
|
((build-qt-headline
|
|
(quarter children)
|
|
(let ((title (list (format "Q%s" quarter))))
|
|
(apply #'org-ml-build-headline :title title :level 2 children)))
|
|
(build-yr-headline
|
|
(year qnum children)
|
|
(->> (build-qt-headline qnum children)
|
|
(org-ml-build-headline! :title-text (number-to-string year)))))
|
|
(org-x-with-file (org-x-qtp-get-file)
|
|
(-let* (((year qnum) quarter)
|
|
(sts (org-ml-parse-subtrees 'all))
|
|
(children (org-x--qtp-to-children qt-plan)))
|
|
(-if-let (st-yr (org-x--qtp-headline-find-year year sts))
|
|
(-if-let (st-qt (->> (org-ml-headline-get-subheadlines st-yr)
|
|
(org-x--qtp-headline-find-quarter qnum)))
|
|
(org-ml-update* (org-ml-set-children children it) st-qt)
|
|
(org-ml-update*
|
|
(-snoc it (build-qt-headline qnum children))
|
|
st-yr))
|
|
(let ((end (1+ (org-ml-get-property :end (-last-item sts)))))
|
|
(org-ml-insert end (build-yr-headline year qnum children))))))))
|
|
|
|
;; TODO some of these repeated args will be eval'd more than once
|
|
|
|
;; TODO this is basically the same pattern as what I have in org-ml (which in
|
|
;; turn is basically the same as 'deriving (Functor)' in Haskell)
|
|
|
|
(defmacro org-x-qtp-map (quarter form)
|
|
(declare (indent 1))
|
|
`(let ((it (org-x-qtp-get ,quarter)))
|
|
(org-x-qtp-set ,quarter ,form)))
|
|
|
|
(defun org-x--qtp-get-key (key quarter)
|
|
(plist-get (org-x-qtp-get quarter) key))
|
|
|
|
(defun org-x--qtp-set-key (key quarter xs)
|
|
(plist-put (org-x-qtp-get quarter) key xs))
|
|
|
|
(defun org-x-qtp-get-categories (quarter)
|
|
(org-x--qtp-get-key :categories quarter))
|
|
|
|
(defun org-x-qtp-get-goals (quarter)
|
|
(org-x--qtp-get-key :goals quarter))
|
|
|
|
(defun org-x-qtp-set-categories (quarter categories)
|
|
(org-x--qtp-set-key quarter :categories categories))
|
|
|
|
(defun org-x-qtp-set-goals (quarter goals)
|
|
(org-x--qtp-set-key quarter :goals goals))
|
|
|
|
(defmacro org-x-qtp-map-categories (quarter form)
|
|
`(let ((it (org-x-qtp-get-categories ,quarter)))
|
|
(org-x-qtp-set-categories ,quarter ,form)))
|
|
|
|
(defmacro org-x-qtp-map-goals (quarter form)
|
|
`(let ((it (org-x-qtp-get-goals ,quarter)))
|
|
(org-x-qtp-set-goals ,quarter ,form)))
|
|
|
|
(defun org-x-qtp-get-goal-category (quarter category)
|
|
(let ((title (org-x-life-category-desc category)))
|
|
(-some->> (org-x-qtp-get-goals quarter)
|
|
(--find (equal (org-ml-get-property :raw-value it) title))
|
|
(org-ml-headline-get-subheadlines))))
|
|
|
|
(defun org-x-qtp-set-goal-category (quarter category goals)
|
|
(cl-flet
|
|
((sort-goal-cats
|
|
(headlines)
|
|
(--sort (string< (org-ml-get-property :raw-value it)
|
|
(org-ml-get-property :raw-value other))
|
|
headlines)))
|
|
(let ((title (org-x-life-category-desc category)))
|
|
(org-x-qtp-map-goals quarter
|
|
(-if-let (i (--find-index
|
|
(equal (org-ml-get-property :raw-value it) title)
|
|
it))
|
|
(let ((new (org-ml-headline-set-subheadlines goals (nth i it))))
|
|
(sort-goal-cats (-replace-at i new it)))
|
|
(let ((h (apply #'org-ml-build-headline!
|
|
:level 3
|
|
:title-text title
|
|
:tags `(,(org-x-life-category-tag ,category))
|
|
goals)))
|
|
(sort-goal-cats (cons h it))))))))
|
|
|
|
(defmacro org-x-qtp-map-goal-category (quarter category form)
|
|
(declare (indent 2))
|
|
`(let ((it (org-x-qtp-get-goal-category ,quarter ,category)))
|
|
(org-x-qtp-set-goal-category ,quarter ,category ,form)))
|
|
|
|
(defun org-x-qtp-add-goal-headline (quarter category headline)
|
|
(org-x-qtp-map-goal-category quarter category (cons headline it)))
|
|
|
|
(defun org-x-qtp-build-goal-headline (ids title)
|
|
(let ((p (->> ids
|
|
(--map (org-ml-to-trimmed-string (org-x-goal-build-link it)))
|
|
(s-join ";"))))
|
|
(->> (org-ml-build-headline! :level 4
|
|
:title-text title
|
|
:todo-keyword org-x-kw-todo)
|
|
(org-ml-headline-set-node-property org-x-prop-goal p))))
|
|
|
|
;; TODO this accepts a list of ids but not sure if this is the best way to use
|
|
;; this functionality
|
|
(defun org-x-qtp-add-goal-id (quarter category ids title)
|
|
(->> (org-x-qtp-build-goal-headline ids title)
|
|
(org-x-qtp-add-goal-headline quarter category)))
|
|
|
|
(defun org-x-qt-plan-add-goal-prompt (quarter)
|
|
(-let* ((files (list (org-x-get-endpoint-goal-file)
|
|
(org-x-get-lifetime-goal-file)))
|
|
(cat (org-x-choose-category))
|
|
;; TODO get ids already present
|
|
((&plist :title :path :id :point)
|
|
(org-x-choose-goal t nil files)))
|
|
(let ((target-id (if id id
|
|
(org-x-with-file path
|
|
(goto-char point)
|
|
(message "ID not present. Creating.")
|
|
(org-id-get-create)))))
|
|
(org-x-qtp-add-goal-id quarter cat (list target-id) title))))
|
|
|
|
(defun org-x-qtp-check-categories (cats)
|
|
(seq-set-equal-p cats (-map #'car org-x-life-categories)))
|
|
|
|
(defvar org-x--qtp-weighted-categories nil
|
|
"Categories for the currently selected quarter.")
|
|
|
|
(defun org-x-qtp-set-categegories (&optional quarter)
|
|
(->> (or quarter (org-x-qtp-read-current-quarter))
|
|
(org-x-qtp-get-categories)
|
|
(setq org-x--qtp-weighted-categories)))
|
|
|
|
;; iterators
|
|
|
|
(defun org-x--clone-get-iterator-project-status (kw)
|
|
"Get the status of a project in an iterator.
|
|
KW is the keyword of the parent."
|
|
(cond
|
|
((or (org-x-headline-is-scheduled-p)
|
|
(member kw org-x--project-invalid-todostates)) :project-error)
|
|
|
|
;; canceled tasks add nothing
|
|
((equal kw org-x-kw-canc) :empt)
|
|
|
|
;;
|
|
;; these require descending into the project subtasks
|
|
;;
|
|
|
|
;; done projects either add nothing (empty) or are not actually
|
|
;; done (project error)
|
|
((equal kw org-x-kw-done)
|
|
(org-x--descend-into-project
|
|
((:empt)
|
|
(:project-error :unscheduled :actv))
|
|
(if (member it-kw org-x-done-keywords) 0 1)
|
|
(org-x--clone-get-iterator-project-status it-kw)))
|
|
|
|
;; project with TODO states could be basically any status
|
|
((equal kw org-x-kw-todo)
|
|
(org-x--descend-into-project
|
|
((:unscheduled :project-error)
|
|
(:empt)
|
|
(:actv))
|
|
(let ((ts (org-x-headline-is-scheduled-p)))
|
|
(cond
|
|
((not ts) 0)
|
|
((> org-x-iterator-active-future-offset (- ts (float-time))) 1)
|
|
(t 2)))
|
|
(org-x--clone-get-iterator-project-status it-kw)))
|
|
|
|
(t (error (concat "invalid keyword detected: " kw)))))
|
|
|
|
(defun org-x-headline-get-iterator-status ()
|
|
"Get the status of an iterator.
|
|
Allowed statuscodes are in list `nd/get-iter-statuscodes.' where
|
|
latter codes in the list trump earlier ones."
|
|
(let ((cur-status (first org-x--iter-statuscodes))
|
|
(breaker-status (-last-item org-x--iter-statuscodes))
|
|
(kw nil)
|
|
(new-status nil)
|
|
(ts nil))
|
|
(org-x--while-child-headlines (not (eq cur-status breaker-status))
|
|
(setq kw (org-x-headline-is-todoitem-p))
|
|
(when kw
|
|
;; test if project or atomic task
|
|
;; assume that there are no todoitems above this headline
|
|
;; to make checking easier
|
|
(setq
|
|
new-status
|
|
(if (org-x--headline-has-children 'org-x-headline-is-todoitem-p)
|
|
(org-x--clone-get-iterator-project-status kw)
|
|
(setq ts (or (org-x-headline-is-scheduled-p)
|
|
(org-x-headline-is-deadlined-p)))
|
|
(cond
|
|
((member kw org-x-done-keywords) :empt)
|
|
((not ts) :unscheduled)
|
|
((< org-x-iterator-active-future-offset (- ts (float-time))) :actv)
|
|
(t :empt))))
|
|
(when (org-x--compare-statuscodes org-x--iter-statuscodes
|
|
new-status > cur-status)
|
|
(setq cur-status new-status))))
|
|
cur-status))
|
|
|
|
;; periodicals
|
|
|
|
(defun org-x-headline-get-periodical-status ()
|
|
"Get the status of a periodical.
|
|
Allowed statuscodes are in list `nd/get-peri-statuscodes.' where
|
|
latter codes in the list trump earlier ones."
|
|
(cl-flet
|
|
((get-ts
|
|
()
|
|
(-some->> (org-ml-parse-this-headline)
|
|
(org-ml-headline-get-contents (org-x-logbook-config))
|
|
;; wrap in a section here because the matcher needs a single node
|
|
;; and not a list
|
|
(apply #'org-ml-build-section)
|
|
(org-ml-match org-x--first-active-ts-pattern)
|
|
(car)
|
|
(org-ml-timestamp-get-start-time)
|
|
(org-ml-time-to-unixtime)))
|
|
(new-status
|
|
(cur-status ts)
|
|
(let ((new (cond
|
|
((not ts) :unscheduled)
|
|
((< org-x-periodical-active-future-offset (- ts (float-time))) :actv)
|
|
(t :empt))))
|
|
(if (org-x--compare-statuscodes org-x--peri-statuscodes new > cur-status)
|
|
new
|
|
cur-status))))
|
|
(let ((cur-status (first org-x--peri-statuscodes))
|
|
(breaker-status (-last-item org-x--peri-statuscodes)))
|
|
(org-x--while-child-headlines (not (eq cur-status breaker-status))
|
|
(setq cur-status (->> (get-ts) (new-status cur-status))))
|
|
cur-status)))
|
|
|
|
;;; SKIP FUNCTIONS
|
|
|
|
;; fundumental skip functions
|
|
|
|
(defun org-x-skip-heading ()
|
|
"Skip forward to next heading."
|
|
(save-excursion (or (outline-next-heading) (point-max))))
|
|
|
|
(defun org-x-skip-subtree ()
|
|
"Skip forward to next subtree."
|
|
(save-excursion (or (org-end-of-subtree t) (point-max))))
|
|
|
|
(defun org-x-skip-children ()
|
|
"Skip to the end of all subheadings on the current subheading level.
|
|
This implies that the current heading has a parent. If it doesn't, this
|
|
function will simply return the point of the next headline."
|
|
(save-excursion
|
|
(if (org-up-heading-safe)
|
|
(org-x-skip-subtree)
|
|
(org-x-skip-heading))))
|
|
|
|
(defun org-x-skip-headings-with-tags (pos-tags-list &optional neg-tags-list)
|
|
"Skip headings that have tags in POS-TAGS-LIST and not in NEG-TAGS-LIST."
|
|
(org-with-wide-buffer
|
|
(-when-let (heading-tags (org-get-tags))
|
|
(when (and (or (not pos-tags-list)
|
|
(-intersection pos-tags-list heading-tags))
|
|
(not (-intersection neg-tags-list heading-tags)))
|
|
(org-x-skip-heading)))))
|
|
|
|
;; high-level skip functions (used in org-agenda)
|
|
|
|
(defun org-x-calendar-skip-function ()
|
|
"Skip function for calendar view."
|
|
(org-x-skip-headings-with-tags
|
|
(list org-x-tag-no-agenda org-x-tag-maybe org-x-tag-refile)))
|
|
|
|
(defun org-x-goal-skip-function ()
|
|
"Skip function for goals view.
|
|
This is similar to the task skip function (only show TODO leaf
|
|
nodes of the outline)."
|
|
(org-with-wide-buffer
|
|
(let ((keyword (org-get-todo-state)))
|
|
(when (org-x-headline-is-project-p keyword)
|
|
(org-x-skip-heading)))))
|
|
|
|
(defun org-x-task-skip-function ()
|
|
"Skip function for task view."
|
|
(org-with-wide-buffer
|
|
(let ((keyword (org-get-todo-state)))
|
|
;; currently we assume that periodicals have no TODOs
|
|
(cond
|
|
;; skip over held/canc projects
|
|
((and (member keyword org-x--project-skip-todostates)
|
|
(org-x-headline-is-project-p keyword))
|
|
(org-x-skip-subtree))
|
|
;; skip iterators
|
|
((org-x-headline-is-iterator-p)
|
|
(org-x-skip-heading))
|
|
;; skip project headings
|
|
((org-x-headline-is-project-p keyword)
|
|
(org-x-skip-heading))
|
|
;; skip canceled tasks
|
|
((and (equal keyword org-x-kw-canc) (org-x-headline-is-task-p keyword))
|
|
(org-x-skip-heading))
|
|
;; skip habits
|
|
((org-x-headline-is-habit-p)
|
|
(org-x-skip-heading))))))
|
|
|
|
(defun org-x-project-skip-function ()
|
|
"Skip function for project view."
|
|
(org-with-wide-buffer
|
|
(cond
|
|
((or (org-x-headline-is-iterator-p) (org-x-headline-is-periodical-p))
|
|
(org-x-skip-subtree))
|
|
((not (org-x-headline-is-project-p))
|
|
(org-x-skip-heading))
|
|
((org-x--headline-has-parent
|
|
(lambda ()
|
|
(member (org-get-todo-state) org-x--project-skip-todostates)))
|
|
(org-x-skip-children)))))
|
|
|
|
(defun org-x-incubator-skip-function ()
|
|
"Skip function for incubator view."
|
|
(org-with-wide-buffer
|
|
(let ((keyword (org-get-todo-state)))
|
|
(cond
|
|
;; skip done/canc projects
|
|
((and (member keyword org-done-keywords) (org-x-headline-is-project-p keyword))
|
|
(org-x-skip-subtree))
|
|
;; skip project tasks
|
|
((org-x-headline-is-project-task-p keyword)
|
|
(org-x-skip-heading))
|
|
;; skip done/canc tasks
|
|
((member keyword org-done-keywords)
|
|
(org-x-skip-heading))
|
|
;; skip non-tasks if they don't have a timestamp
|
|
((not (or keyword (org-x-headline-is-timestamped-p)))
|
|
(org-x-skip-heading))))))
|
|
|
|
(defun org-x-periodical-skip-function ()
|
|
"Skip function for periodical view."
|
|
(org-with-wide-buffer
|
|
(cond
|
|
((not (org-x-headline-is-periodical-p))
|
|
(org-x-skip-heading))
|
|
((org-x--headline-has-parent #'org-x-headline-is-periodical-p)
|
|
(org-x-skip-children)))))
|
|
|
|
(defun org-x-iterator-skip-function ()
|
|
"Skip function for iterator view."
|
|
(org-with-wide-buffer
|
|
(cond
|
|
((not (org-x-headline-is-iterator-p))
|
|
(org-x-skip-heading))
|
|
((org-x--headline-has-parent #'org-x-headline-is-iterator-p)
|
|
(org-x-skip-children)))))
|
|
|
|
(defun org-x-error-skip-function ()
|
|
"Skip function for critical error view."
|
|
(org-with-wide-buffer
|
|
(cond
|
|
((org-x-headline-is-habit-p)
|
|
(org-x-skip-heading))
|
|
((org-x-headline-is-periodical-p)
|
|
(org-x-skip-subtree)))))
|
|
|
|
(defun org-x-archive-skip-function ()
|
|
"Skip function for archive view."
|
|
(org-with-wide-buffer
|
|
(let ((keyword (org-get-todo-state)))
|
|
(cond
|
|
;; skip all non-archivable projects
|
|
((and (org-x-headline-is-project-p keyword)
|
|
(not (eq :archivable (org-x-headline-get-project-status))))
|
|
(org-x-skip-subtree))
|
|
;; skip all incubator tasks
|
|
((org-x-headline-has-tag-p org-x-tag-incubated)
|
|
(org-x-skip-heading))
|
|
;; skip all project tasks
|
|
((and (org-x-headline-is-project-task-p keyword))
|
|
(org-x-skip-heading))
|
|
;; skip all tasks not marked done or archivable
|
|
((and (org-x-headline-is-task-p keyword)
|
|
(not (eq :archivable (org-x-headline-get-task-status))))
|
|
(org-x-skip-heading))
|
|
;; skip all non-todoitems that are not stale
|
|
((and (not keyword) (not (org-x-headline-is-stale-p)))
|
|
(org-x-skip-heading))))))
|
|
|
|
;;; INTERACTIVE BUFFER FUNCTIONS
|
|
|
|
;; meeting
|
|
|
|
(defun org-x--is-drawer-with-name (name node)
|
|
"Return t is NODE is a drawer named NAME."
|
|
(and (org-ml-is-type 'drawer node)
|
|
(equal (org-ml-get-property :drawer-name node) name)))
|
|
|
|
;; TODO try to make agenda come before action
|
|
(defun org-x--headline-meeting-add-link (dname checkbox name)
|
|
"Add a linked headline to drawer with DNAME under the current headline.
|
|
Only ID links are considered. Headline must be a meeting (tagged
|
|
with proper todo keywords). If CHECKBOX is non-nil, add item with
|
|
an empty checkbox. NAME is used as an extra identifier in the
|
|
minibuffer."
|
|
(if (not (org-x-headline-is-meeting-p))
|
|
(message "Not in a meeting headline")
|
|
(-if-let (id-alist (->> org-stored-links
|
|
(--map (cons (car it) (nth 1 it)))
|
|
(--filter (s-prefix-p "id:" (car it)))
|
|
(--map (cons (format "%s: %s" (cdr it) (car it))
|
|
(list (car it) (cdr it))))))
|
|
;; ASSUME this will never return nil due to required read
|
|
(-let* (((id desc) (-> (format "%s Link: " name)
|
|
(completing-read id-alist nil t)
|
|
(alist-get id-alist nil nil #'equal)))
|
|
(item* (->> (org-ml-build-link id desc)
|
|
(org-ml-build-paragraph)
|
|
(org-ml-build-item :checkbox (when checkbox 'off)))))
|
|
(org-ml-update-this-headline*
|
|
(org-ml-headline-map-contents* (org-x-logbook-config)
|
|
(-if-let (i (--find-index (org-x--is-drawer-with-name dname it) it))
|
|
(let ((drawer* (org-ml-map-children*
|
|
(-let* (((all &as x . xs) it))
|
|
(if (org-ml-is-type 'plain-list x)
|
|
(cons (org-ml-map-children*
|
|
(-snoc it item*)
|
|
x)
|
|
xs)
|
|
all))
|
|
(nth i it))))
|
|
(-replace-at i drawer* it))
|
|
(let ((drawer* (->> (org-ml-build-plain-list item*)
|
|
(org-ml-build-drawer dname))))
|
|
(cons drawer* it)))
|
|
it))
|
|
(setq org-stored-links (delq (assoc id org-stored-links)
|
|
org-stored-links)))
|
|
(message "No stored IDs to insert"))))
|
|
|
|
(defun org-x-headline-meeting-add-agenda-item ()
|
|
"Add a link to headline in agenda items for current headline."
|
|
(interactive)
|
|
(org-x--headline-meeting-add-link org-x-drwr-agenda t "Agenda Item"))
|
|
|
|
(defun org-x-headline-meeting-add-action-item ()
|
|
"Add a link to headline in action items for current headline."
|
|
(interactive)
|
|
(org-x--headline-meeting-add-link org-x-drwr-action nil "Action Item"))
|
|
|
|
(defun org-x-id-store-link (arg &optional interactive)
|
|
"Make and ID for the current headline and store it in the org link ring.
|
|
ARG and INTERACTIVE are passed to `org-store-link'."
|
|
(interactive "P\np")
|
|
(org-id-store-link)
|
|
(org-store-link arg interactive))
|
|
|
|
;; metablox
|
|
|
|
(defun org-x-metablock-get-timestamp (node)
|
|
"Return the first timestamp of NODE or nil if not found."
|
|
(car (org-ml-match '(:first section paragraph timestamp) node)))
|
|
|
|
(defun org-x-get-future-metablox ()
|
|
"Return a list of headline nodes representing metablocks in the future."
|
|
(cl-flet
|
|
((is-future
|
|
(node)
|
|
(-some->> (org-x-metablock-get-timestamp node)
|
|
(org-ml-timestamp-get-start-time)
|
|
(org-ml-time-to-unixtime)
|
|
(< (float-time)))))
|
|
(->> (org-x-parse-file-headlines (org-x-get-daily-plan-file) 'all)
|
|
(--filter (null (org-ml-headline-get-subheadlines it)))
|
|
(-filter #'is-future)
|
|
(-map #'org-ml-remove-parents))))
|
|
|
|
;; TODO mark lines in the completion buffer that already have a link with the
|
|
;; ID we are inserting
|
|
(defun org-x-id-store-link-metablock ()
|
|
"Make and ID for the current headline and store it in the org link ring.
|
|
ARG and INTERACTIVE are passed to `org-store-link'."
|
|
(interactive)
|
|
(cl-flet
|
|
((to-menu-line
|
|
(node)
|
|
(let ((ts (->> (org-x-metablock-get-timestamp node)
|
|
(org-ml-get-property :raw-value)))
|
|
(title (org-ml-get-property :raw-value node)))
|
|
(format "%s | %s" ts title))))
|
|
(-if-let (hls (org-x-get-future-metablox))
|
|
(-if-let (desc (-some->> (org-ml-parse-this-headline)
|
|
(org-ml-get-property :raw-value)))
|
|
(-if-let (path (org-id-store-link))
|
|
(let* ((lines (-map #'to-menu-line hls))
|
|
(col (-zip-pair lines hls))
|
|
(sel (completing-read "Metablock: " col nil t))
|
|
(target (alist-get sel col nil nil #'equal))
|
|
(link (org-ml-build-link path desc))
|
|
;; ASSUME there will be one paragraph at the end holding
|
|
;; the timestamp
|
|
(para (car (org-ml-match '(:last section paragraph) target))))
|
|
(org-x-with-file (org-x-get-daily-plan-file)
|
|
(org-ml~update* nil
|
|
(org-ml-map-children* (-snoc it link) it)
|
|
para))
|
|
(message "Successfully added '%s' to block '%s'" desc sel))
|
|
(message "Could not get link to store"))
|
|
(message "Could not get link description (not on headline?)"))
|
|
(message "No metablocks available"))))
|
|
|
|
;; meeting agenda
|
|
|
|
(defun org-x--get-meetings-from-buffer ()
|
|
"Return meeting agenda items from the current buffer."
|
|
(cl-labels
|
|
((has-meeting-tag
|
|
(headline)
|
|
(org-ml-headline-has-tag org-x-tag-meeting headline))
|
|
(has-parent-meeting
|
|
(headline)
|
|
(-when-let (p (org-ml-get-property :parent headline))
|
|
(or (has-meeting-tag p) (has-parent-meeting p))))
|
|
(is-task
|
|
(headline)
|
|
(when (org-ml-get-property :todo-keyword headline)
|
|
(->> (org-ml-headline-get-subheadlines headline)
|
|
(--any (org-ml-get-property :todo-keyword it))
|
|
(not))))
|
|
(is-meeting
|
|
(headline)
|
|
(and (is-task headline)
|
|
(or (has-meeting-tag headline)
|
|
(has-parent-meeting headline)))))
|
|
(->> (org-ml-parse-headlines 'all)
|
|
(-filter #'is-meeting))))
|
|
|
|
(defun org-x--make-agenda-metaitem (headline is-closed ts item)
|
|
(list :meeting-closed-p is-closed
|
|
:meeting-timestamp ts
|
|
:meeting-node (org-ml-remove-parents headline)
|
|
:item-desc (org-ml-item-get-paragraph item)
|
|
:item-closed (eq 'on (org-ml-get-property :checkbox item))))
|
|
|
|
(defun org-x--meeting-get-agenda-items (headline)
|
|
"Return agenda items for HEADLINE."
|
|
(-let ((first (->> (org-ml-headline-get-contents (org-x-logbook-config) headline)
|
|
(--find (org-x--is-drawer-with-name org-x-drwr-agenda it))
|
|
(org-ml-get-children)
|
|
(car)))
|
|
(is-closed (and (member (org-ml-get-property :todo-keyword headline)
|
|
org-x-done-keywords)
|
|
t))
|
|
(ts (-some->> (org-ml-headline-get-planning headline)
|
|
(org-ml-get-property :scheduled)
|
|
(org-ml-timestamp-get-start-time)
|
|
(org-ml-time-to-unixtime))))
|
|
(when (org-ml-is-type 'plain-list first)
|
|
(->> (org-ml-get-children first)
|
|
(--map (org-x--make-agenda-metaitem headline is-closed ts it))))))
|
|
|
|
(defun org-x--metaitem-get-link-target (mi)
|
|
(-let (((&plist :item-desc) mi))
|
|
(-some->> (--find (org-ml-is-type 'link it) item-desc)
|
|
(org-ml-get-property :path))))
|
|
|
|
(defun org-x--group-agenda-metaitems-by-link-target (mis)
|
|
(->> (-group-by #'org-x--metaitem-get-link-target mis)
|
|
(--remove (not (car it)))))
|
|
|
|
(defun org-x--metaitem-is-open (mi)
|
|
(not (plist-get mi :item-closed)))
|
|
|
|
(defun org-x--metaitems-are-unresolved (grouped-mis)
|
|
(-let* ((now (float-time))
|
|
((target . mis) grouped-mis)
|
|
((past future) (--separate
|
|
(< (plist-get it :meeting-timestamp) now)
|
|
mis)))
|
|
(-when-let (most-recent (-last-item past))
|
|
(and (org-x--metaitem-is-open most-recent)
|
|
(-none? #'org-x--metaitem-is-open future)
|
|
(list :item-target target
|
|
:item-headline (plist-get most-recent :meeting-node))))))
|
|
|
|
(defun org-x--metaitems-get-unresolved-link-targets (mis)
|
|
(->> (org-x--group-agenda-metaitems-by-link-target mis)
|
|
(-map #'org-x--metaitems-are-unresolved)
|
|
(-non-nil)))
|
|
|
|
(defun org-x--id-parse-headline (id)
|
|
"Return the headline node for ID."
|
|
(save-excursion
|
|
(-let (((file . offset) (org-id-find id)))
|
|
(with-current-buffer (find-file-noselect file)
|
|
(goto-char offset)
|
|
(org-ml-parse-this-headline)))))
|
|
|
|
(defun org-x--group-unresolved-links (ls)
|
|
"Return links and headlines plist LS grouped by headline offset."
|
|
(->> (--group-by (plist-get it :item-headline) ls)
|
|
(--map
|
|
(-let (((key . rest) it))
|
|
(->> rest
|
|
(--map (org-x--id-parse-headline (plist-get it :item-target)))
|
|
(cons key))))))
|
|
|
|
;; timestamp shifting
|
|
|
|
(defun org-x--read-number-from-minibuffer (prompt &optional return-str)
|
|
"Read a number from the minibuffer using PROMPT.
|
|
If RETURN-STR is t, return the string and not the number."
|
|
(let ((out (read-string (format "%s: " prompt))))
|
|
(if (s-matches-p "[0-9]+" out)
|
|
(if return-str out (string-to-number out))
|
|
(error "Not a valid number: %s" out))))
|
|
|
|
(defun org-x--read-shift-from-minibuffer (&optional default)
|
|
"Read a timestamp shift from the minibuffer.
|
|
|
|
If DEFAULT is a string, process this instead of reading a string
|
|
from the minubuffer.
|
|
|
|
Valid shifts are like +/-(DIGIT)(UNIT) (eg like '+1w') similar to
|
|
`org-clone-subtree-with-time-shift'. If invalid throw an error.
|
|
Else return a list like (OFFSET UNIT) where OFFSET is the numeric
|
|
value of the shift (negative goes back in time) and UNIT is the
|
|
unit of the shift. These are later consumed by
|
|
`org-ml-timestamp-shift'"
|
|
(let* ((out (or default (read-from-minibuffer "Date shift (e.g. +1w): ")))
|
|
(match (s-match "\\`[ \t]*\\([\\+\\-]?[0-9]+\\)\\([MHdwmy]\\)[ \t]*\\'" out)))
|
|
(if (not match) (error "Invalid shift: %s" out)
|
|
(-let* (((_ mag unit) match)
|
|
((mult unit*) (pcase unit
|
|
("M" '(1 minute))
|
|
("H" '(1 hour))
|
|
("d" '(1 day))
|
|
("w" '(7 day))
|
|
("m" '(1 month))
|
|
("y" '(1 year))
|
|
(_ (error "Unsupported time unit")))))
|
|
(list (* mult (string-to-number mag)) unit*)))))
|
|
|
|
(defun org-x--reset-subtree (headline)
|
|
"Reset HEADLINE node to incomplete state.
|
|
This includes unchecking all checkboxes, marking keywords as
|
|
\"TODO\", clearing any unique IDs, etc."
|
|
(cl-labels
|
|
((reset
|
|
(config created-ts headline)
|
|
;; set keyword to TODO
|
|
(->> (org-ml-map-property* :todo-keyword
|
|
(if (member it org-x-done-keywords) "TODO" it)
|
|
headline)
|
|
;; remove logbook items and clocks
|
|
(org-ml-headline-map-supercontents* config
|
|
(-some->> it (org-ml-supercontents-set-logbook nil)))
|
|
(org-ml-headline-set-node-property org-x-prop-created created-ts)
|
|
;; remove agenda/action items (don't bother checking if a meeting)
|
|
(org-ml-headline-map-contents* (org-x-logbook-config)
|
|
(-some->> it
|
|
(--remove (org-x--is-drawer-with-name org-x-drwr-action it))
|
|
(--remove (org-x--is-drawer-with-name org-x-drwr-agenda it))))
|
|
;; remove CLOSED planning entry
|
|
(org-ml-headline-map-planning*
|
|
(-some->> it (org-ml-planning-set-timestamp! :closed nil)))
|
|
;; clear item checkboxes
|
|
(org-ml-match-map* '(section :any * item)
|
|
(org-ml-set-property :checkbox 'off it))
|
|
;; update stats cookie; this obviously will be wrong if I ever want to
|
|
;; use TODO statistics but at least they will be reset to zero
|
|
(org-ml-headline-update-item-statistics)
|
|
;; rinse and repeat for subheadlines
|
|
(org-ml-headline-map-subheadlines*
|
|
(--map (reset config created-ts it) it)))))
|
|
(let ((created-ts (-> (float-time)
|
|
(org-ml-unixtime-to-time-long)
|
|
(org-ml-build-timestamp!)
|
|
(org-ml-to-string))))
|
|
(reset (org-x-logbook-config) created-ts headline))))
|
|
|
|
(defun org-x--subtree-shift-timestamps (offset unit subtree)
|
|
"Return SUBTREE with timestamps shifted OFFSET UNITs.
|
|
In the case of task headlines, only scheduled/deadlined
|
|
timestamps will be shifted. Otherwise only the first active
|
|
timestamp in the contents of the headline will be shifted."
|
|
(cl-labels
|
|
((shift-timestamps
|
|
(offset unit subtree)
|
|
(let ((kw (org-ml-get-property :todo-keyword subtree)))
|
|
(cond
|
|
((null kw)
|
|
(org-ml-headline-map-contents* (org-x-logbook-config)
|
|
;; wrap in a section here because the matcher needs a single node
|
|
;; and not a list
|
|
(->> (apply #'org-ml-build-section it)
|
|
(org-ml-match-map* org-x--first-active-ts-pattern
|
|
(org-ml-timestamp-shift offset unit it))
|
|
(org-ml-get-children))
|
|
subtree))
|
|
((member kw org-x-done-keywords)
|
|
subtree)
|
|
(t
|
|
(org-ml-headline-map-planning*
|
|
(-some->> it
|
|
(org-ml-map-property* :scheduled
|
|
(when it (org-ml-timestamp-shift offset unit it)))
|
|
(org-ml-map-property* :deadline
|
|
(when it (org-ml-timestamp-shift offset unit it))))
|
|
subtree)))))
|
|
(shift
|
|
(offset unit subtree)
|
|
(->> (shift-timestamps offset unit subtree)
|
|
(org-ml-headline-map-subheadlines*
|
|
(--map (shift offset unit it) it)))))
|
|
(shift offset unit subtree)))
|
|
|
|
(defun org-x--subtree-repeat-shifted (n offset unit headline)
|
|
"Return HEADLINE repeated and shifted by OFFSET UNITs N times."
|
|
(cl-labels
|
|
((assign-id
|
|
(hl)
|
|
(->> (org-ml-headline-set-node-property "ID" (org-id-new) hl)
|
|
(org-ml-headline-map-subheadlines*
|
|
(--map-when (org-ml-get-property :todo-keyword it)
|
|
(assign-id it)
|
|
it)))))
|
|
(->> (org-ml-clone-node-n n headline)
|
|
(--map-indexed (org-x--subtree-shift-timestamps
|
|
(* offset (1+ it-index)) unit it))
|
|
(-map #'assign-id))))
|
|
|
|
(defun org-x-clone-subtree-with-time-shift (n)
|
|
"Like `org-clone-subtree-with-time-shift' except reset items and todos.
|
|
N is the number of clones to produce."
|
|
(interactive "nNumber of clones to produce: ")
|
|
(-let* ((subtree (org-ml-parse-this-subtree))
|
|
((offset unit) (-> (org-entry-get nil org-x-prop-time-shift 'selective)
|
|
(org-x--read-shift-from-minibuffer)))
|
|
(ins (->> (org-x--reset-subtree subtree)
|
|
(org-x--subtree-repeat-shifted n offset unit)
|
|
(-map #'org-ml-to-string)
|
|
(s-join "")))
|
|
(end (org-ml-get-property :end subtree)))
|
|
(org-ml-insert end ins)))
|
|
|
|
(defun org-x-clone-subtree-with-time-shift-toplevel (n)
|
|
"Like `org-clone-subtree-with-time-shift' except reset items and todos.
|
|
N is the number of clones to produce."
|
|
(interactive "nNumber of clones to produce: ")
|
|
(-let (((offset unit) (-> (org-entry-get nil org-x-prop-time-shift 'selective)
|
|
(org-x--read-shift-from-minibuffer))))
|
|
(org-ml-update-this-subtree*
|
|
(let ((new (->> (org-ml-headline-get-subheadlines it)
|
|
(-last-item)
|
|
(org-x--reset-subtree))))
|
|
(org-ml-map-children*
|
|
(append it (org-x--subtree-repeat-shifted n offset unit new))
|
|
it)))))
|
|
|
|
(defun org-x-subtree-shift-timestamps ()
|
|
"Shift all timestamps in the current subtree.
|
|
Only deadline/scheduled timestamp are shifted (tasks) or the
|
|
first active timestamp in the contents (non-tasks)."
|
|
(interactive)
|
|
(-let (((offset unit) (org-x--read-shift-from-minibuffer)))
|
|
(org-ml-update-this-subtree*
|
|
(org-x--subtree-shift-timestamps offset unit it))))
|
|
|
|
;; marking subtrees
|
|
|
|
;; put this in terms of org-ml
|
|
(defun org-x-mark-subtree-keyword (new-keyword &optional exclude no-log)
|
|
"Change the todo keyword of all tasks in a subtree to NEW-KEYWORD.
|
|
If EXCLUDE is given, it should be a list of todo keywords; any headline
|
|
matching a keyword in this list will not be changed. If NO-LOG is t,
|
|
don't log changes in the logbook."
|
|
(let ((subtree-end (save-excursion (org-end-of-subtree t)))
|
|
(org-todo-log-states (unless no-log org-todo-log-states)))
|
|
(if (not (listp exclude))
|
|
(error "Exclude must be a list if provided"))
|
|
(save-excursion
|
|
(while (< (point) subtree-end)
|
|
(let ((keyword (org-x-headline-is-todoitem-p)))
|
|
(if (and keyword (not (member keyword exclude)))
|
|
(org-todo new-keyword)))
|
|
(outline-next-heading)))))
|
|
|
|
(defun org-x-mark-subtree-done ()
|
|
"Mark all tasks in subtree as DONE unless they are already CANC."
|
|
(interactive)
|
|
(org-x-mark-subtree-keyword org-x-kw-done `(,org-x-kw-canc)))
|
|
|
|
;; logbook
|
|
|
|
(defun org-x-log-delete ()
|
|
"Delete logbook drawer of subtree."
|
|
(interactive)
|
|
(let ((config (org-x-logbook-config)))
|
|
(org-ml-update-this-headline*
|
|
(->> (org-ml-headline-set-logbook-clocks config nil it)
|
|
(org-ml-headline-set-logbook-items config nil)))))
|
|
|
|
(defun org-x-clock-range (&optional arg)
|
|
"Add a completed clock entry to the current heading.
|
|
Does not touch the running clock. When called with one prefix
|
|
ARG, ask for a range in minutes in place of the second date."
|
|
(interactive "P")
|
|
(cl-flet
|
|
((read-date
|
|
(default-time)
|
|
(round (float-time (org-read-date t t nil nil default-time))))
|
|
(read-duration
|
|
(start-epoch)
|
|
(->> (org-x--read-number-from-minibuffer "Length in minutes")
|
|
(* 60)
|
|
(+ start-epoch))))
|
|
(let* ((t1 (read-date nil))
|
|
(t2 (if (equal arg '(4)) (read-duration t1) (read-date t1))))
|
|
(if (< t2 t1) (message "Second timestamp earlier than first!")
|
|
(let ((s (org-ml-unixtime-to-time-long t1))
|
|
(e (org-ml-unixtime-to-time-long t2)))
|
|
(org-ml-update-this-headline*
|
|
(org-ml-headline-map-logbook-clocks* (org-x-logbook-config)
|
|
(let ((new-clock (org-ml-build-clock! s :end e)))
|
|
(if (org-ml-clock-is-running (car it))
|
|
`(,(car it) ,new-clock ,@(cdr it))
|
|
(cons new-clock it)))
|
|
it)))))))
|
|
|
|
(defun org-x--headline-add-archive-context (afile apath acat atags headline)
|
|
"Add archive context properties to HEADLINE.
|
|
AFILE is the source file. APATH is the headline path in the
|
|
original buffer. ACAT is the category. ATAGS is a list of tags,
|
|
including those that are inherited."
|
|
(let ((atodo (org-ml-get-property :todo-keyword headline))
|
|
(atime (-> (substring (cdr org-time-stamp-formats) 1 -1)
|
|
(format-time-string))))
|
|
(->> (org-ml-clone-node headline)
|
|
(org-ml-headline-set-node-property "ARCHIVE_TIME" atime)
|
|
(org-ml-headline-set-node-property "ARCHIVE_FILE" afile)
|
|
(org-ml-headline-set-node-property "ARCHIVE_OLPATH" apath)
|
|
(org-ml-headline-set-node-property "ARCHIVE_CATEGORY" acat)
|
|
(org-ml-headline-set-node-property "ARCHIVE_TODO" atodo)
|
|
(org-ml-headline-set-node-property "ARCHIVE_ITAGS" atags))))
|
|
|
|
(defun org-x-refile-logbook ()
|
|
"Refile the current headline with it's logbook.
|
|
The original target headline is left in place but without the
|
|
logbook. Intended use is for habits and repeating tasks that
|
|
build up massive logbook entries that will make my org files huge
|
|
and slow."
|
|
(interactive)
|
|
(let* ((acat (org-get-category))
|
|
(afile (abbreviate-file-name
|
|
(or (buffer-file-name (buffer-base-buffer))
|
|
(error "No file associated to buffer"))))
|
|
(apath (s-join "/" (org-get-outline-path)))
|
|
(atags (->> (org-get-tags)
|
|
(--filter (get-text-property 0 'inherited it))
|
|
(s-join " ")))
|
|
(add-context (-partial #'org-x--headline-add-archive-context
|
|
afile apath acat atags))
|
|
(target (format "%s_archive" afile)))
|
|
(cl-flet
|
|
((archive
|
|
(target headline)
|
|
(let* ((level-shift (-some-> (org-ml-get-property :level headline)
|
|
(1-)
|
|
(-)))
|
|
(headline*
|
|
(->> (funcall add-context headline)
|
|
;; remove the ID property if it exists
|
|
(org-ml-headline-set-node-property "ID" nil)
|
|
;; close the headline (assume it isn't already)
|
|
(org-ml-set-property :todo-keyword org-x-kw-done)
|
|
(org-ml-headline-map-planning*
|
|
(let ((time (org-ml-unixtime-to-time-long (float-time))))
|
|
(org-ml-planning-set-timestamp! :closed time it)))
|
|
;; shift it to the top level
|
|
(org-ml-shift-property :level level-shift)
|
|
(org-ml-match-map* '(:any * headline)
|
|
(org-ml-shift-property :level level-shift it)))))
|
|
;; TODO this currently does not refile under specific headlines
|
|
(with-current-buffer (find-file-noselect target)
|
|
(org-ml-insert (point-max) headline*)))))
|
|
(org-ml~update-this-subtree* nil
|
|
(progn
|
|
(archive target it)
|
|
(org-ml-headline-map-supercontents* (org-x-logbook-config)
|
|
(org-ml-supercontents-set-logbook nil it)
|
|
it))))))
|
|
|
|
(defun org-x-delete-subtree ()
|
|
"Delete entire subtree under point without sending to kill ring."
|
|
(interactive)
|
|
(org-back-to-heading t)
|
|
(delete-region (point) (1+ (save-excursion (org-end-of-subtree)))))
|
|
|
|
(defun org-x-set-creation-time ()
|
|
"Set the creation time property of the current heading."
|
|
(let ((np (->> (float-time)
|
|
(org-ml-unixtime-to-time-long)
|
|
(org-ml-build-timestamp!)
|
|
(org-ml-to-string)
|
|
(org-ml-build-node-property org-x-prop-created))))
|
|
(org-ml-update-this-headline*
|
|
(org-ml-headline-map-node-properties* (cons np it) it))))
|
|
|
|
(defun org-x-set-expired-time (&optional arg)
|
|
"Set the expired time of the current headline.
|
|
If ARG is non-nil use long timestamp format."
|
|
(interactive "P")
|
|
(-when-let (ut (-some->> (org-read-date nil t)
|
|
(float-time)
|
|
(round)))
|
|
(let ((np (->> (if arg (org-ml-unixtime-to-time-long ut)
|
|
(org-ml-unixtime-to-time-short ut))
|
|
(org-ml-build-timestamp!)
|
|
(org-ml-to-string)
|
|
(org-ml-build-node-property org-x-prop-expire))))
|
|
(org-ml-update-this-headline*
|
|
(org-ml-headline-map-node-properties* (cons np it) it)))))
|
|
|
|
(defun org-x-set-dtl ()
|
|
"Set days-to-live of the current headline."
|
|
(interactive)
|
|
(let ((np (->> (org-x--read-number-from-minibuffer "Days to live" t)
|
|
(org-ml-build-node-property org-x-prop-days-to-live))))
|
|
(org-ml-update-this-headline*
|
|
(org-ml-headline-map-node-properties* (cons np it) it))))
|
|
|
|
;;; INTERACTIVE AGENDA FUNCTIONS
|
|
|
|
;; lift buffer commands into agenda context
|
|
|
|
(defmacro org-x-agenda-cmd-wrapper (update &rest body)
|
|
"Execute BODY in context of agenda buffer.
|
|
Specifically, navigate to the original header, execute BODY, then
|
|
update the agenda buffer. If UPDATE is 'update-headline', get the
|
|
headline string and use it to update the agenda (this is only
|
|
needed when the headline changes obviously). When update is
|
|
'update-all', reload the entire buffer. When UPDATE is nil, do
|
|
nothing."
|
|
(declare (indent 1))
|
|
(-let* ((newhead (make-symbol "newhead"))
|
|
(hdmarker (make-symbol "hdmarker"))
|
|
((update-form get-head-form)
|
|
(cond
|
|
((eq update 'update-headline)
|
|
(list `((org-agenda-change-all-lines ,newhead ,hdmarker))
|
|
`((setq ,newhead (org-get-heading)))))
|
|
((eq update 'update-all)
|
|
(list '((org-agenda-redo))
|
|
nil)))))
|
|
`(progn
|
|
(org-agenda-check-no-diary)
|
|
(let* ((,hdmarker (or (org-get-at-bol 'org-hd-marker)
|
|
(org-agenda-error)))
|
|
(buffer (marker-buffer ,hdmarker))
|
|
(pos (marker-position ,hdmarker))
|
|
(inhibit-read-only t)
|
|
,newhead)
|
|
(org-with-remote-undo buffer
|
|
(with-current-buffer buffer
|
|
(widen)
|
|
(goto-char pos)
|
|
(org-show-context 'agenda)
|
|
,@body
|
|
,@get-head-form)
|
|
,@update-form
|
|
(beginning-of-line 1))))))
|
|
|
|
(defun org-x-agenda-toggle-checkbox ()
|
|
"Toggle checkboxes in org agenda view using `org-toggle-checkbox'."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper update-headline
|
|
(call-interactively #'org-toggle-checkbox)))
|
|
|
|
(defun org-x-agenda-clone-subtree-with-time-shift ()
|
|
"Apply `org-x-clone-subtree-with-time-shift' to an agenda entry.
|
|
It will clone the last entry in the selected subtree."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper update-all
|
|
(call-interactively #'org-x-clone-subtree-with-time-shift-toplevel)))
|
|
|
|
(defun org-x-agenda-delete-subtree ()
|
|
"Apply `org-x-delete-subtree' to an agenda entry."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper update-all
|
|
(call-interactively #'org-x-delete-subtree)))
|
|
|
|
(defun org-x-agenda-clock-range ()
|
|
"Apply `org-x-clock-range' to an agenda entry."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper nil
|
|
(call-interactively #'org-x-clock-range)))
|
|
|
|
(defun org-x-agenda-id-store-link ()
|
|
"Apply `org-x-id-store-link' to an agenda entry."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper nil
|
|
(call-interactively #'org-x-id-store-link)))
|
|
|
|
(defun org-x-agenda-id-store-link-metablock ()
|
|
"Apply `org-x-id-store-link-metablock' to an agenda entry."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper nil
|
|
(call-interactively #'org-x-id-store-link-metablock)))
|
|
|
|
;; agenda heading navigation functions
|
|
|
|
(defun org-x-agenda--seek-heading (&optional back)
|
|
"Go to next or previous agenda heading.
|
|
If BACK is t seek backward, else forward. Ignore blank lines."
|
|
(cl-flet
|
|
((is-valid-header
|
|
()
|
|
(let ((h (buffer-substring (line-beginning-position)
|
|
(line-end-position))))
|
|
(and (not (equal h ""))
|
|
(get-text-property 0 'org-agenda-structural-header h)))))
|
|
(let ((inc (if back -1 1))
|
|
(header-point))
|
|
(save-excursion
|
|
(while (and (not header-point) (= 0 (forward-line inc)))
|
|
(when (is-valid-header)
|
|
(setq header-point (point))))
|
|
header-point)
|
|
(if header-point (goto-char header-point)
|
|
(message (if back "Cannot move up" "Cannot move down"))))))
|
|
|
|
(defun org-x-agenda-previous-heading ()
|
|
"Go to the previous agenda heading or end of buffer."
|
|
(interactive)
|
|
(org-x-agenda--seek-heading t))
|
|
|
|
(defun org-x-agenda-next-heading ()
|
|
"Go to the next agenda heading or end of buffer."
|
|
(interactive)
|
|
(org-x-agenda--seek-heading))
|
|
|
|
;; agenda tag filtering
|
|
|
|
(defun org-x-agenda-filter-non-context ()
|
|
"Filter all tasks with context tags."
|
|
(interactive)
|
|
(let ((context-tags
|
|
(->> (-map #'car org-tag-alist)
|
|
(-filter #'stringp)
|
|
(--filter (memq (elt it 0) (list org-x-tag-resource-prefix
|
|
org-x-tag-location-prefix))))))
|
|
(setq org-agenda-tag-filter (--map (concat "-" it) context-tags))
|
|
(org-agenda-filter-apply org-agenda-tag-filter 'tag)))
|
|
|
|
(defun org-x-agenda-filter-non-peripheral ()
|
|
"Filter all tasks that don't have peripheral tags."
|
|
(interactive)
|
|
(let* ((peripheral-tags '("PERIPHERAL")))
|
|
(setq org-agenda-tag-filter
|
|
(mapcar (lambda (tag) (concat "-" tag)) peripheral-tags))
|
|
(org-agenda-filter-apply org-agenda-tag-filter 'tag)))
|
|
|
|
;; agenda meeting management
|
|
|
|
(defun org-x-agenda-meeting-add-agenda-item ()
|
|
"Add item to current agenda headline."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper nil
|
|
(call-interactively #'org-x-headline-meeting-add-agenda-item)))
|
|
|
|
(defun org-x-agenda-meeting-add-action-item ()
|
|
"Add item to current action headline."
|
|
(interactive)
|
|
(org-x-agenda-cmd-wrapper nil
|
|
(call-interactively #'org-x-headline-meeting-add-action-item)))
|
|
|
|
;; agenda property filtering
|
|
|
|
;; The agenda buffer doesn't do property filtering out of the box. In order to
|
|
;; implement the property filter, the functions `org-agenda-filter-make-matcher'
|
|
;; and `org-agenda-filter-remove-all' need to be advised; this will add a new
|
|
;; path to check properties against some user-defined filter.
|
|
|
|
;; This allows any property filter using to be applied and removed using the
|
|
;; standard `org-agenda-filter-apply' function with the
|
|
;; `org-x--agenda-property-filter' variable. Obviously these can all be extended
|
|
;; to different filter types. Note this does not give a shiny indicator at the
|
|
;; bottom of modeline like the built-in filter does...oh well.
|
|
|
|
(defun org-x-agenda-filter-make-property-matcher-form (h)
|
|
"Return form to test the presence or absence of properties H.
|
|
H is a string like +prop or -prop"
|
|
(let* ((op (string-to-char h))
|
|
(h (substring h 1))
|
|
(f `(save-excursion
|
|
(let ((m (org-get-at-bol 'org-hd-marker)))
|
|
(with-current-buffer (marker-buffer m)
|
|
(goto-char m)
|
|
(org-entry-get nil ,h))))))
|
|
(if (eq op ?-) `(not ,f) f)))
|
|
|
|
(defun org-x-agenda-filter-make-property-matcher (filter type &rest _args)
|
|
"Make a property agenda filter matcher.
|
|
This will return matching matcher form for FILTER and TYPE
|
|
where TYPE is not in the regular `org-agenda-filter-make-matcher'
|
|
function. This is intended to be used as :before-until advice and
|
|
will return nil if the type is not valid (which is currently
|
|
'property')"
|
|
(when (eq type 'property)
|
|
(-some->> (-map #'org-x-agenda-filter-make-property-matcher-form filter)
|
|
(cons 'and))))
|
|
|
|
(defun org-x-agenda-filter-remove-property ()
|
|
"Remove the agenda property filter.
|
|
This is meant to be :before advice for
|
|
`org-agenda-filter-remove-all'."
|
|
(when org-x--agenda-property-filter
|
|
(org-agenda-remove-filter 'property)))
|
|
|
|
(defun org-x-agenda-filter-non-effort ()
|
|
"Filter agenda by non-effort tasks."
|
|
(interactive)
|
|
(setq org-x--agenda-property-filter '("-Effort"))
|
|
(org-agenda-filter-apply org-x--agenda-property-filter 'property))
|
|
|
|
(defun org-x-agenda-filter-delegate ()
|
|
"Filter agenda by tasks with an external delegate."
|
|
(interactive)
|
|
(setq org-x--agenda-property-filter '("+DELEGATE"))
|
|
(org-agenda-filter-apply org-x--agenda-property-filter 'property))
|
|
|
|
(advice-add #'org-agenda-filter-make-matcher :before-until
|
|
#'org-x-agenda-filter-make-property-matcher)
|
|
|
|
(advice-add #'org-agenda-filter-remove-all :before
|
|
#'org-x-agenda-filter-remove-property)
|
|
|
|
(provide 'org-x)
|
|
;;; org-x.el ends here
|