raindown 0.14.0 → 0.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: be9fc3af39c4d6f20b00b691a14582626448ac903a01aa129a2f33911553a76f
4
- data.tar.gz: d0bf1eea7f8996adb29d97eeeaeda1c38ad16739df7724704ab1402b1bd2c723
3
+ metadata.gz: b35949f91c9cf54ccb1ab0fe6b0e24a2e9529f830cd648f097015aba55acb135
4
+ data.tar.gz: 3b62d3623ea255e6767b349a3210c48a331bcdae8282d9a365adea680e2242e8
5
5
  SHA512:
6
- metadata.gz: b6b4305e373de95235142d2e030849aac517d724232194bd68eb2c16cef7c81dd9594ccc8a80b2cd7d8042af045818773d1059c8d30caef634f0722ed35cd6d4
7
- data.tar.gz: 593a219070643268ddd2c52a25aa34d0fe76d62f1074c2a3a263dddf72407d4b03c3a13113100e684ee19c6168a15525e059bf26c118ea6bca680c7112c3ee62
6
+ metadata.gz: c63653585217df9c3129b554f0619fe024916a73c7eaea2fcad53199ee2f790fbece26bf90c956fa716109080b6c457048acbc3bb39fa97d3520765b4d9ff47d
7
+ data.tar.gz: 1f6e1d91403fcfbf9f2968885bf94a227a1a114084a65bb8735c7dbdff4479b622f8fa3ea17fab42f7e3cc826372c726d1d90f137b50eac32a92f531b935f20a
data/lib/raindown/cli.rb CHANGED
@@ -76,6 +76,8 @@ module Raindown
76
76
  end
77
77
 
78
78
  def self.run(argv)
79
+ # Markdown is UTF-8 whatever the locale, as an app opened from the Dock, like Obsidian, starts Raindown without one.
80
+ Encoding.default_external = Encoding::UTF_8
79
81
  return if command(argv)
80
82
 
81
83
  # Without a path, like "raindown", "raindown -x" or "raindown watch", it's the current directory.
@@ -13,6 +13,7 @@ module Raindown
13
13
  IGNORED = %w[node_modules vendor tmp pkg build dist coverage].freeze
14
14
  # Ruby nodes and JavaScript Components.
15
15
  NODE_EXTENSIONS = ['.rb', '.rbx', *JSNodes::EXTENSIONS].freeze
16
+ VAULT_FOLDER = '.obsidian'
16
17
 
17
18
  module_function
18
19
 
@@ -23,11 +24,11 @@ module Raindown
23
24
  end
24
25
 
25
26
  # The folders below a directory with a .raindown folder. Only folders are walked, leaving out ignored, linked and
26
- # gitignored ones, so it stays quick in a big folder like ~/dev.
27
+ # gitignored ones, so it stays quick in a big folder like ~/dev, and vaults, which their editor writes back to.
27
28
  def project_folders(directory, gitignore = Gitignore.new(directory))
28
29
  Dir.glob('*/', base: directory).flat_map do |folder|
29
30
  path = File.join(directory, folder.chomp('/'))
30
- next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path)
31
+ next [] if ignored?(folder.chomp('/')) || File.symlink?(path) || gitignore.ignores?(path) || vault?(path)
31
32
 
32
33
  inside = project_folders(path, Gitignore.new(path, gitignore))
33
34
  next inside unless project?(path)
@@ -44,13 +45,13 @@ module Raindown
44
45
  end
45
46
 
46
47
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
47
- # since they have nodes of their own.
48
+ # since they have nodes of their own, and vaults, which their editor writes back to.
48
49
  def files(root)
49
50
  files = []
50
51
 
51
52
  Find.find(root) do |path|
52
53
  if File.directory?(path)
53
- Find.prune if path != root && (ignored?(File.basename(path)) || project?(path))
54
+ Find.prune if path != root && (ignored?(File.basename(path)) || project?(path) || vault?(path))
54
55
  elsif markdown?(path, root:)
55
56
  files << path
56
57
  end
@@ -82,6 +83,25 @@ module Raindown
82
83
  File.directory?(File.join(folder, Nodes::FOLDER))
83
84
  end
84
85
 
86
+ # Whether a folder's an Obsidian vault, whose plugin runs a Raindown of its own, so one watching a folder above it
87
+ # leaves it alone, while one pointed at the vault itself doesn't.
88
+ def vault?(folder)
89
+ File.directory?(File.join(folder, VAULT_FOLDER))
90
+ end
91
+
92
+ # Whether a path's in a vault below the directory, which isn't the directory itself.
93
+ def vault_below?(path, directory)
94
+ tops = [File.expand_path(directory), File.realpath(directory)]
95
+ folders = []
96
+ folder = File.dirname(File.expand_path(path))
97
+ until tops.include?(folder) || folder == File.dirname(folder)
98
+ folders << folder
99
+ folder = File.dirname(folder)
100
+ end
101
+
102
+ tops.include?(folder) && folders.any? { vault?(it) }
103
+ end
104
+
85
105
  def ignored?(folder)
