kanban.el
 
(Arne Babenhauserheide)
2017-01-17: finally merge kanban-fill by stackeffect

finally merge kanban-fill by stackeffect

diff --git a/kanban.el b/kanban.el
--- a/kanban.el
+++ b/kanban.el
@@ -48,7 +48,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,7 +57,7 @@
 ;; |---+---+---|
 ;; |   |   |   |
 ;; |   |   |   |
-;; #+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
@@ -78,86 +78,161 @@
 ;; 
 ;;; Code:
 
-;; Get the defined todo-states from the current org-mode document.
-;;;###autoload
-(defun kanban-headers (column)
-  "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)))
-
 (defcustom kanban-max-column-width 30
   "The maximum width of the columns in the KANBAN table.")
 
-(defun kanban--todo-links-function ()
+;; Get the defined todo-states from the current org-mode document.
+;;;###autoload
+(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.
+
+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 (substring
-                       (string-join (cdr (split-string line "* ")) "* ")
-                       (if (version<= (org-version) "9")
-                           0 ; old org-mode matches with TODO keyword
-                         (+ (length keyword) 1))))
-           ; 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))))
+    ; 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
-           (clean (substring nolinks
-                             0
-                             (min kanban-max-column-width (length nolinks)))))
-      (concat "[[" file link "][" clean "]]" ))))
+      (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
                                       ()
@@ -165,6 +240,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 "* ")))
@@ -192,6 +268,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: @2$1..@>$>='(kanban-zero @# $# "+prj-HOLD" '("sample.org"))::@1='(kanban-headers $#)
+
+* 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: