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