raindown 0.16.0 → 0.17.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: b35949f91c9cf54ccb1ab0fe6b0e24a2e9529f830cd648f097015aba55acb135
4
- data.tar.gz: 3b62d3623ea255e6767b349a3210c48a331bcdae8282d9a365adea680e2242e8
3
+ metadata.gz: 2abf28897b1c392d34ec786d7abad550414050fe3f45949929240fa31453cf23
4
+ data.tar.gz: edafdc330bddeaf1b76a4d6538b1e3db9241c1758526d0076b0ffe76d5aac32a
5
5
  SHA512:
6
- metadata.gz: c63653585217df9c3129b554f0619fe024916a73c7eaea2fcad53199ee2f790fbece26bf90c956fa716109080b6c457048acbc3bb39fa97d3520765b4d9ff47d
7
- data.tar.gz: 1f6e1d91403fcfbf9f2968885bf94a227a1a114084a65bb8735c7dbdff4479b622f8fa3ea17fab42f7e3cc826372c726d1d90f137b50eac32a92f531b935f20a
6
+ metadata.gz: 5ce17ac9171dfa2d3a37311698f53e89390eccd2d3105a60adef58136f6bf4f741162ecd6eea0edc10a341434aaa58b1b8832e1971cc1ae3498771255616e3ce
7
+ data.tar.gz: 15ff81a860a3403f8bca98ca63052be5d8ff1cc9feda5da235f6b753ffd92f74ba313471ac60d7b61c59fb7c84f38bd05e8dd7dfa68877a66d84bd5251499217
@@ -5,6 +5,7 @@ require 'json'
5
5
  require 'yaml'
6
6
 
7
7
  require_relative '../node'
8
+ require_relative '../utils'
8
9
 
9
10
  module Raindown
10
11
  class Chart < Node
@@ -43,6 +44,7 @@ module Raindown
43
44
  return numbers(numbers) if numbers.any? && numbers.all? { number(it) }
44
45
 
45
46
  path = File.expand_path(string, @root)
47
+ raise Error, "it's outside the project" unless Utils.inside?(path, @root)
46
48
  raise Error, 'no such file' unless File.file?(path)
47
49
 
48
50
  from(read(path))
data/lib/raindown/cli.rb CHANGED
@@ -10,6 +10,7 @@ require_relative 'blueprints'
10
10
  require_relative 'editing'
11
11
  require_relative 'rewriter'
12
12
  require_relative 'server'
13
+ require_relative 'skeleton'
13
14
  require_relative 'watcher/file_watcher'
14
15
  require_relative 'watcher/supervisor'
15
16
 
@@ -32,6 +33,8 @@ module Raindown
32
33
  PATH is a file, or a directory (default: current directory) whose projects with a
33
34
  .raindown folder are watched
34
35
  init [DIRECTORY] Create a .raindown folder for the project's config and nodes (default: current directory)
36
+ skeleton Set this repo up to commit directives without what they write, in files with "skeleton: true"
37
+ skeleton --check Exit with 1, naming any such file with output staged, for CI
35
38
  server Keep Raindown loaded for an editor, answering a JSON request a line on stdin with its HTML,
36
39
  or its Markdown as it would be written back, like --stdin and --stdin --rewrite
37
40
  blueprints Save the blueprints in Claude Code's last reply to .raindown/data/blueprints.md, for the terminal
@@ -78,17 +81,22 @@ module Raindown
78
81
  def self.run(argv)
79
82
  # Markdown is UTF-8 whatever the locale, as an app opened from the Dock, like Obsidian, starts Raindown without one.
80
83
  Encoding.default_external = Encoding::UTF_8
81
- return if command(argv)
82
-
83
- # Without a path, like "raindown", "raindown -x" or "raindown watch", it's the current directory.
84
- argv = ['watch', '.', *argv.drop(1)] if argv.first == 'watch' && (argv.size == 1 || argv[1].start_with?('-'))
85
- argv = ['.', *argv] if argv.empty? || argv.first.start_with?('-')
84
+ return if command(argv) || Skeleton.run_command(argv)
86
85
 