86
106
  folder.start_with?('.') || IGNORED.include?(folder)
87
107
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative 'node_injector'
4
4
  require_relative 'endpoint/endpoint_runner'
5
- require_relative 'table_formatter'
5
+ require_relative 'table/table_formatter'
6
6
  require_relative 'utils'
7
7
 
8
8
  module Raindown
@@ -19,13 +19,19 @@ module Raindown
19
19
 
20
20
  # The rows of the table in the lines, leaving out its separator, or none when they aren't a table.
21
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)
22
27
  return [] unless lines[1]&.strip&.match?(SEPARATOR)
23
28
 
24
- [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }.map { cells(it.strip) }
29
+ table_lines = [lines[0], *lines.drop(2)].take_while { it.strip.start_with?('|') }
30
+ table_lines.map { it.strip.delete_prefix('|').delete_suffix('|').split(PIPE, -1) }
25
31
  end
26
32
 
27
- def cells(line)
28
- line.delete_prefix('|').delete_suffix('|').split(PIPE, -1).map { it.strip.gsub('\|', '|') }
33
+ def cell(padded)
34
+ padded.strip.gsub('\|', '|')
29
35
  end
30
36
 
31
37
  def escape(cell)
@@ -6,6 +6,8 @@ require 'json'
6
6
 
7
7
  require_relative '../node'
8
8
  require_relative 'markdown_table'
9
+ require_relative 'table_formulas'
10
+ require_relative 'written_table'
9
11
 
10
12
  module Raindown
11
13
  # The node behind table:, a CSV or TSV file relative to the project as a Markdown table, after a comment of how many
@@ -17,13 +19,18 @@ 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.
@@ -35,7 +42,7 @@ module Raindown
35
42
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
36
43
  def render(metadata: NoMetadata)
37
44
  path = data_path(metadata)
