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.
- checksums.yaml +4 -4
- data/lib/elements.rb +12 -25
- data/lib/lexemes/node_lexeme.rb +37 -0
- data/lib/nodes/list_node.rb +22 -25
- data/lib/nodes/write_in_node.rb +95 -0
- data/lib/raindown/bare_directives.rb +1 -1
- data/lib/raindown/chart/chart.rb +1 -1
- data/lib/raindown/cli.rb +2 -0
- data/lib/raindown/context.rb +19 -0
- data/lib/raindown/directives.rb +1 -1
- data/lib/raindown/fragments.rb +6 -1
- data/lib/raindown/js/host.mjs +2 -2
- data/lib/raindown/js/js_nodes.rb +4 -4
- data/lib/raindown/list.rb +31 -0
- data/lib/raindown/markdown_nodes.rb +3 -2
- data/lib/raindown/node.rb +10 -2
- data/lib/raindown/node_blocks.rb +27 -21
- data/lib/raindown/node_injector.rb +1 -1
- data/lib/raindown/nodes.rb +60 -22
- data/lib/raindown/pages.rb +8 -4
- data/lib/raindown/pre_renderer.rb +13 -10
- data/lib/raindown/projects.rb +24 -4
- data/lib/raindown/rewriter.rb +1 -1
- data/lib/raindown/stat.rb +1 -1
- data/lib/raindown/table/markdown_table.rb +11 -3
- data/lib/raindown/table/table.rb +92 -12
- data/lib/raindown/table/table_formulas.rb +50 -0
- data/lib/raindown/table/written_table.rb +68 -0
- data/lib/raindown/toc.rb +2 -2
- data/lib/raindown/tui/navigator/commands.rb +7 -1
- data/lib/raindown/watcher/changes.rb +5 -3
- data/lib/raindown/watcher/supervisor.rb +1 -1
- data/lib/raindown/wiki_links.rb +1 -1
- data/lib/raindown.rb +9 -14
- data/lib/version.rb +1 -1
- metadata +11 -8
- data/lib/lexemes/list_lexeme.rb +0 -32
- data/lib/nodes/component_nodes.rb +0 -38
- data/lib/raindown/files.rb +0 -22
- /data/lib/raindown/{table_formatter.rb → table/table_formatter.rb} +0 -0
data/lib/raindown/nodes.rb
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative 'chart/chart'
|
|
4
|
-
require_relative '
|
|
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
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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,
|
|
22
|
-
BUILT_INS = { 'toc' => 'TOC', '
|
|
23
|
-
# A placeholder for a node's HTML, which stays as it is through Commonmarker and Antlers
|
|
24
|
-
|
|
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
|
|
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?(:
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
#
|
|
107
|
-
def
|
|
108
|
-
|
|
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
|
|
112
|
-
return node.
|
|
126
|
+
def placeholder(node, slot: '')
|
|
127
|
+
return "<!-- raindown:node #{list.index(node)} -->" if slot.empty?
|
|
113
128
|
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/raindown/pages.rb
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:,
|
|
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,
|
|
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,
|
|
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
|
-
#
|
|
57
|
-
|
|
58
|
-
|
|
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
|
data/lib/raindown/projects.rb
CHANGED
|
@@ -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
|
data/lib/raindown/rewriter.rb
CHANGED
data/lib/raindown/stat.rb
CHANGED
|
@@ -23,7 +23,7 @@ module Raindown
|
|
|
23
23
|
'9' => ['█▀█', '▀▀█', '▀▀▀'], '-' => [' ', '▀▀▀', ' '], '+' => [' ▄ ', '▀█▀', ' '],
|
|
24
24
|
'.' => [' ', ' ', '▀'], ',' => [' ', ' ', '▀'] }.freeze
|
|
25
25
|
|
|
26
|
-
def
|
|
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?('|') }
|
|
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
|
|
26
|
-
|
|
33
|
+
def cell(padded)
|
|
34
|
+
padded.strip.gsub('\|', '|')
|
|
27
35
|
end
|
|
28
36
|
|
|
29
37
|
def escape(cell)
|
data/lib/raindown/table/table.rb
CHANGED
|
@@ -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
|
|
31
|
-
path =
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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 =
|
|
44
|
-
return markdown(file) if
|
|
45
|
-
return markdown(write_rows(path, table)) if hash_rows(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|