86
+ argv = with_path(argv)
87
87
  super || abort("Unknown arguments: #{argv.join(' ')}\n\n#{USAGE}")
88
88
  rescue StandardError => e
89
89
  report(e, path: argv.first)
90
90
  end
91
91
 
92
+ # Without a path, like "raindown", "raindown -x" or "raindown watch", it's the current directory.
93
+ def self.with_path(argv)
94
+ return ['watch', '.', *argv.drop(1)] if argv.first == 'watch' && (argv.size == 1 || argv[1].start_with?('-'))
95
+ return ['.', *argv] if argv.empty? || argv.first.start_with?('-')
96
+
97
+ argv
98
+ end
99
+
92
100
  # The :path line would match help, version and init as the file, so they're checked first.
93
101
  # Returns true when argv was one of them.
94
102
  def self.command(argv)
@@ -3,6 +3,8 @@
3
3
  require 'date'
4
4
  require 'yaml'
5
5
 
6
+ require_relative 'utils'
7
+
6
8
  module Raindown
7
9
  # YAML between two --- lines at the very start of a file.
8
10
  module FrontMatter
@@ -19,6 +21,14 @@ module Raindown
19
21
  [metadata.is_a?(Hash) ? metadata : {}, match.post_match]
20
22
  end
21
23
 
24
+ # A file's metadata, or none when it can't be read, with a warning naming it, so one file can't stop a list of them.
25
+ def read(file_path)
26
+ parse(Utils.read_markdown(file_path)).first
27
+ rescue Psych::Exception, ArgumentError => e
28
+ warn "raindown: #{file_path}: #{e.message}, so it's listed without its front matter"
29
+ {}
30
+ end
31
+
22
32
  # How many of the lines are front matter, so directives in its YAML are left alone.
23
33
  def line_count(lines)
24
34
  lines.join.match(PATTERN)&.then { |match| match[0].lines.length } || 0
data/lib/raindown/list.rb CHANGED
@@ -14,18 +14,26 @@ module Raindown
14
14
  @tags = tags
15
15
  end
16
16
 
17
- # Each file has its front matter, its path and its content. Sorted by an order, files without it come last, and ones
18
- # with the same value stay in the order they're listed.
17
+ # Each file has its front matter, its path and its content. Sorted by an order, numbers come first, then other values
18
+ # as text, then files without it, and ones with the same value stay in the order they're listed.
19
19
  def files(metadata: NoMetadata)
20
20
  files = metadata.list(**@tags)
21
21
  return files unless @order
22
22
 
23
23
  sorted, unsorted = files.partition { it[@order] }
24
- sorted.sort_by.with_index { |file, index| [file[@order], index] } + unsorted
24
+ sorted.sort_by.with_index { |file, index| [*order_key(file[@order]), index] } + unsorted
25
25
  end
26
26
 
27
27
  def rewrite(metadata: NoMetadata)
28
- files(metadata:).map { |file| "- [#{file.title || file.path}](#{file.path})" }.join("\n")
28
+ files(metadata:).map { |file| "- [#{file.title || File.basename(file.path, '.*')}](#{file.path})" }.join("\n")
29
+ end
30
+
31
+ private
32
+
33
+ def order_key(value)
34
+ return [0, value, ''] if value.is_a?(Numeric)
35
+
36
+ [1, 0, value.to_s]
29
37
  end
30
38
  end
31
39
  end
@@ -25,10 +25,11 @@ module Raindown
25
25
  @front_matters ||= {}
26
26
  end
27
27
 
28
- # The Markdown files under a project's root, the folder with its .raindown folder or else the file's folder.
28
+ # The Markdown files under a project's root, the folder with its .raindown folder, or a vault, or else the file's
29
+ # folder.
29
30
  def for(path)
30
- file_directory = File.dirname(File.expand_path(path))
31
- load(root: Nodes.find_folder(file_directory)&.then { File.dirname(it) } || file_directory)
31
+ require_relative 'projects'
32
+ load(root: Projects.content_root(path) || File.dirname(File.expand_path(path)))
32
33
  end
