raindown 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/elements.rb +12 -25
  3. data/lib/lexemes/node_lexeme.rb +37 -0
  4. data/lib/nodes/list_node.rb +22 -25
  5. data/lib/nodes/write_in_node.rb +95 -0
  6. data/lib/raindown/bare_directives.rb +1 -1
  7. data/lib/raindown/chart/chart.rb +1 -1
  8. data/lib/raindown/cli.rb +2 -0
  9. data/lib/raindown/context.rb +19 -0
  10. data/lib/raindown/directives.rb +1 -1
  11. data/lib/raindown/fragments.rb +6 -1
  12. data/lib/raindown/js/host.mjs +2 -2
  13. data/lib/raindown/js/js_nodes.rb +4 -4
  14. data/lib/raindown/list.rb +31 -0
  15. data/lib/raindown/markdown_nodes.rb +3 -2
  16. data/lib/raindown/node.rb +10 -2
  17. data/lib/raindown/node_blocks.rb +27 -21
  18. data/lib/raindown/node_injector.rb +1 -1
  19. data/lib/raindown/nodes.rb +60 -22
  20. data/lib/raindown/pages.rb +8 -4
  21. data/lib/raindown/pre_renderer.rb +13 -10
  22. data/lib/raindown/projects.rb +24 -4
  23. data/lib/raindown/rewriter.rb +1 -1
  24. data/lib/raindown/stat.rb +1 -1
  25. data/lib/raindown/table/markdown_table.rb +11 -3
  26. data/lib/raindown/table/table.rb +92 -12
  27. data/lib/raindown/table/table_formulas.rb +50 -0
  28. data/lib/raindown/table/written_table.rb +68 -0
  29. data/lib/raindown/toc.rb +2 -2
  30. data/lib/raindown/tui/navigator/commands.rb +7 -1
  31. data/lib/raindown/watcher/changes.rb +5 -3
  32. data/lib/raindown/watcher/supervisor.rb +1 -1
  33. data/lib/raindown/wiki_links.rb +1 -1
  34. data/lib/raindown.rb +9 -14
  35. data/lib/version.rb +1 -1
  36. metadata +11 -8
  37. data/lib/lexemes/list_lexeme.rb +0 -32
  38. data/lib/nodes/component_nodes.rb +0 -38
  39. data/lib/raindown/files.rb +0 -22
  40. /data/lib/raindown/{table_formatter.rb → table/table_formatter.rb} +0 -0
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'chart/chart'
4
- require_relative 'files'
4
+ require_relative 'fragments'
5
5
  require_relative 'js/js_nodes'
6
+ require_relative 'list'
6
7
  require_relative 'markdown_nodes'
7
8
  require_relative 'node'
8
9
  require_relative 'stat'
@@ -13,15 +14,20 @@ module Raindown
13
14
  # Nodes are LowNodes that a node block writes into the Markdown and renders into the HTML.
14
15
  # A project keeps them in a .raindown folder, which is loaded with Dirload, beside its JavaScript and Markdown
15
16
  # Components, if any.
16
- # A node writes Markdown with #replace, and HTML with #render if it has one. Either can take the keywords
17
- # document: (the Markdown being rendered), metadata:, bare: and written: (the lines it wrote last time), and gets the
18
- # ones it asks for.
17
+ # Bare or in comments, a node writes back: its Markdown, from #rewrite, is written into the file, and its HTML is from
18
+ # #render if it has one. In <{ }> it's write-in: it only renders, with #render, which takes the Markdown between its
19
+ # tags as slot:, in HTML. Either can take the keywords document: (the Markdown being rendered), metadata:, bare: and
20
+ # written: (the lines it wrote last time), and gets the ones it asks for.
19
21
  module Nodes
22
+ # A write-in node that has no render.
23
+ class NoRender < StandardError; end
24
+
20
25
  FOLDER = '.raindown'
