markdownr 0.7.2 → 0.8.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,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module MarkdownServer
6
+ module CsvBrowser
7
+ # Reads CSV files and applies view filtering (column subsets).
8
+ # Coerces values to match schema types (integer, number, string).
9
+ class TableReader
10
+ def initialize(table)
11
+ @table = table
12
+ end
13
+
14
+ # Returns { columns: [{key, title, type}], rows: [[idx, val, ...]] }
15
+ # for the given view. Each row's first element is its original file index.
16
+ def read(view_key = nil)
17
+ view = find_view(view_key)
18
+ all_data = read_csv
19
+ apply_view(all_data, view)
20
+ end
21
+
22
+ # Validates changed cells against the table schema.
23
+ # changes is a hash of { col_key => new_string_value }.
24
+ # Returns { valid: true } or { valid: false, errors: [...] }.
25
+ def validate_cells(row_index, changes)
26
+ rows = read_csv_raw
27
+ if row_index < 0 || row_index >= rows.length
28
+ return { valid: false, errors: [{ "message" => "Row not found", "fields" => [] }] }
29
+ end
30
+
31
+ row = rows[row_index]
32
+ merged = row.to_h.merge(changes)
33
+
34
+ # Coerce to schema types for validation (skip blank optional fields
35
+ # so json_schemer doesn't reject nil as non-string)
36
+ coerced = {}
37
+ @table.columns.each do |col|
38
+ val = coerce_for_validation(merged[col.key], col.type)
39
+ coerced[col.key] = val unless val.nil?
40
+ end
41
+
42
+ errors = @table.schema.validate(coerced).map { |e| format_error(e) }
43
+
44
+ errors.empty? ? { valid: true } : { valid: false, errors: errors }
45
+ end
46
+
47
+ # Validates all rows against the table schema.
48
+ # Returns { row_index => [error_strings, ...], ... } for failing rows only.
49
+ def validate_all
50
+ rows = read_csv_raw
51
+ errors_by_row = {}
52
+
53
+ rows.each_with_index do |row, idx|
54
+ coerced = {}
55
+ @table.columns.each do |col|
56
+ val = coerce_for_validation(row[col.key], col.type)
57
+ coerced[col.key] = val unless val.nil?
58
+ end
59
+
60
+ errors = @table.schema.validate(coerced).map { |e| format_error(e) }
61
+
62
+ errors_by_row[idx] = errors unless errors.empty?
63
+ end
64
+
65
+ errors_by_row
66
+ end
67
+
68
+ # Deletes a row from the CSV file.
69
+ # Returns { deleted: true } or { deleted: false, error: "..." }.
70
+ def delete_row(row_index)
71
+ rows = read_csv_raw
72
+ return { deleted: false, error: "Row not found" } if row_index < 0 || row_index >= rows.length
73
+
74
+ rows.delete_at(row_index)
75
+ write_csv(rows)
76
+ { deleted: true }
77
+ end
78
+
79
+ # Inserts a new row. `values` is { col_key => string_value }.
80
+ # When `at` is nil the row is appended; otherwise it's inserted at that index.
81
+ # Returns { valid: true, new_index: i } or { valid: false, errors: [...] }.
82
+ def insert_row(values, at: nil)
83
+ string_values = values.each_with_object({}) { |(k, v), h| h[k.to_s] = v.nil? ? nil : v.to_s }
84
+
85
+ coerced = {}
86
+ @table.columns.each do |col|
87
+ val = coerce_for_validation(string_values[col.key], col.type)
88
+ coerced[col.key] = val unless val.nil?
89
+ end
90
+ errors = @table.schema.validate(coerced).map { |e| format_error(e) }
91
+ return { valid: false, errors: errors } unless errors.empty?
92
+
93
+ rows = read_csv_raw
94
+ headers = @table.columns.map(&:key)
95
+ new_row = CSV::Row.new(headers, headers.map { |h| string_values[h] })
96
+
97
+ if at.nil? || at >= rows.length
98
+ rows << new_row
99
+ new_index = rows.length - 1
100
+ else
101
+ at = 0 if at < 0
102
+ rows.insert(at, new_row)
103
+ new_index = at
104
+ end
105
+
106
+ write_csv(rows)
107
+ { valid: true, new_index: new_index }
108
+ end
109
+
110
+ # Duplicates a row by inserting an identical copy immediately after it.
111
+ # Returns { duplicated: true, new_index: row_index + 1 } or
112
+ # { duplicated: false, error: "..." }.
113
+ def duplicate_row(row_index)
114
+ rows = read_csv_raw
115
+ return { duplicated: false, error: "Row not found" } if row_index < 0 || row_index >= rows.length
116
+
117
+ source = rows[row_index]
118
+ copy = CSV::Row.new(source.headers, source.fields)
119
+ rows.insert(row_index + 1, copy)
120
+ write_csv(rows)
121
+ { duplicated: true, new_index: row_index + 1 }
122
+ end
123
+
124
+ # Updates a row in the CSV file. changes is { col_key => new_string_value }.
125
+ # Returns { valid: true } or { valid: false, errors: [...] }.
126
+ def update_row(row_index, changes)
127
+ result = validate_cells(row_index, changes)
128
+ return result unless result[:valid]
129
+
130
+ rows = read_csv_raw
131
+ row = rows[row_index]
132
+ changes.each { |k, v| row[k] = v }
133
+
134
+ write_csv(rows)
135
+ { valid: true }
136
+ end
137
+
138
+ private
139
+
140
+ def find_view(view_key)
141
+ if view_key
142
+ @table.views.find { |v| v.key == view_key } || @table.views.first
143
+ else
144
+ @table.views.first
145
+ end
146
+ end
147
+
148
+ # Returns array of arrays: [[coerced_val, ...], ...] in column order
149
+ def read_csv
150
+ return [] unless File.exist?(@table.csv_path)
151
+
152
+ rows = []
153
+ CSV.foreach(@table.csv_path, headers: true) do |row|
154
+ rows << @table.columns.map do |col|
155
+ coerce(row[col.key], col.type)
156
+ end
157
+ end
158
+ rows
159
+ end
160
+
161
+ # Returns array of CSV::Row objects (preserves original string values)
162
+ def read_csv_raw
163
+ return [] unless File.exist?(@table.csv_path)
164
+
165
+ rows = []
166
+ CSV.foreach(@table.csv_path, headers: true) do |row|
167
+ rows << row
168
+ end
169
+ rows
170
+ end
171
+
172
+ def write_csv(csv_rows)
173
+ headers = @table.columns.map(&:key)
174
+ CSV.open(@table.csv_path, "w") do |csv|
175
+ csv << headers
176
+ csv_rows.each do |row|
177
+ csv << headers.map { |h| row[h] }
178
+ end
179
+ end
180
+ end
181
+
182
+ def apply_view(rows, view)
183
+ visible_columns = if view&.columns
184
+ @table.columns.select { |c| view.columns.include?(c.key) }
185
+ else
186
+ @table.columns
187
+ end
188
+
189
+ col_indices = visible_columns.map { |vc| @table.columns.index(vc) }
190
+
191
+ # Prepend original row index to each row
192
+ filtered_rows = rows.each_with_index.map do |row, idx|
193
+ [idx] + col_indices.map { |i| row[i] }
194
+ end
195
+
196
+ {
197
+ columns: visible_columns.map { |c|
198
+ col = { key: c.key, title: c.title, type: c.type }
199
+ col[:references] = c.references if c.references
200
+ col[:constraints] = c.constraints if c.constraints && !c.constraints.empty?
201
+ col
202
+ },
203
+ rows: filtered_rows
204
+ }
205
+ end
206
+
207
+ # Formats a json_schemer error into a hash with message and affected fields.
208
+ # Returns { "message" => String, "fields" => [String] }
209
+ def format_error(error)
210
+ field = error["data_pointer"].sub(%r{^/}, "")
211
+ col = @table.columns.find { |c| c.key == field } unless field.empty?
212
+ label = col ? col.title : field
213
+ fields = field.empty? ? [] : [field]
214
+
215
+ case error["type"]
216
+ when "required"
217
+ keys = error.dig("details", "missing_keys") || []
218
+ titles = keys.map { |k| (@table.columns.find { |c| c.key == k }&.title) || k }
219
+ { "message" => "missing required: #{titles.join(", ")}", "fields" => keys }
220
+ when "enum"
221
+ { "message" => "#{label}: not a valid option", "fields" => fields }
222
+ when "pattern"
223
+ { "message" => "#{label}: does not match expected format", "fields" => fields }
224
+ when "integer", "number"
225
+ { "message" => "#{label}: must be a #{error["type"]}", "fields" => fields }
226
+ else
227
+ msg = col ? "#{label}: #{error["type"]}" : error["error"]
228
+ { "message" => msg, "fields" => fields }
229
+ end
230
+ end
231
+
232
+ def coerce(value, type)
233
+ return nil if value.nil? || value.strip.empty?
234
+
235
+ case type
236
+ when "integer"
237
+ Integer(value) rescue value
238
+ when "number"
239
+ Float(value) rescue value
240
+ else
241
+ value
242
+ end
243
+ end
244
+
245
+ def coerce_for_validation(value, type)
246
+ return nil if value.nil? || value.to_s.strip.empty?
247
+
248
+ case type
249
+ when "integer"
250
+ Integer(value) rescue value
251
+ when "number"
252
+ Float(value) rescue value
253
+ else
254
+ value.to_s
255
+ end
256
+ end
257
+ end
258
+ end
259
+ end
@@ -1,3 +1,5 @@
1
+ require "ipaddr"
2
+
1
3
  module MarkdownServer
