markdownr 0.7.2 → 0.8.1
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 +79 -0
- data/bin/markdownr-servers.yaml +39 -0
- data/bin/start-claude +2 -0
- data/lib/markdown_server/app.rb +953 -107
- data/lib/markdown_server/assets/editor-loader.js +362 -0
- 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 +25 -1
- data/lib/markdown_server/helpers/formatting_helpers.rb +3 -1
- data/lib/markdown_server/helpers/markdown_helpers.rb +132 -5
- data/lib/markdown_server/helpers/path_helpers.rb +56 -7
- data/lib/markdown_server/helpers/search_helpers.rb +31 -3
- data/lib/markdown_server/permitted_bases.rb +13 -0
- data/lib/markdown_server/plugin.rb +11 -0
- data/lib/markdown_server/plugins/bible_citations/citations.rb +4 -4
- data/lib/markdown_server/unhide.rb +114 -0
- data/lib/markdown_server/version.rb +1 -1
- data/views/browser.erb +5794 -0
- data/views/layout.erb +124 -20
- data/views/popup_assets.erb +52 -26
- metadata +40 -2
data/lib/markdown_server/app.rb
CHANGED
|
@@ -10,7 +10,14 @@ require "pathname"
|
|
|
10
10
|
require "set"
|
|
11
11
|
require "net/http"
|
|
12
12
|
require "base64"
|
|
13
|
+
require "digest"
|
|
13
14
|
require_relative "plugin"
|
|
15
|
+
require_relative "csv_browser/config_loader"
|
|
16
|
+
require_relative "csv_browser/table_reader"
|
|
17
|
+
require_relative "csv_browser/addon_registry"
|
|
18
|
+
require_relative "csv_browser/row_context"
|
|
19
|
+
require_relative "permitted_bases"
|
|
20
|
+
require_relative "unhide"
|
|
14
21
|
require_relative "helpers/path_helpers"
|
|
15
22
|
require_relative "helpers/formatting_helpers"
|
|
16
23
|
require_relative "helpers/markdown_helpers"
|
|
@@ -37,6 +44,8 @@ module MarkdownServer
|
|
|
37
44
|
set :protection, false
|
|
38
45
|
set :host_authorization, { permitted_hosts: [] }
|
|
39
46
|
set :behind_proxy, false
|
|
47
|
+
set :admin_all, false
|
|
48
|
+
set :admin_only, false
|
|
40
49
|
set :verbose, false
|
|
41
50
|
set :session_secret, ENV.fetch("MARKDOWNR_SESSION_SECRET", SecureRandom.hex(64))
|
|
42
51
|
set :sessions, key: "markdownr_session", same_site: :strict, httponly: true
|
|
@@ -48,6 +57,12 @@ module MarkdownServer
|
|
|
48
57
|
set :popup_external_domains, []
|
|
49
58
|
set :dictionary_url, nil
|
|
50
59
|
set :plugin_dirs, []
|
|
60
|
+
set :csv_browser_config, nil
|
|
61
|
+
set :followed_links, []
|
|
62
|
+
set :unhide_rules, []
|
|
63
|
+
set :default_vim, false
|
|
64
|
+
set :allow_editor, false
|
|
65
|
+
set :allow_csv_editor, false
|
|
51
66
|
end
|
|
52
67
|
|
|
53
68
|
def self.load_plugins!
|
|
@@ -78,12 +93,21 @@ module MarkdownServer
|
|
|
78
93
|
end
|
|
79
94
|
|
|
80
95
|
before do
|
|
81
|
-
cache_control :public, max_age: 14400
|
|
82
|
-
|
|
83
96
|
if settings.verbose
|
|
84
97
|
$stdout.puts "#{Time.now.strftime("%Y-%m-%d %H:%M:%S")} #{client_ip} #{request.request_method} #{request.fullpath}"
|
|
85
98
|
$stdout.flush
|
|
86
99
|
end
|
|
100
|
+
|
|
101
|
+
if admin_only_mode? && !admin_only_public_route?(request.path_info) && !admin?
|
|
102
|
+
cache_control :no_cache, :no_store, :must_revalidate
|
|
103
|
+
redirect "/admin/login?return_to=#{CGI.escape(request.fullpath)}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if request.path_info.start_with?("/browser", "/csv-browser", "/__markdownr/api")
|
|
107
|
+
cache_control :no_cache, :no_store, :must_revalidate
|
|
108
|
+
else
|
|
109
|
+
cache_control :public, max_age: 14400
|
|
110
|
+
end
|
|
87
111
|
end
|
|
88
112
|
|
|
89
113
|
get "/" do
|
|
@@ -96,6 +120,180 @@ module MarkdownServer
|
|
|
96
120
|
JSON.dump({ version: MarkdownServer::VERSION, plugins: plugins })
|
|
97
121
|
end
|
|
98
122
|
|
|
123
|
+
post "/ping" do
|
|
124
|
+
content_type :json
|
|
125
|
+
{ ok: true }.to_json
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
EDITOR_FILE_MAX_BYTES = 5 * 1024 * 1024
|
|
129
|
+
|
|
130
|
+
def editor_loader_path
|
|
131
|
+
@@editor_loader_path ||= File.expand_path("assets/editor-loader.js", __dir__)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def editor_loader_version
|
|
135
|
+
path = editor_loader_path
|
|
136
|
+
content = File.read(path) + editor_import_map_json
|
|
137
|
+
Digest::SHA256.hexdigest(content)[0, 10]
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
EDITOR_PKGS = {
|
|
141
|
+
"@codemirror/state" => "6.6.0",
|
|
142
|
+
"@codemirror/view" => "6.41.1",
|
|
143
|
+
"@codemirror/commands" => "6.10.3",
|
|
144
|
+
"@codemirror/language" => "6.12.3",
|
|
145
|
+
"@codemirror/search" => "6.7.0",
|
|
146
|
+
"@codemirror/autocomplete" => "6.20.1",
|
|
147
|
+
"@codemirror/theme-one-dark" => "6.1.3",
|
|
148
|
+
"@codemirror/lang-markdown" => "6.5.0",
|
|
149
|
+
"@codemirror/lang-javascript" => "6.2.5",
|
|
150
|
+
"@codemirror/lang-json" => "6.0.2",
|
|
151
|
+
"@codemirror/lang-yaml" => "6.1.3",
|
|
152
|
+
"@codemirror/lang-html" => "6.4.11",
|
|
153
|
+
"@codemirror/lang-css" => "6.3.1",
|
|
154
|
+
"@codemirror/lang-python" => "6.2.1",
|
|
155
|
+
"@codemirror/legacy-modes" => "6.5.2",
|
|
156
|
+
"@replit/codemirror-vim" => "6.3.0"
|
|
157
|
+
}.freeze
|
|
158
|
+
|
|
159
|
+
EDITOR_SHARED_DEPS = %w[
|
|
160
|
+
@codemirror/state
|
|
161
|
+
@codemirror/view
|
|
162
|
+
@codemirror/language
|
|
163
|
+
@codemirror/commands
|
|
164
|
+
].freeze
|
|
165
|
+
|
|
166
|
+
def editor_import_map_json
|
|
167
|
+
@@editor_import_map_json ||= begin
|
|
168
|
+
imports = {}
|
|
169
|
+
EDITOR_PKGS.each do |pkg, ver|
|
|
170
|
+
deps = EDITOR_SHARED_DEPS.reject { |d| d == pkg }
|
|
171
|
+
imports[pkg] = "https://esm.sh/#{pkg}@#{ver}?external=#{deps.join(",")}"
|
|
172
|
+
end
|
|
173
|
+
EDITOR_PKGS.each do |pkg, ver|
|
|
174
|
+
next unless pkg == "@codemirror/legacy-modes"
|
|
175
|
+
%w[ruby shell].each do |mode|
|
|
176
|
+
deps = EDITOR_SHARED_DEPS.reject { |d| d == pkg }
|
|
177
|
+
imports["#{pkg}/mode/#{mode}"] =
|
|
178
|
+
"https://esm.sh/#{pkg}@#{ver}/mode/#{mode}.js?external=#{deps.join(",")}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
JSON.dump({ imports: imports })
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
get "/__markdownr/editor-loader.js" do
|
|
186
|
+
halt 404, "editor disabled" unless settings.allow_editor
|
|
187
|
+
cache_control :public, max_age: 14400
|
|
188
|
+
send_file File.expand_path("assets/editor-loader.js", __dir__),
|
|
189
|
+
type: "application/javascript"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
get "/__markdownr/api/file/source/?*" do
|
|
193
|
+
halt 404, '{"error":"editor disabled"}' unless settings.allow_editor
|
|
194
|
+
requested = params["splat"].first.to_s.chomp("/")
|
|
195
|
+
halt 400, '{"error":"missing path"}' if requested.empty?
|
|
196
|
+
|
|
197
|
+
real_path = safe_path(requested)
|
|
198
|
+
halt 404, '{"error":"not a file"}' unless File.file?(real_path)
|
|
199
|
+
|
|
200
|
+
size = File.size(real_path)
|
|
201
|
+
halt 413, { error: "file too large", max: EDITOR_FILE_MAX_BYTES, size: size }.to_json if size > EDITOR_FILE_MAX_BYTES
|
|
202
|
+
|
|
203
|
+
content = File.read(real_path, encoding: "utf-8")
|
|
204
|
+
etag = Digest::SHA256.hexdigest(content)
|
|
205
|
+
headers "ETag" => %("#{etag}")
|
|
206
|
+
headers "Last-Modified" => File.mtime(real_path).httpdate
|
|
207
|
+
content_type "text/plain; charset=utf-8"
|
|
208
|
+
cache_control :no_cache, :no_store, :must_revalidate
|
|
209
|
+
content
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
put "/__markdownr/api/file/?*" do
|
|
213
|
+
content_type :json
|
|
214
|
+
cache_control :no_cache, :no_store, :must_revalidate
|
|
215
|
+
unless settings.allow_editor
|
|
216
|
+
halt 403, { error: "File editor disabled on this server. Restart with --allow-editor to enable." }.to_json
|
|
217
|
+
end
|
|
218
|
+
unless admin?
|
|
219
|
+
halt 403, { error: "Admin login required to save changes.",
|
|
220
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
221
|
+
client_ip: client_ip }.to_json
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
requested = params["splat"].first.to_s.chomp("/")
|
|
225
|
+
halt 400, { error: "missing path" }.to_json if requested.empty?
|
|
226
|
+
|
|
227
|
+
real_path = safe_path(requested)
|
|
228
|
+
halt 404, { error: "not a file" }.to_json unless File.file?(real_path)
|
|
229
|
+
|
|
230
|
+
body = request.body.read
|
|
231
|
+
halt 413, { error: "file too large", max: EDITOR_FILE_MAX_BYTES, size: body.bytesize }.to_json if body.bytesize > EDITOR_FILE_MAX_BYTES
|
|
232
|
+
|
|
233
|
+
body = body.force_encoding("utf-8")
|
|
234
|
+
halt 400, { error: "body is not valid UTF-8" }.to_json unless body.valid_encoding?
|
|
235
|
+
|
|
236
|
+
if_match = request.env["HTTP_IF_MATCH"].to_s.strip
|
|
237
|
+
force = if_match.empty? || if_match == "*"
|
|
238
|
+
|
|
239
|
+
unless force
|
|
240
|
+
current = File.read(real_path, encoding: "utf-8")
|
|
241
|
+
current_etag = Digest::SHA256.hexdigest(current)
|
|
242
|
+
provided = if_match.gsub(/\A"|"\z/, "")
|
|
243
|
+
if provided != current_etag
|
|
244
|
+
halt 409, { error: "stale", current_etag: current_etag }.to_json
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
tmp_path = "#{real_path}.tmp.#{Process.pid}.#{Thread.current.object_id}"
|
|
249
|
+
File.write(tmp_path, body)
|
|
250
|
+
File.rename(tmp_path, real_path)
|
|
251
|
+
|
|
252
|
+
logger.info("file edit: #{client_ip} #{requested} #{body.bytesize}b") if respond_to?(:logger) && logger
|
|
253
|
+
new_etag = Digest::SHA256.hexdigest(body)
|
|
254
|
+
{ etag: new_etag, mtime: File.mtime(real_path).iso8601, bytes: body.bytesize }.to_json
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
post "/__markdownr/api/render/preview" do
|
|
258
|
+
content_type :json
|
|
259
|
+
cache_control :no_cache, :no_store, :must_revalidate
|
|
260
|
+
unless settings.allow_editor
|
|
261
|
+
halt 403, { error: "File editor disabled on this server. Restart with --allow-editor to enable." }.to_json
|
|
262
|
+
end
|
|
263
|
+
unless admin?
|
|
264
|
+
halt 403, { error: "Admin login required to preview edits.",
|
|
265
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
266
|
+
client_ip: client_ip }.to_json
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
payload = JSON.parse(request.body.read) rescue {}
|
|
270
|
+
content = payload["content"].to_s
|
|
271
|
+
halt 413, { error: "content too large" }.to_json if content.bytesize > EDITOR_FILE_MAX_BYTES
|
|
272
|
+
|
|
273
|
+
wiki_dir = payload["current_wiki_dir"].to_s
|
|
274
|
+
base_real = File.realpath(root_dir)
|
|
275
|
+
if wiki_dir.empty?
|
|
276
|
+
@current_wiki_dir = base_real
|
|
277
|
+
else
|
|
278
|
+
candidate = File.expand_path(wiki_dir, base_real)
|
|
279
|
+
@current_wiki_dir = (candidate == base_real || candidate.start_with?("#{base_real}/")) ? candidate : base_real
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
meta, body = parse_frontmatter(content)
|
|
283
|
+
html = render_markdown(body)
|
|
284
|
+
settings.plugins.each { |p| html = p.post_render(html, meta, self) }
|
|
285
|
+
|
|
286
|
+
frontmatter_html = ""
|
|
287
|
+
if meta && !meta.empty?
|
|
288
|
+
rows = meta.map { |key, value|
|
|
289
|
+
"<tr><th>#{h(key)}</th><td>#{render_frontmatter_value(value)}</td></tr>"
|
|
290
|
+
}.join
|
|
291
|
+
frontmatter_html = %(<div class="frontmatter"><div class="frontmatter-heading">Frontmatter</div><table class="meta-table">#{rows}</table></div>)
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
{ html: html, frontmatter_html: frontmatter_html }.to_json
|
|
295
|
+
end
|
|
296
|
+
|
|
99
297
|
get "/setup-info" do
|
|
100
298
|
@title = "Setup Info"
|
|
101
299
|
@client_ip = client_ip
|
|
@@ -131,6 +329,461 @@ module MarkdownServer
|
|
|
131
329
|
redirect "/"
|
|
132
330
|
end
|
|
133
331
|
|
|
332
|
+
get "/browser" do
|
|
333
|
+
@title = dir_title
|
|
334
|
+
@root_title = dir_title
|
|
335
|
+
@start_mode = "directory"
|
|
336
|
+
@csv_databases = csv_databases_json
|
|
337
|
+
@initial_path = ""
|
|
338
|
+
@is_admin = admin?
|
|
339
|
+
@default_vim = settings.default_vim
|
|
340
|
+
@allow_editor = settings.allow_editor
|
|
341
|
+
@allow_csv_editor = settings.allow_csv_editor
|
|
342
|
+
erb :browser, layout: false
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
get "/csv-browser" do
|
|
346
|
+
@title = dir_title
|
|
347
|
+
@root_title = dir_title
|
|
348
|
+
@start_mode = "csv"
|
|
349
|
+
@csv_databases = csv_databases_json
|
|
350
|
+
@initial_path = ""
|
|
351
|
+
@is_admin = admin?
|
|
352
|
+
@default_vim = settings.default_vim
|
|
353
|
+
@allow_editor = settings.allow_editor
|
|
354
|
+
@allow_csv_editor = settings.allow_csv_editor
|
|
355
|
+
erb :browser, layout: false
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
get "/browser/api/render/?*" do
|
|
359
|
+
content_type :json
|
|
360
|
+
requested = params["splat"].first.to_s.chomp("/")
|
|
361
|
+
|
|
362
|
+
if requested.empty?
|
|
363
|
+
real_path = File.realpath(root_dir)
|
|
364
|
+
else
|
|
365
|
+
real_path = safe_path(requested)
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
if File.directory?(real_path)
|
|
369
|
+
JSON.dump(browser_render_directory(real_path, requested))
|
|
370
|
+
else
|
|
371
|
+
JSON.dump(browser_render_file(real_path, requested, raw: params["raw"] == "1"))
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
get "/browser/api/csv/databases" do
|
|
376
|
+
content_type :json
|
|
377
|
+
csv_databases_json
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
post "/browser/api/csv/reload" do
|
|
381
|
+
content_type :json
|
|
382
|
+
loader = settings.csv_browser_config
|
|
383
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
384
|
+
loader.reload!
|
|
385
|
+
csv_databases_json
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
get "/browser/api/csv/databases/:db/tables/:table" do
|
|
389
|
+
content_type :json
|
|
390
|
+
loader = settings.csv_browser_config
|
|
391
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
392
|
+
|
|
393
|
+
db = loader.database(params[:db])
|
|
394
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
395
|
+
|
|
396
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
397
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
398
|
+
|
|
399
|
+
view_key = params[:view] || "all"
|
|
400
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
401
|
+
data = reader.read(view_key)
|
|
402
|
+
lookups = loader.resolve_references(db, table)
|
|
403
|
+
|
|
404
|
+
# Enrich column references with the referenced table's color
|
|
405
|
+
data[:columns].each do |col|
|
|
406
|
+
next unless col[:references]
|
|
407
|
+
ref_table = db.tables.find { |t| t.key == col[:references][:table] }
|
|
408
|
+
col[:references] = col[:references].merge(color: ref_table.color) if ref_table&.color
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
reverse_refs = loader.resolve_reverse_references(db, table)
|
|
412
|
+
|
|
413
|
+
result = {
|
|
414
|
+
database: db.key,
|
|
415
|
+
table: table.key,
|
|
416
|
+
view: view_key,
|
|
417
|
+
views: table.views.map { |v| { key: v.key, title: v.title } },
|
|
418
|
+
columns: data[:columns],
|
|
419
|
+
rows: data[:rows]
|
|
420
|
+
}
|
|
421
|
+
result[:color] = table.color if table.color
|
|
422
|
+
result[:required] = table.required unless table.required.empty?
|
|
423
|
+
result[:references] = lookups unless lookups.empty?
|
|
424
|
+
result[:reverse_references] = reverse_refs unless reverse_refs.empty?
|
|
425
|
+
result.to_json
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
get "/browser/api/csv/databases/:db/search" do
|
|
429
|
+
content_type :json
|
|
430
|
+
loader = settings.csv_browser_config
|
|
431
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
432
|
+
|
|
433
|
+
db = loader.database(params[:db])
|
|
434
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
435
|
+
|
|
436
|
+
query = (params[:q] || "").strip
|
|
437
|
+
halt 400, { error: "No query" }.to_json if query.empty?
|
|
438
|
+
|
|
439
|
+
terms = query.split(/\s+/).map { |t| parse_filter_term(t) }
|
|
440
|
+
|
|
441
|
+
results = db.tables.filter_map do |table|
|
|
442
|
+
next unless File.exist?(table.csv_path)
|
|
443
|
+
|
|
444
|
+
count = 0
|
|
445
|
+
CSV.foreach(table.csv_path, headers: true) do |row|
|
|
446
|
+
values = row.fields.map { |v| v.to_s }
|
|
447
|
+
match = terms.all? do |term|
|
|
448
|
+
values.any? { |v| filter_term_matches?(term, v) }
|
|
449
|
+
end
|
|
450
|
+
count += 1 if match
|
|
451
|
+
end
|
|
452
|
+
next if count == 0
|
|
453
|
+
|
|
454
|
+
entry = { table: table.key, title: table.title, count: count }
|
|
455
|
+
entry[:color] = table.color if table.color
|
|
456
|
+
entry[:views] = table.views.map { |v| { key: v.key, title: v.title } }
|
|
457
|
+
entry
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
{ database: db.key, query: query, results: results }.to_json
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
get "/browser/api/csv/databases/:db/tables/:table/schema" do
|
|
464
|
+
content_type "text/plain"
|
|
465
|
+
loader = settings.csv_browser_config
|
|
466
|
+
halt 404, "not configured" unless loader
|
|
467
|
+
|
|
468
|
+
db = loader.database(params[:db])
|
|
469
|
+
halt 404, "Database not found" unless db
|
|
470
|
+
|
|
471
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
472
|
+
halt 404, "Table not found" unless table
|
|
473
|
+
|
|
474
|
+
schema = { "title" => table.title }
|
|
475
|
+
schema["csv"] = File.basename(table.csv_path)
|
|
476
|
+
schema["color"] = table.color if table.color
|
|
477
|
+
|
|
478
|
+
props = {}
|
|
479
|
+
table.columns.each do |col|
|
|
480
|
+
col_def = {}
|
|
481
|
+
col_def["type"] = col.type
|
|
482
|
+
col_def["title"] = col.title if col.title != col.key.capitalize
|
|
483
|
+
col.constraints.each { |k, v| col_def[k] = v } if col.constraints
|
|
484
|
+
if col.references
|
|
485
|
+
col_def["references"] = {
|
|
486
|
+
"table" => col.references[:table],
|
|
487
|
+
"column" => col.references[:column],
|
|
488
|
+
"display" => col.references[:display]
|
|
489
|
+
}
|
|
490
|
+
end
|
|
491
|
+
props[col.key] = col_def
|
|
492
|
+
end
|
|
493
|
+
schema["properties"] = props
|
|
494
|
+
schema["required"] = table.required unless table.required.empty?
|
|
495
|
+
|
|
496
|
+
if table.views.length > 1 || (table.views.length == 1 && table.views.first.key != "all")
|
|
497
|
+
views = {}
|
|
498
|
+
table.views.each do |v|
|
|
499
|
+
view_def = { "title" => v.title }
|
|
500
|
+
view_def["columns"] = v.columns if v.columns
|
|
501
|
+
views[v.key] = view_def
|
|
502
|
+
end
|
|
503
|
+
schema["views"] = views
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
yaml = Psych.dump(schema, indentation: 4).sub(/\A---\n/, "")
|
|
507
|
+
yaml.gsub(/^(\s*)- /, '\1 - ')
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
get "/browser/api/csv/databases/:db/tables/:table/validate" do
|
|
511
|
+
content_type :json
|
|
512
|
+
loader = settings.csv_browser_config
|
|
513
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
514
|
+
|
|
515
|
+
db = loader.database(params[:db])
|
|
516
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
517
|
+
|
|
518
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
519
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
520
|
+
|
|
521
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
522
|
+
errors = reader.validate_all
|
|
523
|
+
|
|
524
|
+
{ errors: errors }.to_json
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
get "/browser/api/csv/databases/:db/tables/:table/rows/:row/validate" do
|
|
528
|
+
content_type :json
|
|
529
|
+
loader = settings.csv_browser_config
|
|
530
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
531
|
+
|
|
532
|
+
db = loader.database(params[:db])
|
|
533
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
534
|
+
|
|
535
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
536
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
537
|
+
|
|
538
|
+
row_index = Integer(params[:row]) rescue nil
|
|
539
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
540
|
+
|
|
541
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
542
|
+
result = reader.validate_cells(row_index, {})
|
|
543
|
+
result.to_json
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
put "/browser/api/csv/databases/:db/tables/:table/rows/:row" do
|
|
547
|
+
content_type :json
|
|
548
|
+
unless settings.allow_csv_editor
|
|
549
|
+
halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
|
|
550
|
+
end
|
|
551
|
+
unless admin?
|
|
552
|
+
halt 403, { error: "Admin login required to save changes.",
|
|
553
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
554
|
+
client_ip: client_ip }.to_json
|
|
555
|
+
end
|
|
556
|
+
loader = settings.csv_browser_config
|
|
557
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
558
|
+
|
|
559
|
+
db = loader.database(params[:db])
|
|
560
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
561
|
+
|
|
562
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
563
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
564
|
+
|
|
565
|
+
row_index = Integer(params[:row]) rescue nil
|
|
566
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
567
|
+
|
|
568
|
+
body = JSON.parse(request.body.read) rescue {}
|
|
569
|
+
changes = body["changes"] || {}
|
|
570
|
+
halt 400, { error: "No changes provided" }.to_json if changes.empty?
|
|
571
|
+
|
|
572
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
573
|
+
result = reader.update_row(row_index, changes)
|
|
574
|
+
|
|
575
|
+
if result[:valid]
|
|
576
|
+
result.to_json
|
|
577
|
+
else
|
|
578
|
+
status 422
|
|
579
|
+
result.to_json
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
post "/browser/api/csv/databases/:db/tables/:table/rows/:row/duplicate" do
|
|
584
|
+
content_type :json
|
|
585
|
+
unless settings.allow_csv_editor
|
|
586
|
+
halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
|
|
587
|
+
end
|
|
588
|
+
unless admin?
|
|
589
|
+
halt 403, { error: "Admin login required to duplicate rows.",
|
|
590
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
591
|
+
client_ip: client_ip }.to_json
|
|
592
|
+
end
|
|
593
|
+
loader = settings.csv_browser_config
|
|
594
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
595
|
+
|
|
596
|
+
db = loader.database(params[:db])
|
|
597
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
598
|
+
|
|
599
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
600
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
601
|
+
|
|
602
|
+
row_index = Integer(params[:row]) rescue nil
|
|
603
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
604
|
+
|
|
605
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
606
|
+
result = reader.duplicate_row(row_index)
|
|
607
|
+
|
|
608
|
+
if result[:duplicated]
|
|
609
|
+
result.to_json
|
|
610
|
+
else
|
|
611
|
+
status 400
|
|
612
|
+
result.to_json
|
|
613
|
+
end
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
delete "/browser/api/csv/databases/:db/tables/:table/rows/:row" do
|
|
617
|
+
content_type :json
|
|
618
|
+
unless settings.allow_csv_editor
|
|
619
|
+
halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
|
|
620
|
+
end
|
|
621
|
+
unless admin?
|
|
622
|
+
halt 403, { error: "Admin login required to delete rows.",
|
|
623
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
624
|
+
client_ip: client_ip }.to_json
|
|
625
|
+
end
|
|
626
|
+
loader = settings.csv_browser_config
|
|
627
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
628
|
+
|
|
629
|
+
db = loader.database(params[:db])
|
|
630
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
631
|
+
|
|
632
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
633
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
634
|
+
|
|
635
|
+
row_index = Integer(params[:row]) rescue nil
|
|
636
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
637
|
+
|
|
638
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
639
|
+
result = reader.delete_row(row_index)
|
|
640
|
+
|
|
641
|
+
if result[:deleted]
|
|
642
|
+
result.to_json
|
|
643
|
+
else
|
|
644
|
+
status 400
|
|
645
|
+
result.to_json
|
|
646
|
+
end
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# List add-on actions available for a row on a given table.
|
|
650
|
+
get "/browser/api/csv/databases/:db/tables/:table/addons" do
|
|
651
|
+
content_type :json
|
|
652
|
+
loader = settings.csv_browser_config
|
|
653
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
654
|
+
|
|
655
|
+
db = loader.database(params[:db])
|
|
656
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
657
|
+
|
|
658
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
659
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
660
|
+
|
|
661
|
+
row_index = Integer(params[:row]) rescue nil
|
|
662
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
663
|
+
|
|
664
|
+
row_hash = read_row_hash(table, row_index)
|
|
665
|
+
halt 404, { error: "Row not found" }.to_json unless row_hash
|
|
666
|
+
|
|
667
|
+
attachments = CsvBrowser::CsvAddonRegistry.for_table(db, table)
|
|
668
|
+
actions = attachments.flat_map do |att|
|
|
669
|
+
ctx = CsvBrowser::RowContext.new(
|
|
670
|
+
database: db, table: table, row_index: row_index, row: row_hash,
|
|
671
|
+
options: att[:options]
|
|
672
|
+
)
|
|
673
|
+
att[:definition].actions_for(ctx).map do |a|
|
|
674
|
+
{
|
|
675
|
+
addon: att[:definition].name.to_s,
|
|
676
|
+
id: a[:id].to_s,
|
|
677
|
+
label: a[:label] || a[:id].to_s,
|
|
678
|
+
enabled: a[:enabled]
|
|
679
|
+
}.tap { |h| h[:icon] = a[:icon] if a[:icon] }
|
|
680
|
+
end
|
|
681
|
+
end
|
|
682
|
+
{ actions: actions }.to_json
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
# Invoke an add-on action (initial call or prompt continuation).
|
|
686
|
+
post "/browser/api/csv/databases/:db/tables/:table/addons/:addon/:action" do
|
|
687
|
+
content_type :json
|
|
688
|
+
unless settings.allow_csv_editor
|
|
689
|
+
halt 403, { error: "CSV editor disabled on this server. Restart with --allow-csv-editor to enable." }.to_json
|
|
690
|
+
end
|
|
691
|
+
unless admin?
|
|
692
|
+
halt 403, { error: "Admin login required to run add-on actions.",
|
|
693
|
+
admin_url: "https://github.com/brianmd/markdown-server#admin-access",
|
|
694
|
+
client_ip: client_ip }.to_json
|
|
695
|
+
end
|
|
696
|
+
loader = settings.csv_browser_config
|
|
697
|
+
halt 404, '{"error":"not configured"}' unless loader
|
|
698
|
+
|
|
699
|
+
db = loader.database(params[:db])
|
|
700
|
+
halt 404, { error: "Database not found" }.to_json unless db
|
|
701
|
+
|
|
702
|
+
table = db.tables.find { |t| t.key == params[:table] }
|
|
703
|
+
halt 404, { error: "Table not found" }.to_json unless table
|
|
704
|
+
|
|
705
|
+
attachments = CsvBrowser::CsvAddonRegistry.for_table(db, table)
|
|
706
|
+
attachment = attachments.find { |a| a[:definition].name.to_s == params[:addon] }
|
|
707
|
+
halt 404, { error: "Add-on not found" }.to_json unless attachment
|
|
708
|
+
|
|
709
|
+
handler = attachment[:definition].handler_for(params[:action])
|
|
710
|
+
halt 404, { error: "Action not found" }.to_json unless handler
|
|
711
|
+
|
|
712
|
+
body = JSON.parse(request.body.read) rescue {}
|
|
713
|
+
row_index = Integer(body["row_index"]) rescue nil
|
|
714
|
+
halt 400, { error: "Invalid row index" }.to_json unless row_index
|
|
715
|
+
|
|
716
|
+
row_hash = read_row_hash(table, row_index)
|
|
717
|
+
halt 404, { error: "Row not found" }.to_json unless row_hash
|
|
718
|
+
|
|
719
|
+
ctx = CsvBrowser::RowContext.new(
|
|
720
|
+
database: db, table: table, row_index: row_index, row: row_hash,
|
|
721
|
+
options: attachment[:options], input: body["input"], state: body["state"]
|
|
722
|
+
)
|
|
723
|
+
|
|
724
|
+
result = handler.call(ctx)
|
|
725
|
+
result = { kind: "done", reload: true } unless result.is_a?(Hash)
|
|
726
|
+
result.to_json
|
|
727
|
+
end
|
|
728
|
+
|
|
729
|
+
get "/browser/api/csv/unmapped/*" do
|
|
730
|
+
content_type :json
|
|
731
|
+
relative = params["splat"].first.to_s
|
|
732
|
+
real_path = safe_path(relative)
|
|
733
|
+
halt 404, '{"error":"not found"}' unless real_path && File.exist?(real_path) && real_path.end_with?(".csv")
|
|
734
|
+
|
|
735
|
+
# Verify this is actually an unmapped file
|
|
736
|
+
loader = settings.csv_browser_config
|
|
737
|
+
if loader&.find_table_by_csv_path(File.realpath(real_path))
|
|
738
|
+
halt 400, '{"error":"file belongs to a configured database"}'
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
rows = CSV.read(real_path, headers: true)
|
|
742
|
+
headers = rows.headers.compact
|
|
743
|
+
columns = headers.map { |h| { key: h, title: h, type: "string" } }
|
|
744
|
+
data_rows = rows.each_with_index.map do |row, idx|
|
|
745
|
+
[idx] + headers.map { |h| row[h] }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
{
|
|
749
|
+
database: "_unmapped",
|
|
750
|
+
table: relative,
|
|
751
|
+
view: "all",
|
|
752
|
+
views: [{ key: "all", title: "All" }],
|
|
753
|
+
columns: columns,
|
|
754
|
+
rows: data_rows,
|
|
755
|
+
readonly: true
|
|
756
|
+
}.to_json
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
get "/browser/*" do
|
|
760
|
+
splat = params["splat"].first.to_s
|
|
761
|
+
pass if splat.start_with?("api/")
|
|
762
|
+
@title = dir_title
|
|
763
|
+
@root_title = dir_title
|
|
764
|
+
@start_mode = "directory"
|
|
765
|
+
@csv_databases = csv_databases_json
|
|
766
|
+
@initial_path = splat.chomp("/")
|
|
767
|
+
@is_admin = admin?
|
|
768
|
+
@default_vim = settings.default_vim
|
|
769
|
+
@allow_editor = settings.allow_editor
|
|
770
|
+
@allow_csv_editor = settings.allow_csv_editor
|
|
771
|
+
erb :browser, layout: false
|
|
772
|
+
end
|
|
773
|
+
|
|
774
|
+
get "/csv-browser/*" do
|
|
775
|
+
@title = dir_title
|
|
776
|
+
@root_title = dir_title
|
|
777
|
+
@start_mode = "csv"
|
|
778
|
+
@csv_databases = csv_databases_json
|
|
779
|
+
@initial_path = params["splat"].first.to_s.chomp("/")
|
|
780
|
+
@is_admin = admin?
|
|
781
|
+
@default_vim = settings.default_vim
|
|
782
|
+
@allow_editor = settings.allow_editor
|
|
783
|
+
@allow_csv_editor = settings.allow_csv_editor
|
|
784
|
+
erb :browser, layout: false
|
|
785
|
+
end
|
|
786
|
+
|
|
134
787
|
get "/browse/?*" do
|
|
135
788
|
requested = params["splat"].first.to_s
|
|
136
789
|
requested = requested.chomp("/")
|
|
@@ -155,88 +808,6 @@ module MarkdownServer
|
|
|
155
808
|
end
|
|
156
809
|
end
|
|
157
810
|
|
|
158
|
-
get "/debug/raw-fetch" do
|
|
159
|
-
halt 404, "not available" unless respond_to?(:blueletterbible_html)
|
|
160
|
-
url = params[:url].to_s.strip
|
|
161
|
-
halt 400, "missing ?url=" if url.empty?
|
|
162
|
-
html = fetch_external_page(url)
|
|
163
|
-
halt 502, "fetch failed" unless html
|
|
164
|
-
content_type :text
|
|
165
|
-
# Show processing steps for first verse
|
|
166
|
-
chunk = html.split(/<div\s[^>]*id="bVerse_\d+"[^>]*>/).drop(1).first
|
|
167
|
-
return "no bVerse chunks found" unless chunk
|
|
168
|
-
|
|
169
|
-
cite = chunk[/tablet-order-2[^>]*>[\s\S]{0,400}?<a[^>]*>(.*?)<\/a>/im, 1]
|
|
170
|
-
&.gsub(/<[^>]+>/, "")&.strip || "?"
|
|
171
|
-
raw_html = chunk[/class="EngBibleText[^"]*"[^>]*>([\s\S]*?)<\/div>/im, 1] || "(no EngBibleText found)"
|
|
172
|
-
|
|
173
|
-
lines = ["=== cite: #{cite} ===",
|
|
174
|
-
"=== EngBibleText raw (#{raw_html.length} chars) ===",
|
|
175
|
-
raw_html, ""]
|
|
176
|
-
|
|
177
|
-
# Simulate the processing steps
|
|
178
|
-
rh = raw_html.dup
|
|
179
|
-
rh.gsub!(/<img[^>]*>/, "")
|
|
180
|
-
rh.gsub!(/<a[^>]*class="hide-for-tablet"[^>]*>[\s\S]*?<\/a>/im, "")
|
|
181
|
-
rh.gsub!(/<span[^>]*class="hide-for-tablet"[^>]*>[\s\S]*?<\/span>/im, "")
|
|
182
|
-
|
|
183
|
-
wp_matches = rh.scan(/<span\s[^>]*class="word-phrase"[^>]*>([\s\S]*?)<\/span>/im)
|
|
184
|
-
lines << "=== word-phrase matches (#{wp_matches.length}) ==="
|
|
185
|
-
wp_matches.each_with_index do |(inner), i|
|
|
186
|
-
is_criteria = inner.match?(/<sup[^>]*class="[^"]*strongs criteria[^"]*"/i)
|
|
187
|
-
word = inner.sub(/<sup[\s\S]*/im, "").gsub(/<[^>]+>/, "").gsub(/ /i, " ").strip
|
|
188
|
-
lines << " [#{i}] criteria=#{is_criteria} word=#{word.inspect}"
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
# Now simulate the full processing pipeline
|
|
192
|
-
verse_html = rh.gsub(/<span\s[^>]*class="word-phrase"[^>]*>([\s\S]*?)<\/span>/im) do
|
|
193
|
-
inner = $1
|
|
194
|
-
word = inner.sub(/<sup[\s\S]*/im, "").gsub(/<[^>]+>/, "").gsub(/ /i, " ").strip
|
|
195
|
-
inner.match?(/<sup[^>]*class="[^"]*strongs criteria[^"]*"/i) ? "\x02#{word}\x03" : word
|
|
196
|
-
end
|
|
197
|
-
lines << "\n=== after word-phrase gsub (placeholder check) ==="
|
|
198
|
-
lines << " contains \\x02: #{verse_html.include?("\x02")}"
|
|
199
|
-
lines << " contains \\x03: #{verse_html.include?("\x03")}"
|
|
200
|
-
ph = verse_html[/\x02[^\x03]*\x03/]
|
|
201
|
-
lines << " placeholder found: #{ph.inspect}"
|
|
202
|
-
|
|
203
|
-
verse_html.gsub!(/<sup[^>]*>[\s\S]*?<\/sup>/im, "")
|
|
204
|
-
verse_html.gsub!(/<[^>]+>/, "")
|
|
205
|
-
verse_html.gsub!(/ /i, " ")
|
|
206
|
-
verse_html.gsub!(/&#(\d+);/) { [$1.to_i].pack("U") rescue " " }
|
|
207
|
-
verse_html.gsub!(/&#x([\da-f]+);/i) { [$1.to_i(16)].pack("U") rescue " " }
|
|
208
|
-
verse_html.gsub!(/&/, "&")
|
|
209
|
-
verse_html.gsub!(/</, "<")
|
|
210
|
-
verse_html.gsub!(/>/, ">")
|
|
211
|
-
verse_html.gsub!(/\s+/, " ")
|
|
212
|
-
verse_html.strip!
|
|
213
|
-
|
|
214
|
-
lines << "=== after tag-strip (placeholder check) ==="
|
|
215
|
-
lines << " contains \\x02: #{verse_html.include?("\x02")}"
|
|
216
|
-
ph2 = verse_html[/\x02[^\x03]*\x03/]
|
|
217
|
-
lines << " placeholder found: #{ph2.inspect}"
|
|
218
|
-
lines << " verse_html snippet: #{verse_html[0, 200].inspect}"
|
|
219
|
-
|
|
220
|
-
# Apply the final restore
|
|
221
|
-
restored = verse_html.gsub(/\x02([^\x03]*)\x03/) { "<span class=\"blb-match\">#{$1.strip}</span>" }
|
|
222
|
-
lines << "\n=== after placeholder restore ==="
|
|
223
|
-
lines << " restored snippet: #{restored[0, 300].inspect}"
|
|
224
|
-
|
|
225
|
-
# Now compare with actual blueletterbible_html output
|
|
226
|
-
full_output = blueletterbible_html(html, url)
|
|
227
|
-
conc_match = full_output[/blb-match[^<]*<\/span>/]
|
|
228
|
-
lines << "\n=== blueletterbible_html output (blb-match check) ==="
|
|
229
|
-
lines << " contains blb-match: #{full_output.include?("blb-match")}"
|
|
230
|
-
lines << " blb-match context: #{conc_match.inspect}"
|
|
231
|
-
# Show the concordance section
|
|
232
|
-
conc_start = full_output.index("blb-heading") ? full_output.rindex("<h4", full_output.index("Concordance") || 0) : nil
|
|
233
|
-
if conc_start
|
|
234
|
-
lines << " concordance html (first 500 chars): #{full_output[conc_start, 500].inspect}"
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
lines.join("\n")
|
|
238
|
-
end
|
|
239
|
-
|
|
240
811
|
get "/debug/fetch" do
|
|
241
812
|
halt 404, "not available" unless respond_to?(:blueletterbible_html)
|
|
242
813
|
url = params[:url].to_s.strip
|
|
@@ -269,11 +840,7 @@ module MarkdownServer
|
|
|
269
840
|
halt 404, '{"error":"not found"}'
|
|
270
841
|
end
|
|
271
842
|
|
|
272
|
-
halt 403, '{"error":"forbidden"}' unless
|
|
273
|
-
|
|
274
|
-
relative = real.sub("#{base}/", "")
|
|
275
|
-
first_segment = relative.split("/").first
|
|
276
|
-
halt 403, '{"error":"forbidden"}' if EXCLUDED.include?(first_segment) || first_segment&.start_with?(".")
|
|
843
|
+
halt 403, '{"error":"forbidden"}' unless permitted_path?(real)
|
|
277
844
|
|
|
278
845
|
halt 404, '{"error":"not found"}' unless File.file?(real) && File.extname(real).downcase == ".md"
|
|
279
846
|
|
|
@@ -406,23 +973,37 @@ module MarkdownServer
|
|
|
406
973
|
|
|
407
974
|
private
|
|
408
975
|
|
|
976
|
+
def read_row_hash(table, row_index)
|
|
977
|
+
return nil unless File.exist?(table.csv_path)
|
|
978
|
+
return nil if row_index < 0
|
|
979
|
+
|
|
980
|
+
idx = 0
|
|
981
|
+
CSV.foreach(table.csv_path, headers: true) do |row|
|
|
982
|
+
return row.to_h if idx == row_index
|
|
983
|
+
idx += 1
|
|
984
|
+
end
|
|
985
|
+
nil
|
|
986
|
+
end
|
|
987
|
+
|
|
409
988
|
def render_directory(real_path, relative_path)
|
|
410
|
-
entries = Dir.entries(real_path).
|
|
411
|
-
e
|
|
989
|
+
entries = Dir.entries(real_path).select do |e|
|
|
990
|
+
e != "." && e != ".." && entry_admitted?(real_path, relative_path, e)
|
|
412
991
|
end
|
|
413
992
|
|
|
993
|
+
browse_prefix = relative_path.empty? ? "/browse/" : "/browse/#{relative_path}/"
|
|
994
|
+
|
|
414
995
|
items = entries.map do |name|
|
|
415
996
|
full = File.join(real_path, name)
|
|
416
997
|
stat = File.stat(full) rescue next
|
|
998
|
+
is_dir = stat.directory?
|
|
417
999
|
btime = stat.respond_to?(:birthtime) ? stat.birthtime : stat.mtime rescue stat.mtime
|
|
418
1000
|
{
|
|
419
1001
|
name: name,
|
|
420
|
-
is_dir:
|
|
421
|
-
size:
|
|
1002
|
+
is_dir: is_dir,
|
|
1003
|
+
size: is_dir ? nil : stat.size,
|
|
422
1004
|
mtime: stat.mtime,
|
|
423
1005
|
ctime: btime,
|
|
424
|
-
href:
|
|
425
|
-
encode_path_component(name) + (stat.directory? ? "/" : "")
|
|
1006
|
+
href: browse_prefix + encode_path_component(name) + (is_dir ? "/" : "")
|
|
426
1007
|
}
|
|
427
1008
|
end.compact
|
|
428
1009
|
|
|
@@ -442,8 +1023,9 @@ module MarkdownServer
|
|
|
442
1023
|
effective_order == "desc" ? sorted.reverse : sorted
|
|
443
1024
|
end
|
|
444
1025
|
|
|
445
|
-
dirs =
|
|
446
|
-
|
|
1026
|
+
dirs, files = items.partition { |i| i[:is_dir] }
|
|
1027
|
+
dirs = sort_items.call(dirs)
|
|
1028
|
+
files = sort_items.call(files)
|
|
447
1029
|
|
|
448
1030
|
@items = dirs + files
|
|
449
1031
|
@path = relative_path
|
|
@@ -452,6 +1034,279 @@ module MarkdownServer
|
|
|
452
1034
|
erb :directory
|
|
453
1035
|
end
|
|
454
1036
|
|
|
1037
|
+
def parse_comparable_value(s)
|
|
1038
|
+
stripped = s.gsub(/[$,]/, "")
|
|
1039
|
+
if stripped.match?(/\A-?\d+\.?\d*\z/)
|
|
1040
|
+
return { kind: :number, value: stripped.to_f }
|
|
1041
|
+
end
|
|
1042
|
+
if (m = s.match(%r{\A(\d{1,2})/(\d{1,2})/(\d{4})\z}))
|
|
1043
|
+
d = Date.new(m[3].to_i, m[1].to_i, m[2].to_i)
|
|
1044
|
+
return { kind: :date, value: d }
|
|
1045
|
+
end
|
|
1046
|
+
nil
|
|
1047
|
+
rescue Date::Error
|
|
1048
|
+
nil
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
def parse_filter_term(t)
|
|
1052
|
+
if (m = t.match(/\A(>=?|<=?)\s*(.+)\z/))
|
|
1053
|
+
val = parse_comparable_value(m[2])
|
|
1054
|
+
return { type: :compare, op: m[1], value: val } if val
|
|
1055
|
+
end
|
|
1056
|
+
re = Regexp.new(t, Regexp::IGNORECASE)
|
|
1057
|
+
{ type: :regex, re: re }
|
|
1058
|
+
rescue RegexpError
|
|
1059
|
+
{ type: :regex, re: Regexp.new(Regexp.escape(t), Regexp::IGNORECASE) }
|
|
1060
|
+
end
|
|
1061
|
+
|
|
1062
|
+
def filter_term_matches?(term, cell_str)
|
|
1063
|
+
if term[:type] == :compare
|
|
1064
|
+
cell_val = parse_comparable_value(cell_str)
|
|
1065
|
+
return false unless cell_val && cell_val[:kind] == term[:value][:kind]
|
|
1066
|
+
case term[:op]
|
|
1067
|
+
when ">" then cell_val[:value] > term[:value][:value]
|
|
1068
|
+
when ">=" then cell_val[:value] >= term[:value][:value]
|
|
1069
|
+
when "<" then cell_val[:value] < term[:value][:value]
|
|
1070
|
+
when "<=" then cell_val[:value] <= term[:value][:value]
|
|
1071
|
+
else false
|
|
1072
|
+
end
|
|
1073
|
+
else
|
|
1074
|
+
term[:re].match?(cell_str)
|
|
1075
|
+
end
|
|
1076
|
+
end
|
|
1077
|
+
|
|
1078
|
+
def csv_databases_json
|
|
1079
|
+
loader = settings.csv_browser_config
|
|
1080
|
+
return "[]" unless loader
|
|
1081
|
+
|
|
1082
|
+
databases = loader.databases.map do |db|
|
|
1083
|
+
yaml_dir = File.dirname(db.yaml_path)
|
|
1084
|
+
db_entry = {
|
|
1085
|
+
key: db.key,
|
|
1086
|
+
title: db.title,
|
|
1087
|
+
tables: db.tables.select { |t| File.exist?(t.csv_path) }.map do |t|
|
|
1088
|
+
entry = {
|
|
1089
|
+
key: t.key,
|
|
1090
|
+
title: t.title,
|
|
1091
|
+
views: t.views.map { |v| { key: v.key, title: v.title } }
|
|
1092
|
+
}
|
|
1093
|
+
entry[:color] = t.color if t.color
|
|
1094
|
+
if db.group_by_directory
|
|
1095
|
+
rel_dir = File.dirname(t.csv_path).delete_prefix("#{yaml_dir}/")
|
|
1096
|
+
rel_dir = nil if rel_dir == yaml_dir || rel_dir == "."
|
|
1097
|
+
entry[:group] = rel_dir
|
|
1098
|
+
end
|
|
1099
|
+
entry[:record_count] = [File.foreach(t.csv_path).count - 1, 0].max if db.show_record_counts
|
|
1100
|
+
entry
|
|
1101
|
+
end
|
|
1102
|
+
}
|
|
1103
|
+
db_entry[:group_by_directory] = true if db.group_by_directory
|
|
1104
|
+
base = File.realpath(root_dir)
|
|
1105
|
+
db_entry[:yaml_path] = db.yaml_path.delete_prefix("#{base}/")
|
|
1106
|
+
db_entry
|
|
1107
|
+
end
|
|
1108
|
+
|
|
1109
|
+
unmapped = loader.unmapped_csv_files
|
|
1110
|
+
unless unmapped.empty?
|
|
1111
|
+
databases << {
|
|
1112
|
+
key: "_unmapped",
|
|
1113
|
+
title: "Unmapped Files",
|
|
1114
|
+
virtual: true,
|
|
1115
|
+
tables: unmapped.map do |entry|
|
|
1116
|
+
dir = File.dirname(entry[:relative])
|
|
1117
|
+
{
|
|
1118
|
+
key: entry[:relative],
|
|
1119
|
+
title: File.basename(entry[:relative], ".csv"),
|
|
1120
|
+
group: dir == "." ? nil : dir,
|
|
1121
|
+
views: [{ key: "all", title: "All" }],
|
|
1122
|
+
color: "#888"
|
|
1123
|
+
}
|
|
1124
|
+
end
|
|
1125
|
+
}
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
databases.to_json
|
|
1129
|
+
end
|
|
1130
|
+
|
|
1131
|
+
def browser_render_directory(real_path, requested)
|
|
1132
|
+
entries = Dir.entries(real_path).select do |e|
|
|
1133
|
+
e != "." && e != ".." && entry_admitted?(real_path, requested, e)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1136
|
+
browse_prefix = requested.empty? ? "" : "#{requested}/"
|
|
1137
|
+
|
|
1138
|
+
items = entries.map do |name|
|
|
1139
|
+
full = File.join(real_path, name)
|
|
1140
|
+
stat = File.stat(full) rescue next
|
|
1141
|
+
is_dir = stat.directory?
|
|
1142
|
+
btime = stat.respond_to?(:birthtime) ? stat.birthtime : stat.mtime rescue stat.mtime
|
|
1143
|
+
item = {
|
|
1144
|
+
name: name,
|
|
1145
|
+
is_dir: is_dir,
|
|
1146
|
+
path: browse_prefix + name,
|
|
1147
|
+
icon: icon_for(name, is_dir),
|
|
1148
|
+
mtime: format_date(stat.mtime),
|
|
1149
|
+
mtime_ts: stat.mtime.to_f,
|
|
1150
|
+
ctime: format_date(btime),
|
|
1151
|
+
ctime_ts: btime.to_f
|
|
1152
|
+
}
|
|
1153
|
+
unless is_dir
|
|
1154
|
+
item[:size] = format_size(stat.size)
|
|
1155
|
+
item[:size_bytes] = stat.size
|
|
1156
|
+
end
|
|
1157
|
+
item
|
|
1158
|
+
end.compact
|
|
1159
|
+
|
|
1160
|
+
title = if requested.empty?
|
|
1161
|
+
dir_title
|
|
1162
|
+
else
|
|
1163
|
+
parent = File.dirname(requested)
|
|
1164
|
+
parent == "." ? File.basename(requested) : "#{File.basename(requested)} (#{parent})"
|
|
1165
|
+
end
|
|
1166
|
+
{ type: "directory", path: requested, title: title, items: items }
|
|
1167
|
+
end
|
|
1168
|
+
|
|
1169
|
+
def browser_render_file(real_path, requested, raw: false)
|
|
1170
|
+
ext = File.extname(real_path).downcase
|
|
1171
|
+
title = File.basename(real_path)
|
|
1172
|
+
download_href = "/download/" + requested.split("/").map { |p| encode_path_component(p) }.join("/")
|
|
1173
|
+
|
|
1174
|
+
# Check plugins first
|
|
1175
|
+
settings.plugins.each do |p|
|
|
1176
|
+
result = p.browser_render(requested, real_path, self)
|
|
1177
|
+
return result if result
|
|
1178
|
+
end
|
|
1179
|
+
|
|
1180
|
+
# Check CSV browser (skip when raw rendering requested)
|
|
1181
|
+
loader = settings.csv_browser_config
|
|
1182
|
+
if loader && !raw
|
|
1183
|
+
if ext == ".yaml" || ext == ".yml"
|
|
1184
|
+
db = loader.find_database_by_yaml_path(real_path)
|
|
1185
|
+
if db
|
|
1186
|
+
return {
|
|
1187
|
+
type: "csv_database",
|
|
1188
|
+
title: db.title,
|
|
1189
|
+
db_key: db.key,
|
|
1190
|
+
tables: db.tables.map do |t|
|
|
1191
|
+
entry = {
|
|
1192
|
+
key: t.key,
|
|
1193
|
+
title: t.title,
|
|
1194
|
+
views: t.views.map { |v| { key: v.key, title: v.title } }
|
|
1195
|
+
}
|
|
1196
|
+
entry[:color] = t.color if t.color
|
|
1197
|
+
entry
|
|
1198
|
+
end
|
|
1199
|
+
}
|
|
1200
|
+
end
|
|
1201
|
+
end
|
|
1202
|
+
|
|
1203
|
+
result = loader.find_table_by_csv_path(real_path)
|
|
1204
|
+
if result
|
|
1205
|
+
db, table = result
|
|
1206
|
+
view_key = table.views.first&.key || "all"
|
|
1207
|
+
reader = CsvBrowser::TableReader.new(table)
|
|
1208
|
+
data = reader.read(view_key)
|
|
1209
|
+
lookups = loader.resolve_references(db, table)
|
|
1210
|
+
data[:columns].each do |col|
|
|
1211
|
+
next unless col[:references]
|
|
1212
|
+
ref_table = db.tables.find { |t| t.key == col[:references][:table] }
|
|
1213
|
+
col[:references] = col[:references].merge(color: ref_table.color) if ref_table&.color
|
|
1214
|
+
end
|
|
1215
|
+
reverse_refs = loader.resolve_reverse_references(db, table)
|
|
1216
|
+
resp = {
|
|
1217
|
+
type: "csv_table",
|
|
1218
|
+
title: "#{table.title} \u2014 #{table.views.first&.title || "All"}",
|
|
1219
|
+
db_key: db.key,
|
|
1220
|
+
table_key: table.key,
|
|
1221
|
+
view_key: view_key,
|
|
1222
|
+
views: table.views.map { |v| { key: v.key, title: v.title } },
|
|
1223
|
+
columns: data[:columns],
|
|
1224
|
+
rows: data[:rows]
|
|
1225
|
+
}
|
|
1226
|
+
resp[:color] = table.color if table.color
|
|
1227
|
+
resp[:references] = lookups unless lookups.empty?
|
|
1228
|
+
resp[:reverse_references] = reverse_refs unless reverse_refs.empty?
|
|
1229
|
+
return resp
|
|
1230
|
+
end
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
# Standalone CSV files (not in any database) — render as table
|
|
1234
|
+
if ext == ".csv" && !raw
|
|
1235
|
+
return render_standalone_csv(real_path, title, requested)
|
|
1236
|
+
end
|
|
1237
|
+
|
|
1238
|
+
# Default file rendering
|
|
1239
|
+
case ext
|
|
1240
|
+
when ".md"
|
|
1241
|
+
content = File.read(real_path, encoding: "utf-8")
|
|
1242
|
+
meta, body = parse_frontmatter(content)
|
|
1243
|
+
@current_wiki_dir = File.dirname(real_path)
|
|
1244
|
+
html = render_markdown(body)
|
|
1245
|
+
settings.plugins.each { |p| html = p.post_render(html, meta, self) }
|
|
1246
|
+
|
|
1247
|
+
frontmatter_html = ""
|
|
1248
|
+
if meta && !meta.empty?
|
|
1249
|
+
rows = meta.map { |key, value|
|
|
1250
|
+
"<tr><th>#{h(key)}</th><td>#{render_frontmatter_value(value)}</td></tr>"
|
|
1251
|
+
}.join
|
|
1252
|
+
frontmatter_html = %(<div class="frontmatter"><div class="frontmatter-heading">Frontmatter</div><table class="meta-table">#{rows}</table></div>)
|
|
1253
|
+
end
|
|
1254
|
+
|
|
1255
|
+
{ type: "markdown", title: title, html: html, frontmatter_html: frontmatter_html }
|
|
1256
|
+
|
|
1257
|
+
when ".json"
|
|
1258
|
+
raw = File.read(real_path, encoding: "utf-8")
|
|
1259
|
+
begin
|
|
1260
|
+
data = JSON.parse(raw)
|
|
1261
|
+
yaml_str = YAML.dump(data)
|
|
1262
|
+
{ type: "code", title: title, language: "yaml", html: syntax_highlight(yaml_str, "yaml") }
|
|
1263
|
+
rescue JSON::ParserError
|
|
1264
|
+
{ type: "code", title: title, language: "json", html: syntax_highlight(raw, "json") }
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
when ".pdf"
|
|
1268
|
+
size = File.size(real_path) rescue 0
|
|
1269
|
+
{ type: "download", title: title, href: download_href, size: format_size(size) }
|
|
1270
|
+
|
|
1271
|
+
when ".epub"
|
|
1272
|
+
size = File.size(real_path) rescue 0
|
|
1273
|
+
{ type: "download", title: title, href: download_href, size: format_size(size) }
|
|
1274
|
+
|
|
1275
|
+
when ".html"
|
|
1276
|
+
{ type: "external", title: title, href: "/browse/" + requested.split("/").map { |p| encode_path_component(p) }.join("/") }
|
|
1277
|
+
|
|
1278
|
+
else
|
|
1279
|
+
content = File.read(real_path, encoding: "utf-8") rescue nil
|
|
1280
|
+
if content.nil? || content.encoding == Encoding::BINARY || !content.valid_encoding?
|
|
1281
|
+
size = File.size(real_path) rescue 0
|
|
1282
|
+
{ type: "download", title: title, href: download_href, size: format_size(size) }
|
|
1283
|
+
else
|
|
1284
|
+
lang = detect_source_language(real_path, content)
|
|
1285
|
+
{ type: "code", title: title, language: lang, html: syntax_highlight(content, lang) }
|
|
1286
|
+
end
|
|
1287
|
+
end
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
def render_standalone_csv(real_path, title, requested)
|
|
1291
|
+
rows = CSV.read(real_path, headers: true)
|
|
1292
|
+
headers = rows.headers.compact
|
|
1293
|
+
columns = headers.map { |h| { key: h, title: h, type: "string" } }
|
|
1294
|
+
data_rows = rows.each_with_index.map do |row, idx|
|
|
1295
|
+
[idx] + headers.map { |h| row[h] }
|
|
1296
|
+
end
|
|
1297
|
+
|
|
1298
|
+
{
|
|
1299
|
+
type: "csv_table",
|
|
1300
|
+
title: title,
|
|
1301
|
+
standalone_csv: requested,
|
|
1302
|
+
view_key: "all",
|
|
1303
|
+
views: [{ key: "all", title: "All" }],
|
|
1304
|
+
columns: columns,
|
|
1305
|
+
rows: data_rows,
|
|
1306
|
+
readonly: true
|
|
1307
|
+
}
|
|
1308
|
+
end
|
|
1309
|
+
|
|
455
1310
|
def render_file(real_path, relative_path)
|
|
456
1311
|
ext = File.extname(real_path).downcase
|
|
457
1312
|
@crumbs = breadcrumbs(relative_path)
|
|
@@ -512,16 +1367,7 @@ module MarkdownServer
|
|
|
512
1367
|
if content.nil? || content.encoding == Encoding::BINARY || !content.valid_encoding?
|
|
513
1368
|
send_file real_path, disposition: "inline"
|
|
514
1369
|
else
|
|
515
|
-
lang =
|
|
516
|
-
when ".py" then "python"
|
|
517
|
-
when ".rb" then "ruby"
|
|
518
|
-
when ".csv" then "text"
|
|
519
|
-
when ".sh" then "bash"
|
|
520
|
-
when ".yaml", ".yml" then "yaml"
|
|
521
|
-
when ".html", ".erb" then "html"
|
|
522
|
-
when ".js" then "javascript"
|
|
523
|
-
else "text"
|
|
524
|
-
end
|
|
1370
|
+
lang = detect_source_language(real_path, content)
|
|
525
1371
|
@code = syntax_highlight(content, lang)
|
|
526
1372
|
@language = lang
|
|
527
1373
|
erb :raw
|