21
- # Lowercase directives for Raindown's own nodes. A project's own TOC, Files, Chart or Table node replaces Raindown's.
22
- BUILT_INS = { 'toc' => 'TOC', 'files' => 'Files', 'chart' => 'Chart', 'table' => 'Table' }.freeze
23
- # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers.
24
- PLACEHOLDER = /<!-- raindown:node (\d+) -->/
26
+ # Lowercase directives for Raindown's own nodes. A project's own TOC, List, Chart or Table node replaces Raindown's.
27
+ BUILT_INS = { 'toc' => 'TOC', 'list' => 'List', 'chart' => 'Chart', 'table' => 'Table' }.freeze
28
+ # A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers, with its slot's HTML, as a
29
+ # fragment, when it has one.
30
+ PLACEHOLDER = /<!-- raindown:node (\d+)(?: slot (\d+))? -->/
25
31
 
26
32
  module_function
27
33
 
@@ -63,21 +69,21 @@ module Raindown
63
69
 
64
70
  # A Ruby node of the name, or else a JavaScript one, or else a Markdown one.
65
71
  def loaded_class(name)
66
- klass = Object.const_get(name) if Object.const_defined?(name)
72
+ klass = Object.const_get(name) if name.match?(/\A[A-Z]/) && Object.const_defined?(name)
67
73
  return klass if node?(klass)
68
74
 
69
75
  JSNodes.node_class(name) || MarkdownNodes.node_class(name)
70
76
  end
71
77
 
72
78
  # A LowNode, or one of Raindown's own nodes, that writes Markdown. In an app, LowNodes are also routes and pages,
73
- # which don't have replace, so text like "Note:" only starts a block when the Note node writes something.
79
+ # which don't have rewrite, so text like "Note:" only starts a block when the Note node writes something.
74
80
  def node?(klass)
75
- return false unless klass.is_a?(Class) && klass.method_defined?(:replace)
81
+ return false unless klass.is_a?(Class) && klass.method_defined?(:rewrite)
76
82
 
77
83
  klass < Node || (defined?(::LowNode) && klass < ::LowNode) || false
78
84
  end
79
85
 
80
- # Calls a node's replace or render with the context keywords it takes.
86
+ # Calls a node's rewrite or render with the context keywords it takes.
81
87
  def call(node, name, **context)
82
88
  parameters = node.method(name).parameters
83
89
  return node.public_send(name, **context) if parameters.any? { |type, _| type == :keyrest }
@@ -99,24 +105,56 @@ module Raindown
99
105
  list[instances[key]]
100
106
  end
101
107
 
102
- def placeholder(node)
103
- "<!-- raindown:node #{list.index(node)} -->"
108
+ # Renders the node where it is, as a fragment, so its headings are part of the page, or through a placeholder, once
109
+ # the page is HTML, when its render takes html:, the page's HTML, like TOC's.
110
+ def rendered(node, context, slot: '')
111
+ raise NoRender, "#{node.class.name} has no render, so it can't be write-in: write it bare or in comments" unless renders?(node)
112
+ return placeholder(node, slot:) if !template?(node.class) && takes?(node, :render, :html)
113
+
114
+ Fragments.add(html(node, context, slot:))
115
+ end
116
+
117
+ def takes?(node, method_name, keyword)
118
+ node.method(method_name).parameters.any? { |_, name| name == keyword }
104
119
  end
105
120
 
106
- # Swaps each placeholder for its node's HTML.
107
- def render_placeholders(html, **context)
108
- html.gsub(PLACEHOLDER) { html(list[::Regexp.last_match(1).to_i], **context) }
121
+ # Whether the node's class has a template, like a Raindeer .rbx component, which renders in place of render.
122
+ def template?(klass)
123
+ klass.respond_to?(:template) && !klass.template.nil?
109
124
  end
110
125
 
111
- def html(node, **context)
112
- return node.render_template(props: {}).to_s if node.class.respond_to?(:template) && node.class.template
126
+ def placeholder(node, slot: '')
127
+ return "<!-- raindown:node #{list.index(node)} -->" if slot.empty?
113
128
 
