markdownr 0.7.1 → 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.
- checksums.yaml +4 -4
- data/bin/Dockerfile.markdownr +1 -1
- data/bin/markdownr +15 -0
- data/bin/markdownr-servers.yaml +39 -0
- data/lib/markdown_server/app.rb +729 -90
- data/lib/markdown_server/csv_browser/addon_registry.rb +137 -0
- data/lib/markdown_server/csv_browser/config_loader.rb +231 -0
- data/lib/markdown_server/csv_browser/row_context.rb +146 -0
- data/lib/markdown_server/csv_browser/table_reader.rb +259 -0
- data/lib/markdown_server/helpers/admin_helpers.rb +15 -1
- data/lib/markdown_server/plugin.rb +11 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/browser.erb +4408 -0
- data/views/layout.erb +2 -15
- metadata +35 -1
|
@@ -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"]
|
|
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?)
|