(Arne Babenhauserheide)
2017-02-04: merge merge
diff --git a/kanban.el b/kanban.el
--- a/kanban.el
+++ b/kanban.el
@@ -1,8 +1,9 @@
;;; kanban.el --- Parse org-todo headlines to use org-tables as Kanban tables
;;
-;; Copyright (C) 2012-2013 Arne Babenhauserheide <arne_bab@web.de>
+;; Copyright (C) 2012-2016 Arne Babenhauserheide <arne_bab@web.de>
+;; and 2013 stackeffect
-;; Version: 0.1.3
+;; Version: 0.2.0
;; Author: Arne Babenhauserheide <arne_bab@web.de>
;; Keywords: outlines, convenience
@@ -39,7 +40,7 @@
;; * Zero state Kanban: Directly displaying org-mode todo states as kanban board
;;
;; Use the functions kanban-headers and kanban-zero in TBLFM lines to
-;; get your org-mode todo states as kanban table. Update with C-c C-c
+;; get your org-mode todo states as kanban table. Update with C-c C-c
;; on the TBLFM line.
;;
;; Example:
@@ -48,7 +49,7 @@
;; |---+---+---|
;; | | | |
;; | | | |
-;; #+TBLFM: @1='(kanban-headers $#)::@2$1..@>$>='(kanban-zero @# $# "TAG" '(list-of-files))
+;; #+TBLFM: @1$1='(kanban-headers)::@2$1..@>$>='(kanban-zero @# $# "TAG" '(list-of-files))
;; "TAG" and the list of files are optional
;;
;; * Stateful Kanban: Use org-mode to retrieve tasks, but track their state in the Kanban board
@@ -57,96 +58,193 @@
;; |---+---+---|
;; | | | |
;; | | | |
-;; #+TBLFM: @1='(kanban-headers $#)::@2$1..@>$1='(kanban-todo @# @2$2..@>$> "TAG" '(list-of-files))
+;; #+TBLFM: @1$1='(kanban-headers)::@2$1..@>$1='(kanban-todo @# @2$2..@>$> "TAG" '(list-of-files))
;; "TAG" and the list of files are optional
;;
-;; TODO: The links don’t yet work for tagged entries. Fix that. There
-;; has to be some org-mode function to retrieve the plain header.
+;; Faster Example with kanban-fill (fills fields into their starting
+;; state but does not change them):
;;
-;; NEWS
-;;
-;; 0.1.3: just two more TODO entries.
-;;
+;; | | | |
+;; |---+---+---|
+;; | | | |
+;; | | | |
+;; #+TBLFM: @2='(kanban-fill "TAG" '(list-of-files))::@1$1='(kanban-headers $#)::
+;; "TAG" and the list of files are optional
+;;
+;; More complex use cases are described in the file sample.org
;;
;; TODO: kanban-todo sometimes inserts no tasks at all if there are multiple tasks in non-standard states.
;;
;; TODO: bold text in headlines breaks the parser (*bold*).
;;
+;; ChangeLog:
+;;
+;; - 0.2.0: Finally merge the much faster kanban-fill from stackeffect.
+;; I’m sorry that it took me 3 years to get there.
+;; - 0.1.7: strip keyword from link for org-version >= 9 and
+;; avoid stripping trailing "* .*" in lines
+;; - 0.1.6: defcustom instead of defvar
+;; - 0.1.5: Allow customizing the maximum column width with
+;; kanban-max-column-width
+;; - 0.1.4: Test version to see whether the marmalade upload works.
+;;
;;; Code:
+(defcustom kanban-max-column-width 30
+ "The maximum width of the columns in the KANBAN table.")
+
;; Get the defined todo-states from the current org-mode document.
;;;###autoload
-(defun kanban-headers (column)
+(defun kanban-headers (&optional startcolumn)
"Fill the headers of your table with your org-mode TODO
states. If the table is too narrow, the only the first n TODO
states will be shown, with n as the number of columns in your
-table."
- (let ((words org-todo-keywords-1))
- (nth (- column 1) words)))
+table.
-(defun kanban--todo-links-function ()
+Only not already present TODO states will be filled into empty
+fields starting from the current column. All columns left of
+the current one are left untouched.
+
+Optionally ignore fields in columns left of STARTCOLUMN"
+ (let* ((ofc (org-table-get nil nil)) ; remember old field content
+ (col (org-table-current-column)) ; and current column
+ (startcolumn (or startcolumn col))
+ (kwl org-todo-keywords-1)
+ kw)
+ (while (setq kw (pop kwl)) ; iterate over all TODO states
+ (let ((matchcol 0) ; insert kw in this empty column
+ (n startcolumn)
+ field)
+ (while (and matchcol (<= n org-table-current-ncol))
+ (if (equal kw (setq field (org-table-get nil n)))
+ (setq matchcol nil) ; kw already in column n
+ (if (and (= 0 matchcol) (equal "" field))
+ (setq matchcol n))) ; remember first empty column
+ (setq n (+ 1 n)))
+ (when (and matchcol (> matchcol 0))
+ (if (= matchcol col) (setq ofc kw))
+ (save-excursion
+ (org-table-get-field matchcol kw)))))
+ ofc))
+
+(defun kanban--todo-links-function (srcfile)
"Retrieve the current header as org-mode link."
- (let ((file (buffer-file-name))
- (line (filter-buffer-substring
- (point) (line-end-position)))
+ (let* ((file (buffer-file-name))
+ (oe (org-element-at-point))
+ (title (org-element-property :title oe))
+ (link (org-element-property :CUSTOM_ID oe)))
+; (if (equal file srcfile) (setq file nil))
; yes, I can use the row variable. It bleeds over from the
; calling function.
- (keyword (nth (- row 1) org-todo-keywords-1)))
(if file
(setq file (concat file "::")))
- ; clean up the string
- (let* (; first remove the initial headline marker FIXME: currently gets later "* ", too
- (cleanline (nth 1 (split-string line "* ")))
- ; and kill off links in the link part
- (link (replace-regexp-in-string "\\[" "%5B"
- (replace-regexp-in-string "\\]" "%5D" cleanline)))
- ; then kill off trailing space and tags in the name part
- (notrailing (replace-regexp-in-string "\\( +$\\| +:\\w.*: *$\\)" "" cleanline))
- ; and links
- (nolinks (replace-regexp-in-string
- "\\[" "{" (replace-regexp-in-string
- "\\]" "}" (replace-regexp-in-string
- "\\[\\[\\(.*\\)\\]\\[\\(.*\\)\\]\\]" "{\\2}" notrailing))))
- ; finally shorten the string to a maximum length of 30 chars
- (clean (substring nolinks
- (+ (length keyword) 1)
- (min 30 (length nolinks)))))
- (concat "[[" file link "][" clean "]]" ))))
+ ; find best target
+ (cond
+ (link
+ (setq link (concat "#" link)))
+ ((string-match org-target-regexp title)
+ (setq link (match-string 1 title))
+ (setq title nil))
+ (file
+ (setq link title)))
+ ; clean up the title
+ (when title
+ ; first substitute links with their title
+ (setq title (replace-regexp-in-string "\\[\\(\\[[^]]+\\]\\)?\\[\\([^]]+\\)\\]\\]" "\\2" title))
+ ; then kill off special link relevant characters
+ (setq title (replace-regexp-in-string "\\[" "{"
+ (replace-regexp-in-string "\\]" "}" title)))
+ ; finally shorten the string to a maximum length of kanban-max-column-width chars
+ (setq title (substring title 0 (min kanban-max-column-width (length title)))))
+ ; clean up the link
+ (when (string-match "[\][]" link)
+ (setq link (substring link 1))
+ (setq link (regexp-quote link))
+ (setq link (replace-regexp-in-string "\\(\\\\\\[\\|]\\|/\\)" "." link))
+ (setq link (concat "/\\*.*" link "/"))
+ (if (not file) (setq file "file:::")))
+ (concat "[[" file link (if title (concat "][" title)) "]]" )))
+
+;; Get TODO of current column from field in row 1
+(defun kanban--get-todo-of-current-col ()
+ "Get TODO of current column from field in row 1 or nil if
+row 1 does not contain a valid TODO"
+ (let ((todo (org-table-get 1 nil)))
+ (if (member todo org-todo-keywords-1) todo)))
;; Fill the kanban table with tasks with corresponding TODO states from org files
;;;###autoload
-(defun kanban-zero (column row &optional match scope)
+(defun kanban-zero (row column &optional match scope)
"Zero-state Kanban board: This Kanban board just displays all
org-mode headers which have a TODO state in their respective TODO
state. Useful for getting a simple overview of your tasks.
-Gets the COLUMN and ROW via TBLFM ($# and @#) and can get a string as MATCH to select only entries with a matching tag, as well as a list of org-mode files as the SCOPE to search for tasks."
- (let
- ((elem (nth (- column 2)
- (delete nil (org-map-entries
- 'kanban--todo-links-function
+Gets the ROW and COLUMN via TBLFM ($# and @#) and can get a string as MATCH to select only entries with a matching tag, as well as a list of org-mode files as the SCOPE to search for tasks."
+ (let*
+ ((todo (kanban--get-todo-of-current-col))
+ (srcfile (buffer-file-name))
+ (elem (and todo (nth (- row 2)
+ (delete nil (org-map-entries
+ '(kanban--todo-links-function srcfile)
; select the TODO state via the matcher: just match the TODO.
(if match
- (concat match "+TODO=\"" (nth (- row 1) org-todo-keywords-1) "\"")
- (concat "+TODO=\"" (nth (- row 1) org-todo-keywords-1) "\""))
+ (concat match "+TODO=\"" todo "\"")
+ (concat "+TODO=\"" todo "\""))
; read all agenda files
(if scope
scope
- 'agenda))))))
+ 'agenda)))))))
(if (equal elem nil)
""
elem)))
-; Fill the first row with TODO items, except if they exist in other cels
+(defun kanban--normalize-whitespace (elem)
+"Return ELEM with sequences of spaces reduced to 1 space"
+(replace-regexp-in-string "\\W\\W+" " " elem))
+
+(defun kanban--member-of-table (elem &optional skipcol)
+"Check if ELEM is in some table field
+ignoring all elements of column SKIPCOL.
+
+If SKIPCOL is not set column 1 will be ignored."
+(if (org-at-table-p)
+ (let ((row 2)
+ (skipcol (or skipcol 1))
+ col result field)
+ (while (and (not result) (<= row (length org-table-dlines)))
+ (setq col (if (= 1 skipcol) 2 1))
+ (while (and (not result) (<= col org-table-current-ncol))
+ (setq field (org-table-get row col))
+ (if (and field elem)
+ (setq result (or result (equal (kanban--normalize-whitespace elem) (kanban--normalize-whitespace field)))))
+ (setq col (1+ col))
+ (if (= col skipcol) (setq col (1+ col))))
+ (setq row (1+ row)))
+ result)))
+
+(defun kanban--max-row-or-hline ()
+ "Determine data row just above next hline or last row of current table"
+ (if (org-at-table-p)
+ (let ((row (org-table-current-dline)))
+ (while (and
+ (< row (1- (length org-table-dlines)))
+ (aref org-table-dlines row)
+ (aref org-table-dlines (1+ row))
+ (= (1+ (aref org-table-dlines row)) (aref org-table-dlines (1+ row))))
+ (setq row (1+ row)))
+ row)))
+
+; Fill the first column with TODO items, except if they exist in other cels
;;;###autoload
-(defun kanban-todo (column cels &optional match scope)
- "Kanban TODO item grabber. Fills the first row of the kanban
+(defun kanban-todo (row cels &optional match scope)
+ "Kanban TODO item grabber. Fills the first column of the kanban
table with org-mode TODO entries, if they are not in another cell
of the table. This allows you to set the state manually and just
use org-mode to supply new TODO entries.
-Gets the COLUMN and all other CELS via TBLFM ($# and @2$2..@>$>) and can get a string as MATCH to select only entries with a matching tag, as well as a list of org-mode files as the SCOPE to search for tasks."
- (let ((elem (nth (- column 2) (delete nil
+Gets the ROW and all other CELS via TBLFM ($# and @2$2..@>$>) and can get a string as MATCH to select only entries with a matching tag, as well as a list of org-mode files as the SCOPE to search for tasks."
+ (let* ((srcfile (buffer-file-name))
+ (elem (nth (- row 2) (delete nil
(org-map-entries
(lambda
()
@@ -154,6 +252,7 @@ Gets the COLUMN and all other CELS via T
((file (buffer-file-name))
(line (filter-buffer-substring (point) (line-end-position)))
(keyword (nth 0 org-todo-keywords-1)))
+ (if (equal file srcfile) (setq file nil))
(if file
(setq file (concat file "::")))
(let* ((cleanline (nth 1 (split-string line "* ")))
@@ -181,6 +280,51 @@ Gets the COLUMN and all other CELS via T
elem))) ; otherwise use the element.
+(defun kanban-fill (&optional match scope)
+ "Kanban TODO item grabber. Fills the current row of the kanban
+table with org-mode TODO entries, if they are not in another cell
+of the table. This allows you to set the state manually and just
+use org-mode to supply new TODO entries.
+
+Can get a string as MATCH to select only entries with a matching tag, as well as a list of org-mode files as the SCOPE to search for tasks.
+
+Only not already present TODO states will be filled into empty
+fields starting from the current field. All fields above the current
+one are left untouched."
+ (let* ((ofc (org-table-get nil nil)) ; remember old field content
+ (row (org-table-current-dline))
+ (startrow row)
+ (col (org-table-current-column)) ; and current column
+ (srcfile (buffer-file-name))
+ (todo (kanban--get-todo-of-current-col))
+ (maxrow (kanban--max-row-or-hline))
+ (elems (delete nil (org-map-entries
+ '(kanban--todo-links-function srcfile)
+ ; select the TODO state via the matcher: just match the TODO.
+ (if match
+ (concat match "+TODO=\"" todo "\"")
+ (concat "+TODO=\"" todo "\""))
+ ; read all agenda files
+ (if scope
+ scope
+ 'agenda))))
+ (elem t))
+ (save-excursion
+ (while (and elem (<= row maxrow))
+ (setq elem (pop elems))
+ (while (and elem (kanban--member-of-table elem 0))
+ (setq elem (pop elems)))
+ (while (and elem (<= row maxrow) (not (equal "" (org-table-get row col))))
+ (if (= row maxrow)
+ (setq elem nil) ; stop search
+ (setq row (1+ row)))) ; skip non empty rows
+ (when (and elem (equal "" (org-table-get row col)))
+ (if (= row startrow) (setq ofc elem))
+ (save-excursion
+ (if (org-table-goto-line row)
+ (org-table-get-field col elem))))))
+ (org-table-goto-column col)
+ ofc))
;; An example for auto-updating kanban tables from duply.han
;; I use the double-dash to mark this as "private" function
diff --git a/sample.org b/sample.org
--- a/sample.org
+++ b/sample.org
@@ -5,17 +5,73 @@ Example Kanban File
#+FILETAGS: board
#+CATEGORY: board
#+STARTUP: nofold
-* Zero state Kanban: == * Attention * ==
-| TODO | NEXT | DONE | WAITING | HOLD | MAYBE |
-|------+----------------------+------+---------+------+-------|
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-| | | | | | |
-#+TBLFM: @2$1..@>$>='(kanban-zero @# $# "+prj-HOLD" '(org-agenda-files))::@1='(kanban-headers $#)
+#+TODO: TODO NEXT DOING WAITING | DONE HOLD
+
+* Zero state Kanban: kanban-zero: == * Attention * ==
+| TODO | NEXT | DONE | WAITING | HOLD | MAYBE |
+|------+------+------+---------+------+-------|
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+#+TBLFM: @1='(kanban-headers $#)::@2$1..@>$>='(kanban-zero @# $# "+prj-HOLD" '("sample.org"))
+
+* Zero state Kanban: kanban-fill: == * Attention * ==
+| | | | | WAITING | |
+|---+---+---+---+---------+---|
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+#+TBLFM: @2='(kanban-fill "+prj-HOLD" '("sample.org"))::@1$1='(kanban-headers $#)
+
+* Todo state Kanban: == * Attention * ==
+| | | | | WAITING | |
+|---+---+---+---+---------+---|
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+| | | | | | |
+#+TBLFM: @2$1='(kanban-fill "+prj-HOLD" '("sample.org"))::@1$1='(kanban-headers $#)
+
+* item 1 :prj:
+ This item is totally ignored because not having any active state.
+* TODO item 2 :prj:
+ Only TODO items are relevant for todo state kanban.
+* NEXT item 3 :prj:
+* DOING item 4 :prj:
+* WAITING item 5 :prj:
+* DONE item 6 :prj:
+* HOLD item 7 :prj:
+ This item is ignored because of state "HOLD".
+* TODO item 8 :prj:
+ Only TODO items are relevant for todo state kanban.
+* TODO <<item 9>> :prj:
+ Link destination formatted as dedicated target.
+* NEXT item 10 :prj:
+ :PROPERTIES:
+ :CUSTOM_ID: i_am_item_10
+ :END:
+ Using CUSTOM_ID is the most safe method to set a link destination.
+* DOING item 11 :prj:
+* TODO item 12 :prj:
+* TODO item 13
+ Ignored because of missing tag prj.
+* DOING item 14 :prj:
+* NEXT item 15 :prj: