okf 1.10.0 → 1.12.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 +294 -0
- data/README.md +310 -445
- 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 +93 -13
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +19 -14
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +241 -14
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +33 -2
- data/lib/okf/cli/server.rb +16 -5
- data/lib/okf/cli/stats.rb +36 -11
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +10 -6
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +504 -103
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/server/app.rb +74 -3
- data/lib/okf/server/hub.rb +40 -30
- data/lib/okf/skill/SKILL.md +17 -10
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +3 -2
- data/lib/okf/skill/playbooks/refine.md +30 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +171 -32
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +10 -0
- metadata +21 -8
data/lib/okf/registry.rb
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "pathname"
|
|
4
5
|
|
|
5
6
|
module OKF
|
|
6
7
|
# A persistent, ordered registry of bundle references — the kernel behind the
|
|
7
|
-
# multi-bundle server. It is a plain JSON file (no database)
|
|
8
|
-
# (default ~/.okf),
|
|
9
|
-
#
|
|
8
|
+
# multi-bundle server. It is a plain JSON file (no database): the global one
|
|
9
|
+
# under $OKF_HOME (default ~/.okf), or a project-local .okf-registry.json
|
|
10
|
+
# discovered by walking up from cwd (see .load / .discover), which replaces the
|
|
11
|
+
# global one while you stand in its tree. Either way `okf registry set`/`del`
|
|
12
|
+
# and a later bare `okf server` share one on-disk list. Part of the shell — it
|
|
13
|
+
# reads and writes a file.
|
|
10
14
|
#
|
|
11
15
|
# registry = OKF::Registry.load
|
|
12
16
|
# registry.add("docs") # persists, returns the Entry
|
|
@@ -31,9 +35,40 @@ module OKF
|
|
|
31
35
|
# human-readable +title+ ("parent/dir").
|
|
32
36
|
Entry = Struct.new(:slug, :path, :title)
|
|
33
37
|
|
|
38
|
+
# A named set of bundle sources: a unique +slug+ and an ordered list of
|
|
39
|
+
# +members+ (bundle *or* group slugs), stored normalized. A group has no path
|
|
40
|
+
# — it resolves, recursively, to the bundles its members name. It shares the
|
|
41
|
+
# slug namespace with Entry: a slug names one *or* the other, never both, so
|
|
42
|
+
# `@backend` is unambiguous. Only `okf search`/`okf server` consume one — the
|
|
43
|
+
# two verbs that already take several bundles.
|
|
44
|
+
#
|
|
45
|
+
# A plain class, not a Struct like Entry: the field we want is +members+, and
|
|
46
|
+
# `Struct.new(:slug, :members)` would shadow Struct#members (the field-name
|
|
47
|
+
# introspection) — the gotcha Lint/StructNewOverride flags. This keeps
|
|
48
|
+
# `group.members` as the natural accessor without the override.
|
|
49
|
+
class Group
|
|
50
|
+
attr_accessor :slug, :members
|
|
51
|
+
|
|
52
|
+
def initialize(slug, members)
|
|
53
|
+
@slug = slug
|
|
54
|
+
@members = members
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
34
58
|
HOME_ENV = "OKF_HOME"
|
|
35
59
|
DEFAULT_HOME = "~/.okf"
|
|
36
60
|
|
|
61
|
+
# A project-local registry: the same JSON, discovered by walking up from the
|
|
62
|
+
# working directory rather than read from $OKF_HOME. Its presence is the whole
|
|
63
|
+
# state — no stored "local mode" flag — so a bare `okf server` inside a repo
|
|
64
|
+
# serves that repo's bundles with no global setup.
|
|
65
|
+
LOCAL_FILE = ".okf-registry.json"
|
|
66
|
+
|
|
67
|
+
# The lever that forces the global registry even when a local one is on the
|
|
68
|
+
# path up from cwd. Set it (inline) and discovery is skipped — the escape hatch
|
|
69
|
+
# for a fixed-cwd caller (CI, a tool, the tests) that wants $OKF_HOME.
|
|
70
|
+
NO_DISCOVERY_ENV = "OKF_NO_DISCOVERY"
|
|
71
|
+
|
|
37
72
|
# Slugs the ref grammar has already spoken for. `@all` means every registered
|
|
38
73
|
# bundle, so a bundle slugged "all" could never be named — reserve it here,
|
|
39
74
|
# where both slug paths pass, rather than let one register and then be
|
|
@@ -64,8 +99,36 @@ module OKF
|
|
|
64
99
|
raise OKF::Error, "cannot expand #{base}: #{e.message}"
|
|
65
100
|
end
|
|
66
101
|
|
|
67
|
-
|
|
68
|
-
|
|
102
|
+
# The registry a run resolves to. Precedence, highest first: OKF_NO_DISCOVERY
|
|
103
|
+
# forces the global one; else a `.okf-registry.json` discovered on the path
|
|
104
|
+
# up from +cwd+ wins; else the global $OKF_HOME registry, exactly as before.
|
|
105
|
+
# +cwd+ nil ⇒ no discovery, so an embedding app that calls `load` with no
|
|
106
|
+
# arguments keeps the global-only behavior — only the CLI opts in by passing
|
|
107
|
+
# `cwd: Dir.pwd`. $OKF_HOME names *where the global registry lives*; it does
|
|
108
|
+
# not veto a nearer local one (it is commonly exported, so letting it would
|
|
109
|
+
# silently defeat the feature for its own audience).
|
|
110
|
+
def load(home: nil, cwd: nil)
|
|
111
|
+
looking = cwd && ENV[NO_DISCOVERY_ENV].to_s.empty?
|
|
112
|
+
local = looking ? discover(cwd) : nil
|
|
113
|
+
# A local registry anchors its relative paths on its own directory; the
|
|
114
|
+
# global one has no common anchor, so it stays absolute (relative_base nil).
|
|
115
|
+
new(local || path(home: home), relative_base: local && File.dirname(local))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Walk up from +start+ looking for a local registry; return its absolute path
|
|
119
|
+
# or nil. Stops at the filesystem root (parent == self), so it never loops.
|
|
120
|
+
def discover(start)
|
|
121
|
+
dir = expand(start.to_s)
|
|
122
|
+
loop do
|
|
123
|
+
candidate = File.join(dir, LOCAL_FILE)
|
|
124
|
+
return candidate if File.file?(candidate)
|
|
125
|
+
|
|
126
|
+
parent = File.dirname(dir)
|
|
127
|
+
break if parent == dir
|
|
128
|
+
|
|
129
|
+
dir = parent
|
|
130
|
+
end
|
|
131
|
+
nil
|
|
69
132
|
end
|
|
70
133
|
|
|
71
134
|
# Normalize +base+ to a url-safe slug (lowercase, dashes) — "" when nothing
|
|
@@ -115,9 +178,15 @@ module OKF
|
|
|
115
178
|
|
|
116
179
|
attr_reader :path
|
|
117
180
|
|
|
118
|
-
|
|
181
|
+
# +relative_base+ is the directory a local registry's relative paths anchor on
|
|
182
|
+
# (see .load). nil means an absolute-path registry — the global $OKF_HOME one,
|
|
183
|
+
# and every library caller — so its behavior is exactly what it was before
|
|
184
|
+
# relative storage existed.
|
|
185
|
+
def initialize(path, relative_base: nil)
|
|
119
186
|
@path = path
|
|
187
|
+
@relative_base = relative_base
|
|
120
188
|
@entries = []
|
|
189
|
+
@groups = []
|
|
121
190
|
read
|
|
122
191
|
end
|
|
123
192
|
|
|
@@ -141,6 +210,11 @@ module OKF
|
|
|
141
210
|
@entries.find { |entry| entry.slug == slug }
|
|
142
211
|
end
|
|
143
212
|
|
|
213
|
+
# The group registered under +slug+ (already normalized, like #get), or nil.
|
|
214
|
+
def group?(slug)
|
|
215
|
+
@groups.find { |group| group.slug == slug }
|
|
216
|
+
end
|
|
217
|
+
|
|
144
218
|
# The default bundle a bare `okf server` selects: the first entry still on
|
|
145
219
|
# disk. Position decides it, but a position the hub cannot serve decides
|
|
146
220
|
# nothing — it drops a vanished directory rather than serving a hole, so the
|
|
@@ -163,7 +237,12 @@ module OKF
|
|
|
163
237
|
# allowing the move would answer `default bundle → <some other slug>` to
|
|
164
238
|
# someone who named this one.
|
|
165
239
|
def default=(slug)
|
|
166
|
-
|
|
240
|
+
normalized = self.class.normalize(slug)
|
|
241
|
+
if group?(normalized)
|
|
242
|
+
raise OKF::Error, "cannot default to a group: @#{normalized} names a set of bundles, and the default is one bundle"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
entry = get(normalized)
|
|
167
246
|
raise OKF::Error, "no such bundle: #{slug}" unless entry
|
|
168
247
|
unless File.directory?(entry.path)
|
|
169
248
|
raise OKF::Error, "cannot default to #{entry.slug}: #{entry.path} is not a directory " \
|
|
@@ -180,11 +259,15 @@ module OKF
|
|
|
180
259
|
# silently suffixing — a rename is explicit. Position is untouched, so a
|
|
181
260
|
# renamed default stays the default with no bookkeeping.
|
|
182
261
|
def rename(old_slug, new_slug)
|
|
183
|
-
|
|
184
|
-
|
|
262
|
+
old = self.class.normalize(old_slug)
|
|
263
|
+
entry = get(old) || group?(old)
|
|
264
|
+
raise OKF::Error, "no such bundle or group: #{old_slug}" unless entry
|
|
185
265
|
|
|
186
266
|
slug = explicit_slug(new_slug, entry)
|
|
187
267
|
entry.slug = slug
|
|
268
|
+
# A member list stores slugs, so a rename that stopped at the entry would
|
|
269
|
+
# orphan every group that named it — cascade the new name across them.
|
|
270
|
+
cascade_rename(old, slug)
|
|
188
271
|
write
|
|
189
272
|
entry
|
|
190
273
|
end
|
|
@@ -241,11 +324,118 @@ module OKF
|
|
|
241
324
|
target = get(slug) ||
|
|
242
325
|
@entries.find { |entry| entry.path == self.class.expand(slug.to_s) } ||
|
|
243
326
|
(self.class.path_shaped?(slug) ? nil : get(self.class.normalize(slug)))
|
|
244
|
-
|
|
327
|
+
if target
|
|
328
|
+
@entries.delete(target)
|
|
329
|
+
cascade_remove(target.slug)
|
|
330
|
+
write
|
|
331
|
+
return target
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# Not a bundle — a group answers to its slug only (having no path, it can
|
|
335
|
+
# never match the path-shaped reading). Removing it, like removing a bundle,
|
|
336
|
+
# drops the slug from every group that named it.
|
|
337
|
+
group = self.class.path_shaped?(slug) ? nil : (group?(slug) || group?(self.class.normalize(slug)))
|
|
338
|
+
return nil unless group
|
|
245
339
|
|
|
246
|
-
@
|
|
340
|
+
@groups.delete(group)
|
|
341
|
+
cascade_remove(group.slug)
|
|
247
342
|
write
|
|
248
|
-
|
|
343
|
+
group
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Create the group +slug+, or add +member_asks+ to an existing one (a union,
|
|
347
|
+
# order-preserving). Members are bundle *or* group slugs, given bare or as
|
|
348
|
+
# `@ref`; each must already name a bundle or a group, and the result must not
|
|
349
|
+
# reach itself (a cycle is refused before the write). Persists, returns the
|
|
350
|
+
# Group.
|
|
351
|
+
def set_group(slug, member_asks)
|
|
352
|
+
name = explicit_group_slug(slug)
|
|
353
|
+
members = normalize_members(member_asks)
|
|
354
|
+
raise OKF::Error, "a group needs at least one member (okf registry group #{name} <@bundle…>)" if members.empty?
|
|
355
|
+
|
|
356
|
+
members.each do |member|
|
|
357
|
+
next if get(member) || group?(member)
|
|
358
|
+
|
|
359
|
+
raise OKF::Error, "no such bundle or group: @#{member} (okf registry list)"
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
group = group?(name)
|
|
363
|
+
merged = group ? group.members.dup : []
|
|
364
|
+
members.each { |member| merged << member unless merged.include?(member) }
|
|
365
|
+
raise OKF::Error, "group cycle: @#{name} would contain itself" if reaches_self?(name, merged)
|
|
366
|
+
|
|
367
|
+
if group
|
|
368
|
+
group.members = merged
|
|
369
|
+
else
|
|
370
|
+
group = Group.new(name, merged)
|
|
371
|
+
@groups << group
|
|
372
|
+
end
|
|
373
|
+
write
|
|
374
|
+
group
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Drop +member_asks+ from the group +slug+. Removing the last member deletes
|
|
378
|
+
# the group — an empty group resolves to nothing, so it is not worth keeping.
|
|
379
|
+
# Returns [removed_members, emptied?]. Raises on an unknown group; a member
|
|
380
|
+
# that was not there is simply not in the returned list.
|
|
381
|
+
def unset_group_members(slug, member_asks)
|
|
382
|
+
name = self.class.normalize(slug)
|
|
383
|
+
group = group?(name)
|
|
384
|
+
raise OKF::Error, "no such group: #{slug} (okf registry list)" unless group
|
|
385
|
+
|
|
386
|
+
asks = normalize_members(member_asks)
|
|
387
|
+
removed = group.members & asks
|
|
388
|
+
group.members -= asks
|
|
389
|
+
emptied = group.members.empty?
|
|
390
|
+
@groups.delete(group) if emptied
|
|
391
|
+
write
|
|
392
|
+
[ removed, emptied ]
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
# Resolve +slug+ to its ordered, path-deduped bundle Entries — a group flattens
|
|
396
|
+
# recursively, a bundle slug resolves to itself. Returns leaves even when their
|
|
397
|
+
# directory has vanished; the caller (search/server) decides whether to skip
|
|
398
|
+
# one, the way `@all` tolerates a gap. Raises OKF::Error on a cycle — a
|
|
399
|
+
# defense-in-depth guard, since #set_group already blocks one at write time but
|
|
400
|
+
# the file is hand-editable.
|
|
401
|
+
def expand(slug)
|
|
402
|
+
entries = []
|
|
403
|
+
seen = []
|
|
404
|
+
resolve_into(self.class.normalize(slug), entries, seen, [])
|
|
405
|
+
entries
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Persist the current state to disk. The mutating verbs write as a side effect
|
|
409
|
+
# of the change; `save` is the public seam for the one caller that creates a
|
|
410
|
+
# registry with nothing to change yet — `okf registry init`, materializing an
|
|
411
|
+
# empty local file so discovery has something to find.
|
|
412
|
+
def save
|
|
413
|
+
write
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# A fresh instance over the same file, anchored the same way. The server
|
|
417
|
+
# re-opens the registry per request (to show an edit made elsewhere) and
|
|
418
|
+
# after each write; it must keep the +relative_base+ a discovered local
|
|
419
|
+
# registry carries. A bare `Registry.new(path)` would drop it — so a local
|
|
420
|
+
# registry's in-tree paths would resolve against the wrong directory (every
|
|
421
|
+
# served bundle reads as "folder is gone" in the manager) and a browser
|
|
422
|
+
# write would flatten a newly-added in-tree bundle to an absolute path,
|
|
423
|
+
# silently undoing the portability the base exists for.
|
|
424
|
+
def reopen
|
|
425
|
+
self.class.new(@path, relative_base: @relative_base)
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# One row per group for `registry list`: its members and how many bundles it
|
|
429
|
+
# resolves to (+resolved+ is nil when a hand-edited cycle makes it unanswerable).
|
|
430
|
+
def groups_listing
|
|
431
|
+
@groups.map do |group|
|
|
432
|
+
resolved = begin
|
|
433
|
+
expand(group.slug).size
|
|
434
|
+
rescue OKF::Error
|
|
435
|
+
nil
|
|
436
|
+
end
|
|
437
|
+
{ slug: group.slug, members: group.members.dup, resolved: resolved }
|
|
438
|
+
end
|
|
249
439
|
end
|
|
250
440
|
|
|
251
441
|
private
|
|
@@ -256,8 +446,15 @@ module OKF
|
|
|
256
446
|
# and a suffix is expected; #explicit_slug refuses instead, because there the
|
|
257
447
|
# name is the user's.
|
|
258
448
|
def unique_slug(base, skip)
|
|
259
|
-
|
|
260
|
-
|
|
449
|
+
self.class.dedupe(base, taken_slugs(skip) + RESERVED_SLUGS)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# Every slug spoken for, bundle *and* group, except +skip+ (the entry or group
|
|
453
|
+
# being re-slugged in place). The unified namespace: a slug names one thing, so
|
|
454
|
+
# a collision check has to see both lists.
|
|
455
|
+
def taken_slugs(skip)
|
|
456
|
+
@entries.reject { |entry| entry.equal?(skip) }.map(&:slug) +
|
|
457
|
+
@groups.reject { |group| group.equal?(skip) }.map(&:slug)
|
|
261
458
|
end
|
|
262
459
|
|
|
263
460
|
# An explicitly requested slug (--as, rename): normalized, and a collision
|
|
@@ -276,21 +473,110 @@ module OKF
|
|
|
276
473
|
raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
|
|
277
474
|
end
|
|
278
475
|
|
|
279
|
-
taken = @entries.reject { |entry| entry.equal?(skip) }.map(&:slug)
|
|
280
476
|
# Refusing is the "never substitute a name you chose" rule, but a refusal
|
|
281
477
|
# with no way forward is a dead end: the slug is spoken for by another
|
|
282
|
-
# entry, so say which move frees it.
|
|
283
|
-
raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if
|
|
478
|
+
# entry or group, so say which move frees it.
|
|
479
|
+
raise OKF::Error, "slug already taken: #{slug} (rename or remove that entry first)" if taken_slugs(skip).include?(slug)
|
|
284
480
|
|
|
285
481
|
slug
|
|
286
482
|
end
|
|
287
483
|
|
|
484
|
+
# The group slug for #set_group: usable, not reserved, and not a bundle's. A
|
|
485
|
+
# group re-using its own slug is the update path (so a group collision is not
|
|
486
|
+
# checked here); a bundle's slug is a hard collision.
|
|
487
|
+
def explicit_group_slug(base)
|
|
488
|
+
slug = self.class.normalize(base)
|
|
489
|
+
raise OKF::Error, "not a usable slug: #{base} (letters and digits, please)" if slug.empty?
|
|
490
|
+
|
|
491
|
+
if RESERVED_SLUGS.include?(slug)
|
|
492
|
+
raise OKF::Error, "not a usable slug: #{slug} is reserved (@#{slug} names every registered bundle)"
|
|
493
|
+
end
|
|
494
|
+
if get(slug)
|
|
495
|
+
raise OKF::Error, "slug already taken: #{slug} names a bundle (rename or remove that entry first)"
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
slug
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# Member asks (bare or `@ref`) normalized to bundle/group slugs, empties dropped.
|
|
502
|
+
# #normalize maps a leading @ to nothing, so "@alpha" and "alpha" both arrive as
|
|
503
|
+
# "alpha".
|
|
504
|
+
def normalize_members(asks)
|
|
505
|
+
asks.map { |ask| self.class.normalize(ask) }.reject(&:empty?)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Would a group named +start+ with +members+ reach itself? Walks the member
|
|
509
|
+
# graph (members that are groups expand), returning true on any path back to
|
|
510
|
+
# +start+ — the direct self-reference and the indirect cycle both. Other groups
|
|
511
|
+
# are already acyclic, so only edges out of +start+ can newly close a loop.
|
|
512
|
+
def reaches_self?(start, members)
|
|
513
|
+
stack = members.dup
|
|
514
|
+
seen = []
|
|
515
|
+
until stack.empty?
|
|
516
|
+
member = stack.pop
|
|
517
|
+
return true if member == start
|
|
518
|
+
next if seen.include?(member)
|
|
519
|
+
|
|
520
|
+
seen << member
|
|
521
|
+
nested = group?(member)
|
|
522
|
+
stack.concat(nested.members) if nested
|
|
523
|
+
end
|
|
524
|
+
false
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
# Depth-first expansion of +slug+ into bundle entries, deduped by path and
|
|
528
|
+
# cycle-guarded by the +chain+ of groups already open above it.
|
|
529
|
+
def resolve_into(slug, entries, seen_paths, chain)
|
|
530
|
+
if chain.include?(slug)
|
|
531
|
+
raise OKF::Error, "group cycle: #{(chain + [ slug ]).map { |name| "@#{name}" }.join(" → ")}"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
group = group?(slug)
|
|
535
|
+
if group
|
|
536
|
+
group.members.each { |member| resolve_into(member, entries, seen_paths, chain + [ slug ]) }
|
|
537
|
+
return
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
entry = get(slug)
|
|
541
|
+
return unless entry # a dangling member (hand-edited) resolves to nothing
|
|
542
|
+
return if seen_paths.include?(entry.path)
|
|
543
|
+
|
|
544
|
+
seen_paths << entry.path
|
|
545
|
+
entries << entry
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Drop +slug+ from every group's members, and any group thereby emptied — which
|
|
549
|
+
# itself becomes a slug to drop, so a chain of one-member groups unwinds cleanly.
|
|
550
|
+
def cascade_remove(slug)
|
|
551
|
+
dropping = [ slug ]
|
|
552
|
+
until dropping.empty?
|
|
553
|
+
gone = dropping.shift
|
|
554
|
+
@groups.each { |group| group.members.delete(gone) }
|
|
555
|
+
@groups.select { |group| group.members.empty? }.each do |empty|
|
|
556
|
+
@groups.delete(empty)
|
|
557
|
+
dropping << empty.slug
|
|
558
|
+
end
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# Rewrite +from+ to +to+ across every group's members, deduping when the new
|
|
563
|
+
# name already sits beside the old one.
|
|
564
|
+
def cascade_rename(from, to)
|
|
565
|
+
@groups.each do |group|
|
|
566
|
+
next unless group.members.include?(from)
|
|
567
|
+
|
|
568
|
+
group.members = group.members.map { |member| member == from ? to : member }.uniq
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
|
|
288
572
|
def read
|
|
289
573
|
return unless File.exist?(@path)
|
|
290
574
|
|
|
291
575
|
data = JSON.parse(File.read(@path, encoding: "UTF-8"))
|
|
292
576
|
rows = data.is_a?(Hash) ? Array(data["bundles"]) : Array(data) # bare array: the original shape
|
|
293
577
|
@entries = rows.map { |row| entry_from(row) }
|
|
578
|
+
group_rows = data.is_a?(Hash) ? Array(data["groups"]) : [] # a groups-less file has none
|
|
579
|
+
@groups = group_rows.map { |row| group_from(row) }.reject { |group| group.members.empty? }
|
|
294
580
|
normalize_slugs
|
|
295
581
|
rescue JSON::ParserError => e
|
|
296
582
|
malformed("#{e.message} (fix or delete the file)")
|
|
@@ -306,7 +592,46 @@ module OKF
|
|
|
306
592
|
unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["path"].is_a?(String) && !row["path"].empty?
|
|
307
593
|
malformed('every entry needs a "slug" and a "path" (fix or delete the file)')
|
|
308
594
|
end
|
|
309
|
-
|
|
595
|
+
path = resolve_stored(row["path"])
|
|
596
|
+
Entry.new(row["slug"], path, row["title"] || File.basename(path))
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Resolve a stored path to an absolute one: a relative path is anchored on the
|
|
600
|
+
# local registry's directory, an absolute one (and every path in the global
|
|
601
|
+
# registry) is returned untouched. So entry.path is *always* absolute in
|
|
602
|
+
# memory, and every consumer — File.directory?, the listing, the server mount —
|
|
603
|
+
# goes on seeing the absolute paths it always did.
|
|
604
|
+
def resolve_stored(raw)
|
|
605
|
+
return raw if @relative_base.nil? || raw.start_with?("/")
|
|
606
|
+
|
|
607
|
+
File.expand_path(raw, @relative_base)
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# The on-disk form of an absolute path. In a local registry a bundle inside the
|
|
611
|
+
# registry's own tree is stored *relative* to it, so the file travels with the
|
|
612
|
+
# repo (a checkout elsewhere, a container mounting it) and still resolves; a
|
|
613
|
+
# bundle outside the tree stays absolute, since a relative path that climbs out
|
|
614
|
+
# cannot be re-anchored anywhere useful, and being honest about that beats a
|
|
615
|
+
# `../../..` that breaks on the first move. The global registry (no base) always
|
|
616
|
+
# stores absolute — its behavior is unchanged.
|
|
617
|
+
def store_form(abs)
|
|
618
|
+
return abs if @relative_base.nil?
|
|
619
|
+
|
|
620
|
+
# Both are absolute — entry.path always is, and @relative_base is a dirname of
|
|
621
|
+
# one — so relative_path_from cannot fail to relate them on a POSIX tree.
|
|
622
|
+
rel = Pathname.new(abs).relative_path_from(Pathname.new(@relative_base)).to_s
|
|
623
|
+
rel.start_with?("..") ? abs : rel
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# One row to a Group, shape-checked like #entry_from. Members are normalized on
|
|
627
|
+
# the way in, the same asymmetry-fix #normalize_slugs applies to slugs: a
|
|
628
|
+
# hand-typed "@My Docs" member becomes "my-docs" so it can resolve.
|
|
629
|
+
def group_from(row)
|
|
630
|
+
unless row.is_a?(Hash) && row["slug"].is_a?(String) && row["members"].is_a?(Array)
|
|
631
|
+
malformed('every group needs a "slug" and a "members" array (fix or delete the file)')
|
|
632
|
+
end
|
|
633
|
+
members = row["members"].map { |member| self.class.normalize(member.to_s) }.reject(&:empty?)
|
|
634
|
+
Group.new(row["slug"], members)
|
|
310
635
|
end
|
|
311
636
|
|
|
312
637
|
# Slugs enter this list three ways — minted from a basename, asked for with
|
|
@@ -330,6 +655,11 @@ module OKF
|
|
|
330
655
|
|
|
331
656
|
entry.slug = unique_slug(entry.slug, entry)
|
|
332
657
|
end
|
|
658
|
+
@groups.each do |group|
|
|
659
|
+
next if usable_slug?(group.slug)
|
|
660
|
+
|
|
661
|
+
group.slug = unique_slug(group.slug, group)
|
|
662
|
+
end
|
|
333
663
|
end
|
|
334
664
|
|
|
335
665
|
# A stored slug that registration would have handed back untouched:
|
|
@@ -347,8 +677,9 @@ module OKF
|
|
|
347
677
|
# Two racing writers stay last-writer-wins; the registry is a per-user file.
|
|
348
678
|
def write
|
|
349
679
|
FileUtils.mkdir_p(File.dirname(@path))
|
|
350
|
-
rows = @entries.map { |entry| { "slug" => entry.slug, "path" => entry.path, "title" => entry.title } }
|
|
351
|
-
|
|
680
|
+
rows = @entries.map { |entry| { "slug" => entry.slug, "path" => store_form(entry.path), "title" => entry.title } }
|
|
681
|
+
groups = @groups.map { |group| { "slug" => group.slug, "members" => group.members } }
|
|
682
|
+
payload = { "bundles" => rows, "groups" => groups }
|
|
352
683
|
tmp = "#{@path}.tmp-#{Process.pid}"
|
|
353
684
|
begin
|
|
354
685
|
File.write(tmp, JSON.pretty_generate(payload) + "\n")
|