TerminalWidgets dashboard with links to flagged Bike rows

Hogbay Software’s Bike 2.0 Outliner (build 294+) lets you flag any row (choosing from 7 flag colors, defaulting to red), via (⇧⌘A) Format > Row Attributes...

This TerminalWidget script shows hyperlinks to any flagged rows in a Bike outline, using the .bike or .biked filename as the widget title, and prefixing each link with its colored flag.

With several, you can build a dashboard which:

  • shows where you are, in your various projects, and
  • lets you jump straight to the relevant lines.

Requirements

Flagged lines in a Bike.app outline file

  • The file doesn’t have to be open in Bike.app
  • It must be in .bike or .biked format – this script relies on X/HTML structure for fast parsing, and will not work with files saved in .md or .txt format.

Displaying links to flagged lines

Updates on file change

Installation

Terminal widget with target name matching the file name (minus extension)

  • Create a new TerminalWidget with the size you prefer
  • Ctrl-click on the new widget, and choose Edit Terminal Widget...
  • Set the target name to the Bike outline filename, dropping the .bike[d] extension

e.g. for ~/Desktop/Project notes.bike the TerminalWidget target name would be:

Project notes

Shell script :: flaggedBikeLines.sh

Expand disclosure triangle to view shell script source
#!/bin/zsh

# Ver 0.6

# Links to flagged lines in a Hogbay Bike 2.0 (294+) .bike outline.

# Output to a TerminalWidget with a target name which matches
# the filename stem of the .bike (or .biked) filepath 
# which is passed to the script.

# e.g. for `~/Desktop/Project\ Notes.bike` we use
# "Project Notes" as the widget identifier.

# ---------

# In the case of a .biked bundle containing attachments, the
# xhtml source is expected in a `text.bike` file in the bundle.
local bike_xml_path="$1"
[[ "$1" == *.biked ]] && bike_xml_path="$1/text.bike"

# A message for the case in which no `.bike` file is found at the path.
if [[ ! -f "$bike_xml_path" ]]; then
  echo "Error: .bike file not found: '$bike_xml_path'" >&2
  exit 1
fi

