okf 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rack"
4
+ require "securerandom"
5
+ require "uri"
4
6
 
5
7
  require "okf/server/app"
8
+ require "okf/server/hub/not_found"
6
9
 
7
10
  module OKF
8
11
  module Server
@@ -14,11 +17,21 @@ module OKF
14
17
  # here plus a trailing-slash redirect. Part of the shell — it is a Rack app.
15
18
  #
16
19
  # GET / 302 -> /b/<default>/ (empty-state page when no bundles)
17
- # GET /b/ the bundle index — every hosted bundle, default marked
20
+ # GET /search?q=… ranked concepts across *every* hosted bundle (JSON) —
21
+ # the only route that answers about the whole set, and
22
+ # so the one route that can only live here
23
+ # GET /b/ the bundles list — every bundle, its health, and the
24
+ # way into it. Read-only: managing the registry belongs
25
+ # to the graph page's Bundles panel, which is where the
26
+ # reader already is
18
27
  # GET /b/<slug> 301 -> /b/<slug>/ (query string preserved)
19
28
  # GET /b/<slug>/... delegated to that bundle's App (the prefix stripped)
20
- # GET (unknown slug) 404 as a page listing the hosted bundles — a stale
21
- # bookmark after a rename gets a way home, not bare text
29
+ # GET (unknown slug) 404 on the app shell (Hub::NotFound) — the asked
30
+ # path, a did-you-mean, and the hosted bundles, so a
31
+ # stale bookmark after a rename gets a way home
32
+ # POST /registry/<verb> default | rename | remove | add — the only routes
33
+ # that change anything, gated four ways (see #write),
34
+ # answering JSON to the Bundles panel's fetch()
22
35
  #
23
36
  # +bundles+ is an ordered array of Hub::Bundle (slug, folder, title). Apps are
24
37
  # built up front, each carrying the *other* bundles as siblings so the in-page
@@ -27,24 +40,115 @@ module OKF
27
40
  class Hub
28
41
  MOUNT = "/b"
29
42
 
43
+ # The registry verbs POST reaches, and nothing else. A list rather than a
44
+ # method lookup: the route is user input, and "whatever method the path
45
+ # names" is how a router becomes an eval.
46
+ WRITES = %w[default rename remove add].freeze
47
+
48
+ # How many rows /search answers with. The palette shows a handful and the
49
+ # rest is scroll nobody reaches, but the count is reported alongside so a
50
+ # capped answer never reads as a complete one.
51
+ SEARCH_LIMIT = 50
52
+
53
+ # The engine /search runs on, named rather than inferred. `fuzzy: true`
54
+ # would route here on its own today — the index is the only registered
55
+ # engine that offers it — but that is correctness by coincidence, and an
56
+ # addon declaring :fuzzy would silently take the route.
57
+ #
58
+ # It is also the *right* engine here for a reason the CLI's default does
59
+ # not share: `okf search` is one-shot and cannot amortize an index build,
60
+ # while this is a long-lived server answering keystroke after keystroke.
61
+ # And the page's own MiniSearch is what minifts is a port of, so a palette
62
+ # hit and an in-page search rank alike instead of nearly alike.
63
+ SEARCH_ENGINE = :index
64
+
30
65
  # One hosted bundle: its +slug+ (unique mount key), the on-disk +folder+, and
31
66
  # its display +title+.
32
67
  Bundle = Struct.new(:slug, :folder, :title)
33
68
 
34
- # Shared style for the hub's own pages (empty landing, /b/ index, 404) —
69
+ # Shared style for the hub's own pages (empty landing, /b/ manager, 404) —
35
70
  # self-contained and theme-aware, no external requests, in keeping with the
36
71
  # graph page's own no-CDN-at-rest rule.
72
+ #
73
+ # The tokens are the graph page's own values, not a second palette: this is
74
+ # the same product, and a bundle index that looks like a different app is
75
+ # worse than a plain list. `--warn` is the one addition — the graph page
76
+ # never had to draw a middle verdict, and the manager does.
77
+ #
78
+ # `body.mgr` opts out of the centred card the landing and the 404 want. A
79
+ # one-paragraph page centres well; a list of bundles does not.
37
80
  STYLE = <<~CSS