33
34
 
34
35
  # Lists the files under the root, without loading them, for Dirload.
@@ -118,7 +119,7 @@ module Raindown
118
119
  modified_then, front_matter = self.class.front_matters[file_path]
119
120
  return front_matter if modified_then == modified
120
121
 
121
- FrontMatter.parse(Utils.read_markdown(file_path)).first.tap { self.class.front_matters[file_path] = [modified, it] }
122
+ FrontMatter.read(file_path).tap { self.class.front_matters[file_path] = [modified, it] }
122
123
  end
123
124
 
124
125
  # The path is relative to the root, like docs/_basics/_1-getting-started.md.
@@ -44,6 +44,18 @@ module Raindown
44
44
  folder && File.dirname(folder)
45
45
  end
46
46
 
47
+ # The folder a file's paths are relative to: the nearest with a .raindown folder or that's a vault, or nil without one.
48
+ # A vault doesn't need a .raindown folder, as Obsidian's links and a table's path are from its top.
49
+ def content_root(path)
50
+ folder = File.dirname(File.expand_path(path))
51
+ until project?(folder) || vault?(folder)
52
+ return if folder == File.dirname(folder)
53
+
54
+ folder = File.dirname(folder)
55
+ end
56
+ folder
57
+ end
58
+
47
59
  # The project's Markdown files, sorted, leaving out ignored folders. The projects inside it are left out too,
48
60
  # since they have nodes of their own, and vaults, which their editor writes back to.
