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.
@@ -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(/&nbsp;/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(/&nbsp;/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!(/&nbsp;/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!(/&amp;/, "&")
209
- verse_html.gsub!(/&lt;/, "<")
210
- verse_html.gsub!(/&gt;/, ">")
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 real.start_with?(base)
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).reject do |e|
411
- e.start_with?(".") || EXCLUDED.include?(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: stat.directory?,
421
- size: stat.directory? ? nil : stat.size,
1002
+ is_dir: is_dir,
1003
+ size: is_dir ? nil : stat.size,
422
1004
  mtime: stat.mtime,
423
1005
  ctime: btime,
424
- href: "/browse/" + (relative_path.empty? ? "" : relative_path + "/") +
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 = sort_items.call(items.select { |i| i[:is_dir] })
446
- files = sort_items.call(items.reject { |i| i[:is_dir] })
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 = case ext
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