114
- call(node, :render, **context).to_s
129
+ "<!-- raindown:node #{list.index(node)} slot #{Fragments.store(slot)} -->"
130
+ end
131
+
132
+ # Swaps each placeholder for its node's HTML, given the page's HTML and its slot, the HTML between its tags in <{ }>,
133
+ # which can hold components' fragments.
134
+ def render_placeholders(page_html, context)
135
+ page_html.gsub(PLACEHOLDER) do
136
+ match = ::Regexp.last_match
137
+ slot = match[2] ? Fragments.list[match[2].to_i] : ''
138
+ Fragments.fill(html(list[match[1].to_i], context, slot:, html: page_html))
139
+ end
115
140
  end
116
141
 
117
- # Whether the node renders its own HTML rather than the HTML of its Markdown.
142
+ def html(node, context, **keywords)
143
+ return node.render_template(props: {}).to_s if template?(node.class)
144
+
145
+ call(node, :render, **context.to_h, **keywords).to_s
146
+ end
147
+
148
+ # Whether the node renders its own HTML rather than the HTML of its Markdown, which Raindown's nodes render by default.
118
149
  def html?(node)
119
- node.respond_to?(:render) || (node.class.respond_to?(:template) && !node.class.template.nil?)
150
+ return true if template?(node.class)
151
+
152
+ node.respond_to?(:render) && node.method(:render).owner != Node
153
+ end
154
+
155
+ # Whether the node renders at all, so it can be write-in, in <{ }>.
156
+ def renders?(node)
157
+ node.respond_to?(:render) || html?(node)
120
158
  end
121
159
 
122
160
  def instances
@@ -5,7 +5,7 @@ require_relative 'page'
5
5
  require_relative 'utils'
6
6
 
7
7
  module Raindown
8
- # The metadata of a project's Markdown files, which list: and files: list by tag.
8
+ # The metadata of a project's Markdown files, which list: lists by tag.
9
9
  # Ported from Raindeer's Rain::Pages, and takes the same Dirload metadata.
10
10
  #
11
11
  # Each file is tagged with its front matter, like "tags: [ruby, docs]" or "date: 2099", and by its path,
@@ -65,7 +65,7 @@ module Raindown
65
65
  def initialize(root:, metadata:, render_options: {}, eager: false)
66
66
  @root = root
67
67
  @render_options = render_options
68
- @file_paths = metadata.file_types.values_at(*EXTENSIONS).flatten.compact
68
+ @file_paths = filtered_paths(metadata:)
69
69
  load_metadata if eager
70
70
  end
71
71
 
@@ -82,6 +82,11 @@ module Raindown
82
82
 
83
83
  private
84
84
 
85
+ # The Markdown files in Dirload's metadata, only those under the root path.
86
+ def filtered_paths(metadata:)
87
+ metadata.file_types.values_at(*EXTENSIONS).flatten.compact.select { it.start_with?("#{@root}/") }
88
+ end
89
+
85
90
  def metadata
86
91
  load_metadata unless @metadata
87
92
  @metadata
@@ -103,8 +108,7 @@ module Raindown
103
108
  end
104
109
 
105
110
  def process(file_path:)
106
- metadata = implicit_metadata(file_path:).merge(front_matter(file_path))
107
- @metadata[file_path] = metadata
111
+ metadata = @metadata[file_path] = implicit_metadata(file_path:).merge(front_matter(file_path))
108
112
  metadata.each { |type, tag| Array(tag).each { |item| tag(type:, tag: item, file_path:) } }
109
113
  end
110
114
 
@@ -5,31 +5,33 @@ require_relative '../elements'
5
5
  require_relative 'bare_directives'
6
6
  require_relative 'code'
7
7
  require_relative 'code_blocks'
8
+ require_relative 'context'
8
9
  require_relative 'front_matter'
9
10
 
10
11
  module Raindown
11
12
  # Renders the directives whose output is Markdown, before Commonmarker reads it: variables, loops, conditions and lists.
12
13
  # So a loop's items become one Markdown list or table, and a heading's id and its entry in a table of contents
13
14
  # come from its text, like "# Notes for {name}" becoming "Notes for Mark" with the id "notes-for-mark".
14
- # Nodes like toc: and files: render before this. Components and endpoints render here too, as fragments of HTML
15
- # that Raindown puts back once the document is HTML.
15
+ # Nodes bare or in comments, like toc: and list:, render before this. Nodes in <{ }> and endpoints render here, as
16
+ # fragments of HTML that Raindown puts back once the document is HTML.
16
17
  module PreRenderer