49
61
  def files(root)
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ require_relative 'bare_directives'
6
+ require_relative 'document'
7
+ require_relative 'front_matter'
8
+ require_relative 'nodes'
9
+ require_relative 'pages'
10
+ require_relative 'utils'
11
+
12
+ module Raindown
13
+ # A file with "skeleton: true" in its front matter keeps its directives in git, but not what they write, so dashboards
14
+ # don't fill its history and sensitive reports stay out of it. It's a git clean filter: the file on disk keeps its
15
+ # output, and what's committed doesn't, which a clone writes again when it's rendered. The filter's required, so when
16
+ # a file can't be stripped, git stops rather than commit its output.
17
+ module Skeleton
18
+ FILTER = 'raindown'
19
+ SKELETON = /^skeleton:[ \t]*true[ \t]*$/
20
+
21
+ module_function
22
+
23
+ # Returns true when the arguments were "raindown skeleton", with --check or a file or neither.
24
+ def run_command(argv)
25
+ return false unless argv.first == 'skeleton' && argv.size <= 2
26
+
27
+ run(argv[1])
28
+ true
29
+ end
30
+
31
+ # "raindown skeleton" sets the repo up, --check is for CI, and with a file it's git's filter.
32
+ def run(argument)
33
+ root, = Open3.capture2('git', 'rev-parse', '--show-toplevel')
34
+ abort('raindown skeleton needs a git repo') if root.strip.empty?
35
+ return install(root.strip) unless argument
36
+ return check(root.strip) if argument == '--check'
37
+
38
+ filter(argument)
39
+ end
40
+
41
+ # From stdin to stdout, which it has to itself, so what nodes print goes to stderr, like the server's answers.
42
+ def filter(path)
43
+ output = $stdout.dup
44
+ $stdout.reopen($stderr)
45
+ output.write(strip($stdin.read, path:))
46
+ end
47
+
48
+ def check(root)
49
+ files = staged_output(root)
50
+ return if files.empty?
51
+
52
+ warn('Output is staged in these skeleton files. Run "raindown skeleton", then "git add --renormalize .":', *files)
53
+ exit 1
54
+ end
55
+
56
+ # The Markdown without what its directives wrote, or as it is, to the byte, without "skeleton: true".
57
+ def strip(markdown, path:)
58
+ return markdown unless skeleton?(markdown)
59
+
60
+ FrontMatter.parse(markdown) # Raises when it can't be read, as bare directives would be missed.
61
+ Nodes.load(path)
62
+ markdown = markdown.gsub("\r\n", "\n")
63
+ lines = markdown.lines
64
+ outputs(markdown, path).reverse_each { |output| lines.slice!((output.begin - 1)..(output.end - 1)) }
65
+ lines.join
66
+ end
67
+
68
+ # Sets the filter up for a repo. Git keeps a filter's command in its own config, so each clone needs this.
69
+ def install(root)
70
+ { clean: "#{FILTER} skeleton %f", smudge: 'cat', required: 'true' }.each do |name, value|
71
+ system('git', 'config', "filter.#{FILTER}.#{name}", value, chdir: root, exception: true)
72
+ end
73
+ add_attributes(File.join(root, '.gitattributes'))
74
+ end
75
+
76
+ # The staged files that still have output, like ones added before the filter was set up, or in a clone without it.
77
+ def staged_output(root)
78
+ tracked(root).reject do |path|
79
+ staged = staged_markdown(root, path)
80
+ strip(staged, path: File.join(root, path)) == staged
81
+ end
82
+ end
83
+
84
+ # Read from the front matter's text, so a file with front matter that isn't valid YAML, or isn't UTF-8, can still be
85
+ # committed when it isn't a skeleton.
86
+ def skeleton?(markdown)
87
+ markdown.scrub.match(FrontMatter::PATTERN)&.[](1)&.match?(SKELETON) || false
88
+ end
89
+
90
+ def outputs(markdown, path)
91
+ bare = BareDirectives.enabled?(path:, content: markdown)
92
+ Document.new(markdown:, bare:).directives.select(&:writes).filter_map(&:output)
93
+ end
94
+
95
+ def add_attributes(path)
96
+ existing = File.exist?(path) ? File.read(path) : ''
97
+ missing = Pages::EXTENSIONS.map { "*.#{it} filter=#{FILTER}" }.reject { existing.include?(it) }
98
+ return if missing.empty?
99
+
100
+ existing += "\n" unless existing.empty? || existing.end_with?("\n")
101
+ File.write(path, "#{existing}#{missing.join("\n")}\n")
102
+ end
103
+
104
+ def tracked(root)
105
+ output, = Open3.capture2('git', 'ls-files', '-z', chdir: root)
106
+ output.split("\0").select { Pages::EXTENSIONS.include?(File.extname(it).delete_prefix('.').downcase) }
107
+ end
108
+
109
+ def staged_markdown(root, path)
110
+ output, = Open3.capture2('git', 'show', ":#{path}", chdir: root)
111
+ output.force_encoding(Encoding::UTF_8)
112
+ end
113
+ end
114
+ end
@@ -35,7 +35,12 @@ module Raindown
35
35
  end
36
36
 
37
37
  def escape(cell)
38
- cell.gsub('|', '\|').gsub(/\s*\R\s*/, ' ')
38
+ one_line(cell).gsub('|', '\|')
39
+ end
40
+
41
+ # The cell as a table shows it, on one line.
42
+ def one_line(cell)
43
+ cell.gsub(/\s*\R\s*/, ' ')
39
44
  end
40
45
  end
41
46
  end
@@ -5,6 +5,7 @@ require 'digest'
5
5
  require 'json'
6
6
 
7
7
  require_relative '../node'
8
+ require_relative '../utils'
8
9
  require_relative 'markdown_table'
9
10
  require_relative 'table_formulas'
10
11
  require_relative 'written_table'
@@ -36,24 +37,31 @@ module Raindown
36
37
  # Written is what it wrote last time, so it can tell which of the table and the file changed since.
37
38
  def rewrite(written: [], metadata: NoMetadata)
38
39
  path = data_path(metadata)
39
- data_error(path) || synced(path, written.map(&:chomp))
40
+ data_error(path, metadata) || synced(path, written.map(&:chomp))
40
41
  end
41
42
 
42
43
  # Write-in, in <{ }>, the table only shows its file, and never writes to it.
43
44
  def render(metadata: NoMetadata)
44
45
  path = data_path(metadata)
