omoteura-core 0.1.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.
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "psych"
6
+ require "omoteura/lz_string"
7
+
8
+ module Omoteura
9
+ # An Excalidraw drawing read from disk, either a plain .excalidraw file or
10
+ # an Obsidian Excalidraw plugin markdown file.
11
+ class Diagram
12
+ class NotADiagram < Error; end
13
+
14
+ class ParseError < Error; end
15
+
16
+ LINK_PREFIX = "omoteura:"
17
+
18
+ Element = Data.define(:id, :type, :link, :label) do
19
+ def omoteura_link?
20
+ link&.start_with?(LINK_PREFIX) || false
21
+ end
22
+ end
23
+
24
+ FRONTMATTER = /\A---[ \t]*\r?\n(.*?)^---[ \t]*$/m
25
+ DRAWING_BLOCK = /^\#\#[ \t]+Drawing[ \t]*\r?\n+```(json|compressed-json)[ \t]*\r?\n(.*?)^```/m
26
+ ELEMENT_LINKS = /^\#\# Element Links\r?\n(.*?)(?=^\#\# Embedded Files|^\#\#? Drawing|\z)/m
27
+ # The plugin's ids are eight characters.
28
+ ELEMENT_LINK = /^(.{8}):[ \t]*(.*?)\r?$/
29
+
30
+ class << self
31
+ def load(path)
32
+ path = path.to_s
33
+ source = File.read(path, encoding: Encoding::UTF_8)
34
+ format = detect_format(path, source)
35
+ raise NotADiagram, "#{path} is not an Excalidraw drawing" unless format
36
+
37
+ new(path: path, format: format, data: parse_data(format, source, path), digest: Digest::SHA256.hexdigest(source))
38
+ end
39
+
40
+ def diagram_file?(path)
41
+ path = path.to_s
42
+ return false unless File.file?(path)
43
+
44
+ !detect_format(path, File.read(path, encoding: Encoding::UTF_8)).nil?
45
+ end
46
+
47
+ private
48
+
49
+ # Mirrors the Obsidian plugin: a .md file is a drawing when its
50
+ # frontmatter has the excalidraw-plugin key, whatever its name.
51
+ def detect_format(path, source)
52
+ case File.extname(path)
53
+ when ".excalidraw" then :excalidraw
54
+ when ".md" then :obsidian if obsidian_drawing?(source)
55
+ end
56
+ end
57
+
58
+ def obsidian_drawing?(source)
59
+ match = FRONTMATTER.match(source) or return false
60
+ frontmatter = Psych.safe_load(match[1])
61
+ frontmatter.is_a?(Hash) && frontmatter.key?("excalidraw-plugin")
62
+ rescue Psych::Exception
63
+ false
64
+ end
65
+
66
+ def parse_data(format, source, path)
67
+ json =
68
+ if format == :excalidraw
69
+ source
70
+ else
71
+ drawing_json(source, path)
72
+ end
73
+ data = JSON.parse(json)
74
+ raise ParseError, "#{path} has no elements list" unless data.is_a?(Hash) && data["elements"].is_a?(Array)
75
+
76
+ apply_element_links(source, data["elements"]) if format == :obsidian
77
+ data
78
+ rescue JSON::ParserError => e
79
+ raise ParseError, "#{path} has invalid drawing JSON: #{e.message}"
80
+ end
81
+
82
+ def drawing_json(source, path)
83
+ match = DRAWING_BLOCK.match(source) or raise ParseError, "#{path} has no ## Drawing block"
84
+ return match[2] if match[1] == "json"
85
+
86
+ LZString.decompress_from_base64(match[2]) or
87
+ raise ParseError, "#{path} has a corrupt compressed-json block"
88
+ end
89
+
90
+ # As in the plugin, a link listed under Element Links wins over the
91
+ # one in the drawing, so a link edited in the markdown counts before
92
+ # Obsidian has saved the drawing again.
93
+ def apply_element_links(source, elements)
94
+ section = ELEMENT_LINKS.match(source) or return
95
+ links = section[1].scan(ELEMENT_LINK).to_h
96
+ elements.each { |e| e["link"] = links[e["id"]] if e.is_a?(Hash) && links.key?(e["id"]) }
97
+ end
98
+ end
99
+
100
+ # data is the whole drawing as read, for writing it back; digest is the
101
+ # SHA-256 of the file as read, for noticing later changes.
102
+ attr_reader :path, :format, :elements, :digest, :data
103
+
104
+ def initialize(path:, format:, data:, digest: nil)
105
+ @path = path
106
+ @format = format
107
+ @data = data
108
+ @digest = digest
109
+ @elements = build_elements(data["elements"])
110
+ end
111
+
112
+ # The drawing as Excalidraw itself reads it, for showing it in an editor.
113
+ def scene
114
+ {"elements" => @data["elements"], "appState" => @data["appState"] || {}, "files" => @data["files"] || {}}
115
+ end
116
+
117
+ def linked_elements
118
+ elements.select(&:omoteura_link?)
119
+ end
120
+
121
+ private
122
+
123
+ def build_elements(raw_elements)
124
+ live = raw_elements.select { |e| e.is_a?(Hash) && !e["isDeleted"] }
125
+ labels = live.each_with_object({}) do |e, acc|
126
+ acc[e["containerId"]] = text_of(e) if e["type"] == "text" && e["containerId"]
127
+ end
128
+
129
+ live.map do |e|
130
+ label = (e["type"] == "text") ? text_of(e) : labels[e["id"]]
131
+ Element.new(id: e["id"], type: e["type"], link: e["link"], label: label)
132
+ end
133
+ end
134
+
135
+ def text_of(element)
136
+ element["originalText"] || element["text"]
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Omoteura
6
+ class Draft
7
+ # Builds Excalidraw elements laid out in columns: a title, then for each
8
+ # column a heading and a stack of boxes, each box labelled and linked.
9
+ # Element ids and seeds derive from the file and the box, so drafting
10
+ # the same code twice gives the same drawing.
11
+ class SceneBuilder
12
+ Box = Data.define(:label, :link)
13
+ Heading = Data.define(:text, :link)
14
+ Column = Data.define(:heading, :items)
15
+
16
+ INK = "#1e1e1e"
17
+ FONT_EXCALIFONT = 5
18
+ BOX_FONT_SIZE = 16
19
+ HEADING_FONT_SIZE = 20
20
+ TITLE_FONT_SIZE = 28
21
+ LINE_HEIGHT = 1.25
22
+ COLUMN_WIDTH = 280
23
+ COLUMN_GAP = 60
24
+ BOX_GAP = 16
25
+ BOX_PADDING = 14
26
+ MAX_LABEL_WIDTH = COLUMN_WIDTH - 2 * BOX_PADDING
27
+ # A word with its trailing space, or one Japanese or Chinese character.
28
+ WRAP_TOKENS = /[\p{Han}\p{Hiragana}\p{Katakana}ー、。]|[^\p{Han}\p{Hiragana}\p{Katakana}ー、。\s]+\s*|\s+/
29
+
30
+ # salt varies the ids, for adding to a drawing that has these already.
31
+ def initialize(code_path, updated: (Time.now.to_f * 1000).to_i, salt: nil)
32
+ @code_path = code_path
33
+ @updated = updated
34
+ @salt = salt
35
+ @elements = []
36
+ end
37
+
38
+ # columns are Columns whose items are Boxes, or Headings for a nested
39
+ # group, in reading order.
40
+ # origin is where the title's top left corner goes.
41
+ def build(title, columns, origin: [0, 0])
42
+ left, top_edge = origin
43
+ add_text("title", title, x: left, y: top_edge, size: TITLE_FONT_SIZE)
44
+ top = top_edge + TITLE_FONT_SIZE * LINE_HEIGHT + 32
45
+ columns.each_with_index do |column, index|
46
+ x = left + index * (COLUMN_WIDTH + COLUMN_GAP)
47
+ y = top
48
+ if column.heading
49
+ add_text("column #{index}", column.heading.text, x: x, y: y, size: HEADING_FONT_SIZE, link: column.heading.link)
50
+ y += HEADING_FONT_SIZE * LINE_HEIGHT + BOX_GAP
51
+ end
52
+ column.items.each_with_index do |item, row|
53
+ y += item.is_a?(Heading) ? place_heading(item, index, row, x, y) : place_box(item, index, row, x, y)
54
+ end
55
+ end
56
+ @elements
57
+ end
58
+
59
+ private
60
+
61
+ def place_heading(heading, column, row, x, y)
62
+ add_text("heading #{column}/#{row}", heading.text, x: x, y: y + 8, size: BOX_FONT_SIZE, link: heading.link)
63
+ 8 + BOX_FONT_SIZE * LINE_HEIGHT + BOX_GAP / 2
64
+ end
65
+
66
+ def place_box(box, column, row, x, y)
67
+ key = "box #{column}/#{row}"
68
+ lines = wrap(box.label, MAX_LABEL_WIDTH, BOX_FONT_SIZE)
69
+ text_height = lines.size * BOX_FONT_SIZE * LINE_HEIGHT
70
+ height = (text_height + 2 * BOX_PADDING).round
71
+ box_id = id_for(key)
72
+ label_id = id_for("#{key} label")
73
+
74
+ @elements << base(key, type: "rectangle", x: x, y: y, width: COLUMN_WIDTH, height: height).merge(
75
+ "roundness" => {"type" => 3},
76
+ "boundElements" => [{"type" => "text", "id" => label_id}],
77
+ "link" => box.link
78
+ )
79
+ text = lines.join("\n")
80
+ width = lines.map { |line| text_width(line, BOX_FONT_SIZE) }.max
81
+ @elements << base("#{key} label", type: "text", x: x + (COLUMN_WIDTH - width) / 2.0, y: y + BOX_PADDING,
82
+ width: width, height: text_height).merge(text_fields(text, BOX_FONT_SIZE, container: box_id))
83
+ height + BOX_GAP
84
+ end
85
+
86
+ def add_text(key, text, x:, y:, size:, link: nil)
87
+ width = text.lines.map { |line| text_width(line.chomp, size) }.max
88
+ @elements << base(key, type: "text", x: x, y: y, width: width, height: size * LINE_HEIGHT)
89
+ .merge(text_fields(text, size, container: nil))
90
+ .merge("link" => link)
91
+ end
92
+
93
+ def text_fields(text, size, container:)
94
+ {
95
+ "text" => text,
96
+ "originalText" => text,
97
+ "fontSize" => size,
98
+ "fontFamily" => FONT_EXCALIFONT,
99
+ "textAlign" => container ? "center" : "left",
100
+ "verticalAlign" => container ? "middle" : "top",
101
+ "containerId" => container,
102
+ "autoResize" => true,
103
+ "lineHeight" => LINE_HEIGHT
104
+ }
105
+ end
106
+
107
+ def base(key, type:, x:, y:, width:, height:)
108
+ {
109
+ "id" => id_for(key),
110
+ "type" => type,
111
+ "x" => x,
112
+ "y" => y,
113
+ "width" => width,
114
+ "height" => height,
115
+ "angle" => 0,
116
+ "strokeColor" => INK,
117
+ "backgroundColor" => "transparent",
118
+ "fillStyle" => "solid",
119
+ "strokeWidth" => 2,
120
+ "strokeStyle" => "solid",
121
+ "roughness" => 1,
122
+ "opacity" => 100,
123
+ "groupIds" => [],
124
+ "frameId" => nil,
125
+ "index" => nil,
126
+ "roundness" => nil,
127
+ "seed" => seed_for(key),
128
+ "version" => 1,
129
+ "versionNonce" => seed_for("#{key} nonce"),
130
+ "isDeleted" => false,
131
+ "boundElements" => nil,
132
+ "updated" => @updated,
133
+ "link" => nil,
134
+ "locked" => false
135
+ }
136
+ end
137
+
138
+ # Eight characters, as the Obsidian Excalidraw plugin expects.
139
+ def id_for(key)
140
+ digest(key)[0, 8]
141
+ end
142
+
143
+ def seed_for(key)
144
+ digest(key)[0, 8].to_i(16) % 2**31
145
+ end
146
+
147
+ def digest(key)
148
+ Digest::SHA256.hexdigest(@salt ? "#{@code_path}\0#{@salt}\0#{key}" : "#{@code_path}\0#{key}")
149
+ end
150
+
151
+ # An estimate, since the real width depends on the font: Excalidraw
152
+ # measures text again when the drawing opens.
153
+ def text_width(text, size)
154
+ text.each_char.sum { |char| char.ascii_only? ? size * 0.55 : size * 1.0 }.ceil
155
+ end
156
+
157
+ # Breaks between words, or between characters in Japanese and Chinese
158
+ # text, which has no spaces; a word wider than a line is split.
159
+ def wrap(text, max_width, size)
160
+ lines = [+""]
161
+ text.scan(WRAP_TOKENS).each do |token|
162
+ if lines.last != "" && text_width(lines.last + token.rstrip, size) > max_width
163
+ lines.last.rstrip!
164
+ lines << +""
165
+ token = token.lstrip
166
+ end
167
+ token.each_char do |char|
168
+ lines << +"" if lines.last != "" && text_width(lines.last + char, size) > max_width
169
+ lines.last << char
170
+ end
171
+ end
172
+ lines.each(&:rstrip!)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "omoteura/draft/scene_builder"
6
+ require "omoteura/outline"
7
+
8
+ module Omoteura
9
+ # Drafts a diagram from a code file: for a spec, a column per example
10
+ # group with a box per example; for other code, a column per kind of
11
+ # declaration and one for public methods. Every box links to its code,
12
+ # and a link is added only when it resolves to exactly that code.
13
+ #
14
+ # A draft is a starting point for a hand-drawn diagram. It never
15
+ # replaces one: when the file already has a diagram, the draft goes
16
+ # beside it as <name>.draft.excalidraw, or .draft.excalidraw.md when
17
+ # drafting for Obsidian.
18
+ class Draft
19
+ CATEGORIES = Outline::KINDS
20
+ FORMAT_SOURCE = "https://github.com/kaibyaku-jp/omoteura"
21
+ EXTENSIONS = {excalidraw: ".excalidraw", obsidian: ".excalidraw.md"}.freeze
22
+
23
+ # status is :written, :draft (written beside an existing diagram),
24
+ # :exists (a previous draft is in the way) or :empty (nothing to draw);
25
+ # for #merge, :merged, :up_to_date (nothing missing) or :no_diagram.
26
+ Result = Data.define(:code_path, :diagram_path, :shape_count, :status)
27
+
28
+ # format is :excalidraw for a .excalidraw file or :obsidian for an
29
+ # Obsidian Excalidraw plugin note.
30
+ def initialize(config, only: CATEGORIES, out: nil, format: :excalidraw)
31
+ @config = config
32
+ @only = only
33
+ @out = out
34
+ @format = format
35
+ @extension = EXTENSIONS.fetch(format)
36
+ @resolver = Resolver.new(config)
37
+ @link_resolver = LinkResolver.new(config)
38
+ @drawn = nil
39
+ end
40
+
41
+ # code_path is relative to the project root. Nothing is written when
42
+ # dry_run is true.
43
+ def draft(code_path, dry_run: false)
44
+ index = SourceIndex.parse_file(File.join(@config.root, code_path))
45
+ columns = index.spec_entries.empty? ? code_columns(index, code_path) : spec_columns(index, code_path)
46
+ boxes = columns.sum { |column| column.items.count { |item| item.is_a?(SceneBuilder::Box) } }
47
+ return Result.new(code_path: code_path, diagram_path: nil, shape_count: 0, status: :empty) if boxes.zero?
48
+
49
+ diagram_path, status = destination(code_path)
50
+ return Result.new(code_path: code_path, diagram_path: diagram_path, shape_count: boxes, status: :exists) if status == :exists
51
+
52
+ write(diagram_path, SceneBuilder.new(code_path).build(code_path, columns)) unless dry_run
53
+ Result.new(code_path: code_path, diagram_path: diagram_path, shape_count: boxes, status: status)
54
+ end
55
+
56
+ # Adds to the file's diagram what it has no shape for yet, in columns to
57
+ # the right of everything drawn. Nothing drawn is moved or removed. A
58
+ # shape counts as drawn when its link points at the same code, or its
59
+ # label is the same.
60
+ def merge(code_path, dry_run: false)
61
+ attachment = @resolver.resolve(code_path)
62
+ if attachment.nil? || attachment.missing?
63
+ return Result.new(code_path: code_path, diagram_path: attachment&.diagram_path, shape_count: 0, status: :no_diagram)
64
+ end
65
+
66
+ file = @config.expand(attachment.diagram_path)
67
+ diagram = Diagram.load(file)
68
+ existing = diagram.data["elements"].select { |e| e.is_a?(Hash) && !e["isDeleted"] }
69
+ @drawn = drawn_in(diagram, code_path)
70
+ index = SourceIndex.parse_file(File.join(@config.root, code_path))
71
+ columns = prune(index.spec_entries.empty? ? code_columns(index, code_path) : spec_columns(index, code_path))
72
+ boxes = columns.sum { |column| column.items.count { |item| item.is_a?(SceneBuilder::Box) } }
73
+ return Result.new(code_path: code_path, diagram_path: attachment.diagram_path, shape_count: 0, status: :up_to_date) if boxes.zero?
74
+
75
+ unless dry_run
76
+ added = merged_elements(code_path, columns, existing, diagram.data["elements"])
77
+ data = diagram.data.merge("elements" => diagram.data["elements"] + added)
78
+ Writer.new(@config).save(file, data, if_unchanged: diagram.digest)
79
+ end
80
+ Result.new(code_path: code_path, diagram_path: attachment.diagram_path, shape_count: boxes, status: :merged)
81
+ ensure
82
+ @drawn = nil
83
+ end
84
+
85
+ private
86
+
87
+ MERGE_TITLE = "Not drawn yet"
88
+
89
+ # The lines of this file the diagram's shapes link to, and their labels.
90
+ def drawn_in(diagram, code_path)
91
+ lines = diagram.linked_elements.filter_map do |element|
92
+ target = @link_resolver.resolve(element.link, context_path: code_path).target
93
+ target.start_line if target && target.path == code_path
94
+ end
95
+ labels = diagram.elements.filter_map { |element| element.label&.tr("\n", " ")&.squeeze(" ") }
96
+ {lines: lines.to_h { |line| [line, true] }, labels: labels.to_h { |label| [label, true] }}
97
+ end
98
+
99
+ def drawn?(entry, label)
100
+ @drawn && (@drawn[:lines][entry.start_line] || @drawn[:labels][label]) || false
101
+ end
102
+
103
+ # Without the boxes taken out for being drawn, headings left with no
104
+ # box under them go, and so do columns left with none.
105
+ def prune(columns)
106
+ columns.filter_map do |column|
107
+ items = column.items.each_with_index.select do |item, i|
108
+ item.is_a?(SceneBuilder::Box) || column.items[(i + 1)..].take_while { |next_item| !next_item.is_a?(SceneBuilder::Heading) }.any?
109
+ end.map(&:first)
110
+ column.with(items: items) if items.any? { |item| item.is_a?(SceneBuilder::Box) }
111
+ end
112
+ end
113
+
114
+ # The new elements, under a title to the right of everything drawn, with
115
+ # ids that no existing element has.
116
+ def merged_elements(code_path, columns, existing, all)
117
+ right = existing.map { |e| e["x"].to_f + e["width"].to_f }.max || 0
118
+ top = existing.map { |e| e["y"].to_f }.min || 0
119
+ taken = all.filter_map { |e| e["id"] if e.is_a?(Hash) }.to_h { |id| [id, true] }
120
+ (1..).each do |attempt|
121
+ elements = SceneBuilder.new(code_path, salt: "merge #{attempt}")
122
+ .build(MERGE_TITLE, columns, origin: [(right + SceneBuilder::COLUMN_GAP * 2).round, top.round])
123
+ return elements if elements.none? { |e| taken[e["id"]] }
124
+ end
125
+ end
126
+
127
+ def spec_columns(index, code_path)
128
+ entries = index.spec_entries.sort_by(&:start_line)
129
+ parents = parents_of(entries)
130
+ roots = entries.reject { |entry| parents[entry] }
131
+ # A single top-level group, the usual RSpec.describe, gives its
132
+ # children a column each; otherwise each top-level group is a column.
133
+ tops = (roots.size == 1 && roots.first.group) ? entries.select { |e| parents[e] == roots.first } : roots
134
+ loose = tops.reject(&:group)
135
+ loose_boxes = loose.filter_map { |e| spec_box(e, parents, code_path) }
136
+ columns = loose_boxes.empty? ? [] : [SceneBuilder::Column.new(heading: nil, items: loose_boxes)]
137
+ columns + tops.select(&:group).map do |group|
138
+ items = entries.select { |e| descendant?(e, group, parents) }.filter_map do |entry|
139
+ entry.group ? SceneBuilder::Heading.new(text: own_label(entry, parents), link: spec_link(entry, code_path)) : spec_box(entry, parents, code_path)
140
+ end
141
+ SceneBuilder::Column.new(heading: SceneBuilder::Heading.new(text: own_label(group, parents), link: spec_link(group, code_path)), items: items)
142
+ end
143
+ end
144
+
145
+ # nil when the diagram being merged into already has it.
146
+ def spec_box(entry, parents, code_path)
147
+ label = own_label(entry, parents)
148
+ return nil if drawn?(entry, label)
149
+
150
+ SceneBuilder::Box.new(label: label, link: spec_link(entry, code_path))
151
+ end
152
+
153
+ def spec_link(entry, code_path)
154
+ verified_link(["omoteura:##{entry.name}"], entry, code_path)
155
+ end
156
+
157
+ # Entries are sorted by start line and RSpec blocks nest, so each
158
+ # entry's parent is the nearest open entry that still contains it.
159
+ def parents_of(entries)
160
+ open = []
161
+ entries.to_h do |entry|
162
+ open.pop while open.any? && open.last.end_line < entry.start_line
163
+ parent = open.last
164
+ open.push(entry)
165
+ [entry, parent]
166
+ end
167
+ end
168
+
169
+ def descendant?(entry, ancestor, parents)
170
+ parent = parents[entry]
171
+ parent = parents[parent] while parent && parent != ancestor
172
+ !parent.nil?
173
+ end
174
+
175
+ # The description the entry adds to its parent's: "keeps emails
176
+ # unique" for "User validations keeps emails unique".
177
+ def own_label(entry, parents)
178
+ parent = parents[entry]
179
+ return entry.name unless parent && entry.name.start_with?(parent.name)
180
+
181
+ entry.name.delete_prefix(parent.name).delete_prefix(" ")
182
+ end
183
+
184
+ def code_columns(index, code_path)
185
+ Outline.new(index).items(only: @only).chunk_while { |a, b| a.kind == b.kind }.map do |items|
186
+ kind = items.first.kind
187
+ boxes = items.filter_map do |item|
188
+ entry = item.entry
189
+ label = (kind == "methods") ? entry.name : entry.name.delete_suffix(",")
190
+ next if drawn?(entry, label)
191
+
192
+ if kind == "methods"
193
+ SceneBuilder::Box.new(label: entry.name, link: verified_link(["omoteura:##{entry.name}"], entry, code_path))
194
+ else
195
+ prefix = entry.name[/\A\S+\s+[^,(\s]+/]
196
+ SceneBuilder::Box.new(label: entry.name.delete_suffix(","), link: verified_link(["omoteura:##{prefix}", "omoteura:##{entry.name}"].uniq, entry, code_path))
197
+ end
198
+ end
199
+ SceneBuilder::Column.new(heading: SceneBuilder::Heading.new(text: kind.capitalize, link: nil), items: boxes) unless boxes.empty?
200
+ end.compact
201
+ end
202
+
203
+ # The first candidate that resolves to exactly this entry, or nil.
204
+ def verified_link(candidates, entry, code_path)
205
+ @link_resolver.exact_link(candidates, path: code_path, line: entry.start_line)
206
+ end
207
+
208
+ # The draft takes the conventional place, unless the file already has
209
+ # a diagram, by any means, or a file is there. --out, relative to the
210
+ # root, names a file or a directory instead. Nothing is ever replaced.
211
+ def destination(code_path)
212
+ if @out
213
+ path = @out.end_with?(@extension) ? @out : File.join(@out, File.basename(base_name(code_path)) + @extension)
214
+ return [path, File.exist?(File.expand_path(path, @config.root)) ? :exists : :written]
215
+ end
216
+
217
+ base = File.join(@config.diagrams_dir, base_name(code_path))
218
+ attachment = @resolver.resolve(code_path)
219
+ taken = attachment || EXTENSIONS.each_value.any? { |ext| File.exist?(File.join(@config.root, base + ext)) }
220
+ return [base + @extension, :written] unless taken
221
+
222
+ path = base + ".draft" + @extension
223
+ [path, File.exist?(File.join(@config.root, path)) ? :exists : :draft]
224
+ end
225
+
226
+ def base_name(code_path)
227
+ code_path.delete_suffix(File.extname(code_path))
228
+ end
229
+
230
+ def write(diagram_path, elements)
231
+ full = File.expand_path(diagram_path, @config.root)
232
+ FileUtils.mkdir_p(File.dirname(full))
233
+ scene = {
234
+ "type" => "excalidraw",
235
+ "version" => 2,
236
+ "source" => FORMAT_SOURCE,
237
+ "elements" => elements,
238
+ "appState" => {"viewBackgroundColor" => "#ffffff", "gridSize" => nil},
239
+ "files" => {}
240
+ }
241
+ File.write(full, (@format == :obsidian) ? Obsidian.render(scene) : JSON.pretty_generate(scene) + "\n")
242
+ end
243
+ end
244
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Omoteura
4
+ # Everything an editor needs about one code file or one diagram, as plain
5
+ # data: the attached diagram or the code it is attached to, where each
6
+ # linked shape points and how the tests behind it did. Broken links and
7
+ # unreadable diagrams are reported in the data rather than raised, so the
8
+ # editor can show them.
9
+ class Inspector
10
+ FORMAT_VERSION = 1
11
+
12
+ def initialize(config, results: TestResults.new([]))
13
+ @config = config
14
+ @results = results
15
+ @resolver = Resolver.new(config)
16
+ @link_resolver = LinkResolver.new(config)
17
+ end
18
+
19
+ # code_path must be an existing file inside the project root.
20
+ def report(code_path)
21
+ file = @config.expand(code_path) or raise ArgumentError, "#{code_path} is outside the project"
22
+ relative = @config.relative(file)
23
+ data = {version: FORMAT_VERSION, code_path: relative, new_diagram_path: new_diagram_path(relative),
24
+ attachment: nil, shapes: [], error: nil}
25
+
26
+ attachment = @resolver.resolve(relative) or return data
27
+ data[:attachment] = {diagram_path: attachment.diagram_path, source: attachment.source.to_s, missing: attachment.missing?}
28
+ return data if attachment.missing?
29
+
30
+ diagram = Diagram.load(@config.expand(attachment.diagram_path))
31
+ data[:shapes] = diagram.linked_elements.map { |element| shape(element, relative) }
32
+ data
33
+ rescue MagicComment::Duplicate, Resolver::InvalidPath, Diagram::NotADiagram, Diagram::ParseError => e
34
+ data.merge(error: e.message.delete_prefix("#{@config.root}/"))
35
+ end
36
+
37
+ # diagram_path must be an existing file inside the project root. A shared
38
+ # diagram can be attached to several files; links without a path are
39
+ # resolved against the first of them.
40
+ def diagram_report(diagram_path)
41
+ file = @config.expand(diagram_path) or raise ArgumentError, "#{diagram_path} is outside the project"
42
+ relative = @config.relative(file)
43
+ attached_to = code_attached_to(relative)
44
+ data = {version: FORMAT_VERSION, diagram_path: relative, attached_to: attached_to, digest: nil, scene: nil, shapes: [], error: nil}
45
+
46
+ diagram = Diagram.load(file)
47
+ data[:digest] = diagram.digest
48
+ data[:scene] = diagram.scene
49
+ data[:shapes] = diagram.linked_elements.map { |element| shape(element, attached_to.first) }
50
+ data
51
+ rescue Diagram::NotADiagram, Diagram::ParseError => e
52
+ data.merge(error: e.message.delete_prefix("#{@config.root}/"))
53
+ end
54
+
55
+ private
56
+
57
+ def code_attached_to(diagram_path)
58
+ Project.new(@config).code_paths.select do |code_path|
59
+ attachment = @resolver.resolve(code_path)
60
+ attachment && !attachment.missing? && attachment.diagram_path == diagram_path
61
+ rescue MagicComment::Duplicate, Resolver::InvalidPath
62
+ false
63
+ end
64
+ end
65
+
66
+ # Where a new plain Excalidraw diagram for the file belongs by the path
67
+ # convention, so an editor can offer to create one.
68
+ def new_diagram_path(code_path)
69
+ File.join(@config.diagrams_dir, code_path.delete_suffix(File.extname(code_path)) + ".excalidraw")
70
+ end
71
+
72
+ def shape(element, context_path)
73
+ result = @link_resolver.resolve(element.link, context_path: context_path)
74
+ target = result.target
75
+ summary = target && @results.summary_for(target)
76
+ {
77
+ id: element.id,
78
+ label: element.label,
79
+ link: element.link,
80
+ target: target&.to_h,
81
+ error: result.error,
82
+ test: summary && {
83
+ status: summary.status,
84
+ examples: summary.examples.map { |e| e.to_h.slice(:line_number, :full_description, :status, :message) }
85
+ }
86
+ }
87
+ end
88
+ end
89
+ end