38
- body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f7;color:#1f2328;font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
81
+ :root{--bg:#f4f5f7;--panel:#ffffff;--ink:#1f2328;--muted:#63697a;--faint:#9298a4;
82
+ --line:#e6e8eb;--line-2:#eef0f2;--accent:#e21e1e;--ok:#1a9e5f;--warn:#b7791f;--err:#c81a1a}
83
+ @media(prefers-color-scheme:dark){:root{--bg:#111318;--panel:#1d2026;--ink:#eceef1;--muted:#9aa0aa;--faint:#6b7178;
84
+ --line:#2a2e36;--line-2:#232830;--accent:#f5433b;--ok:#37c07f;--warn:#e0a13a;--err:#ff726b}}
85
+ body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--bg);color:var(--ink);
86
+ font:15px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
39
87
  main{max-width:34rem;width:calc(100% - 4rem);padding:2rem}h1{font-size:1.3rem;margin:0 0 .5rem}
40
- code{background:#e6e8eb;padding:.15rem .4rem;border-radius:.35rem}
41
- ul.bundles{list-style:none;margin:1rem 0 0;padding:0}
42
- ul.bundles li{padding:.45rem 0;border-top:1px solid #e6e8eb;display:flex;justify-content:space-between;gap:1rem;align-items:baseline}
43
- ul.bundles a{color:inherit;font-weight:600;text-decoration:none}ul.bundles a:hover{text-decoration:underline}
44
- .meta{color:#63697a;font-size:.85rem;white-space:nowrap}
45
- .def{margin-left:.5rem;padding:.05rem .45rem;border-radius:99px;background:#e6e8eb;font-size:.75rem}
46
- @media(prefers-color-scheme:dark){body{background:#111318;color:#eceef1}code,.def{background:#232833}
47
- ul.bundles li{border-color:#2a2e36}.meta{color:#9aa0aa}}
88
+ code{background:var(--line);padding:.15rem .4rem;border-radius:.35rem}
89
+ .def{margin-left:.5rem;padding:.05rem .45rem;border-radius:99px;background:var(--line);font-size:.75rem}
90
+
91
+ /* ── the bundles manager ── */
92
+ body.mgr{display:block;place-items:initial}
93
+ body.mgr main{max-width:60rem;width:auto;margin:0 auto;padding:3rem 2rem 4rem}
94
+ .mhead{margin-bottom:1.5rem}
95
+ /* the same 3px accent rule the graph page draws under a section head */
96
+ .mhead h1{margin:0;padding-bottom:.55rem;position:relative;font-size:1.45rem;letter-spacing:-.01em}
97
+ .mhead h1::after{content:"";position:absolute;left:0;bottom:0;width:34px;height:3px;border-radius:3px;background:var(--accent)}
98
+ .mhead .sub{margin:.7rem 0 0;color:var(--muted);font-size:.9rem}
99
+ ol.rows{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}
100
+ .row{position:relative;display:flex;flex-wrap:wrap;gap:.5rem 1.5rem;align-items:baseline;
101
+ padding:.95rem .9rem;border-bottom:1px solid var(--line)}
102
+ .row:hover{background:var(--line-2)}
103
+ /* The verdict as a left edge — the head's accent rule, stood on end and
104
+ put to work. Colour only reinforces it; the word beside it is the
105
+ message, so nothing here depends on being able to see red. */
106
+ .row::before{content:"";position:absolute;left:0;top:.55rem;bottom:.55rem;width:3px;border-radius:0 3px 3px 0;background:var(--line)}
107
+ .row[data-health=ok]::before{background:var(--ok)}
108
+ .row[data-health=warn]::before{background:var(--warn)}
109
+ .row[data-health=error]::before{background:var(--err)}
110
+ .who{flex:1 1 16rem;min-width:0}
111
+ .who .name{color:var(--ink);font-weight:600;text-decoration:none;font-size:1rem}
112
+ .who .name:hover{text-decoration:underline}
113
+ .who .name.off{color:var(--muted);font-weight:500}
114
+ /* nowrap, because @slug and the folder are one identity read left to
115
+ right — split over two lines they read as two facts */
116
+ .ref{margin-top:.15rem;display:flex;flex-wrap:nowrap;gap:.55rem;align-items:baseline;
117
+ font:12.5px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--faint)}
118
+ .ref .slug{flex:none}
119
+ /* Monospace is not decoration here: @slug is what you type at the CLI
120
+ and the folder is a real path. Both are literals, so both are set as
121
+ literals. */
122
+ .ref .slug{color:var(--muted)}
123
+ /* Truncate a long path from the *left*: the tail (…/repo/.okf) is the
124
+ part that identifies it, and clipping the tail identifies nothing.
125
+ An rtl box puts the ellipsis at the front — but a leading "/" is a
126
+ neutral character and would reorder to the far end, printing
127
+ "…/repo/.okf/" for a path that has no trailing slash. The inner <bdi>
128
+ isolates the path as one ltr run, so nothing in it moves. */
129
+ .ref .dir{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;direction:rtl;text-align:left}
130
+ .ref .dir bdi{direction:ltr}
131
+ /* Fixed slots, right-aligned as a block: these are columns of counts and
132
+ verdicts read down as much as across, and a ragged column of numbers
133
+ is a column nobody scans. The last slot stays reserved even when the
134
+ row is not the default, so the two before it cannot shift. */
135
+ .facts{margin-left:auto;display:flex;gap:1.1rem;align-items:baseline;font-size:.85rem;color:var(--muted);white-space:nowrap}
136
+ .facts span{display:inline-block}
137
+ .f-count{min-width:6.5rem;text-align:right}
138
+ .f-health{min-width:8.5rem}
139
+ .f-flag{min-width:4.6rem}
140
+ .hv-word{color:var(--muted)}
141
+ .row[data-health=warn] .hv-word{color:var(--warn)}
142
+ .row[data-health=error] .hv-word{color:var(--err)}
143
+ .row[data-health=missing] .who .name{color:var(--faint)}
144
+ .mnote{margin:1.4rem 0 0;color:var(--faint);font-size:.85rem}
145
+
146
+ /* Stacked, the fixed slots stop being columns and become indentation on
147
+ a row that has nothing to put in one — so they collapse to their
148
+ content, and an empty one takes no space at all. */
149
+ @media(max-width:640px){body.mgr main{padding:2rem 1.1rem 3rem}
150
+ .facts{margin-left:0;gap:.9rem}.f-count,.f-health,.f-flag{min-width:0;text-align:left}
151
+ .facts span:empty{display:none}}
48
152
  CSS
49
153
 
50
154
  # The hosted bundles in mount order, and the one `/` redirects to — so a
@@ -55,27 +159,70 @@ module OKF
55
159
  # The first bundle is the one `/` redirects to — the registry hands them over
56
160
  # in its own order, where first *is* the default (`okf registry default`
57
161
  # moves an entry to the front), and an ephemeral run takes the dirs as typed.
58
- def initialize(bundles, layout: "cose")
162
+ # +registry+ is the OKF::Registry this hub was booted from, when it was. It
163
+ # is what separates the two kinds of hub: a registry-backed one can report
164
+ # on entries it could not host (a folder that has since been deleted), and
165
+ # an ephemeral one (`okf server ./a ./b`) has no such list and says so.
166
+ # The object carries its own path, so the manager re-reads the file per
167
+ # request rather than trusting a snapshot taken at boot.
168
+ # +writable+ decides whether the manager offers the registry forms and
169
+ # whether the POST routes answer at all. The CLI sets it: a loopback bind
170
+ # gets it for free and `--read-only` declines it, while any other address
171
+ # is refused outright with no flag that says otherwise — `--bind 0.0.0.0`
172
+ # turns a personal tool into a public one, and the write surface does not
173
+ # follow it there at all.
174
+ def initialize(bundles, layout: "cose", registry: nil, writable: false)
59
175
  @bundles = bundles
60
176
  @default = bundles.first
177
+ @boot_registry = registry
178
+ @layout = layout
179
+ @writable = writable
61
180
  @apps = build_apps(layout)
62
181
  end
63
182
 
183
+ # Load every registered entry the hub can actually serve, skipping the ones
184
+ # it cannot and yielding each skipped entry so a caller with a terminal can
185
+ # say so. One implementation, used at boot by the CLI and again after every
186
+ # write — a second copy is a second answer waiting to disagree.
187
+ def self.bundles_for(registry)
188
+ registry.each_with_object([]) do |entry, bundles|
189
+ bundle = load_entry(entry)
190
+ bundle ? bundles << bundle : (yield entry if block_given?)
191
+ end
192
+ end
193
+
194
+ # The Reader maps a nonexistent directory to an *empty* bundle, so the
195
+ # directory check has to be explicit — nothing raises for the commonest
196
+ # failure, which is that someone moved the folder.
197
+ # Method-level rescue, not a `do…end`-block rescue: that is a 2.6 feature
198
+ # and the floor here is 2.4.
199
+ def self.load_entry(entry)
200
+ return nil unless File.directory?(entry.path)
201
+
202
+ Bundle.new(entry.slug, OKF::Bundle::Folder.load(entry.path), entry.title)
203
+ rescue SystemCallError, OKF::Error
204
+ nil
205
+ end
206
+ private_class_method :load_entry
207
+
64
208
  def call(env)
65
209
  request = Rack::Request.new(env)
210
+ # Everything this class *emits* must carry the prefix a host mounted it
211
+ # under; PATH_INFO is already relative to it.
212
+ base = env["SCRIPT_NAME"].to_s
213
+ return write(request) if request.post? && request.path_info.start_with?("/registry/")
66
214
  return not_found unless request.get?
67
215
 
68
216
  path = request.path_info
69
217
  query = request.query_string.to_s
70
- # Everything this class *emits* must carry the prefix a host mounted it
71
- # under; PATH_INFO is already relative to it.
72
- base = env["SCRIPT_NAME"].to_s
73
218
  return landing(base, query) if [ "", "/" ].include?(path)
219
+ return search(request.params["q"]) if path == "/search"
220
+ return bundles_json if path == "/bundles"
74
221
  return html(200, index_page(base)) if [ MOUNT, "#{MOUNT}/" ].include?(path)
75
222
 
76
223
  slug, rest = split(path)
77
224
  app = slug && @apps[slug]
78
- return html(404, missing_page(base, path)) unless app
225
+ return html(404, missing_page(base, path, slug)) unless app
79
226
  return redirect("#{base}#{MOUNT}/#{slug}/", 301, query) if rest.empty?
80
227
 
81
228
  app.call(mounted(env, slug, rest))
@@ -83,6 +230,191 @@ module OKF
83
230
 
84
231
  private
85
232
 
233
+ # The only route that changes anything, and so the only one with locks on
234
+ # it. Four of them, in the order that leaks the least:
235
+ #
236
+ # 1. the verb is one of WRITES, or there is nothing here to talk about;
237
+ # 2. this hub is writable at all (see #initialize) — a read-only server
238
+ # offers no controls, and refuses the request that skipped them anyway;
239
+ # 3. there is a registry to write to — an ephemeral hub is serving
240
+ # directories somebody typed, and has no list to edit;
241
+ # 4. the request is same-origin and carries this boot's token.
242
+ #
243
+ # The answer is always data. It used to be a redirect, because /b/ managed
244
+ # the registry with plain forms and a POST/redirect/GET keeps a reload from
245
+ # re-posting. Those forms are gone: the graph page's Bundles panel does the
246
+ # same four verbs where the reader already is, and two implementations of
247
+ # one contract is the thing that drifts. So there is one caller, it is a
248
+ # fetch(), and asking for HTML no longer resurrects a page-shaped answer
249
+ # that nothing would read.
250
+ def write(request)
251
+ verb = request.path_info.sub("/registry/", "")
252
+ return not_found unless WRITES.include?(verb)
253
+
254
+ return deny(403, "This server is read-only. Bundles are managed from a loopback bind, without --read-only.") unless @writable
255
+ return deny(409, "These bundles were named on the command line, so there is no registry to change.") if @boot_registry.nil?
256
+ return deny(403, "That request did not come from this page. Reload and try again.") unless authentic?(request)
257
+
258
+ apply(verb, request.params)
259
+ end
260
+
261
+ def apply(verb, params)
262
+ registry = OKF::Registry.new(@boot_registry.path)
263
+ message = mutate(verb, registry, params)
264
+ reload(registry)
265
+ json("ok" => true, "message" => message)
266
+ rescue OKF::Error => e
267
+ deny(400, e.message)
268
+ end
269
+
270
+ def deny(status, message)
271
+ [ status, { "content-type" => "application/json; charset=utf-8" }, [ JSON.generate("ok" => false, "error" => message) ] ]
272
+ end
273
+
274
+ # Every branch ends in the sentence the manager will show. The core does
275
+ # the refusing — a reserved slug, a collision, a slug nothing carries all
276
+ # raise OKF::Error with a message written for a person, and repeating that
277
+ # judgement here is how the two come to disagree.
278
+ def mutate(verb, registry, params)
279
+ case verb
280
+ when "default"
281
+ slug = required(params, "slug")
282
+ registry.default = slug
283
+ "@#{slug} is now the bundle this server opens."
284
+ when "rename"
285
+ from = required(params, "slug")
286
+ # The core normalizes what it is given, so the message reads back the
287
+ # slug that was *stored* rather than the string that was typed —
288
+ # "@a is now @My Notes" would be a sentence about a bundle nobody has.
289
+ entry = registry.rename(from, required(params, "to"))
290
+ "@#{from} is now @#{entry.slug}."
291
+ when "remove"
292
+ slug = required(params, "slug")
293
+ # #remove answers nil for a slug nothing carries rather than raising —
294
+ # it is a delete, and deleting nothing is not an error to the core. It
295
+ # is one here: the button that sent this named a row, so a miss means
296
+ # the page is stale and saying so is the useful answer.
297
+ raise OKF::Error, "no bundle is registered as @#{slug}" if registry.remove(slug).nil?
298
+
299
+ "@#{slug} is no longer registered. Its folder is untouched."
300
+ else
301
+ add_entry(registry, params)
302
+ end
303
+ end
304
+
305
+ # Registry#add already refuses a path that is not a directory. The concept
306
+ # check is this layer's own: a registry full of empty folders is the shape
307
+ # of somebody pasting the wrong path, and catching it here is the
308
+ # difference between a sentence and a mystery.
309
+ def add_entry(registry, params)
310
+ root = File.expand_path(required(params, "path"))
311
+ raise OKF::Error, "not a directory: #{root}" unless File.directory?(root)
312
+
313
+ if OKF::Bundle::Folder.load(root).bundle.concepts.empty?
314
+ raise OKF::Error, "no concepts in #{root} — is this an OKF bundle?"
315
+ end
316
+
317
+ entry = registry.add(root, as: blank_to_nil(params["as"]), default: !OKF.blank?(params["default"]))
318
+ "@#{entry.slug} is registered and ready to read."
319
+ end
320
+
321
+ def required(params, key)
322
+ value = params[key]
323
+ raise OKF::Error, "#{key} is required" if OKF.blank?(value)
324
+
325
+ value.to_s.strip
326
+ end
327
+
328
+ def blank_to_nil(value)
329
+ OKF.blank?(value) ? nil : value.to_s.strip
330
+ end
331
+
332
+ # Rebuild the served set from the registry that was just written. Without
333
+ # this the file and the running server disagree until a restart, and every
334
+ # link the manager draws afterwards points at the world as it was.
335
+ def reload(registry)
336
+ @boot_registry = registry
337
+ @bundles = self.class.bundles_for(registry)
338
+ @default = @bundles.first
339
+ @apps = build_apps(@layout)
340
+ @counts = nil
341
+ @health = nil
342
+ end
343
+
344
+ # Same-origin *and* the token. Neither alone is enough: the token lives in
345
+ # a page, and a page is a thing another site can get a reader to submit;
346
+ # Origin alone would trust every tab this browser has open on this host.
347
+ # An unstated origin is refused rather than assumed — a form POST from the
348
+ # manager always states one.
349
+ def authentic?(request)
350
+ same_origin?(request) && Rack::Utils.secure_compare(token, request.params["token"].to_s)
351
+ end
352
+
353
+ def same_origin?(request)
354
+ source = request.get_header("HTTP_ORIGIN") || request.get_header("HTTP_REFERER")
355
+ return false if OKF.blank?(source)
356
+
357
+ URI.parse(source).host == request.host
358
+ rescue URI::Error
359
+ false
360
+ end
361
+
362
+ # One token per boot, minted lazily. Per-boot rather than per-session
363
+ # because the hub has no sessions and wants none: it is a local tool, and
364
+ # a cookie jar is a whole subsystem to defend for a page four people see.
365
+ def token
366
+ @token ||= SecureRandom.hex(16)
367
+ end
368
+
369
+ # Cross-bundle search, straight from the pure OKF::Bundle::Search.across —
370
+ # one shared index over every hosted bundle, so BM25 weighs a term against
371
+ # the whole corpus and the merged ranking is comparable by construction.
372
+ #
373
+ # A blank q is an ordinary answer, not a 400: the palette fetches on every
374
+ # keystroke, and the box starts empty. `fuzzy: true` matches both the TUI
375
+ # and the page's own MiniSearch, so all three forgive the same typos.
376
+ def search(query)
377
+ terms = query.to_s.split(/\s+/).reject(&:empty?)
378
+ rows = terms.empty? ? [] : OKF::Bundle::Search.across(pairs, terms, fuzzy: true, engine: SEARCH_ENGINE)
379
+ json(
380
+ "query" => query.to_s.strip,
381
+ "total" => rows.length,
382
+ "truncated" => rows.length > SEARCH_LIMIT,
383
+ "results" => rows.first(SEARCH_LIMIT)
384
+ )
385
+ end
386
+
387
+ # The /b/ manager's own rows, as JSON — what the graph page's Bundles panel
388
+ # reads. One source for both surfaces, so the panel and the page cannot
389
+ # disagree about what is registered, how big it is, or how healthy.
390
+ #
391
+ # Fetched rather than baked into every page because the registry is
392
+ # re-read per request: a rename made in another terminal shows the next
393
+ # time the panel is opened, where a boot snapshot would go stale silently.
394
+ #
395
+ # `writable` and `registry` are two different reasons the panel might offer
396
+ # nothing, and it says different things for each — a read-only bind names
397
+ # the flag, an ephemeral set names the terminal. The token is deliberately
398
+ # absent: it is baked into the page that may use it, and a credential in a
399
+ # listing endpoint is a habit worth not forming.
400
+ def bundles_json
401
+ json(
402
+ "writable" => @writable,
403
+ "registry" => !@boot_registry.nil?,
404
+ "bundles" => manager_rows.map { |row| stringify_row(row) }
405
+ )
406
+ end
407
+
408
+ def stringify_row(row)
409
+ row.each_with_object({}) { |(key, value), memo| memo[key.to_s] = value }
410
+ end
411
+
412
+ # [ slug, bundle ] for every hosted bundle — the in-memory model behind each
413
+ # on-disk folder, which is all the search engine reads.
414
+ def pairs
415
+ @bundles.map { |bundle| [ bundle.slug, bundle.folder.bundle ] }
416
+ end
417
+
86
418
  # Split "/b/<slug>/rest" into [ "<slug>", "/rest" ] (rest "" for just
87
419
  # "/b/<slug>"). A path outside the mount prefix, or an empty slug, is [ nil, nil ].
88
420
  def split(path)
@@ -122,7 +454,17 @@ module OKF
122
454
  layout: layout,
123
455
  siblings: siblings_of(bundle),
124
456
  self_slug: bundle.slug,
125
- hub_path: "/"
457
+ hub_path: "/",
458
+ # Relative for the same reason siblings are: every page lives at
459
+ # <prefix>/b/<slug>/, so "../../search" reaches the hub's own route
460
+ # under any mount without knowing the prefix.
461
+ search_endpoint: "../../search",
462
+ # Where the Bundles panel reads /bundles and posts /registry/<verb>,
463
+ # by the same relative rule. The token rides along only where a write
464
+ # could actually be honoured — a read-only or ephemeral hub bakes
465
+ # nothing, so the page holds no credential it may not use.
466
+ manage_root: "../../",
467
+ manage_token: (@writable && @boot_registry ? token : nil)
126
468
  )
127
469
  end
128
470
  end
@@ -146,40 +488,164 @@ module OKF
146
488
  BODY
147
489
  end
148
490
 
149
- # The /b/ index — every hosted bundle with its mount link, concept count,
150
- # and the default marked. The browser counterpart of `okf registry`.
491
+ # The /b/ page — the bundles manager, and the browser counterpart of the
492
+ # TUI's bundles view. Every fact a person needs to choose between bundles
493
+ # is on the row: size, health, which one `/` opens, and whether the folder
494
+ # is still there. A registry-backed hub reads the file per request rather
495
+ # than a boot snapshot, so an edit made elsewhere shows on a refresh.
151
496
  def index_page(base)
152
- page("OKF · bundles", "<h1>Bundles</h1>#{bundle_list(base)}")
497
+ rows = manager_rows
498
+ manager_page("OKF · bundles", <<~BODY)
499
+ <header class="mhead"><h1>Bundles</h1><p class="sub">#{escape(manager_summary(rows))}</p></header>
500
+ <ol class="rows">#{rows.map { |row| manager_row(base, row) }.join}</ol>
501
+ #{manager_note}
502
+ BODY
153
503
  end
154
504
 
155
- # The 404 for a slug the hub does not host: name what was asked for, then
156
- # list what exists — a stale bookmark after a rename gets a way home.
157
- def missing_page(base, path)
158
- body = "<h1>No bundle here</h1><p><code>#{escape(path)}</code> does not match a hosted bundle.</p>"
159
- body += bundle_list(base) unless @bundles.empty?
160
- page("OKF · not found", body)
161
- end
505
+ # One row per bundle this server knows about — which is not the same as
506
+ # one per bundle it *hosts*. A registry entry whose folder was deleted
507
+ # cannot be served, and leaving it off the page would answer "where did my
508
+ # bundle go?" with silence. Matched to a hosted bundle by directory rather
509
+ # than by slug: a rename in the file changes the slug and nothing else,
510
+ # and a row that lost its identity over a rename is the bug this avoids.
511
+ def manager_rows
512
+ return @bundles.map { |bundle| hosted_row(bundle, bundle.slug) } if registry.nil?
162
513
 
163
- def bundle_list(base)
164
- rows = @bundles.map do |bundle|
165
- count = counts[bundle.slug]
166
- badge = bundle.equal?(@default) ? %(<span class="def">default</span>) : ""
167
- %(<li><a href="#{escape(base)}#{MOUNT}/#{escape(bundle.slug)}/">#{escape(bundle.title)}</a>) +
168
- %(<span class="meta">#{escape(bundle.slug)} · #{count} concepts#{badge}</span></li>)
514
+ registry.listing.map do |entry|
515
+ hosted = @bundles.find { |bundle| bundle.folder.root == entry[:dir] }
516
+ hosted ? hosted_row(hosted, entry[:slug], entry[:dir]) : unhosted_row(entry)
169
517
  end
170
- %(<ul class="bundles">#{rows.join}</ul>)
171
518
  end
172
519
 
173
- def page(title, body)
520
+ def hosted_row(bundle, slug, dir = nil)
521
+ verdict, word = health(bundle)
522
+ { slug: slug, title: bundle.title, dir: dir || bundle.folder.root, mount: bundle.slug,
523
+ count: counts[bundle.slug], health: verdict, word: word, default: bundle.equal?(@default) }
524
+ end
525
+
526
+ # A registered entry the hub could not load. `missing` is the registry's
527
+ # own flag (the directory is not there); anything else that failed to load
528
+ # is a folder that exists and cannot be read, which is a different problem
529
+ # and gets a different sentence.
530
+ def unhosted_row(entry)
531
+ word = entry[:missing] ? "folder is gone" : "could not be read"
532
+ { slug: entry[:slug], title: entry[:title], dir: entry[:dir], mount: nil,
533
+ count: nil, health: "missing", word: word, default: false }
534
+ end
535
+
536
+ def manager_row(base, row)
537
+ name = if row[:mount]
538
+ %(<a class="name" href="#{escape(base)}#{MOUNT}/#{escape(row[:mount])}/">#{escape(row[:title])}</a>)
539
+ else
540
+ %(<span class="name off">#{escape(row[:title])}</span>)
541
+ end
542
+ %(<li class="row" data-health="#{escape(row[:health])}">) +
543
+ %(<div class="who">#{name}<div class="ref"><span class="slug">@#{escape(row[:slug])}</span>) +
544
+ # The row shows the tail; the tooltip is where the whole path stays
545
+ # reachable, since nothing else on the page carries it.
546
+ %(<span class="dir" title="#{escape(row[:dir])}"><bdi>#{escape(row[:dir])}</bdi></span></div></div>) +
547
+ %(<div class="facts">#{facts(row)}</div></li>)
548
+ end
549
+
550
+ # Three slots, always all three, so the columns line up down the page even
551
+ # when a row has nothing to put in one of them.
552
+ def facts(row)
553
+ count = row[:count] ? tally(row[:count], "concept") : ""
554
+ flag = row[:default] ? %(<span class="def">default</span>) : ""
555
+ %(<span class="f-count">#{count}</span>) +
556
+ %(<span class="f-health"><span class="hv-word">#{escape(row[:word])}</span></span>) +
557
+ %(<span class="f-flag">#{flag}</span>)
558
+ end
559
+
560
+ # Counts what is on the page, including the rows the hub cannot serve —
561
+ # a summary that omits them would contradict the list right underneath it.
562
+ def manager_summary(rows)
563
+ return "Nothing is registered yet." if rows.empty?
564
+
565
+ hosted = rows.count { |row| row[:mount] }
566
+ line = "#{tally(hosted, "bundle")} on this server. Open one to read its graph."
567
+ gone = rows.length - hosted
568
+ gone.zero? ? line : "#{line} #{tally(gone, "entry")} cannot be opened."
569
+ end
570
+
571
+ # An ephemeral hub has no registry behind it, which is why these bundles
572
+ # will not be here next run — and why the Bundles panel offers nothing on
573
+ # them either. Saying so beats leaving a reader to wonder why the controls
574
+ # they were told about are absent.
575
+ def manager_note
576
+ return "" unless registry.nil?
577
+
578
+ %(<p class="mnote">These bundles were named on the command line and are ) +
579
+ %(<strong>not registered</strong> — they last as long as this server does. ) +
580
+ %(<code>okf registry set &lt;dir&gt;</code> registers one for good.</p>)
581
+ end
582
+
583
+ # The registry as it is on disk right now, or nil for an ephemeral hub.
584
+ # Re-read per request on purpose: the file is the source of truth, so an
585
+ # `okf registry rename` in another terminal shows on the next refresh
586
+ # instead of waiting for a restart.
587
+ def registry
588
+ @boot_registry && OKF::Registry.new(@boot_registry.path)
589
+ end
590
+
591
+ # ok / warn / error, with the word that carries the same message for a
592
+ # reader who cannot see the colour. validate and lint stay separate (§9):
593
+ # a curation finding is a warning and never a conformance error, so a thin
594
+ # bundle keeps its link and only a non-conformant one reads as broken.
595
+ # Memoised like #counts — every stray 404 renders a bundle list too, and
596
+ # linting every hosted bundle per request is not a page render.
597
+ def health(bundle)
598
+ @health ||= {}
599
+ @health[bundle.slug] ||= verdict_for(bundle)
600
+ end
601
+
602
+ def verdict_for(bundle)
603
+ result = bundle.folder.validate
604
+ return [ "error", tally(result.errors.length, "error") ] unless result.valid?
605
+
606
+ warnings = bundle.folder.lint.warnings.length
607
+ return [ "warn", tally(warnings, "warning") ] if warnings.positive?
608
+
609
+ [ "ok", "no problems" ]
610
+ rescue OKF::Error, SystemCallError
611
+ [ "error", "could not be checked" ]
612
+ end
613
+
614
+ def tally(count, noun)
615
+ "#{count} #{count == 1 ? noun : "#{noun}s"}"
616
+ end
617
+
618
+ # The 404 for a slug the hub does not host: name what was asked for, guess
619
+ # what was meant, then list what exists — a stale bookmark after a rename
620
+ # gets a way home rather than a dead end. Built on the app shell, in
621
+ # NotFound; the rows are the manager's own, so a bundle reads the same
622
+ # here as it does there.
623
+ def missing_page(base, path, slug)
624
+ rows = @bundles.map { |bundle| hosted_row(bundle, bundle.slug) }
625
+ NotFound.page(path, slug, rows, base, MOUNT)
626
+ end
627
+
628
+ def page(title, body, body_class = "")
174
629
  <<~HTML
175
630
  <!doctype html><html lang="en"><head><meta charset="utf-8">
176
631
  <meta name="viewport" content="width=device-width,initial-scale=1">
632
+ <meta name="color-scheme" content="dark light">
177
633
  <title>#{escape(title)}</title>
178
634
  <style>#{STYLE}</style>
179
- </head><body><main>#{body}</main></body></html>
635
+ </head><body class="#{body_class}"><main>#{body}</main></body></html>
180
636
  HTML
181
637
  end
182
638
 
639
+ # The manager is a list, not a one-paragraph notice, so it drops the
640
+ # centred card the landing and the 404 are shaped for.
641
+ def manager_page(title, body)
642
+ page(title, body, "mgr")
643
+ end
644
+
645
+ def json(object)
646
+ [ 200, { "content-type" => "application/json; charset=utf-8" }, [ JSON.generate(object) ] ]
647
+ end
648
+
183
649
  def html(status, body)
184
650
  [ status, { "content-type" => "text/html; charset=utf-8" }, [ body ] ]
185
651
  end