45
- Raindown.commonmark(data_error(path) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
46
+ Raindown.commonmark(data_error(path, metadata) || MarkdownTable.format(with_total(TableFormulas.evaluate(read_rows(path)))))
46
47
  end
47
48
 
48
49
  private
49
50
 
50
51
  def data_path(metadata)
51
- File.expand_path(@data.to_s, metadata.respond_to?(:root) ? metadata.root : Dir.pwd)
52
+ File.expand_path(@data.to_s, data_root(metadata))
53
+ end
54
+
55
+ def data_root(metadata)
56
+ return metadata.root if metadata.respond_to?(:root)
57
+
58
+ Dir.pwd
52
59
  end
53
60
 
54
61
  # Why the file can't be a table, or nil when it can.
55
- def data_error(path)
62
+ def data_error(path, metadata)
56
63
  return "Can't show #{@data.inspect} as a table: only CSV and TSV files can be" unless SEPARATORS.key?(File.extname(path).downcase)
64
+ return "Can't show #{@data.inspect} as a table: it's outside the project" unless Utils.inside?(path, data_root(metadata))
57
65
 
58
66
  "Can't show #{@data.inspect} as a table: no such file" unless File.file?(path)
59
67
  end
@@ -64,7 +72,7 @@ module Raindown
64
72
  synced_hash = written.first&.[](COMMENT, 1)
65
73
  table = WrittenTable.new(written_rows(written), file)
66
74
  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
75
+ return markdown(write_rows(path, with_file_cells(table.rows, file, shown)), table.revealed) if hash_rows(shown) == synced_hash
68
76
 
69
77
  warn "raindown: #{@data} and its table both changed, so neither is written until they match"
70
78
  ["<!-- table conflict · #{synced_hash} -->", *written.drop(1)].join("\n")
@@ -95,12 +103,13 @@ module Raindown
95
103
  revealed.each_with_object(values.map(&:dup)) { |(row, column), shown| shown[row][column] = rows[row][column] }
96
104
  end
97
105
 
98
- # The table's rows, keeping the file's formula in each cell still showing its value.
99
- def with_formulas(table, file, shown)
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)
100
109
  table.each_with_index.map do |row, row_index|
101
110
  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
111
+ kept = file.dig(row_index, column)
112
+ next kept if kept && MarkdownTable.one_line(shown.dig(row_index, column).to_s) == cell
104
113
 
105
114
  cell
106
115
  end
@@ -146,8 +155,9 @@ module Raindown
146
155
  rows
147
156
  end
148
157
 
158
+ # As the table shows them, so a cell with line breaks hashes the same in both.
149
159
  def hash_rows(rows)
150
- Digest::SHA256.hexdigest(JSON.generate(rows))[0, 6]
160
+ Digest::SHA256.hexdigest(JSON.generate(rows.map { |row| row.map { MarkdownTable.one_line(it.to_s) } }))[0, 6]
151
161
  end
152
162
  end
153
163
  end
@@ -4,6 +4,11 @@ module Raindown
4
4
  module Utils
5
5
  module_function
6
6
 
7
+ # Whether a path is below a folder, so a note can't reach a file outside its project, like '../../secrets.csv'.
8
+ def inside?(path, folder)
9
+ File.expand_path(path).start_with?(File.join(File.expand_path(folder), ''))
10
+ end
11
+
7
12
  # A Markdown file's text, with its line endings as LF, "\n", whatever they are in the file.
8
13
  #
9
14
  # Windows ends lines with CRLF, "\r\n", and Git for Windows checks files out that way, so a file written or cloned on
data/lib/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Raindown
4
- VERSION = '0.16.0'
4
+ VERSION = '0.17.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: raindown
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.16.0
4
+ version: 0.17.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - maedi
@@ -251,6 +251,7 @@ files:
251
251
  - lib/raindown/projects.rb
252
252
  - lib/raindown/rewriter.rb
253
253
  - lib/raindown/server.rb
254
+ - lib/raindown/skeleton.rb
254
255
  - lib/raindown/stat.rb
255
256
  - lib/raindown/table/markdown_table.rb
256
257
  - lib/raindown/table/table.rb