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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
data/lib/okf/server/hub.rb
CHANGED
|
@@ -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 /
|
|
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
|
|
21
|
-
#
|
|
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/
|
|
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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
45
|
-
.
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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/
|
|
150
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
156
|
-
#
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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 <dir></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
|