17
18
  BLOCK_DIRECTIVES = %w[for if list].freeze
18
19
  # A for, if or list directive alone on its line, which leaves no line behind, so a loop's list items stay together.
19
- BLOCK_LINE = /^[ \t]*(<\{\s*:?(?:#{BLOCK_DIRECTIVES.join('|')})\b[^}]*\}>)[ \t]*\n/
20
+ # A self-closing <{ :list folder: 'notes' }> isn't one, as only an open tag has props after its name.
21
+ BLOCK_LINE = /^[ \t]*(<\{\s*(?:(?:#{BLOCK_DIRECTIVES.join('|')}):[^}]*|:(?:#{BLOCK_DIRECTIVES.join('|')})\s*)\}>)[ \t]*\n/
20
22
  # An endpoint's directive or end tag in a comment, which renders as it would in Antlers' delimiters.
21
23
  ENDPOINT_COMMENT = /\A( {0,3})<!--\s*(endpoint:.*?|:endpoint)\s*-->/
22
24
 
23
25
  module_function
24
26
 
25
- def render(markdown, variables:, metadata: {}, bare: false)
26
- markdown = BareDirectives.expand(markdown) if bare
27
+ def render(markdown, variables:, context: Context.new)
28
+ markdown = BareDirectives.expand(markdown) if context.bare
27
29
  markdown = delimited_endpoints(markdown) if markdown.include?('<!--')
28
30
  return markdown unless markdown.include?('{')
29
31
 
30
32
  markdown = Code.hide_braces(markdown.dup.force_encoding('UTF-8'))
31
33
  body = FrontMatter.parse(markdown).last
32
- "#{markdown.delete_suffix(body)}#{render_body(body, variables, metadata)}"
34
+ "#{markdown.delete_suffix(body)}#{render_body(body, variables, context)}"
33
35
  end
34
36
 
35
37
  # The endpoints in comments in Antlers' delimiters, which the template pass renders, leaving code as it is.
@@ -46,16 +48,17 @@ module Raindown
46
48
  lines.join
47
49
  end
48
50
 
49
- def render_body(body, variables, metadata)
51
+ def render_body(body, variables, context)
50
52
  template = body.gsub(BLOCK_LINE, '\1')
51
53
  return body unless template.include?('{')
52
54
 
53
55
  # Antlers strips the whitespace around a template, which can be Markdown, like an indented code block.
54
56
  leading = template[/\A\s*/]
55
57
  trailing = template[/\s*\z/]
56
- # Components and endpoints render here too, where loop variables are in scope, and keep their HTML as fragments.
57
- elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:list, :endpoint, :prop_component, :slot_component]
58
- rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, metadata:)
58
+ # Write-in nodes and endpoints render here too, where loop variables are in scope, and keep their HTML as
59
+ # fragments.
60
+ elements = Antlers::Elements[:html, :var, :if, :for] + Elements[:write_in, :endpoint]
61
+ rendered = Antlers.render(ast: Antlers.ast(template:, elements:), current_binding: variables.binding, context:)
59
62
 
60
63
  "#{leading}#{rendered}#{trailing}"
61
64
  end
@@ -13,6 +13,7 @@ module Raindown
13
13
  IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
14
14
  # Ruby nodes and JavaScript Components.
15
15
  NODE_EXTENSIONS = ['.rb', '.rbx', *JSNodes::EXTENSIONS].freeze
16
+ VAULT_FOLDER = '.obsidian'
16
17
 
17
18
  module_function
18
19
 
@@ -23,11 +24,11 @@ module Raindown
23
24
  end
24
25
 
25
26
  # The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
26
- # gitignored ones, so it stays quick in a big folder like ~/dev.
27
+ # gitignored ones, so it stays quick in a big folder like ~/dev, and vaults, which their editor writes back to.
27
28
  def project_folders(directory, gitignore = Gitignore.new(directory))
28
29
  Dir.glob('*/', base: directory).flat_map do |folder|
29
30
  path = File.join(directory, folder.chomp('/'))
30
- next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
31
+ next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path) || vault?(path)
31
32
 
32
33
  inside = project_folders(path, Gitignore.new(path, gitignore))
33
34
  next inside unless project?(path)
@@ -44,13 +45,13 @@ module Raindown
44
45
  end
45
46
 
46
47
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
47
- # since they have nodes of their own.
48
+ # since they have nodes of their own, and vaults, which their editor writes back to.
48
49
  def files(root)
49
50
  files = []
50
51
 
51
52
  Find.find(root) do |path|
52
53
  if File.directory?(path)
53
- Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
54
+ Find.prune if path != root && (ignored?(File.basename(path)) || project?(path) || vault?(path))
54
55
  elsif markdown?(path, root:)
55
56
  files << path
56
57
  end
@@ -82,6 +83,25 @@ module Raindown
82
83
  File.directory?(File.join(folder, Nodes::FOLDER))
83
84
  end
84
85
 
86
+ # Whether a folder's an Obsidian vault, whose plugin runs a Raindown of its own, so one watching a folder above it
87
+ # leaves it alone, while one pointed at the vault itself doesn't.
88
+ def vault?(folder)
89
+ File.directory?(File.join(folder, VAULT_FOLDER))
90
+ end
91
+
92
+ # Whether a path's in a vault below the directory, which isn't the directory itself.
93
+ def vault_below?(path, directory)
94
+ tops = [File.expand_path(directory), File.realpath(directory)]
95
+ folders = []
96
+ folder = File.dirname(File.expand_path(path))
97
+ until tops.include?(folder) || folder == File.dirname(folder)
98
+ folders << folder
99
+ folder = File.dirname(folder)
100
+ end
101
+
102
+ tops.include?(folder) && folders.any? { vault?(it) }
103
+ end
104
+
85
105
  def ignored?(folder)
86
106
  folder.start_with?('.') || IGNORED.include?(folder)
87
107
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative 'node_injector'
4
4
  require_relative 'endpoint/endpoint_runner'
5
- require_relative 'table_formatter'
5
+ require_relative 'table/table_formatter'
6
6
  require_relative 'utils'
7
7
 
8
8
  module Raindown
data/lib/raindown/stat.rb CHANGED
@@ -23,7 +23,7 @@ module Raindown
23
23
  '9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
24
24
  '.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
25
25
 
26
- def replace(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
26
+ def rewrite(metadata: NoMetadata) = "```\n#{card_lines(metadata:).join("\n")}\n```"
27
27
 
28
28
  # The number in block digits, then the label, as a card on the Dashboard shows them.
29
29
  def card_lines(metadata: NoMetadata)
@@ -11,19 +11,27 @@ module Raindown
11
11
  module_function
12
12
 
13
13
  def format(rows)
14
+ return '' if rows.empty?
15
+
14
16
  header, *body = rows.map { |row| "| #{row.map { escape(it) }.join(' | ')} |" }
15
17
  [header, "|#{Array.new(rows.first.length, '---').join('|')}|", *body].join("\n")
16
18
  end
17
19
 
18
20
  # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
19
21
  def parse(lines)
22
+ padded_rows(lines).map { |row| row.map { cell(it) } }
23
+ end
24
+
25
+ # The rows of the table in the lines, their cells as written, padding and all, leaving out its separator.
26
+ def padded_rows(lines)
20
27
  return [] unless lines[1]&.strip&.match?(SEPARATOR)
21
28
 
22
- [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
29
+ table_lines = [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }
30
+ table_lines.map { it.strip.delete_prefix('|').delete_suffix('|').split(PIPE, -1) }
23
31
  end
24
32
 
25
- def cells(line)
26
- line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
33
+ def cell(padded)
34
+ padded.strip.gsub('\|', '|')
27
35
  end
28
36
 
29
37
  def escape(cell)
@@ -6,6 +6,8 @@ require 'json'
6
6
 
7
7
  require_relative '../node'
8
8
  require_relative 'markdown_table'
9
+ require_relative 'table_formulas'
10
+ require_relative 'written_table'
9
11
 
10
12
  module Raindown
11
13
  # The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
@@ -17,42 +19,120 @@ module Raindown
17
19
  # | Ada | engineer |
18
20
  # | Lin | design |
19
21
  # Edit the table and its rows are written back to the file, or edit the file and the table's written again. When
20
- # both changed, neither is, and the table's comment says so until they match again.
22
+ # both changed, neither is, and the table's comment says so until they match again. With total: true, a bold row
23
+ # below the table sums its columns of numbers, which is worked out each time and never written to the file. Formula
24
+ # cells in the file, like =SUM(B), show their values, and keep their formulas while their values are left alone. Delete
25
+ # the padding around one's value and it shows its formula, to edit, until it's changed.
21
26
  class Table < Node
22
27
  COMMENT = /\A<!-- table (?:conflict|\d+ rows?) · (\h+) -->\z/
23
28
  SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
29
+ BOLD = /\A\*\*.*\*\*\z/
24
30
 
25
- def initialize(data)
31
+ def initialize(data, total: false)
26
32
  @data = data
33
+ @total = total
27
34
  end
28
35
 
29
36
  # Written is what it wrote last time, so it can tell which of the table and the file changed since.
30
- def replace(written: [], metadata: NoMetadata)
31
- path = File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
32
- return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
33
- return "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
37
+ def rewrite(written: [], metadata: NoMetadata)
38
+ path = data_path(metadata)
39
+ data_error(path) || synced(path, written.map(&:chomp))
40
+ end
34
41
 
35
- synced(path, written.map(&:chomp))
42
+ # Write-in, in <{ }>, the table only shows its file, and never writes to it.
43
+ def render(metadata: NoMetadata)
44
+ path = data_path(metadata)
45
+ Raindown.commonmark(data_error(path) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
36
46
  end
37
47
 
38
48
  private
39
49
 
50
+ def data_path(metadata)
51
+ File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
52
+ end
53
+
54
+ # Why the file can't be a table, or nil when it can.
55
+ def data_error(path)
56
+ return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
57
+
58
+ "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
59
+ end
60
+
40
61
  def synced(path, written)
41
62
  file = read_rows(path)
63
+ shown = TableFormulas.evaluate(file)
42
64
  synced_hash = written.first&.[](COMMENT, 1)
43
- table = MarkdownTable.parse(written.drop(1))
44
- return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
45
- return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
65
+ table = WrittenTable.new(written_rows(written), file)
66
+ return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
67
+ return markdown(write_rows(path, with_formulas(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
46
68
 
47
69
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
48
70
  ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
49
71
  end
50
72
 
51
- def markdown(rows)
73
+ # Whether there's nothing to write back: the table's rows are as it wrote them, or as the file's are, or it's new.
74
+ def unchanged?(rows, synced_hash, shown)
75
+ synced_hash.nil? || rows.empty? || [synced_hash, hash_rows(shown)].include?(hash_rows(rows))
76
+ end
77
+
78
+ # The table's rows as it was written, padding and all, leaving out its total.
79
+ def written_rows(written)
80
+ header, *body = MarkdownTable.padded_rows(written.drop(1))
81
+ [header, *body.reject { total_row?(it.map(&:strip)) }].compact
82
+ end
83
+
84
+ def markdown(rows, revealed = [])
52
85
  return '' if rows.empty?
53
86
 
87
+ shown = TableFormulas.evaluate(rows)
54
88
  count = rows.length - 1
55
- "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
89
+ table = MarkdownTable.format(with_total(with_revealed(shown, rows, revealed)))
90
+ "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(shown)} -->\n#{table}"
91
+ end
92
+
93
+ # The values, but for each revealed cell, which shows its formula.
94
+ def with_revealed(values, rows, revealed)
95
+ revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
96
+ end
97
+
98
+ # The table's rows, keeping the file's formula in each cell still showing its value.
99
+ def with_formulas(table, file, shown)
100
+ table.each_with_index.map do |row, row_index|
101
+ row.each_with_index.map do |cell, column|
102
+ formula = file.dig(row_index, column)
103
+ next formula if TableFormulas.formula?(formula) && shown.dig(row_index, column) == cell
104
+
105
+ cell
106
+ end
107
+ end
108
+ end
109
+
110
+ def with_total(rows)
111
+ return rows unless @total && rows.length > 1
112
+
113
+ body = rows.drop(1)
114
+ sums = rows.first.each_index.map { |column| sum_cells(body.map { it[column].to_s }) }
115
+ sums[0] ||= 'Total'
116
+ [*rows, sums.map { bold_cell(it) }]
117
+ end
118
+
119
+ # The sum of cells that are all numbers, or blank, to the most decimal places any has, or nil when they aren't.
120
+ def sum_cells(cells)
121
+ numbers = cells.reject(&:empty?)
122
+ return if numbers.empty? || !numbers.all? { it.match?(TableFormulas::NUMBER) }
123
+
124
+ TableFormulas.sum(numbers)
125
+ end
126
+
127
+ def bold_cell(text)
128
+ return '' unless text
129
+
130
+ "**#{text}**"
131
+ end
132
+
133
+ # The total row it wrote, its cells bold or blank, which isn't one of the file's rows.
134
+ def total_row?(row)
135
+ row.any? { !it.empty? } && row.all? { it.empty? || it.match?(BOLD) }
56
136
  end
57
137
 
58
138
  def read_rows(path)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raindown
4
+ # Formula cells in a table's rows, like =SUM(B), by its column's letter, or =sum(Price), by its header, worked out
5
+ # as spreadsheets do: the numbers in the column's other rows below the header, leaving out other formulas. Any other
6
+ # formula, or one naming no column, like one still being typed, is left as it's written.
7
+ module TableFormulas
8
+ NUMBER = /\A-?\d+(?:\.\d+)?\z/
9
+ SUM = /\A=SUM\(\s*([^)]+?)\s*\)\z/i
10
+
11
+ module_function
12
+
13
+ def evaluate(rows)
14
+ header, *body = rows
15
+ return rows unless header
16
+
17
+ [header, *body.map { |row| row.map { sum_formula(it, header, body) || it } }]
18
+ end
19
+
20
+ def formula?(cell)
21
+ cell.to_s.match?(SUM)
22
+ end
23
+
24
+ # The sum of the numbers, to the most decimal places any of them has.
25
+ def sum(numbers)
26
+ places = numbers.map { it[/\.(\d+)/, 1].to_s.length }.max.to_i
27
+ total = numbers.sum { Rational(it) }
28
+ return total.round.to_s if places.zero?
29
+
30
+ format("%.#{places}f", total)
31
+ end
32
+
33
+ def sum_formula(cell, header, body)
34
+ column_ref = cell[SUM, 1]
35
+ return unless column_ref
36
+
37
+ column = header.index { it.casecmp?(column_ref) } || column_index(column_ref)
38
+ return unless column && column < header.length
39
+
40
+ sum(body.map { it[column].to_s }.select { it.match?(NUMBER) })
41
+ end
42
+
43
+ # A column's letters as its index, like B as 1 and AA as 26, or nil when they aren't letters.
44
+ def column_index(letters)
45
+ return unless letters.match?(/\A[A-Z]{1,3}\z/i)
46
+
47
+ letters.upcase.chars.reduce(0) { |index, letter| (index * 26) + letter.ord - 64 } - 1
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unicode/display_width'
4
+
5
+ require_relative 'markdown_table'
6
+ require_relative 'table_formulas'
7
+
8
+ module Raindown
9
+ # A table: block's table as it was written, its cells padded as they were, against its file's rows. A formula cell
10
+ # shows its formula, so it can be edited, when its value's padding was deleted, no longer the width of its column's
11
+ # header, and keeps showing it, until it's changed from the file's, ignoring whitespace.
12
+ class WrittenTable
13
+ def initialize(padded_rows, file)
14
+ @padded_rows = padded_rows
15
+ @file = file
16
+ @values = TableFormulas.evaluate(file)
17
+ end
18
+
19
+ # Its rows, as wide as each other, each formula still shown as it's saved being its value, as they're compared with the file's.
20
+ def rows
21
+ cells.each_with_index.map do |row, row_index|
22
+ row.each_with_index.map do |cell, column|
23
+ next @values.dig(row_index, column) if same_formula?(cell, @file.dig(row_index, column))
24
+
25
+ cell
26
+ end
27
+ end
28
+ end
29
+
30
+ # The row and column of each cell to show as its formula.
31
+ def revealed
32
+ cells.each_with_index.flat_map do |row, row_index|
33
+ row.each_index.select { reveal?(row_index, it) }.map { [row_index, it] }
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def cells
40
+ @cells ||= @padded_rows.map { |row| Array.new(width_in_cells) { MarkdownTable.cell(row[it].to_s) } }
41
+ end
42
+
43
+ # As wide as its widest row, as its file's rows are read, so a row still being typed reads the same in both.
44
+ def width_in_cells
45
+ @padded_rows.map(&:length).max
46
+ end
47
+
48
+ def reveal?(row, column)
49
+ formula = @file.dig(row, column)
50
+ return false unless TableFormulas.formula?(formula)
51
+
52
+ cell = cells[row][column]
53
+ same_formula?(cell, formula) || (cell == @values.dig(row, column) && squeezed?(row, column))
54
+ end
55
+
56
+ def same_formula?(cell, formula)
57
+ TableFormulas.formula?(formula) && cell.gsub(/\s+/, '') == formula.gsub(/\s+/, '')
58
+ end
59
+
60
+ def squeezed?(row, column)
61
+ width(@padded_rows[row][column]) != width(@padded_rows.first[column])
62
+ end
63
+
64
+ def width(padded)
65
+ Unicode::DisplayWidth.of(padded.to_s, emoji: :all)
66
+ end
67
+ end
68
+ end
data/lib/raindown/toc.rb CHANGED
@@ -14,7 +14,7 @@ module Raindown
14
14
  IGNORED = %r{<!--.*?-->|<script\b.*?</script\s*>}mi
15
15
  TAG = /<[^>]*>/
16
16
 
17
- def replace(document:, metadata: nil, bare: false)
17
+ def rewrite(document:, metadata: nil, bare: false)
18
18
  headings = headings(markdown: document, metadata:, bare:)
19
19
  return '' if headings.empty?
20
20
 
@@ -45,7 +45,7 @@ module Raindown
45
45
 
46
46
  # The document's headings, from its final HTML, or else by rendering it with its components.
47
47
  def headings(markdown:, html: nil, metadata: nil, bare: false)
48
- html ||= Fragments.collect { Raindown.assembled(markdown:, metadata: metadata || NoMetadata, bare:) }
48
+ html ||= Fragments.collect { Raindown.assembled(Context.new(document: markdown, metadata: metadata || NoMetadata, bare:)) }
49
49
 
50
50
  html.gsub(IGNORED, '').scan(HEADING).map do |level, *ids, content|
51
51
  { level: level.to_i, text: CGI.unescapeHTML(content.gsub(TAG, '')).strip, id: CGI.unescapeHTML(ids.compact.first) }
@@ -90,7 +90,7 @@ module Raindown
90
90
  # and the files and endpoints are read again for the files added, removed or changed. When endpoints run as files
91
91
  # are written back, like with -x, the screen says so while they do, as they can take a while.
92
92
  def watched(paths)
93
- changes = Watcher.changes(paths, root:)
93
+ changes = Watcher.changes(paths, directory: root, root: plain_root)
94
94
  return reload if changes.any?(&:nodes?)
95
95
  return if changes.empty?
96
96
 
@@ -100,6 +100,12 @@ module Raindown
100
100
  Wait.new(message: "Writing back #{relative(saved.first)}...", task: -> { write_back_all(saved) })
101
101
  end
102
102
 
103
+ # A file outside a project belongs to the folder only when it has no projects, as in a folder of notes. In one
104
+ # like ~/dev, only its projects are written back to, as they're all that's shown.
105
+ def plain_root
106
+ root if @workers.roots.empty?
107
+ end
108
+
103
109
  def write_back_all(files)
104
110
  files.each { write_back_saved(it) }
105
111
  @files.load