├── images ├── 2021.png ├── January, 2021.png ├── Week 1, 2021.png └── January 1st, 2021.png ├── roam-date ├── roam-month ├── roam_month.rb ├── roam_date.rb ├── README.md ├── roam-importable-year └── roam-mine-daily /images/2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjthrash/roam-tools/HEAD/images/2021.png -------------------------------------------------------------------------------- /images/January, 2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjthrash/roam-tools/HEAD/images/January, 2021.png -------------------------------------------------------------------------------- /images/Week 1, 2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjthrash/roam-tools/HEAD/images/Week 1, 2021.png -------------------------------------------------------------------------------- /images/January 1st, 2021.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jjthrash/roam-tools/HEAD/images/January 1st, 2021.png -------------------------------------------------------------------------------- /roam-date: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'date' 4 | require_relative 'roam_date' 5 | 6 | def date_from_args(args) 7 | if args.count == 0 8 | Date.today 9 | else 10 | Date.parse(args[0]) 11 | end 12 | end 13 | 14 | if __FILE__ == $0 15 | puts RoamDate.roam_date(date_from_args(ARGV)) 16 | end 17 | -------------------------------------------------------------------------------- /roam-month: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'set' 4 | require 'date' 5 | require_relative 'roam_month' 6 | 7 | 8 | def to_markdown(node, depth=0) 9 | result = [] 10 | result << " "*depth + "- " + node["string"] 11 | node["children"].each do |child| 12 | result.push(*to_markdown(child, depth+1)) 13 | end 14 | result 15 | end 16 | 17 | # TODO: proper args, allow specifying a range, month containing date, etc 18 | def dates_from_args(args) 19 | if args.count == 0 20 | RoamCalendar.month_range_containing_date(Date.today) 21 | elsif args.count == 1 22 | RoamCalendar.month_range_containing_date(Date.parse(args[0])) 23 | else 24 | args.map {|arg| Date.parse(arg)} 25 | end 26 | end 27 | 28 | if __FILE__ == $0 29 | node = RoamCalendar.roam_calendar(dates_from_args(ARGV)) 30 | puts to_markdown(node).join("\n") 31 | end 32 | -------------------------------------------------------------------------------- /roam_month.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | require 'date' 3 | require_relative 'roam_date' 4 | 5 | class RoamCalendar 6 | def self.nil_pad_dates_by_weeks(dates) 7 | dates = dates.sort 8 | first = RoamDate.sunday_on_or_before_date(dates.first) 9 | last = RoamDate.saturday_on_or_after_date(dates.last) 10 | date_set = Set.new(dates) 11 | (first..last).map {|date| 12 | date_set.include?(date) ? date : nil 13 | } 14 | end 15 | 16 | def self.string_node(string) 17 | { 18 | "string" => string, 19 | "children" => [] 20 | } 21 | end 22 | 23 | def self.roam_calendar(dates) 24 | dates = nil_pad_dates_by_weeks(dates) 25 | weeks = dates.each_slice(7) 26 | table = string_node("{{table}}") 27 | (0..6).inject(table) do |n, i| 28 | sn = string_node("**#{Date::ABBR_DAYNAMES[i]}**") 29 | n["children"] << sn 30 | sn 31 | end 32 | 33 | weeks.each do |week| 34 | week.inject(table) do |n, date| 35 | sn = date.nil? ? 36 | string_node("") : 37 | string_node(RoamDate.roam_date_link(date, "#{date.mday}")) 38 | n["children"] << sn 39 | sn 40 | end 41 | end 42 | 43 | table 44 | end 45 | 46 | def self.month_range_containing_date(date) 47 | first = Date.new(date.year, date.month, 1) 48 | last = first.next_month - 1 49 | (first..last) 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /roam_date.rb: -------------------------------------------------------------------------------- 1 | require 'date' 2 | 3 | module RoamDate 4 | extend self 5 | 6 | def roam_date_link(date, label_text=nil) 7 | return "" if date.nil? 8 | 9 | date_text = "[[#{roam_date(date)}]]" 10 | return date_text if label_text.nil? 11 | 12 | "[#{label_text}](#{date_text})" 13 | end 14 | 15 | def roam_date(date) 16 | return "" if date.nil? 17 | 18 | "#{Date::MONTHNAMES[date.month]} #{ordinal(date.mday)}, #{date.year}" 19 | end 20 | 21 | def ordinal(n) 22 | "#{n}#{ordinal_part(n)}" 23 | end 24 | 25 | # Lifted from: https://stackoverflow.com/questions/37364637/trying-to-convert-and-display-an-ordinal-number#37364719 26 | def ordinal_part(n) 27 | last_number = n % 10 28 | if [11,12,13].include?(n) 29 | return "th" 30 | elsif last_number == 1 31 | return "st" 32 | elsif last_number == 2 33 | return "nd" 34 | elsif last_number == 3 35 | return "rd" 36 | else 37 | return "th" 38 | end 39 | end 40 | 41 | def monday_on_or_before_date(date) 42 | date.monday? ? 43 | date : 44 | monday_on_or_before_date(date-1) 45 | end 46 | 47 | def sunday_on_or_before_date(date) 48 | date.sunday? ? 49 | date : 50 | sunday_on_or_before_date(date-1) 51 | end 52 | 53 | def saturday_on_or_after_date(date) 54 | date.saturday? ? 55 | date : 56 | saturday_on_or_after_date(date+1) 57 | end 58 | 59 | def sunday_on_or_after_date(date) 60 | date.sunday? ? 61 | date : 62 | sunday_on_or_after_date(date+1) 63 | end 64 | 65 | end 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Personal tools for Roam Research 2 | 3 | ## Overview 4 | 5 | These tools are not supported, but you're welcome to use them. 6 | 7 | 8 | ### License 9 | 10 | These tools are public domain. 11 | 12 | ## Tools 13 | 14 | ### Roam Month 15 | 16 | Spit out a `{{table}}` that contains a calendar. Specify a month, or print out the current month if blank. 17 | 18 | E.g. 19 | 20 | - `roam-month` - render the current month 21 | - `roam-month 2020/07` - render July, 2020 22 | - `roam-month 2020/07/01` - render July, 2020 23 | 24 | 25 | ### Roam Day Miner 26 | 27 | Mine the day pages for nodes matching a pattern and render `{{embed: (())}}` nodes for each block reference that matches 28 | 29 | You must export your DB as JSON for this to work 30 | 31 | E.g. 32 | 33 | - `roam-mine-daily -f my-db.json -m 2020/07 -s "[[DONE]]" -a` - Show all completed TODOs for July, 2020 34 | 35 | 36 | ### Roam Importable Year 37 | 38 | Spit out an importable JSON file corresponding to a year. Built along the lines of the technique posted in this forum thread: [How I quickly navigate in time](https://web.archive.org/web/20210228071506/https://forum.roamresearch.com/t/how-i-quickly-navigate-in-time/610). 39 | 40 | Includes: 41 | - A year page, e.g. `[[2021]]` 42 | - Month pages, e.g. `[[January, 2021]]` 43 | - Week pages, e.g. `[[Week 1, 2021]]` 44 | 45 | Results in: 46 | 47 | #### Year 48 | ![2021](images/2021.png) 49 | 50 | #### Day 51 | ![January 1st, 2021](images/January%201st,%202021.png) 52 | 53 | #### Month 54 | 55 | ![January, 2021](images/January,%202021.png) 56 | 57 | #### Week 58 | ![Week 1, 2021](images/Week%201,%202021.png) 59 | 60 | 61 | All interconnected 62 | 63 | E.g. 64 | 65 | - `roam-importable-year 2021 > 2021.json` 66 | -------------------------------------------------------------------------------- /roam-importable-year: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'date' 4 | require 'json' 5 | require 'stringio' 6 | 7 | require_relative 'roam_date' 8 | require_relative 'roam_month' 9 | 10 | class SundayFirstWeekBuilder 11 | def days_in_week(year, week) 12 | date = Date.ordinal(year, 7*(week-1)+1) 13 | sunday = RoamDate.sunday_on_or_before_date(date) 14 | saturday = RoamDate.saturday_on_or_after_date(date) 15 | (sunday..saturday) 16 | end 17 | end 18 | 19 | class MondayFirstWeekBuilder 20 | def days_in_week(year, week) 21 | date = Date.ordinal(year, 7*(week-1)+1) 22 | monday = RoamDate.monday_on_or_before_date(date) 23 | sunday = RoamDate.sunday_on_or_after_date(date) 24 | (monday..sunday) 25 | end 26 | end 27 | 28 | def build_year_page(year) 29 | quarters = (1..4).map {|quarter| 30 | "[Q#{quarter}]([[Q#{quarter}, #{year}]])" 31 | } 32 | 33 | months = (1..12).map {|m| 34 | d = Date.new(year, m) 35 | d.strftime("[%b %y]([[%B, %Y]])") 36 | } 37 | 38 | { 39 | "title" => year.to_s, 40 | "children" => [ 41 | { 42 | "string" => quarters.join(" | ") 43 | }, 44 | { 45 | "string" => months.join(" | ") 46 | } 47 | ], 48 | } 49 | end 50 | 51 | def weeks_by_date(year, week_builder) 52 | @weeks_by_date ||= 53 | (1..53).each_with_object({}) do |week, h| 54 | days_in_week(year, week, week_builder).each do |weekday| 55 | h[weekday] = week 56 | end 57 | end 58 | end 59 | 60 | def days_in_week(year, week, week_builder) 61 | week_builder.days_in_week(year, week) 62 | end 63 | 64 | def build_month_page(year, month, week_builder) 65 | first_day = Date.new(year, month, 1) 66 | last_day = first_day.next_month - 1 67 | weeks = 68 | (first_day..last_day). 69 | map {|date| 70 | weeks_by_date(year, week_builder)[date] 71 | }. 72 | uniq.sort. 73 | map {|week| 74 | "[Week #{week}]([[Week #{week}, #{year}]])" 75 | } 76 | first = Date.new(year, month) 77 | { 78 | "title" => first.strftime("%B, %Y"), 79 | "children" => [ 80 | RoamCalendar.roam_calendar(RoamCalendar.month_range_containing_date(first)), 81 | { 82 | "string" => weeks.join(" | ") 83 | } 84 | ] 85 | } 86 | end 87 | 88 | def build_month_pages(year, week_builder) 89 | (1..12).map {|month| 90 | build_month_page(year, month, week_builder) 91 | } 92 | end 93 | 94 | def build_quarter_page(year, quarter) 95 | months = 96 | (0..2). 97 | map {|i| 1+(quarter-1)*3+i}. 98 | map {|month| Date.new(year, month, 1)}. 99 | map {|date| date.strftime("[%b %y]([[%B, %Y]])")} 100 | { 101 | "title" => "Q#{quarter}, #{year}", 102 | "children" => [ 103 | { 104 | "string" => months.join(" | ") 105 | } 106 | ] 107 | } 108 | end 109 | 110 | def build_quarter_pages(year) 111 | (1..4).map {|quarter| 112 | build_quarter_page(year, quarter) 113 | } 114 | end 115 | 116 | def build_week_page(year, week, week_builder) 117 | children = days_in_week(year, week, week_builder).map {|date| 118 | link_text = date.strftime("%a %-d") 119 | "[#{link_text}]([[#{RoamDate.roam_date(date)}]])" 120 | } 121 | 122 | { 123 | "title" => "Week #{week}, #{Date.new(year, 1).year}", 124 | "children" => [ 125 | { 126 | "string" => children.join(" | ") 127 | } 128 | ] 129 | } 130 | end 131 | 132 | def build_week_pages(year, week_builder) 133 | (1..53).map {|week| 134 | build_week_page(year, week, week_builder) 135 | } 136 | end 137 | 138 | def build_pages(year, week_builder) 139 | year_page = build_year_page(year) 140 | month_pages = build_month_pages(year, week_builder) 141 | quarter_pages = build_quarter_pages(year) 142 | week_pages = build_week_pages(year, week_builder) 143 | 144 | [year_page] + month_pages + quarter_pages + week_pages 145 | end 146 | 147 | def doit(year:, first_day_of_week: :sunday) 148 | JSON.generate(build_pages(year.to_i, build_week_builder(first_day_of_week))) 149 | end 150 | 151 | def build_week_builder(first_day_of_week) 152 | case first_day_of_week 153 | when :monday 154 | MondayFirstWeekBuilder.new 155 | else 156 | SundayFirstWeekBuilder.new 157 | end 158 | end 159 | 160 | if __FILE__ == $0 161 | puts doit(year: ARGV[0], first_day_of_week: (ARGV[1] || 'sunday').to_sym) 162 | end 163 | -------------------------------------------------------------------------------- /roam-mine-daily: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'json' 4 | require 'date' 5 | require 'optparse' 6 | 7 | # Does the page have a "Roam day-like" title? 8 | def is_day_page?(page) 9 | page["title"] =~ /^(#{Date::MONTHNAMES[1..-1].join('|')}).*, \d{4}/ 10 | end 11 | 12 | # pattern - the pattern to match (String or Regexp) 13 | # string - the string to check for match 14 | def string_matches_pattern(pattern, string) 15 | case pattern 16 | when Regexp 17 | pattern === node['string'] 18 | when String 19 | string.include?(pattern) 20 | end 21 | end 22 | 23 | def traverse_nodes_breadth_first(nodes, &block) 24 | nodes.each do |node| 25 | block.call(node) 26 | end 27 | 28 | nodes.each do |node| 29 | traverse_nodes_breadth_first(node['children'] || [], &block) 30 | end 31 | 32 | nil 33 | end 34 | 35 | def traverse_nodes_depth_first(nodes, &block) 36 | nodes.each do |node| 37 | block.call(node) 38 | traverse_nodes_depth_first(node['children'] || [], &block) 39 | end 40 | 41 | nil 42 | end 43 | 44 | # Recursively find the first block reference for a node 45 | # that matches the pattern. 46 | # pattern - can === a string 47 | # nodes - a list of things that have a 'uid', and maybe a 'children' 48 | def nested_block_reference_matching_pattern(pattern:, nodes:) 49 | value = [] 50 | traverse_nodes_depth_first(nodes) {|node| 51 | if string_matches_pattern(pattern, node['string']) 52 | value << node["uid"] 53 | end 54 | } 55 | 56 | value 57 | end 58 | 59 | # Return [uid] 60 | def get_matching_for_page(pattern:, page:) 61 | nested_block_reference_matching_pattern(pattern: pattern, nodes: page['children'] || []) 62 | end 63 | 64 | # Given the pages and a pattern, return the list of uids that match the 65 | # pattern for each page. 66 | # 67 | # Return [[title, [uid]] 68 | def get_matching_for_pages(pattern:, pages:) 69 | pages.map {|page| 70 | uids = get_matching_for_page(pattern: pattern, page: page) 71 | uids.any? ? 72 | [ page['title'], uids ] : 73 | nil 74 | }.compact 75 | end 76 | 77 | def month_for_date(date_string) 78 | date = Date.parse(date_string) 79 | "#{Date::MONTHNAMES[date.month]}, #{date.year}" 80 | end 81 | 82 | # [page] => {"month page": [page]} 83 | def group_by_month(day_pages) 84 | day_pages.inject({}) {|h, page| 85 | month_string = month_for_date(page['title']) 86 | h[month_string] ||= [] 87 | h[month_string] << page 88 | h 89 | } 90 | end 91 | 92 | # Given an Enumerable of pages, find the ones that are day pages. 93 | def get_day_pages(pages) 94 | pages.select(&method(:is_day_page?)) 95 | end 96 | 97 | # Parse the IO as a single JSON doc. 98 | def parse(stream) 99 | pages = JSON.load(stream) 100 | end 101 | 102 | # Given the arguments, return the stream to parse. 103 | # options - Configuration based on command line arguments 104 | def get_stream(options) 105 | options[:file].nil? ? 106 | $stdin : 107 | open(File.expand_path(options[:file])) 108 | end 109 | 110 | def first_only(titles_and_uids) 111 | titles_and_uids.map do |title, uids| 112 | [title, uids[0,1]] 113 | end 114 | end 115 | 116 | # Return a hash of { month_name: [page] } 117 | def process(pattern:, pages:, include_all: false) 118 | day_pages = get_day_pages(pages) 119 | by_month = group_by_month(day_pages) 120 | by_month.map {|month, pages| 121 | matching = get_matching_for_pages(pattern: pattern, pages: pages) 122 | next nil if matching.empty? 123 | 124 | matching = first_only(matching) unless include_all 125 | 126 | matching = matching.sort_by {|d, e| Date.parse(d)} 127 | [month, matching] 128 | }.compact.sort_by {|month, _| 129 | Date.parse(month) 130 | }.to_h 131 | end 132 | 133 | # Format the list of pages for Roam, nested under the pattern. 134 | # 135 | # title - The pattern to render as the top-level node 136 | # pages - The list of day, uid to format 137 | # E.g. 138 | # [[Journal]] 139 | # [[August 1st, 2020]] 140 | # {{embed: ((abcdefghi))}} 141 | # [[August 6th, 2020]] 142 | # {{embed: ((bcdefghij))}} 143 | def format(title, uids_by_day, embed) 144 | return nil if uids_by_day.nil? 145 | maybe_embed = ->(s) { 146 | embed ? 147 | "{{embed: ((#{s}))}}" : 148 | "((#{s}))" 149 | } 150 | formatted_pages = uids_by_day.map {|day, uids| 151 | formatted_uids = uids.map {|uid| " #{maybe_embed.call(uid)}"}.join("\n") 152 | formatted_day = " [[#{day}]]" 153 | "#{formatted_day}\n#{formatted_uids}" 154 | }.join("\n") 155 | <<-OUTPUT 156 | #{title} 157 | #{formatted_pages} 158 | OUTPUT 159 | end 160 | 161 | # Optionally return only the results matching the specified month. 162 | # 163 | # match_month - The month to match. If nil, don't filter anything. 164 | # results_by_month - [month, [uid]] 165 | # 166 | # Return results in the same shape as the input, potentially filtered. 167 | def filter_months(match_month, results_by_month) 168 | return results_by_month if match_month.nil? 169 | 170 | results_by_month.select {|month, _| 171 | month == match_month 172 | } 173 | end 174 | 175 | # Is the configuration built from the command line args valid? 176 | def config_valid?(config) 177 | !config[:pattern].nil? 178 | end 179 | 180 | # Return a configuration based on the command line arguments. 181 | def parse_arguments!(argv) 182 | results = { 183 | embed: false 184 | } 185 | 186 | option_parser = OptionParser.new do |opts| 187 | opts.on("-h", "--help", "Prints this help") do 188 | results[:help] = opts.help 189 | end 190 | 191 | opts.on("-sS", "--match-string=S", "Match nodes containing the string") do |s| 192 | results[:pattern] = s 193 | end 194 | 195 | opts.on("-mD", "--month=D", "Only return results for the month containing the given date") do |d| 196 | results[:month] = month_for_date(d) 197 | end 198 | 199 | opts.on("-fF", "--file=F", "Use the given file as input. If absent, will parse STDIN") do |f| 200 | results[:file] = f 201 | end 202 | 203 | opts.on("-a", "--include-all", "Include all the matching results, not just the first") do |a| 204 | results[:include_all] = true 205 | end 206 | 207 | opts.on("-e", "--embed", "Place resulting block refs in an embed") do |a| 208 | results[:embed] = true 209 | end 210 | 211 | opts.on("-E", "--no-embed", "Do not place resulting block refs in an embed (default)") do |a| 212 | results[:embed] = false 213 | end 214 | end 215 | 216 | option_parser.parse!(argv) 217 | if argv.count != 0 || !config_valid?(results) 218 | results = { :help => option_parser.help } 219 | end 220 | 221 | results 222 | end 223 | 224 | if __FILE__ == $0 225 | options = parse_arguments!(ARGV) 226 | 227 | if options[:help] 228 | puts options[:help] 229 | exit(1) 230 | end 231 | 232 | stream = get_stream(options) 233 | pages = parse(stream) 234 | results_by_month = process(pattern: options[:pattern], include_all: options[:include_all], pages: pages) 235 | relevant_results = filter_months(options[:month], results_by_month) 236 | formatted = relevant_results.map {|month, results| 237 | format(options[:pattern], results, options[:embed]) 238 | }.join("\n\n") 239 | puts formatted 240 | end 241 | --------------------------------------------------------------------------------