raindown 0.17.1 → 0.18.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/raindown/chart/chart.rb +3 -1
- data/lib/raindown/chart/points.rb +1 -1
- data/lib/raindown/cli.rb +3 -2
- data/lib/raindown/editing.rb +1 -1
- data/lib/raindown/node_blocks.rb +34 -7
- data/lib/raindown/node_injector.rb +44 -3
- data/lib/raindown/nodes.rb +1 -0
- data/lib/raindown/problem.rb +34 -0
- data/lib/raindown/rewriter.rb +7 -4
- data/lib/raindown/table/file_cell.rb +83 -0
- data/lib/raindown/table/markdown_table.rb +51 -10
- data/lib/raindown/table/matched_rows.rb +71 -0
- data/lib/raindown/table/table.rb +47 -46
- data/lib/raindown/table/table_column.rb +115 -0
- data/lib/raindown/table/table_file.rb +50 -0
- data/lib/raindown/table/table_formatter.rb +63 -67
- data/lib/raindown/table/written_table.rb +49 -16
- data/lib/raindown/watcher/file_watcher.rb +1 -0
- data/lib/raindown/watcher/project.rb +7 -1
- data/lib/raindown/written.rb +20 -0
- data/lib/raindown.rb +2 -1
- data/lib/version.rb +1 -1
- metadata +8 -2
data/lib/raindown/table/table.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'csv'
|
|
4
3
|
require 'digest'
|
|
5
4
|
require 'json'
|
|
6
5
|
|
|
7
6
|
require_relative '../node'
|
|
8
7
|
require_relative '../utils'
|
|
8
|
+
require_relative '../written'
|
|
9
9
|
require_relative 'markdown_table'
|
|
10
|
+
require_relative 'table_column'
|
|
11
|
+
require_relative 'table_file'
|
|
10
12
|
require_relative 'table_formulas'
|
|
11
13
|
require_relative 'written_table'
|
|
12
14
|
|
|
@@ -14,7 +16,7 @@ module Raindown
|
|
|
14
16
|
# The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
|
|
15
17
|
# rows it has and a hash of them, as they were when the table and the file last matched:
|
|
16
18
|
# table: 'data/team.csv'
|
|
17
|
-
# <!--
|
|
19
|
+
# <!-- 2 rows · 3f2a9c -->
|
|
18
20
|
# | name | role |
|
|
19
21
|
# |------|----------|
|
|
20
22
|
# | Ada | engineer |
|
|
@@ -25,25 +27,33 @@ module Raindown
|
|
|
25
27
|
# cells in the file, like =SUM(B), show their values, and keep their formulas while their values are left alone. Delete
|
|
26
28
|
# the padding around one's value and it shows its formula, to edit, until it's changed.
|
|
27
29
|
class Table < Node
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
# The hash in its comment, like 3f2a9c in "2 rows · 3f2a9c".
|
|
31
|
+
HASH = /· (\h+)\z/
|
|
30
32
|
BOLD = /\A\*\*.*\*\*\z/
|
|
33
|
+
# The table a new file starts with, when there's no table to make it from.
|
|
34
|
+
STARTER = [['Column 1', 'Column 2'], ['', '']].freeze
|
|
31
35
|
|
|
32
|
-
def initialize(data, total: false)
|
|
36
|
+
def initialize(data = nil, total: false)
|
|
33
37
|
@data = data
|
|
34
38
|
@total = total
|
|
35
39
|
end
|
|
36
40
|
|
|
37
|
-
# Written is what it wrote last time, so it can tell which of the table and the file changed since.
|
|
38
|
-
|
|
41
|
+
# Written is what it wrote last time, so it can tell which of the table and the file changed since. A file that's
|
|
42
|
+
# not there yet is made, from the table below it, or else one to fill in.
|
|
43
|
+
def rewrite(written: [], metadata: NoMetadata, comment: nil)
|
|
39
44
|
path = data_path(metadata)
|
|
40
|
-
|
|
45
|
+
lines = written.map(&:chomp)
|
|
46
|
+
create_file(path, lines) if missing_file?(path, metadata)
|
|
47
|
+
data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines, comment)
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
# Write-in, in <{ }>, the table only shows its file, and never writes to it.
|
|
44
51
|
def render(metadata: NoMetadata)
|
|
45
52
|
path = data_path(metadata)
|
|
46
|
-
|
|
53
|
+
error = data_error(path, metadata)
|
|
54
|
+
return Raindown.commonmark(Problem.alert(error)) if error
|
|
55
|
+
|
|
56
|
+
Raindown.commonmark(MarkdownTable.format(with_total(TableFormulas.evaluate(TableFile.read(path)))))
|
|
47
57
|
end
|
|
48
58
|
|
|
49
59
|
private
|
|
@@ -58,24 +68,36 @@ module Raindown
|
|
|
58
68
|
Dir.pwd
|
|
59
69
|
end
|
|
60
70
|
|
|
71
|
+
def missing_file?(path, metadata)
|
|
72
|
+
TableFile.supported?(path) && Utils.inside?(path, data_root(metadata)) && !File.exist?(path)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# From the table as it's written, below any alert saying why there was no file.
|
|
76
|
+
def create_file(path, lines)
|
|
77
|
+
rows = MarkdownTable.parse(lines.grep_v(/\A>/))
|
|
78
|
+
rows = STARTER if rows.empty?
|
|
79
|
+
TableFile.create(path, rows)
|
|
80
|
+
end
|
|
81
|
+
|
|
61
82
|
# Why the file can't be a table, or nil when it can.
|
|
62
83
|
def data_error(path, metadata)
|
|
63
|
-
return
|
|
84
|
+
return 'Needs a file path to a CSV/TSV that exists or will be created' if @data.to_s.strip.empty?
|
|
85
|
+
return "#{@data.inspect} must be a file path to a CSV/TSV that exists or will be created" unless TableFile.supported?(path)
|
|
64
86
|
return "Can't show #{@data.inspect} as a table: it's outside the project" unless Utils.inside?(path, data_root(metadata))
|
|
65
87
|
|
|
66
88
|
"Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
|
|
67
89
|
end
|
|
68
90
|
|
|
69
|
-
def synced(path, written)
|
|
70
|
-
file =
|
|
91
|
+
def synced(path, written, comment)
|
|
92
|
+
file = TableFile.read(path)
|
|
71
93
|
shown = TableFormulas.evaluate(file)
|
|
72
|
-
synced_hash =
|
|
73
|
-
table = WrittenTable.new(written_rows(written), file)
|
|
74
|
-
return markdown(file, table
|
|
75
|
-
return markdown(
|
|
94
|
+
synced_hash = comment&.[](HASH, 1)
|
|
95
|
+
table = WrittenTable.new(written_rows(written), file, MarkdownTable.delimiter(written))
|
|
96
|
+
return markdown(file, table) if unchanged?(table.rows, synced_hash, shown)
|
|
97
|
+
return markdown(TableFile.write(path, table.saved_rows), table) if hash_rows(shown) == synced_hash
|
|
76
98
|
|
|
77
99
|
warn "raindown: #{@data} and its table both changed, so neither is written until they match"
|
|
78
|
-
|
|
100
|
+
Written.new(written.join("\n"), comment: "conflict · #{synced_hash}")
|
|
79
101
|
end
|
|
80
102
|
|
|
81
103
|
# 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.
|
|
@@ -85,37 +107,27 @@ module Raindown
|
|
|
85
107
|
|
|
86
108
|
# The table's rows as it was written, padding and all, leaving out its total.
|
|
87
109
|
def written_rows(written)
|
|
88
|
-
header, *body = MarkdownTable.padded_rows(written
|
|
110
|
+
header, *body = MarkdownTable.padded_rows(written)
|
|
89
111
|
[header, *body.reject { total_row?(it.map(&:strip)) }].compact
|
|
90
112
|
end
|
|
91
113
|
|
|
92
|
-
|
|
114
|
+
# Written with the delimiter it was written with, its cells fit to its fixed columns.
|
|
115
|
+
def markdown(rows, written)
|
|
93
116
|
return '' if rows.empty?
|
|
94
117
|
|
|
95
118
|
shown = TableFormulas.evaluate(rows)
|
|
96
119
|
count = rows.length - 1
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
cells = with_total(with_revealed(shown, rows, written.revealed))
|
|
121
|
+
columns = TableColumn.all(written.delimiter, rows)
|
|
122
|
+
table = MarkdownTable.format(TableColumn.fit(cells, columns, written.revealed), written.delimiter)
|
|
123
|
+
Written.new(table, comment: "#{count} row#{'s' unless count == 1} · #{hash_rows(shown)}")
|
|
99
124
|
end
|
|
100
125
|
|
|
101
|
-
# The values, but for each revealed cell, which shows its formula.
|
|
126
|
+
# The values, but for each revealed cell, which shows its formula, or its full text.
|
|
102
127
|
def with_revealed(values, rows, revealed)
|
|
103
128
|
revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
|
|
104
129
|
end
|
|
105
130
|
|
|
106
|
-
# The table's rows, keeping the file's cell where the table still shows it, like a formula as its value, or a cell
|
|
107
|
-
# with line breaks on one line.
|
|
108
|
-
def with_file_cells(table, file, shown)
|
|
109
|
-
table.each_with_index.map do |row, row_index|
|
|
110
|
-
row.each_with_index.map do |cell, column|
|
|
111
|
-
kept = file.dig(row_index, column)
|
|
112
|
-
next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
|
|
113
|
-
|
|
114
|
-
cell
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
end
|
|
118
|
-
|
|
119
131
|
def with_total(rows)
|
|
120
132
|
return rows unless @total && rows.length > 1
|
|
121
133
|
|
|
@@ -144,17 +156,6 @@ module Raindown
|
|
|
144
156
|
row.any? { !it.empty? } && row.all? { it.empty? || it.match?(BOLD) }
|
|
145
157
|
end
|
|
146
158
|
|
|
147
|
-
def read_rows(path)
|
|
148
|
-
rows = CSV.read(path, col_sep: SEPARATORS[File.extname(path).downcase], skip_blanks: true, encoding: Encoding::UTF_8)
|
|
149
|
-
width = rows.map(&:length).max.to_i
|
|
150
|
-
rows.map { |row| Array.new(width) { row[it].to_s.strip } }
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def write_rows(path, rows)
|
|
154
|
-
File.write(path, rows.map { CSV.generate_line(it, col_sep: SEPARATORS[File.extname(path).downcase]) }.join)
|
|
155
|
-
rows
|
|
156
|
-
end
|
|
157
|
-
|
|
158
159
|
# As the table shows them, so a cell with line breaks hashes the same in both.
|
|
159
160
|
def hash_rows(rows)
|
|
160
161
|
Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row.map { MarkdownTable.one_line(it.to_s) } }))[0, 6]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'unicode/display_width'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# A table's column: its alignment, from its delimiter cell's colons, and its width, that of its widest cell, or as wide
|
|
7
|
+
# as its delimiter when that's equals signs, like |====|, which is fixed. An equals sign typed into dashes, like
|
|
8
|
+
# |-=---|, makes it fixed as wide as it was, and a dash typed into equals signs, like |==-==|, makes it widen again.
|
|
9
|
+
TableColumn = Data.define(:delimiter, :width) do
|
|
10
|
+
# The columns of the delimiter's cells, while it has one for each of the rows' columns.
|
|
11
|
+
def self.all(delimiter, rows)
|
|
12
|
+
return [] unless delimiter&.length == rows.first&.length
|
|
13
|
+
|
|
14
|
+
delimiter.each_with_index.map { |cell, i| from(cell, rows.map { it[i].to_s }) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The rows with each cell clipped to fit its fixed column, but for those revealed, which are wrapped to fit it as
|
|
18
|
+
# lines instead, but for the header's, as + rows can only be below the delimiter.
|
|
19
|
+
def self.fit(rows, columns, revealed)
|
|
20
|
+
rows.each_with_index.map do |row, row_index|
|
|
21
|
+
row.each_with_index.map do |cell, column|
|
|
22
|
+
next cell unless columns[column]&.fixed?
|
|
23
|
+
next columns[column].clip(cell) unless revealed.include?([row_index, column])
|
|
24
|
+
next cell if row_index.zero?
|
|
25
|
+
|
|
26
|
+
columns[column].wrap(cell)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.from(delimiter, cells)
|
|
32
|
+
return new(delimiter:, width: delimiter.length - fixing(delimiter)) if fixed?(delimiter)
|
|
33
|
+
|
|
34
|
+
new(delimiter:, width: [*cells.map { display_width(it) }, 3].max)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
|
|
38
|
+
# instead of guessing from the terminal, so a file formats the same on every machine.
|
|
39
|
+
def self.display_width(cell)
|
|
40
|
+
Unicode::DisplayWidth.of(cell, emoji: :all)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Equals signs, or one typed into dashes, as fewer of them than dashes.
|
|
44
|
+
def self.fixed?(delimiter)
|
|
45
|
+
equals = delimiter.count('=')
|
|
46
|
+
equals.positive? && (delimiter.count('-').zero? || equals < delimiter.count('-'))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The equals signs typed into dashes, which aren't part of its width, or none when it's all equals signs.
|
|
50
|
+
def self.fixing(delimiter)
|
|
51
|
+
return 0 if delimiter.count('-').zero?
|
|
52
|
+
|
|
53
|
+
delimiter.count('=')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def fixed?
|
|
57
|
+
TableColumn.fixed?(delimiter)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def alignment
|
|
61
|
+
if delimiter.start_with?(':') && delimiter.end_with?(':') then :center
|
|
62
|
+
elsif delimiter.end_with?(':') then :right
|
|
63
|
+
elsif delimiter.start_with?(':') then :left
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def delimiter_cell
|
|
68
|
+
case alignment
|
|
69
|
+
when :center then ":#{dashes * (width - 2)}:"
|
|
70
|
+
when :right then "#{dashes * (width - 1)}:"
|
|
71
|
+
when :left then ":#{dashes * (width - 1)}"
|
|
72
|
+
else dashes * width
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def pad(cell)
|
|
77
|
+
padding = [width - TableColumn.display_width(cell), 0].max
|
|
78
|
+
|
|
79
|
+
case alignment
|
|
80
|
+
when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
|
|
81
|
+
when :right then "#{' ' * padding}#{cell}"
|
|
82
|
+
else "#{cell}#{' ' * padding}"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The cell cut to fit a fixed column, ending in an ellipsis, or as it is when it fits.
|
|
87
|
+
def clip(cell)
|
|
88
|
+
return cell unless fixed? && TableColumn.display_width(cell) > width
|
|
89
|
+
|
|
90
|
+
kept = cell.grapheme_clusters.each_with_object(+'') do |grapheme, text|
|
|
91
|
+
break text if TableColumn.display_width(text + grapheme) > width - 1
|
|
92
|
+
|
|
93
|
+
text << grapheme
|
|
94
|
+
end
|
|
95
|
+
"#{kept.rstrip}…"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The cell's words as lines that fit the column, each as many as fit, and a word longer than it on a line of its own.
|
|
99
|
+
def wrap(cell)
|
|
100
|
+
cell.split(' ').each_with_object([]) do |word, lines|
|
|
101
|
+
next lines.last << " #{word}" if lines.any? && TableColumn.display_width("#{lines.last} #{word}") <= width
|
|
102
|
+
|
|
103
|
+
lines << +word
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def dashes
|
|
110
|
+
return '=' if fixed?
|
|
111
|
+
|
|
112
|
+
'-'
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'csv'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Raindown
|
|
7
|
+
# The CSV or TSV file behind a table:, as rows of cells as wide as each other, written back in its own format.
|
|
8
|
+
module TableFile
|
|
9
|
+
SEPARATORS = { '.csv' => ',', '.tsv' => "\t" }.freeze
|
|
10
|
+
BYTE_ORDER_MARK = "\uFEFF"
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def supported?(path)
|
|
15
|
+
SEPARATORS.key?(File.extname(path).downcase)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def read(path)
|
|
19
|
+
rows = CSV.read(path, col_sep: separator(path), skip_blanks: true, encoding: 'bom|utf-8')
|
|
20
|
+
width = rows.map(&:length).max.to_i
|
|
21
|
+
rows.map { |row| Array.new(width) { row[it].to_s.strip } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A new file, and the folders it's in, which has no format of its own to keep yet.
|
|
25
|
+
def create(path, rows)
|
|
26
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
27
|
+
File.binwrite(path, rows.map { CSV.generate_line(without_empty_cells(it), col_sep: separator(path)) }.join)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Keeps the file's line endings and any byte order mark, like Excel's.
|
|
31
|
+
def write(path, rows)
|
|
32
|
+
text = File.binread(path).force_encoding(Encoding::UTF_8)
|
|
33
|
+
options = { col_sep: separator(path), row_sep: text[/\r?\n/] || "\n" }
|
|
34
|
+
lines = rows.map { CSV.generate_line(without_empty_cells(it), **options) }
|
|
35
|
+
File.binwrite(path, [(BYTE_ORDER_MARK if text.start_with?(BYTE_ORDER_MARK)), *lines].join)
|
|
36
|
+
rows
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def separator(path)
|
|
40
|
+
SEPARATORS[File.extname(path).downcase]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Empty cells as nil, which CSV leaves empty rather than quoting, but for a row of one, as it would be a blank line.
|
|
44
|
+
def without_empty_cells(row)
|
|
45
|
+
return row if row.one?
|
|
46
|
+
|
|
47
|
+
row.map { it unless it.empty? }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -1,22 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative 'table_column'
|
|
4
4
|
|
|
5
5
|
module Raindown
|
|
6
|
-
# Pads every column of each Markdown table to the width of its widest cell
|
|
6
|
+
# Pads every column of each Markdown table to the width of its widest cell, but for a column whose delimiter is
|
|
7
|
+
# equals signs, like |====|, which is as wide as they are, letting a longer cell run past it. A row starting with |+,
|
|
8
|
+
# like "|+more | |", carries on the row above it, which Raindown joins into its cells when it renders. Its |+ takes
|
|
9
|
+
# the place of a row's "| ", so its columns line up.
|
|
7
10
|
class TableFormatter
|
|
8
|
-
DELIMITER_CELL = /\A
|
|
11
|
+
DELIMITER_CELL = /\A:?[-=]+:?\z/
|
|
9
12
|
FENCE = /\A\s*(```|~~~)/
|
|
13
|
+
CONTINUATION = /\A\s*\|\+/
|
|
10
14
|
|
|
11
15
|
def self.rewrite(content:, **)
|
|
12
16
|
new(content:).format
|
|
13
17
|
end
|
|
14
18
|
|
|
19
|
+
# The Markdown as Commonmarker reads it: each table's equals signs as dashes, and its + rows joined into the row
|
|
20
|
+
# above them, each cell carrying on with a space.
|
|
21
|
+
def self.gfm(markdown)
|
|
22
|
+
new(content: markdown).gfm
|
|
23
|
+
end
|
|
24
|
+
|
|
15
25
|
def initialize(content:)
|
|
16
26
|
@lines = content.lines
|
|
17
27
|
end
|
|
18
28
|
|
|
19
29
|
def format
|
|
30
|
+
replace_tables { align(it) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def gfm
|
|
34
|
+
replace_tables { gfm_table(it) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# The content with each table, outside code, replaced by the block's lines for it.
|
|
40
|
+
def replace_tables
|
|
20
41
|
output = []
|
|
21
42
|
index = 0
|
|
22
43
|
in_fence = false
|
|
@@ -27,7 +48,7 @@ module Raindown
|
|
|
27
48
|
|
|
28
49
|
if !in_fence && table_start?(index)
|
|
29
50
|
table_end = find_table_end(index)
|
|
30
|
-
output.concat(
|
|
51
|
+
output.concat(yield(@lines[index...table_end]))
|
|
31
52
|
index = table_end
|
|
32
53
|
else
|
|
33
54
|
output << line
|
|
@@ -38,12 +59,26 @@ module Raindown
|
|
|
38
59
|
output.join
|
|
39
60
|
end
|
|
40
61
|
|
|
41
|
-
|
|
62
|
+
# Split on pipes that aren't escaped, dropping the optional outer pipes, or the |+ starting a continuation row.
|
|
63
|
+
def cells(line)
|
|
64
|
+
content = line.strip
|
|
65
|
+
content = content.delete_prefix(row_lead(line).rstrip)
|
|
66
|
+
content = content.delete_suffix('|') unless content.end_with?('\|')
|
|
67
|
+
|
|
68
|
+
content.split(/(?<!\\)\|/, -1).map(&:strip)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# How the row's line starts, before its first cell: |+ when it carries on the row above, which is as wide as "| ".
|
|
72
|
+
def row_lead(line)
|
|
73
|
+
return '|+' if line.match?(CONTINUATION)
|
|
74
|
+
|
|
75
|
+
'| '
|
|
76
|
+
end
|
|
42
77
|
|
|
43
78
|
def table_start?(index)
|
|
44
79
|
header = @lines[index]
|
|
45
80
|
delimiter = @lines[index + 1]
|
|
46
|
-
return false unless header&.include?('|') && delimiter
|
|
81
|
+
return false unless header&.include?('|') && delimiter && !header.match?(CONTINUATION)
|
|
47
82
|
|
|
48
83
|
delimiter_cells = cells(delimiter)
|
|
49
84
|
delimiter_cells.any? && delimiter_cells.all? { |cell| cell.match?(DELIMITER_CELL) } &&
|
|
@@ -56,79 +91,40 @@ module Raindown
|
|
|
56
91
|
table_end
|
|
57
92
|
end
|
|
58
93
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
rows = table.
|
|
62
|
-
|
|
63
|
-
return table if body.any? { |row| row.length > header.length }
|
|
64
|
-
|
|
65
|
-
table_lines(indent:, header:, body:, alignments: delimiter.map { |cell| alignment(cell) })
|
|
94
|
+
def gfm_table(table)
|
|
95
|
+
header, delimiter, *body = table.map { |line| cells(line) }
|
|
96
|
+
rows = joined_rows(table.drop(2), header, body)
|
|
97
|
+
rows.insert(1, delimiter.map { it.tr('=', '-') }).map { "#{table.first[/\A\s*/]}| #{it.join(' | ')} |\n" }
|
|
66
98
|
end
|
|
67
99
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
[
|
|
73
|
-
row_line(indent:, cells: header, widths:, alignments:),
|
|
74
|
-
delimiter_line(indent:, widths:, alignments:),
|
|
75
|
-
*body.map { |row| row_line(indent:, cells: row, widths:, alignments:) }
|
|
76
|
-
].map { |line| "#{line}\n" }
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
# Split on pipes that aren't escaped, dropping the optional outer pipes.
|
|
80
|
-
def cells(line)
|
|
81
|
-
content = line.strip
|
|
82
|
-
content = content.delete_prefix('|')
|
|
83
|
-
content = content.delete_suffix('|') unless content.end_with?('\|')
|
|
84
|
-
|
|
85
|
-
content.split(/(?<!\\)\|/, -1).map(&:strip)
|
|
86
|
-
end
|
|
100
|
+
# The rows, each with the + rows below it joined into its cells.
|
|
101
|
+
def joined_rows(lines, header, body)
|
|
102
|
+
lines.zip(body).each_with_object([header]) do |(line, row), joined|
|
|
103
|
+
next joined << row unless line.match?(CONTINUATION)
|
|
87
104
|
|
|
88
|
-
|
|
89
|
-
if cell.start_with?(':') && cell.end_with?(':') then :center
|
|
90
|
-
elsif cell.end_with?(':') then :right
|
|
91
|
-
elsif cell.start_with?(':') then :left
|
|
105
|
+
joined[-1] = joined.last.zip(row).map { |above, more| [above, more].reject { it.to_s.empty? }.join(' ') }
|
|
92
106
|
end
|
|
93
107
|
end
|
|
94
108
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
# Wide characters like CJK and emoji take two columns. Emoji are always measured as two,
|
|
101
|
-
# instead of guessing from the terminal, so a file formats the same on every machine.
|
|
102
|
-
def display_width(cell)
|
|
103
|
-
Unicode::DisplayWidth.of(cell, emoji: :all)
|
|
104
|
-
end
|
|
109
|
+
def align(table)
|
|
110
|
+
header, delimiter, *body = table.map { |line| cells(line) }
|
|
111
|
+
return table if body.any? { |row| row.length > header.length }
|
|
105
112
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
"#{indent}| #{padded.join(' | ')} |"
|
|
113
|
+
rows = [header, *body.map { |row| row + ([''] * (header.length - row.length)) }]
|
|
114
|
+
aligned_lines(table, rows, TableColumn.all(delimiter, rows))
|
|
109
115
|
end
|
|
110
116
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
when :right then "#{'-' * (width - 1)}:"
|
|
116
|
-
when :left then ":#{'-' * (width - 1)}"
|
|
117
|
-
else '-' * width
|
|
118
|
-
end
|
|
117
|
+
def aligned_lines(table, rows, columns)
|
|
118
|
+
indent = table.first[/\A\s*/]
|
|
119
|
+
lines = rows.zip(table.values_at(0, 2...table.length)).map do |row, line|
|
|
120
|
+
row_line(indent, line, columns.zip(row).map { |column, cell| column.pad(cell) })
|
|
119
121
|
end
|
|
120
|
-
|
|
121
|
-
"#{indent}| #{dashes.join(' | ')} |"
|
|
122
|
+
lines.insert(1, row_line(indent, table[1], columns.map(&:delimiter_cell)))
|
|
122
123
|
end
|
|
123
124
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
case alignment
|
|
128
|
-
when :center then "#{' ' * (padding / 2)}#{cell}#{' ' * (padding - (padding / 2))}"
|
|
129
|
-
when :right then "#{' ' * padding}#{cell}"
|
|
130
|
-
else "#{cell}#{' ' * padding}"
|
|
131
|
-
end
|
|
125
|
+
# The row's line, starting with |+ when it carries on the row above, as the line it was did.
|
|
126
|
+
def row_line(indent, line, cells)
|
|
127
|
+
"#{indent}#{row_lead(line)}#{cells.join(' | ')} |\n"
|
|
132
128
|
end
|
|
133
129
|
end
|
|
134
130
|
end
|
|
@@ -2,32 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
require 'unicode/display_width'
|
|
4
4
|
|
|
5
|
+
require_relative 'file_cell'
|
|
5
6
|
require_relative 'markdown_table'
|
|
6
|
-
require_relative '
|
|
7
|
+
require_relative 'matched_rows'
|
|
8
|
+
require_relative 'table_column'
|
|
7
9
|
|
|
8
10
|
module Raindown
|
|
9
|
-
# A table: block's table as it was written, its cells padded as they were, against its file's
|
|
10
|
-
#
|
|
11
|
-
#
|
|
11
|
+
# A table: block's table as it was written, its cells padded as they were, against its file's cells, as its columns
|
|
12
|
+
# show them. Each of its rows is matched to the file's row it shows, so a row added or removed above a formula
|
|
13
|
+
# doesn't move it onto another. A formula's value is no longer lined up when its padding's deleted, the width of its
|
|
14
|
+
# column's header.
|
|
12
15
|
class WrittenTable
|
|
13
|
-
|
|
16
|
+
# The delimiter's cells, like ':===', as it was written, or nil when it's not a table yet.
|
|
17
|
+
attr_reader :delimiter
|
|
18
|
+
|
|
19
|
+
def initialize(padded_rows, file, delimiter = nil)
|
|
14
20
|
@padded_rows = padded_rows
|
|
15
21
|
@file = file
|
|
16
22
|
@values = TableFormulas.evaluate(file)
|
|
23
|
+
@delimiter = delimiter
|
|
24
|
+
@columns = TableColumn.all(delimiter, file)
|
|
17
25
|
end
|
|
18
26
|
|
|
19
27
|
# 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
28
|
def rows
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
29
|
+
each_cell { |cell, row, column| file_cell(row, column)&.value_from(cell) || cell }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Its rows to write to the file, keeping the file's cell where the table still shows it, like a formula as its
|
|
33
|
+
# value, or a cell with line breaks on one line.
|
|
34
|
+
def saved_rows
|
|
35
|
+
rows.each_with_index.map do |cells, row|
|
|
36
|
+
cells.each_with_index.map do |cell, column|
|
|
37
|
+
kept = file_cell(row, column)
|
|
38
|
+
next kept.saved if kept && MarkdownTable.one_line(kept.value) == cell
|
|
24
39
|
|
|
25
40
|
cell
|
|
26
41
|
end
|
|
27
42
|
end
|
|
28
43
|
end
|
|
29
44
|
|
|
30
|
-
# The row and column of each cell to show as its formula.
|
|
45
|
+
# The row and column of each cell to show as its formula, or in full.
|
|
31
46
|
def revealed
|
|
32
47
|
cells.each_with_index.flat_map do |row, row_index|
|
|
33
48
|
row.each_index.select { reveal?(row_index, it) }.map { [row_index, it] }
|
|
@@ -40,21 +55,39 @@ module Raindown
|
|
|
40
55
|
@cells ||= @padded_rows.map { |row| Array.new(width_in_cells) { MarkdownTable.cell(row[it].to_s) } }
|
|
41
56
|
end
|
|
42
57
|
|
|
58
|
+
def each_cell
|
|
59
|
+
cells.each_with_index.map { |row, row_index| row.each_with_index.map { |cell, column| yield cell, row_index, column } }
|
|
60
|
+
end
|
|
61
|
+
|
|
43
62
|
# 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
63
|
def width_in_cells
|
|
45
64
|
@padded_rows.map(&:length).max
|
|
46
65
|
end
|
|
47
66
|
|
|
48
|
-
def
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
def file_cell(row, column)
|
|
68
|
+
file_rows[row]&.then { cell_in_file(it, column) }
|
|
69
|
+
end
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
71
|
+
def cell_in_file(file_row, column)
|
|
72
|
+
saved = @file.dig(file_row, column)
|
|
73
|
+
FileCell.new(saved:, value: @values.dig(file_row, column), column: @columns[column]) if saved
|
|
54
74
|
end
|
|
55
75
|
|
|
56
|
-
|
|
57
|
-
|
|
76
|
+
# The index of each of its rows' file row, or nil for a row that's new.
|
|
77
|
+
def file_rows
|
|
78
|
+
@file_rows ||= MatchedRows.new(cells.length, @file.length) { |row, file_row| same_row?(row, file_row) }.file_rows
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether the table's row shows the file's row, each cell unchanged, or blank where the file has none.
|
|
82
|
+
def same_row?(row, file_row)
|
|
83
|
+
cells[row].each_with_index.all? do |cell, column|
|
|
84
|
+
cell_in_file(file_row, column)&.unchanged_by?(cell) || (cell.empty? && !@file.dig(file_row, column))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reveal?(row, column)
|
|
89
|
+
padded = @padded_rows[row][column].to_s
|
|
90
|
+
file_cell(row, column)&.revealed_by?(cells[row][column], squeezed: squeezed?(row, column), wrapped: padded.include?("\n"))
|
|
58
91
|
end
|
|
59
92
|
|
|
60
93
|
def squeezed?(row, column)
|
|
@@ -53,6 +53,7 @@ module Raindown
|
|
|
53
53
|
def rewrite
|
|
54
54
|
rewrite = Rewriter.update(path:, bare:, execute:, metadata: Pages.for(path))
|
|
55
55
|
rewrite.failures.each { warn "Endpoint check failed: #{it}" }
|
|
56
|
+
rewrite.problems.each { warn "raindown: #{path}: #{it}" }
|
|
56
57
|
rewrite.changed?
|
|
57
58
|
end
|
|
58
59
|
end
|
|
@@ -30,7 +30,7 @@ module Raindown
|
|
|
30
30
|
def update(path)
|
|
31
31
|
rewrite = Rewriter.update(path:, bare: @bare, execute: @execute, metadata: Pages.for(path))
|
|
32
32
|
puts "[#{name}] Updated #{relative(path)}" if rewrite.changed?
|
|
33
|
-
rewrite
|
|
33
|
+
report(rewrite, path)
|
|
34
34
|
rescue StandardError => e
|
|
35
35
|
raise if ENV['RAINDOWN_DEBUG']
|
|
36
36
|
|
|
@@ -40,6 +40,12 @@ module Raindown
|
|
|
40
40
|
|
|
41
41
|
private
|
|
42
42
|
|
|
43
|
+
# What writing back found wrong: failed endpoint checks, and directives that couldn't run, written in their place.
|
|
44
|
+
def report(rewrite, path)
|
|
45
|
+
rewrite.failures.each { warn "[#{name}] Endpoint check failed: #{it}" }
|
|
46
|
+
rewrite.problems.each { warn "[#{name}] raindown: #{relative(path)}: #{it}" }
|
|
47
|
+
end
|
|
48
|
+
|
|
43
49
|
def relative(path)
|
|
44
50
|
File.expand_path(path).delete_prefix(File.join(root, ''))
|
|
45
51
|
end
|