# Otherwise, a filename header, followed by zero or more lines consisting of 
# MD links to flagged lines in the Bike outline, each prefixed by a colored flag 
# (color specified by the value of `data-flagged` in the given line)
{
  # As a header – the matched file name without its extension
  printf "%s\n\n" "${1:t:r}"
  
  # jaq query (xml-capable jq) ( https://gedenkt.at/jaq/manual/ )
  # jaq can be installed from brew ( https://formulae.brew.sh/formula/jaq )
  /opt/homebrew/bin/jaq -r --from xml '
    def get_text:
      if type == "string" then .
      elif type == "object" then (.c[]? | get_text)
      else empty end;

    # ANSI codes for Bike flag colors
    {
      "orange": 208,
      "red": 196,
      "purple": 129,
      "blue": 33,
      "yellow": 226,
      "green": 46,
      "gray": 244
    } as $color_map |

    # Unique identifier of the .bike document (used in Bike link urls)
    ( [.. | objects | select(.t == "ul" and (.a.id? != null)) | .a.id] | first ) as $rootid |

    # Rows of the .bike outline, paired with their zero-based indices (for line numbers)
    [.. | objects | select(.t == "li")] |
    to_entries[] |

    # ONLY LINES WITH a `data-flagged` ATTRIBUTE
    select(.value.a? | has("data-flagged")?) |

    # ANSI color code matching any `data-flagged` attribute value,
    # defaulting to red (code 196) for empty or unknown values.

    (.value.a["data-flagged"]) as $color_name |
    ($color_map[$color_name] // 196) as $color_code |

   # Plain text version of Bike outline row, with XML entity decoding
   # falling back to `alt` or `src` of img if text is empty
    (
      ( [.value.c[]? | objects | select(.t == "p") | get_text] | join("") ) as $parsed_text |
      if $parsed_text != "" then
        $parsed_text
      else
        # Find the first img tag inside the p element and check its attributes (.a)
        ( [.value.c[]? | objects | select(.t == "p") | .. | objects | select(.t == "img") | .a] | first
          | if (.alt // "") != "" then .alt else .src end
        ) // "(Line \(.key + 1))"
      end
    ) as $label_text |

    # XML entity decoding and bracket substitution
    ( $label_text
      | gsub("""; "\"")
      | gsub("'"; "\u0027")
      | gsub(">"; ">")
      | gsub("&lt;"; "<")
      | gsub("&amp;"; "&")
      | gsub("\\["; "{") 
      | gsub("\\]"; "}")
    ) as $text |

    # Unique persistentId of the row if it has one,
    # otherwise, its one-based row number.
    (if .value.a.id? != null then .value.a.id else "#\(.key + 1)" end) as $focus_path |

    # MD link to row, with flag prefix colored by ANSI escape sequence (using \u001b for ESC)
    "\u001b[38;5;\($color_code)m⚑\u001b[0m [\($text)](bike://\($rootid)/\($focus_path))"
  ' "$bike_xml_path"
} | /Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget --target "${1:t:r}" --text - --bg d0d0e0

Command line test

  • Ensure that flaggedBikeLines.sh is executable, with chmod +x flaggedBikeLines.sh
  • Apply it to the full path of a Bike outline file (.bike or .biked) for which there is a TerminalWidget with a target name matching its file name (minus extension)

Update on file change

  • Create a Hazel.app rule for a folder containing a .bike or .biked file for which you have created a TerminalWidget
  • Add a rule which applies the script to any newly-changed .bike or .biked file(s) in the folder.
If all of the following conditions are met
Date Last Modified is after Date Last Matched
If any of the following conditions are met for the current file or folder
Extension is bike
Extension is biked
Do the following to the matched file or folder:
Run shell script flaggedBikeLines.sh
4 Likes

Wow! I’m just headed out for the day, but will dig into this all tonight/tomorrow. Excited to see the details!

1 Like

Generously assisted by Brett Terpstra, the author of TerminalWidget, Marked 3, and a number of other remarkable things, who wrote very elegant additional API to enable this.

1 Like

A slightly fuller version which adds traffic-lighting for any due dates in the targeted .bike or .biked file.

Due items are sorted to the top of the listing, which otherwise preserves the order of the source file.

(Due status is ignored if the outline row is marked as done)

{red, amber, green} are defined in terms of the number of days (if any) remaining before the due date.

These values are editable in the script. (Overdue items remain red)

{
    "red_days": 1,
    "amber_days": 7,
    "green_days": 14
}
Expand disclosure triangle to view zsh source
#!/bin/zsh

# Ver 0.10

# Links to flagged and due lines in a Hogbay Bike 2.0 (294+) .bike outline.
# Output to a TerminalWidget with a target name which matches
# the filename stem of the .bike (or .biked) filepath 
# which is passed to the script.

local bike_xml_path="$1"
[[ "$1" == *.biked ]] && bike_xml_path="$1/text.bike"

if [[ ! -f "$bike_xml_path" ]]; then
  echo "Error: .bike file not found at $bike_xml_path" >&2
  exit 1
fi

{
  # As a header – the matched file name without its extension
  printf "%s\n\n" "${1:t:r}"
  
  # Current system time for traffic light triage
  local current_time=$(date +%s)

  /opt/homebrew/bin/jaq -r --argjson now "$current_time" --from xml '
    def get_text:
      if type == "string" then .
      elif type == "object" then (.c[]? | get_text)
      else empty end;

    # Due Date Thresholds (in days)
    {
      "red_days": 1,
      "amber_days": 7,
      "green_days": 14
    } as $due_thresholds |

    # ANSI codes for Bike flag colors
    {
      "orange": 208,
      "red": 196,
      "purple": 129,
      "blue": 33,
      "yellow": 226,
      "green": 46,
      "gray": 244
    } as $color_map |

    ( [.. | objects | select(.t == "ul" and (.a.id? != null)) | .a.id] | first ) as $rootid |

    # STREAM WRAPPED IN AN ARRAY TO ENABLE SORTING
    [
      [.. | objects | select(.t == "li")] |
      to_entries[] |

      # FILTER: Either `data-flagged`, OR (`data-due` without `data-done`)
      select(.value.a? | (has("data-flagged") or (has("data-due") and (has("data-done") | not)))) |

      # Traffic light logic for `data-due`
      (.value.a | has("data-done")) as $is_done |
      (.value.a["data-due"] // null) as $due_str |
      
      # Due epoch is null where item is done 
      # No traffic light, and not sorted by urgency
      (if ($due_str != null and ($is_done | not)) then
         ($due_str | if length == 10 then . + "T00:00:00Z" else . end | fromdateiso8601)
       else
         null
       end) as $due_epoch |

      (if $due_epoch != null then
         (($due_epoch - $now) / 86400) as $days_left |
         if $days_left <= $due_thresholds.red_days then "\u001b[38;5;196m●\u001b[0m "
         elif $days_left <= $due_thresholds.amber_days then "\u001b[38;5;214m●\u001b[0m "
         elif $days_left <= $due_thresholds.green_days then "\u001b[38;5;46m●\u001b[0m "
         else "" end
       else
         ""
       end) as $due_prefix |

      # Flag color for `data-flagged`
      (.value.a["data-flagged"] // null) as $flag_val |
      (if $flag_val != null then
         ($color_map[$flag_val] // 196) as $color_code |
         "\u001b[38;5;\($color_code)m⚑\u001b[0m "
       else
         ""
       end) as $flag_prefix |

      # UNPREFIXED LINES NOT INCLUDED
      select(($due_prefix != "") or ($flag_prefix != "")) |

      # Either plain text, image name or source, or line number for Bike outline row
      (
        ( [.value.c[]? | objects | select(.t == "p") | get_text] | join("") ) as $parsed_text |
        if $parsed_text != "" then
          $parsed_text
        else
          ( [.value.c[]? | objects | select(.t == "p") | .. | objects | select(.t == "img") | .a] | first
            | if (.alt // "") != "" then .alt else .src end
          ) // "(Line \(.key + 1))"
        end
      ) as $label_text |

      # XML entity decoding and bracket substitution
      ( $label_text
        | gsub("&quot;"; "\"")
        | gsub("&apos;"; "\u0027")
        | gsub("&gt;"; ">")
        | gsub("&lt;"; "<")
        | gsub("&amp;"; "&")
        | gsub("\\["; "{") 
        | gsub("\\]"; "}")
      ) as $text |

      # Unique persistentId of the row if it has one, otherwise one-based row number
      (if .value.a.id? != null then .value.a.id else "#\(.key + 1)" end) as $focus_path |

      # Sortable object
      {
        has_no_due: (if $due_epoch == null then 1 else 0 end),
        due_epoch: ($due_epoch // 0),
        doc_index: .key,
        rendered_md: "\($due_prefix)\($flag_prefix)[\($text)](bike://\($rootid)/\($focus_path))"
      }
    ]
    # SORT BY [ HAS_NO_DUE, DUE_EPOCH, ORIGINAL_INDEX ]
    | sort_by([.has_no_due, .due_epoch, .doc_index])
    
    # ARRAY OF RENDERED STRINGS AS STREAM
    | .[].rendered_md
  ' "$bike_xml_path"
} | /Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget --target "${1:t:r}" --text - --bg d0d0e0