2
4
  module Helpers
3
5
  module AdminHelpers
@@ -25,7 +27,7 @@ module MarkdownServer
25
27
  adm = setup_config["admin"]
26
28
  return false unless adm.is_a?(Hash)
27
29
 
28
- return true if adm["ip"].to_s.strip == client_ip
30
+ return true if ip_allowed?(adm["ip"], client_ip)
29
31
 
30
32
  if adm["user"] && adm["pw"]
31
33
  auth = request.env["HTTP_AUTHORIZATION"].to_s
@@ -37,6 +39,18 @@ module MarkdownServer
37
39
 
38
40
  false
39
41
  end
42
+
43
+ private
44
+
45
+ def ip_allowed?(allowed, ip)
46
+ return false unless allowed && ip
47
+
48
+ entries = allowed.is_a?(Array) ? allowed : [allowed]
49
+ client = IPAddr.new(ip.to_s.strip)
50
+ entries.any? { |entry| IPAddr.new(entry.to_s.strip).include?(client) }
51
+ rescue IPAddr::InvalidAddressError
52
+ false
53
+ end
40
54
  end
41
55
  end
42
56
  end
@@ -33,6 +33,10 @@ module MarkdownServer
33
33
 
34
34
  # Hook: post-process rendered markdown HTML (runs after render_markdown)
35
35
  def post_render(html, meta, app) html end
36
+
37
+ # Hook: claim a file for custom popup rendering in /browser
38
+ # Return { type:, title:, ... } hash or nil to pass
39
+ def browser_render(relative_path, real_path, app) nil end
36
40
  end
37
41
 
38
42
  class PluginRegistry
@@ -53,6 +57,13 @@ module MarkdownServer
53
57
  end
54
58
 
55
59
  config = resolve_config(root_dir, cli_overrides)
60
+ known_names = registered.map(&:plugin_name)
61
+ config.each do |name, cfg|
62
+ next unless cfg.is_a?(Hash) && cfg["enabled"]
63
+ unless known_names.include?(name)
64
+ $stderr.puts "\n\e[1;33mWarning: unknown plugin '#{name}' (available: #{known_names.join(", ")})\e[0m\n\n"
65
+ end
66
+ end
56
67
  registered.filter_map do |klass|
57
68
  plugin_config = config.fetch(klass.plugin_name, {})
58
69
  enabled = plugin_config.fetch("enabled", klass.enabled_by_default?)
@@ -1,3 +1,3 @@
1
1
  module MarkdownServer
2
- VERSION = "0.7.2"
2
+ VERSION = "0.8.0"
3
3
  end