raindown 0.17.2 → 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/node_injector.rb +30 -6
- 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 +23 -31
- data/lib/raindown/table/table_column.rb +115 -0
- data/lib/raindown/table/table_formatter.rb +63 -67
- data/lib/raindown/table/written_table.rb +49 -16
- data/lib/raindown/written.rb +20 -0
- data/lib/raindown.rb +2 -1
- data/lib/version.rb +1 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: baf58826a9f492870795c2cfb36104ffb214b7494c0b14a2b11aa5f5a7711c64
|
|
4
|
+
data.tar.gz: 136d56da8dd7574698769f971bcce51662c89f29b28e2b87acb758017f9ee708
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2ceb319ab5a5c1482a6d09f98b75e95bf22ece5ca8477b54621ee542691c6de3ee243dc0ef3f580971167cb116990a89a28ae5a4012b50c16ec659a8fcdf5cfa
|
|
7
|
+
data.tar.gz: 411f712343878e32a1efc7d99932254984e3f0dc45ba91a8b41316fcbf61e9aa0c404ea7d66fc75e0293975d60632e28bde04adcd044d8e3dc36c02ddf9390c2
|
|
@@ -4,6 +4,7 @@ require_relative '../raindown'
|
|
|
4
4
|
require_relative 'bare_directives'
|
|
5
5
|
require_relative 'front_matter'
|
|
6
6
|
require_relative 'node_blocks'
|
|
7
|
+
require_relative 'written'
|
|
7
8
|
|
|
8
9
|
module Raindown
|
|
9
10
|
# Writes each node block's Markdown below its directive, replacing what it wrote before, when it's in comments or
|
|
@@ -11,6 +12,10 @@ module Raindown
|
|
|
11
12
|
class NodeInjector
|
|
12
13
|
class BlankLine < StandardError; end
|
|
13
14
|
|
|
15
|
+
# A comment on its own line that isn't a directive, like <!-- 2 rows · 3f2a9c -->, which straight below a directive
|
|
16
|
+
# is its node's comment, passed to its rewrite as comment:.
|
|
17
|
+
COMMENT = /\A\s*<!--(?!\s*(?::[A-Za-z]|[A-Za-z][\w-]*(?:::[A-Z]\w*)*:))\s*(.*?)\s*-->\s*\z/
|
|
18
|
+
|
|
14
19
|
# Rewriter interface. Content without a node block is returned unchanged.
|
|
15
20
|
def self.rewrite(content:, path: nil, bare: false, metadata: NoMetadata, **)
|
|
16
21
|
new(content:, path:, bare:, metadata:).call
|
|
@@ -42,22 +47,25 @@ module Raindown
|
|
|
42
47
|
private
|
|
43
48
|
|
|
44
49
|
def block_lines(lines, block)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
written =
|
|
50
|
+
comment, written = comment_and_written(written_lines(lines, block))
|
|
51
|
+
context = { document: @content, metadata: @metadata, bare: @bare, comment:, written: }
|
|
52
|
+
written = written_lines_of(node_written(block, context))
|
|
48
53
|
return [line(lines[block[:open]]), *bare(written, block)] unless block[:close]
|
|
49
54
|
|
|
50
55
|
delimited(lines, block, written)
|
|
51
56
|
end
|
|
52
57
|
|
|
53
58
|
# A node that raises writes why in its place, like a Problem, so the rest of the file still writes back.
|
|
54
|
-
def
|
|
59
|
+
def node_written(block, context)
|
|
55
60
|
@problems << block[:node].warning if block[:node].is_a?(Problem)
|
|
56
|
-
Nodes.call(block[:node], :rewrite, **context)
|
|
61
|
+
written = Nodes.call(block[:node], :rewrite, **context)
|
|
62
|
+
return written if written.is_a?(Written)
|
|
63
|
+
|
|
64
|
+
Written.new(written.to_s)
|
|
57
65
|
rescue StandardError => e
|
|
58
66
|
problem = Problem.new("Couldn't write: #{e.message}", block[:open] + 1)
|
|
59
67
|
@problems << problem.warning
|
|
60
|
-
problem.rewrite
|
|
68
|
+
Written.new(problem.rewrite)
|
|
61
69
|
end
|
|
62
70
|
|
|
63
71
|
# The directive, what the node wrote and the end tag, straight after each other, as a comment on its own line ends
|
|
@@ -72,6 +80,22 @@ module Raindown
|
|
|
72
80
|
raise BlankLine, "#{name} writes blank lines, so it needs <!-- #{name}: --> and <!-- :#{name} --> around it"
|
|
73
81
|
end
|
|
74
82
|
|
|
83
|
+
# Its lines: its comment, straight below its directive, then its Markdown.
|
|
84
|
+
def written_lines_of(written)
|
|
85
|
+
markdown = written.to_s.chomp
|
|
86
|
+
lines = markdown.empty? ? [] : "#{markdown}\n".lines
|
|
87
|
+
return lines unless written.comment
|
|
88
|
+
|
|
89
|
+
["<!-- #{written.comment} -->\n", *lines]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The comment the block's node wrote straight below its directive, if it wrote one, and the lines it wrote below that.
|
|
93
|
+
def comment_and_written(lines)
|
|
94
|
+
return [nil, lines] unless lines.first&.match?(COMMENT)
|
|
95
|
+
|
|
96
|
+
[lines.first[COMMENT, 1], lines.drop(1)]
|
|
97
|
+
end
|
|
98
|
+
|
|
75
99
|
# What the block's node wrote last time, between its directive and its end tag, or the next blank line.
|
|
76
100
|
def written_lines(lines, block)
|
|
77
101
|
lines[(block[:open] + 1)...(block[:close] || (block[:last] + 1))]
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'table_formulas'
|
|
4
|
+
|
|
5
|
+
module Raindown
|
|
6
|
+
# A cell of a table: block's file, as its table shows it: its value, worked out from its formula, and clipped to fit
|
|
7
|
+
# its column, ending in an ellipsis, when the column's fixed. Deleting the padding around a formula's value shows its
|
|
8
|
+
# formula, and deleting a clipped cell's ellipsis shows its full text, wrapped onto |+ rows, each until it's changed,
|
|
9
|
+
# or, for its full text, an ellipsis is typed into it again, which clips it.
|
|
10
|
+
FileCell = Data.define(:saved, :value, :column) do
|
|
11
|
+
# As the table shows it, unless it's revealed.
|
|
12
|
+
def shown
|
|
13
|
+
return value unless column
|
|
14
|
+
|
|
15
|
+
column.clip(value)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Whether the written cell still shows it: its value, as shown or in full, or clipped at another width, as it was
|
|
19
|
+
# before its column's width changed, or in full with an ellipsis typed into it, its formula, ignoring whitespace, or
|
|
20
|
+
# its clipped start, with the ellipsis deleted.
|
|
21
|
+
def unchanged_by?(cell)
|
|
22
|
+
[shown, value, revealing].include?(cell) || formula_in?(cell) || clip_of_value?(cell) || collapsing?(cell)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Whether the written cell shows its formula, or its full text, in place of what the table shows. A full text written
|
|
26
|
+
# on one line, before its column was fixed, isn't, so it's clipped.
|
|
27
|
+
def revealed_by?(cell, squeezed:, wrapped:)
|
|
28
|
+
return formula_in?(cell) || (cell == value && squeezed) if formula?
|
|
29
|
+
|
|
30
|
+
clipped? && (cell == revealing || (cell == value && wrapped))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The value the written cell gives it: its own while it's unchanged, or else what was typed, with the end that was
|
|
34
|
+
# clipped off back in place of the ellipsis, while it has one, like when its start's edited, or more typed after it.
|
|
35
|
+
def value_from(cell)
|
|
36
|
+
return value if unchanged_by?(cell)
|
|
37
|
+
return cell unless cell.include?('…')
|
|
38
|
+
|
|
39
|
+
before, _, after = cell.partition('…')
|
|
40
|
+
hidden = clipped_off(before)
|
|
41
|
+
return cell unless hidden
|
|
42
|
+
|
|
43
|
+
"#{before}#{hidden}#{after}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def formula?
|
|
49
|
+
TableFormulas.formula?(saved)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def formula_in?(cell)
|
|
53
|
+
formula? && cell.gsub(/\s+/, '') == saved.gsub(/\s+/, '')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Its full text with an ellipsis typed into it, like … or ..., anywhere between its words, to clip it again.
|
|
57
|
+
def collapsing?(cell)
|
|
58
|
+
ellipsis = /\s*(?:…|\.\.\.)\s*/
|
|
59
|
+
cell.match?(ellipsis) && cell.sub(ellipsis, ' ').strip == value
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def clip_of_value?(cell)
|
|
63
|
+
cell.end_with?('…') && value.length >= cell.length && value.start_with?(cell.delete_suffix('…'))
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The end clipped off after the start before the ellipsis: the rest of its value when that's how it starts, or else,
|
|
67
|
+
# when its start's been edited, what's after the start it's clipped to now.
|
|
68
|
+
def clipped_off(before)
|
|
69
|
+
return value.delete_prefix(before) if value.start_with?(before)
|
|
70
|
+
|
|
71
|
+
value.delete_prefix(revealing) if clipped?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def clipped?
|
|
75
|
+
shown != value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Its clipped start, without the ellipsis, which shows its full text when that's deleted.
|
|
79
|
+
def revealing
|
|
80
|
+
shown.delete_suffix('…') if clipped?
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -2,19 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
module Raindown
|
|
4
4
|
# Rows of cells as a Markdown table, its first row the header, and back again. A | in a cell is escaped as \|, and
|
|
5
|
-
# a line break is written as a space, as a table's cells are one line.
|
|
5
|
+
# a line break is written as a space, as a table's cells are one line. A row starting with |+, carrying on the row
|
|
6
|
+
# above, is read as part of it. The TableFormatter lines up its columns.
|
|
6
7
|
module MarkdownTable
|
|
7
|
-
SEPARATOR = /\A\|?(\s
|
|
8
|
+
SEPARATOR = /\A\|?(\s*:?[-=]+:?\s*\|)*\s*:?[-=]+:?\s*\|?\z/
|
|
8
9
|
# A | that isn't escaped.
|
|
9
10
|
PIPE = /(?<!\\)\|/
|
|
10
11
|
|
|
11
12
|
module_function
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
# With the delimiter's cells, like ':===', when it has one for each column, keeping their alignment and widths. A
|
|
15
|
+
# cell can be lines, wrapped onto |+ rows below its row.
|
|
16
|
+
def format(rows, delimiter = nil)
|
|
14
17
|
return '' if rows.empty?
|
|
15
18
|
|
|
16
|
-
|
|
17
|
-
|
|
19
|
+
delimiter = Array.new(rows.first.length, '---') unless delimiter&.length == rows.first.length
|
|
20
|
+
header, *body = rows.flat_map { row_lines(it) }
|
|
21
|
+
[header, "|#{delimiter.join('|')}|", *body].join("\n")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def row_lines(row)
|
|
25
|
+
lines = row.map { Array(it) }
|
|
26
|
+
starts = ['| ', *Array.new(lines.map(&:length).max - 1, '|+')]
|
|
27
|
+
starts.each_with_index.map { |start, line| "#{start}#{lines.map { escape(it[line].to_s) }.join(' | ')} |" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The cells of the table's delimiter row, like ['---', ':==='], or nil when the lines aren't a table.
|
|
31
|
+
def delimiter(lines)
|
|
32
|
+
return unless lines[1]&.strip&.match?(SEPARATOR)
|
|
33
|
+
|
|
34
|
+
lines[1].strip.delete_prefix('|').delete_suffix('|').split('|').map(&:strip)
|
|
18
35
|
end
|
|
19
36
|
|
|
20
37
|
# The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
|
|
@@ -22,16 +39,40 @@ module Raindown
|
|
|
22
39
|
padded_rows(lines).map { |row| row.map { cell(it) } }
|
|
23
40
|
end
|
|
24
41
|
|
|
25
|
-
# The rows of the table in the lines, their cells as written, padding and all, leaving out its separator
|
|
42
|
+
# The rows of the table in the lines, their cells as written, padding and all, leaving out its separator, with each
|
|
43
|
+
# |+ row's cells joined into the row above's, on lines of their own.
|
|
26
44
|
def padded_rows(lines)
|
|
27
|
-
return [] unless lines
|
|
45
|
+
return [] unless delimiter(lines)
|
|
46
|
+
|
|
47
|
+
table_lines = [lines[0], *lines.drop(2)].map(&:strip).take_while { it.start_with?('|') }
|
|
48
|
+
table_lines.each_with_object([]) do |line, rows|
|
|
49
|
+
cells = padded_cells(line)
|
|
50
|
+
next rows << cells unless line.start_with?('|+') && rows.any?
|
|
51
|
+
|
|
52
|
+
rows[-1] = rows.last.zip(cells).map { |above, more| joined_cell(above, more.to_s) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The line's cells, padding and all, without its outer pipes, or the |+ starting it.
|
|
57
|
+
def padded_cells(line)
|
|
58
|
+
line.delete_prefix(lead(line)).delete_suffix('|').split(PIPE, -1)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def lead(line)
|
|
62
|
+
return '|+' if line.start_with?('|+')
|
|
63
|
+
|
|
64
|
+
'|'
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The cell carried on by the one below it, or as it was, padding and all, when that's blank.
|
|
68
|
+
def joined_cell(above, more)
|
|
69
|
+
return above if more.strip.empty?
|
|
28
70
|
|
|
29
|
-
|
|
30
|
-
table_lines.map { it.strip.delete_prefix('|').delete_suffix('|').split(PIPE, -1) }
|
|
71
|
+
[above.strip, more.strip].reject(&:empty?).join("\n")
|
|
31
72
|
end
|
|
32
73
|
|
|
33
74
|
def cell(padded)
|
|
34
|
-
padded.strip.gsub('\|', '|')
|
|
75
|
+
one_line(padded.strip).gsub('\|', '|')
|
|
35
76
|
end
|
|
36
77
|
|
|
37
78
|
def escape(cell)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# Which of a file's rows each of a table's rows shows, by index, given whether a table row shows a file row, so a row
|
|
5
|
+
# added or removed doesn't move the rows after it onto others. Rows that are the same in both are matched, in order,
|
|
6
|
+
# then those left between them are paired in order, like a row that's been edited.
|
|
7
|
+
class MatchedRows
|
|
8
|
+
def initialize(rows, file_rows, &same)
|
|
9
|
+
@rows = rows
|
|
10
|
+
@file_rows = file_rows
|
|
11
|
+
@same = same
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Each table row's file row, or nil for a row that's new.
|
|
15
|
+
def file_rows
|
|
16
|
+
[].tap do |matched|
|
|
17
|
+
[[-1, -1], *common_rows, [@rows, @file_rows]].each_cons(2) do |(row, file_row), (next_row, next_file_row)|
|
|
18
|
+
matched[row] = file_row unless row.negative?
|
|
19
|
+
(row + 1...next_row).zip(file_row + 1...next_file_row) { |gap_row, gap_file_row| matched[gap_row] = gap_file_row }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# The longest run of rows, in order, the same in both, as pairs of their indexes. Rows the same at the start and
|
|
27
|
+
# end are matched first, so only the few between them, being edited, are compared each with each.
|
|
28
|
+
def common_rows
|
|
29
|
+
head = matching_run((0...@rows).zip(0...@file_rows))
|
|
30
|
+
rows, file_rows = [@rows, @file_rows].map { (head.length...it).to_a }
|
|
31
|
+
tail = matching_run(rows.reverse.zip(file_rows.reverse)).reverse
|
|
32
|
+
rows, file_rows = [rows, file_rows].map { it.take(it.length - tail.length) }
|
|
33
|
+
[*head, *common_pairs(rows, file_rows, common_lengths(rows, file_rows)), *tail]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def matching_run(pairs)
|
|
37
|
+
pairs.take_while { |row, file_row| file_row && @same.call(row, file_row) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# How many rows, at most, the rows and file rows from each on have in common.
|
|
41
|
+
def common_lengths(rows, file_rows)
|
|
42
|
+
rows.reverse.each_with_object([Array.new(file_rows.length + 1, 0)]) do |row, lengths|
|
|
43
|
+
lengths.unshift(common_lengths_from(row, file_rows, lengths.first))
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The common lengths from the row, from those from the row below it.
|
|
48
|
+
def common_lengths_from(row, file_rows, below)
|
|
49
|
+
file_rows.each_index.reverse_each.with_object(Array.new(file_rows.length + 1, 0)) do |j, lengths|
|
|
50
|
+
lengths[j] = @same.call(row, file_rows[j]) ? below[j + 1] + 1 : [below[j], lengths[j + 1]].max
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def common_pairs(rows, file_rows, lengths)
|
|
55
|
+
i = j = 0
|
|
56
|
+
pairs = []
|
|
57
|
+
while i < rows.length && j < file_rows.length
|
|
58
|
+
if @same.call(rows[i], file_rows[j])
|
|
59
|
+
pairs << [rows[i], file_rows[j]]
|
|
60
|
+
i += 1
|
|
61
|
+
j += 1
|
|
62
|
+
elsif lengths[i + 1][j] >= lengths[i][j + 1]
|
|
63
|
+
i += 1
|
|
64
|
+
else
|
|
65
|
+
j += 1
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
pairs
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/raindown/table/table.rb
CHANGED
|
@@ -5,7 +5,9 @@ require 'json'
|
|
|
5
5
|
|
|
6
6
|
require_relative '../node'
|
|
7
7
|
require_relative '../utils'
|
|
8
|
+
require_relative '../written'
|
|
8
9
|
require_relative 'markdown_table'
|
|
10
|
+
require_relative 'table_column'
|
|
9
11
|
require_relative 'table_file'
|
|
10
12
|
require_relative 'table_formulas'
|
|
11
13
|
require_relative 'written_table'
|
|
@@ -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,7 +27,8 @@ 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
|
-
|
|
30
|
+
# The hash in its comment, like 3f2a9c in "2 rows · 3f2a9c".
|
|
31
|
+
HASH = /· (\h+)\z/
|
|
29
32
|
BOLD = /\A\*\*.*\*\*\z/
|
|
30
33
|
# The table a new file starts with, when there's no table to make it from.
|
|
31
34
|
STARTER = [['Column 1', 'Column 2'], ['', '']].freeze
|
|
@@ -37,11 +40,11 @@ module Raindown
|
|
|
37
40
|
|
|
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
|
|
39
42
|
# not there yet is made, from the table below it, or else one to fill in.
|
|
40
|
-
def rewrite(written: [], metadata: NoMetadata)
|
|
43
|
+
def rewrite(written: [], metadata: NoMetadata, comment: nil)
|
|
41
44
|
path = data_path(metadata)
|
|
42
45
|
lines = written.map(&:chomp)
|
|
43
46
|
create_file(path, lines) if missing_file?(path, metadata)
|
|
44
|
-
data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines)
|
|
47
|
+
data_error(path, metadata)&.then { Problem.alert(it) } || synced(path, lines, comment)
|
|
45
48
|
end
|
|
46
49
|
|
|
47
50
|
# Write-in, in <{ }>, the table only shows its file, and never writes to it.
|
|
@@ -69,10 +72,9 @@ module Raindown
|
|
|
69
72
|
TableFile.supported?(path) && Utils.inside?(path, data_root(metadata)) && !File.exist?(path)
|
|
70
73
|
end
|
|
71
74
|
|
|
72
|
-
# From the table as it's written,
|
|
73
|
-
# there was no file.
|
|
75
|
+
# From the table as it's written, below any alert saying why there was no file.
|
|
74
76
|
def create_file(path, lines)
|
|
75
|
-
rows = MarkdownTable.parse(lines.grep_v(
|
|
77
|
+
rows = MarkdownTable.parse(lines.grep_v(/\A>/))
|
|
76
78
|
rows = STARTER if rows.empty?
|
|
77
79
|
TableFile.create(path, rows)
|
|
78
80
|
end
|
|
@@ -86,16 +88,16 @@ module Raindown
|
|
|
86
88
|
"Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
|
|
87
89
|
end
|
|
88
90
|
|
|
89
|
-
def synced(path, written)
|
|
91
|
+
def synced(path, written, comment)
|
|
90
92
|
file = TableFile.read(path)
|
|
91
93
|
shown = TableFormulas.evaluate(file)
|
|
92
|
-
synced_hash =
|
|
93
|
-
table = WrittenTable.new(written_rows(written), file)
|
|
94
|
-
return markdown(file, table
|
|
95
|
-
return markdown(TableFile.write(path,
|
|
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
|
|
96
98
|
|
|
97
99
|
warn "raindown: #{@data} and its table both changed, so neither is written until they match"
|
|
98
|
-
|
|
100
|
+
Written.new(written.join("\n"), comment: "conflict · #{synced_hash}")
|
|
99
101
|
end
|
|
100
102
|
|
|
101
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.
|
|
@@ -105,37 +107,27 @@ module Raindown
|
|
|
105
107
|
|
|
106
108
|
# The table's rows as it was written, padding and all, leaving out its total.
|
|
107
109
|
def written_rows(written)
|
|
108
|
-
header, *body = MarkdownTable.padded_rows(written
|
|
110
|
+
header, *body = MarkdownTable.padded_rows(written)
|
|
109
111
|
[header, *body.reject { total_row?(it.map(&:strip)) }].compact
|
|
110
112
|
end
|
|
111
113
|
|
|
112
|
-
|
|
114
|
+
# Written with the delimiter it was written with, its cells fit to its fixed columns.
|
|
115
|
+
def markdown(rows, written)
|
|
113
116
|
return '' if rows.empty?
|
|
114
117
|
|
|
115
118
|
shown = TableFormulas.evaluate(rows)
|
|
116
119
|
count = rows.length - 1
|
|
117
|
-
|
|
118
|
-
|
|
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)}")
|
|
119
124
|
end
|
|
120
125
|
|
|
121
|
-
# 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.
|
|
122
127
|
def with_revealed(values, rows, revealed)
|
|
123
128
|
revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
|
|
124
129
|
end
|
|
125
130
|
|
|
126
|
-
# The table's rows, keeping the file's cell where the table still shows it, like a formula as its value, or a cell
|
|
127
|
-
# with line breaks on one line.
|
|
128
|
-
def with_file_cells(table, file, shown)
|
|
129
|
-
table.each_with_index.map do |row, row_index|
|
|
130
|
-
row.each_with_index.map do |cell, column|
|
|
131
|
-
kept = file.dig(row_index, column)
|
|
132
|
-
next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
|
|
133
|
-
|
|
134
|
-
cell
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
|
|
139
131
|
def with_total(rows)
|
|
140
132
|
return rows unless @total && rows.length > 1
|
|
141
133
|
|
|
@@ -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
|
|
@@ -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)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Raindown
|
|
4
|
+
# What a node's rewrite writes below its directive: its Markdown, and a comment straight below the directive, like
|
|
5
|
+
# table:'s "2 rows · 3f2a9c", which its rewrite gets back as comment: the next time it writes. A rewrite can return a
|
|
6
|
+
# string instead, when it has no comment.
|
|
7
|
+
class Written
|
|
8
|
+
attr_reader :markdown, :comment
|
|
9
|
+
|
|
10
|
+
def initialize(markdown, comment: nil)
|
|
11
|
+
@markdown = markdown
|
|
12
|
+
@comment = comment
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Its Markdown, as a rewrite's string would be, like when it's rendered as HTML.
|
|
16
|
+
def to_s
|
|
17
|
+
markdown.to_s
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
data/lib/raindown.rb
CHANGED
|
@@ -16,6 +16,7 @@ require_relative 'raindown/heading_ids'
|
|
|
16
16
|
require_relative 'raindown/node_blocks'
|
|
17
17
|
require_relative 'raindown/pages'
|
|
18
18
|
require_relative 'raindown/pre_renderer'
|
|
19
|
+
require_relative 'raindown/table/table_formatter'
|
|
19
20
|
require_relative 'raindown/utils'
|
|
20
21
|
require_relative 'raindown/variables'
|
|
21
22
|
require_relative 'raindown/wiki_links'
|
|
@@ -89,7 +90,7 @@ module Raindown
|
|
|
89
90
|
|
|
90
91
|
# Markdown to HTML, like a document's or a component's slot.
|
|
91
92
|
def commonmark(markdown)
|
|
92
|
-
doc = Commonmarker.parse(hide_directives(markdown:), options: MARKDOWN_OPTIONS)
|
|
93
|
+
doc = Commonmarker.parse(hide_directives(markdown: TableFormatter.gfm(markdown)), options: MARKDOWN_OPTIONS)
|
|
93
94
|
preserve_code(doc:)
|
|
94
95
|
|
|
95
96
|
html = highlight_code? ? doc.to_html(options: HTML_OPTIONS) : unhighlighted_html(doc:)
|
data/lib/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: raindown
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.18.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- maedi
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-11-
|
|
10
|
+
date: 2026-11-09 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
12
|
- !ruby/object:Gem::Dependency
|
|
13
13
|
name: antlers
|
|
@@ -254,8 +254,11 @@ files:
|
|
|
254
254
|
- lib/raindown/server.rb
|
|
255
255
|
- lib/raindown/skeleton.rb
|
|
256
256
|
- lib/raindown/stat.rb
|
|
257
|
+
- lib/raindown/table/file_cell.rb
|
|
257
258
|
- lib/raindown/table/markdown_table.rb
|
|
259
|
+
- lib/raindown/table/matched_rows.rb
|
|
258
260
|
- lib/raindown/table/table.rb
|
|
261
|
+
- lib/raindown/table/table_column.rb
|
|
259
262
|
- lib/raindown/table/table_file.rb
|
|
260
263
|
- lib/raindown/table/table_formatter.rb
|
|
261
264
|
- lib/raindown/table/table_formulas.rb
|
|
@@ -339,6 +342,7 @@ files:
|
|
|
339
342
|
- lib/raindown/wiki_links.rb
|
|
340
343
|
- lib/raindown/workers/worker.rb
|
|
341
344
|
- lib/raindown/workers/workers.rb
|
|
345
|
+
- lib/raindown/written.rb
|
|
342
346
|
- lib/version.rb
|
|
343
347
|
homepage: https://github.com/rainpour/raindown
|
|
344
348
|
licenses: []
|