38
- Raindown.commonmark(data_error(path) || MarkdownTable.format(read_rows(path)))
45
+ Raindown.commonmark(data_error(path) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
39
46
  end
40
47
 
41
48
  private
@@ -53,20 +60,79 @@ module Raindown
53
60
 
54
61
  def synced(path, written)
55
62
  file = read_rows(path)
63
+ shown = TableFormulas.evaluate(file)
56
64
  synced_hash = written.first&.[](COMMENT, 1)
57
- table = MarkdownTable.parse(written.drop(1))
58
- return markdown(file) if synced_hash.nil? || table.empty? || [synced_hash, hash_rows(file)].include?(hash_rows(table))
59
- return markdown(write_rows(path, table)) if hash_rows(file) == synced_hash
65
+ table = WrittenTable.new(written_rows(written), file)
66
+ return markdown(file, table.revealed) if unchanged?(table.rows, synced_hash, shown)
67
+ return markdown(write_rows(path, with_formulas(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
60
68
 
61
69
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
62
70
  ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
63
71
  end
64
72
 
65
- def markdown(rows)
73
+ # Whether there's nothing to write back: the table's rows are as it wrote them, or as the file's are, or it's new.
74
+ def unchanged?(rows, synced_hash, shown)
75
+ synced_hash.nil? || rows.empty? || [synced_hash, hash_rows(shown)].include?(hash_rows(rows))
76
+ end
77
+
78
+ # The table's rows as it was written, padding and all, leaving out its total.
79
+ def written_rows(written)
80
+ header, *body = MarkdownTable.padded_rows(written.drop(1))
81
+ [header, *body.reject { total_row?(it.map(&:strip)) }].compact
82
+ end
83
+
84
+ def markdown(rows, revealed = [])
66
85
  return '' if rows.empty?
67
86
 
87
+ shown = TableFormulas.evaluate(rows)
68
88
  count = rows.length - 1
69
- "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(rows)} -->\n#{MarkdownTable.format(rows)}"
89
+ table = MarkdownTable.format(with_total(with_revealed(shown, rows, revealed)))
90
+ "<!-- table #{count} row#{'s' unless count == 1} · #{hash_rows(shown)} -->\n#{table}"
91
+ end
92
+
93
+ # The values, but for each revealed cell, which shows its formula.
94
+ def with_revealed(values, rows, revealed)
95
+ revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
96
+ end
97
+
98
+ # The table's rows, keeping the file's formula in each cell still showing its value.
99
+ def with_formulas(table, file, shown)
100
+ table.each_with_index.map do |row, row_index|
101
+ row.each_with_index.map do |cell, column|
102
+ formula = file.dig(row_index, column)
103
+ next formula if TableFormulas.formula?(formula) && shown.dig(row_index, column) == cell
104
+
105
+ cell
106
+ end
107
+ end
108
+ end
109
+
110
+ def with_total(rows)
111
+ return rows unless @total && rows.length > 1
112
+
113
+ body = rows.drop(1)
114
+ sums = rows.first.each_index.map { |column| sum_cells(body.map { it[column].to_s }) }
115
+ sums[0] ||= 'Total'
116
+ [*rows, sums.map { bold_cell(it) }]
117
+ end
118
+
119
+ # The sum of cells that are all numbers, or blank, to the most decimal places any has, or nil when they aren't.
120
+ def sum_cells(cells)
121
+ numbers = cells.reject(&:empty?)
122
+ return if numbers.empty? || !numbers.all? { it.match?(TableFormulas::NUMBER) }
123
+
124
+ TableFormulas.sum(numbers)
125
+ end
126
+
127
+ def bold_cell(text)
128
+ return '' unless text
129
+
130
+ "**#{text}**"
131
+ end
132
+
133
+ # The total row it wrote, its cells bold or blank, which isn't one of the file's rows.
134
+ def total_row?(row)
135
+ row.any? { !it.empty? } && row.all? { it.empty? || it.match?(BOLD) }
70
136
  end
71
137
 
72
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
@@ -15,7 +15,7 @@ module Raindown
15
15
  TAG = /<[^>]*>/
16
16
 
17
17
  def rewrite(document:, metadata: nil, bare: false)
18
- headings = headings(markdown: document, metadata:, bare:)
18
+ headings = headings_below_title(markdown: document, metadata:, bare:)
19
19
  return '' if headings.empty?
20
20
 
21
21
  min_level = headings.map { |heading| heading[:level] }.min
@@ -27,7 +27,7 @@ module Raindown
27
27
 
28
28
  # With the document's final HTML, the headings include ones its components render.
29
29
  def render(document:, html: nil, metadata: nil, bare: false)
30
- items = headings(markdown: document, html:, metadata:, bare:).map do |heading|
30
+ items = headings_below_title(markdown: document, html:, metadata:, bare:).map do |heading|
31
31
  "<li class='h#{heading[:level]}'><a href='##{heading[:id]}'>#{CGI.escapeHTML(heading[:text])}</a></li>"
32
32
  end
33
33
 
@@ -43,6 +43,14 @@ module Raindown
43
43
  HTML
44
44
  end
45
45
 
46
+ # A lone top-level heading that starts the document is its title, so it's left out when there are headings below it.
47
+ def headings_below_title(**)
48
+ title, *below = headings = headings(**)
49
+ return headings if below.empty? || title[:level] != 1 || below.any? { it[:level] == 1 }
50
+
51
+ below
52
+ end
53
+
46
54
  # The document's headings, from its final HTML, or else by rendering it with its components.
47
55
  def headings(markdown:, html: nil, metadata: nil, bare: false)
48
56
  html ||= Fragments.collect { Raindown.assembled(Context.new(document: markdown, metadata: metadata || NoMetadata, bare:)) }
@@ -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
@@ -19,10 +19,12 @@ module Raindown
19
19
  Listen.to(directory, ignore: IGNORE) { |modified, added, removed| yield(modified + added + removed) }
20
20
  end
21
21
 
22
- # The changes among the paths, by the project each is in, the nearest one above it, or else the root, when there is
23
- # one, leaving out files that are neither nodes nor Markdown, or are in ignored folders.
24
- def self.changes(paths, root: nil)
22
+ # The changes among the paths in the directory, by the project each is in, the nearest one above it, or else the
23
+ # root, when there is one, leaving out files that are neither nodes nor Markdown, or are in ignored folders or vaults.
24
+ def self.changes(paths, directory:, root: nil)
25
25
  paths.filter_map do |path|
26
+ next if Projects.vault_below?(path, directory)
27
+
26
28
  project = Projects.root(path) || root || next
27
29
  next Change.new(root: project, path:, nodes: true) if Projects.node?(path, root: project)
28
30
 
@@ -42,7 +42,7 @@ module Raindown
42
42
  # endpoint's request with -x, only holds up its own project. Without fork, the projects share this process, so
43
43
  # they're taken in turn.
44
44
  def handle(paths)
45
- Watcher.changes(paths).each do |change|
45
+ Watcher.changes(paths, directory:).each do |change|
46
46
  next unless change.nodes? || change.saved?
47
47
  next take(change) unless fork?
48
48
 
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.14.0'
4
+ VERSION = '0.16.0'
5
5
  end
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.14.0
4
+ version: 0.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-11-06 00:00:00.000000000 Z
10
+ date: 2026-11-07 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: antlers
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: 1.4.0
18
+ version: 1.5.0
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: 1.4.0
25
+ version: 1.5.0
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: commonmarker
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -254,7 +254,9 @@ files:
254
254
  - lib/raindown/stat.rb
255
255
  - lib/raindown/table/markdown_table.rb
256
256
  - lib/raindown/table/table.rb
257
- - lib/raindown/table_formatter.rb
257
+ - lib/raindown/table/table_formatter.rb
258
+ - lib/raindown/table/table_formulas.rb
259
+ - lib/raindown/table/written_table.rb
258
260
  - lib/raindown/toc.rb
259
261
  - lib/raindown/tui.rb
260
262
  - lib/raindown/tui/blueprints/blueprints_renderer.rb