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.
data/lib/okf/cli/index.rb CHANGED
@@ -5,9 +5,15 @@ module OKF
5
5
  # The progressive-disclosure map (spec §6): every directory that holds concepts
6
6
  # or carries an index.md, with its authored index body, a type/tag rollup, its
7
7
  # child directories, and — for a directory with no index.md — the listing
8
- # synthesized from the concepts there. The "orient before you read" view. `--area`
9
- # is repeatable (one or many directories; `root` is the bundle root); `--no-body`
10
- # drops the prose to a skeleton; advisory, exit 0.
8
+ # synthesized from the concepts there. The "orient before you read" view. `--dir`
9
+ # is repeatable and selects a directory *and its subtree* (`root` is the bundle
10
+ # root), `--depth N` bounds how far below the starting point that goes, and
11
+ # `--no-body` drops the prose to a skeleton; advisory, exit 0.
12
+ #
13
+ # The two narrowings are what make the map usable on a deep bundle: every
14
+ # directory is a section, so a few hundred concepts is a map nobody reads at
15
+ # once. `--depth 1` is the top of the tree, `--dir X --depth 1` is one branch
16
+ # of it, and the pair walks down a level at a time.
11
17
  class Index < Command
12
18
  def self.id
13
19
  :index
@@ -19,26 +25,47 @@ module OKF
19
25
 
20
26
  def self.help_rows
21
27
  [
22
- [ "index <dir|@slug> [--json] [--area A] [--no-body]", "the index map: dirs, their listings and rollups" ]
28
+ [ "index <dir|@slug> [--dir D] [--depth N] [--no-body]", "the index map: dirs, their listings and rollups" ]
23
29
  ]
24
30
  end
25
31
 
26
32
  def call(argv)
27
- options = { json: false, body: true, areas: nil }
33
+ options = { json: false, body: true, dirs: nil, areas: nil, depth: nil, ancestors: true }
28
34
  parser = OptionParser.new do |o|
29
- o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
35
+ o.banner = "Usage: okf index <dir|@slug> [--dir PATH] [--depth N] [--no-body] [--json]"
30
36
  json_flags(o, options, "emit the index map as JSON")
31
37
  projection_flags(o, options)
32
- o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
38
+ o.on("--dir PATH", "only this directory and the ones below it",
39
+ "(repeatable; `root` for the bundle root)") { |v| (options[:dirs] ||= []) << v }
40
+ depth_flag(o, options)
41
+ ancestors_flag(o, options)
42
+ o.on("--area AREA", "deprecated: use --dir (this directory exactly)") do |v|
43
+ (options[:areas] ||= []) << v
44
+ deprecated("--area", "--dir")
45
+ end
33
46
  o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
34
47
  help_flag(o)
35
48
  end
36
49
  dir = positional_dir(parser, argv) or return 2
50
+ bad_depth = depth_error(options)
51
+ return bad_depth if bad_depth
52
+ # --area is exact and names no starting point, so --depth has nothing to
53
+ # be relative *to*: the pair used to union the area with every directory
54
+ # at that depth from the root. Refusing beats answering with more.
55
+ #
56
+ # --dir is refused for the same reason and not a weaker one: one flag is
57
+ # exact and the other a prefix, so the pair came back with the area *and*
58
+ # the subtree — an answer to neither question, from a combination only
59
+ # someone mid-migration would type. A deprecated flag that quietly widens
60
+ # is worse than one that is merely old.
61
+ if options[:areas] && (options[:depth] || options[:dirs])
62
+ return usage_error("--area and #{options[:dirs] ? "--dir" : "--depth"} do not combine: use --dir")
63
+ end
37
64
 
38
65
  folder = OKF::Bundle::Folder.load(dir)
39
66
  report_skipped(folder)
40
67
  entries = folder.directory_index
41
- selected = select_directories(entries, options[:areas])
68
+ selected, chain = select_directories(entries, options)
42
69
  if options[:json]
43
70
  # --no-body is shorthand for --except body, so asking for the body by
44
71
  # name in the same breath is a contradiction. Letting --fields quietly
@@ -48,30 +75,44 @@ module OKF
48
75
  end
49
76
 
50
77
  options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
51
- return print_index_map_json(dir, selected, options)
78
+ return print_index_map_json(dir, selected, chain, options)
52
79
  end
53
- print_index_map(dir, selected, options[:body])
80
+ print_index_map(dir, selected, chain, options[:body])
54
81
  0
55
82
  end
56
83
 
57
84
  private
58
85
 
59
- # Narrow the map to the named directories/areas — case-insensitive, `root`
60
- # matching the bundle root (".") so no shell quoting is needed. No --area passed
61
- # keeps the whole map.
62
- def select_directories(entries, areas)
63
- return entries if areas.nil? || areas.empty?
64
-
65
- wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
66
- entries.select { |entry| wanted.include?(entry[:dir].downcase) }
86
+ # Narrow the map — case-insensitive, `root` matching the bundle root (".").
87
+ # --dir takes the named directory *and its subtree* and --depth bounds how
88
+ # far below the starting point that reaches, both through the shared
89
+ # select_dirs so the whole CLI answers "which directories?" one way. The
90
+ # deprecated --area keeps its old exact match beside them, because a
91
+ # deprecated flag that quietly widens is worse than one that is merely old.
92
+ # Nothing passed keeps the whole map.
93
+ def select_directories(entries, options)
94
+ areas = Array(options[:areas]).map { |area| fold_dir(area) }
95
+ scoped = !options[:dirs].nil? || !options[:depth].nil?
96
+ return [ entries, [] ] if areas.empty? && !scoped
97
+
98
+ all_dirs = entries.map { |entry| entry[:dir] }
99
+ wanted = scoped ? select_dirs(all_dirs, options) : []
100
+ chain = ancestor_dirs(options, all_dirs) - wanted
101
+ selected = entries.select do |entry|
102
+ areas.include?(fold(entry[:dir])) || wanted.include?(entry[:dir]) || chain.include?(entry[:dir])
103
+ end
104
+ [ selected, chain ]
67
105
  end
68
106
 
69
- def print_index_map(dir, entries, body)
107
+ def print_index_map(dir, entries, chain, body)
70
108
  noun = entries.size == 1 ? "directory" : "directories"
71
109
  @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
72
110
  entries.each do |entry|
73
111
  @out.puts
74
- @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
112
+ # ↑ marks a row the reader did not ask for: it is here to place the
113
+ # branch, not to answer about it.
114
+ up = chain.include?(entry[:dir]) ? "↑ " : ""
115
+ @out.puts " #{up}#{index_dir_label(entry)}#{index_dir_meta(entry)}"
75
116
  subdirs = entry[:subdirs]
76
117
  @out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
77
118
  if entry[:present]
@@ -83,7 +124,7 @@ module OKF
83
124
  end
84
125
 
85
126
  def index_dir_label(entry)
86
- base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
127
+ base = dir_label(entry[:dir], slash: true)
87
128
  entry[:present] ? base : "#{base} (no index.md)"
88
129
  end
89
130
 
@@ -107,13 +148,14 @@ module OKF
107
148
  end
108
149
  end
109
150
 
110
- def print_index_map_json(dir, entries, options)
111
- emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
151
+ def print_index_map_json(dir, entries, chain, options)
152
+ rows = entries.map { |entry| index_map_entry_json(entry, chain.include?(entry[:dir])) }
153
+ emit_list_json(dir, "directories", rows, options)
112
154
  end
113
155
 
114
- def index_map_entry_json(entry)
156
+ def index_map_entry_json(entry, ancestor)
115
157
  {
116
- "dir" => entry[:dir], "index_path" => entry[:index_path],
158
+ "dir" => entry[:dir], "ancestor" => ancestor, "index_path" => entry[:index_path],
117
159
  "present" => entry[:present], "synthesized" => entry[:synthesized],
118
160
  "count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
119
161
  "subdirs" => entry[:subdirs], "body" => entry[:body],
data/lib/okf/cli/loose.rb CHANGED
@@ -44,7 +44,7 @@ module OKF
44
44
  def loose_files(graph)
45
45
  titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
46
46
  graph.unlinked_ids
47
- .map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
47
+ .map { |id| { id: id, title: titles[id], dir: OKF.dir_of(id) } }
48
48
  .sort_by { |file| file[:id] }
49
49
  end
50
50
 
@@ -58,7 +58,7 @@ module OKF
58
58
  files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
59
59
  width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
60
60
  @out.puts
61
- @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
61
+ @out.puts " #{dir_label(folder, slash: true)}"
62
62
  group.each do |file|
63
63
  @out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
64
64
  end
@@ -10,7 +10,7 @@ module OKF
10
10
  class Registry < Command
11
11
  # The `registry` umbrella's subcommands — the dispatch, and the words a
12
12
  # flag-first invocation is checked against.
13
- SUBCOMMANDS = %w[set del list default rename].freeze
13
+ SUBCOMMANDS = %w[init set del list default rename group ungroup].freeze
14
14
 
15
15
  def self.id
16
16
  :registry
@@ -22,11 +22,14 @@ module OKF
22
22
 
23
23
  def self.help_rows
24
24
  [
25
+ [ "registry init", "create a project-local .okf-registry.json (nearest one wins)" ],
25
26
  [ "registry list [--json]", "list registered bundles (* marks the default)" ],
26
27
  [ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
27
- [ "registry del <dir|@slug>", "remove a bundle from the registry" ],
28
+ [ "registry del <dir|@slug>", "remove a bundle or group from the registry" ],
28
29
  [ "registry default <@slug>", "move a bundle to the front (the default)" ],
29
- [ "registry rename <@slug> <new>", "rename a registered bundle (<new> is a new name, not a ref)" ]
30
+ [ "registry rename <@slug> <new>", "rename a bundle or group (<new> is a new name, not a ref)" ],
31
+ [ "registry group <slug> <@member…>", "create a group, or add members (search/server can target @slug)" ],
32
+ [ "registry ungroup <slug> <@member…>", "remove members from a group (emptying it deletes it)" ]
30
33
  ]
31
34
  end
32
35
 
@@ -35,11 +38,14 @@ module OKF
35
38
 
36
39
  sub = argv.first
37
40
  case sub
41
+ when "init" then registry_init(argv.drop(1))
38
42
  when "set" then registry_set(argv.drop(1))
39
43
  when "del" then registry_del(argv.drop(1))
40
44
  when "list" then registry_list(argv.drop(1))
41
45
  when "default" then registry_default(argv.drop(1))
42
46
  when "rename" then registry_rename(argv.drop(1))
47
+ when "group" then registry_group(argv.drop(1))
48
+ when "ungroup" then registry_ungroup(argv.drop(1))
43
49
  else
44
50
  # A bare word that isn't a known subcommand is a typo (`registry remove x`
45
51
  # must not silently render the list and read as success).
@@ -61,6 +67,38 @@ module OKF
61
67
 
62
68
  private
63
69
 
70
+ # Create a project-local .okf-registry.json in the current directory. Once it
71
+ # exists, discovery finds it (walking up from cwd) and every registry op —
72
+ # and every @ref — resolves through it instead of the global $OKF_HOME one.
73
+ # init only writes the empty file; `registry set` fills it. Refuses to clobber
74
+ # an existing local registry, and notes a parent one it would shadow.
75
+ def registry_init(argv)
76
+ parser = OptionParser.new do |o|
77
+ o.banner = "Usage: okf registry init"
78
+ help_flag(o)
79
+ end
80
+ parser.parse!(argv)
81
+ no_extras?(argv) or return 2
82
+
83
+ target = File.join(Dir.pwd, OKF::Registry::LOCAL_FILE)
84
+ display = "./#{OKF::Registry::LOCAL_FILE}"
85
+ return usage_error("already initialized: #{display}") if File.exist?(target)
86
+
87
+ # The parent it would shadow, if any — a courtesy, not a barrier: nested
88
+ # registries resolve nearest-first, so creating one here is legitimate.
89
+ parent = OKF::Registry.discover(File.dirname(Dir.pwd))
90
+ @err.puts "note: a parent registry at #{parent} — the nearest one wins" if parent
91
+
92
+ OKF::Registry.new(target).save
93
+ @out.puts "initialized #{display}"
94
+ 0
95
+ rescue OptionParser::ParseError => e
96
+ @err.puts e.message
97
+ 2
98
+ rescue OKF::Error => e
99
+ usage_error(e.message)
100
+ end
101
+
64
102
  # Add a bundle to the persistent registry (so a later bare `okf server` finds
65
103
  # it), or update one already there. The entry is keyed by the bundle's path: a
66
104
  # path already registered refreshes its title in place, and --as renames it. A
@@ -78,7 +116,7 @@ module OKF
78
116
  # positional through `positional`, which does not check.
79
117
  dir = positional_dir(parser, argv) or return 2
80
118
 
81
- reg = OKF::Registry.load
119
+ reg = open_registry
82
120
  # Said before the upsert: after it, an update is indistinguishable from an
83
121
  # add, and "registered" for what was a rename reads as a duplicate entry.
84
122
  known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
@@ -104,7 +142,7 @@ module OKF
104
142
  slug = positional(parser, argv) or return 2
105
143
  no_extras?(argv) or return 2
106
144
 
107
- reg = OKF::Registry.load
145
+ reg = open_registry
108
146
  slug = registry_slug(slug, reg) or return 2
109
147
  removed = reg.remove(slug)
110
148
  return usage_error("no such bundle: #{slug}") unless removed
@@ -131,8 +169,11 @@ module OKF
131
169
  end
132
170
  no_extras?(argv) or return 2
133
171
 
134
- reg = OKF::Registry.load
135
- return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
172
+ reg = open_registry
173
+ if options[:json]
174
+ groups = { "groups" => reg.groups_listing.map { |row| stringify(row) } }
175
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options, groups)
176
+ end
136
177
 
137
178
  print_registry(reg)
138
179
  0
@@ -153,7 +194,7 @@ module OKF
153
194
  slug = positional(parser, argv) or return 2
154
195
  no_extras?(argv) or return 2
155
196
 
156
- reg = OKF::Registry.load
197
+ reg = open_registry
157
198
  slug = registry_slug(slug, reg) or return 2
158
199
  reg.default = slug
159
200
  @out.puts "default bundle → #{reg.default.slug} (now first)"
@@ -196,7 +237,7 @@ module OKF
196
237
  end
197
238
  no_extras?(argv) or return 2
198
239
 
199
- reg = OKF::Registry.load
240
+ reg = open_registry
200
241
  # The old name may be a ref; the new one is a name being minted, never one.
201
242
  old_slug = registry_slug(old_slug, reg) or return 2
202
243
  entry = reg.rename(old_slug, new_slug)
@@ -211,15 +252,111 @@ module OKF
211
252
  usage_error(e.message)
212
253
  end
213
254
 
255
+ # Create a group, or add members to one. Members are bundle or group slugs,
256
+ # bare or as @refs; the model normalizes, unions, checks each names something,
257
+ # and refuses a cycle. Only `search`/`server` can then target @slug.
258
+ def registry_group(argv)
259
+ parser = OptionParser.new do |o|
260
+ o.banner = "Usage: okf registry group <slug> <@member…>"
261
+ help_flag(o)
262
+ end
263
+ parser.parse!(argv)
264
+ slug = argv.shift
265
+ if slug.nil? || argv.empty?
266
+ @err.puts parser.banner
267
+ return 2
268
+ end
269
+
270
+ reg = open_registry
271
+ group = reg.set_group(slug, argv)
272
+ count = reg.expand(group.slug).size
273
+ @out.puts "grouped #{group.slug} → #{group.members.map { |m| "@#{m}" }.join(", ")} " \
274
+ "(#{count} #{pluralize(count, "bundle")})"
275
+ 0
276
+ rescue OptionParser::ParseError => e
277
+ @err.puts e.message
278
+ 2
279
+ rescue OKF::Error => e
280
+ usage_error(e.message)
281
+ end
282
+
283
+ # Remove members from a group. Emptying it deletes the group — an empty group
284
+ # resolves to nothing, so it is not worth keeping.
285
+ def registry_ungroup(argv)
286
+ parser = OptionParser.new do |o|
287
+ o.banner = "Usage: okf registry ungroup <slug> <@member…>"
288
+ help_flag(o)
289
+ end
290
+ parser.parse!(argv)
291
+ slug = argv.shift
292
+ if slug.nil? || argv.empty?
293
+ @err.puts parser.banner
294
+ return 2
295
+ end
296
+
297
+ reg = open_registry
298
+ removed, emptied = reg.unset_group_members(slug, argv)
299
+ name = OKF::Registry.normalize(slug)
300
+ if emptied
301
+ @out.puts "removed empty group #{name}"
302
+ elsif removed.empty?
303
+ @out.puts "no members removed from #{name} (none of #{argv.join(", ")} were in it)"
304
+ else
305
+ @out.puts "ungrouped #{removed.map { |m| "@#{m}" }.join(", ")} from #{name}"
306
+ end
307
+ 0
308
+ rescue OptionParser::ParseError => e
309
+ @err.puts e.message
310
+ 2
311
+ rescue OKF::Error => e
312
+ usage_error(e.message)
313
+ end
314
+
214
315
  def print_registry(reg)
215
- return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
316
+ # A header only when a project-local registry is in play — the case where
317
+ # "which registry am I looking at?" is a real question. The global $OKF_HOME
318
+ # one is the default, so it stays headerless (and the JSON envelope names
319
+ # the file for a script either way).
320
+ @out.puts "registry: #{registry_display(reg)}" if local_registry?(reg)
321
+ groups = reg.groups_listing
322
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty? && groups.empty?
216
323
 
217
324
  rows = reg.listing
218
- width = rows.map { |row| row[:slug].length }.max
219
- rows.each do |row|
220
- marker = row[:default] ? "*" : " "
221
- missing = row[:missing] ? " (missing)" : ""
222
- @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
325
+ unless rows.empty?
326
+ width = rows.map { |row| row[:slug].length }.max
327
+ rows.each do |row|
328
+ marker = row[:default] ? "*" : " "
329
+ missing = row[:missing] ? " (missing)" : ""
330
+ @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
331
+ end
332
+ end
333
+ print_groups(groups, rows) unless groups.empty?
334
+ end
335
+
336
+ # Whether this registry was discovered as a project-local file rather than
337
+ # read from $OKF_HOME — the basename settles it (only a local one is named
338
+ # .okf-registry.json).
339
+ def local_registry?(reg)
340
+ File.basename(reg.path) == OKF::Registry::LOCAL_FILE
341
+ end
342
+
343
+ # How to name the local registry in the header: `./` when it sits in cwd
344
+ # (the common case, a bare `init` here), its absolute path when discovery
345
+ # walked up to an ancestor.
346
+ def registry_display(reg)
347
+ File.dirname(reg.path) == Dir.pwd ? "./#{OKF::Registry::LOCAL_FILE}" : reg.path
348
+ end
349
+
350
+ # The groups section under the bundle listing: one row per group, its members
351
+ # and how many bundles it resolves to (a hand-edited cycle shows `(cycle)`).
352
+ def print_groups(groups, rows)
353
+ @out.puts "" unless rows.empty?
354
+ @out.puts "groups:"
355
+ width = groups.map { |group| group[:slug].length }.max
356
+ groups.each do |group|
357
+ members = group[:members].map { |m| "@#{m}" }.join(", ")
358
+ count = group[:resolved].nil? ? "cycle" : "#{group[:resolved]} #{pluralize(group[:resolved], "bundle")}"
359
+ @out.puts " #{group[:slug].ljust(width)} #{members} (#{count})"
223
360
  end
224
361
  end
225
362
  end
@@ -23,20 +23,21 @@ module OKF
23
23
  def call(argv)
24
24
  require "okf/render/graph"
25
25
 
26
- options = { output: nil, title: nil, link: nil, layout: "cose" }
26
+ options = { output: nil, title: nil, link: nil, layout: "cose", map: false }
27
27
  parser = OptionParser.new do |o|
28
28
  o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
29
29
  o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
30
30
  o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
31
31
  o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
32
32
  o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
33
+ o.on("--map", "open in the Map view: concepts boxed by directory, links on selection") { options[:map] = true }
33
34
  help_flag(o)
34
35
  end
35
36
  dir = positional_dir(parser, argv) or return 2
36
37
 
37
38
  folder = OKF::Bundle::Folder.load(dir)
38
39
  report_skipped(folder)
39
- html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
40
+ html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout], map: options[:map])
40
41
  if options[:output]
41
42
  # A bad -o path (a missing directory, a permission denial) is a bad
42
43
  # *argument*: exit 2 with the reason, never a backtrace and an exit code
@@ -34,7 +34,7 @@ module OKF
34
34
  def call(argv)
35
35
  options = { json: false, regexp: false, fuzzy: false, engine: nil }
36
36
  parser = OptionParser.new do |o|
37
- o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
37
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--dir D] [--tag T] [--json]"
38
38
  search_engine_note(o)
39
39
  json_flags(o, options, "emit the matches as JSON")
40
40
  projection_flags(o, options)
@@ -158,8 +158,17 @@ module OKF
158
158
  pairs
159
159
  end
160
160
 
161
- # One @ref as a single-element [[slug, dir]], or nil after reporting.
161
+ # One @ref as [[slug, dir], …]: a group fans out to its readable member
162
+ # bundles, a plain @slug is the single-element pair it always was. nil after
163
+ # reporting. `ref_targets` dedupes across refs, and #expand within a group, so
164
+ # `@backend @okf` (okf ∈ backend) still searches okf once.
162
165
  def ref_pair(ref)
166
+ registry = load_registry
167
+ return nil unless registry
168
+
169
+ slug = OKF::Registry.normalize(ref[1..-1])
170
+ return group_pairs(registry, slug) if !slug.empty? && registry.group?(slug)
171
+
163
172
  path = resolve_registered(ref)
164
173
  unless path
165
174
  # Only an unknown slug is plausibly a mistyped term — a broken registry
@@ -170,6 +179,28 @@ module OKF
170
179
  [ [ ref_slugs[path], path ] ]
171
180
  end
172
181
 
182
+ # A group's readable member bundles as [slug, dir] pairs, skipping vanished
183
+ # ones with a note (as `@all` does) and labelling each leaf by its own slug.
184
+ # nil (reported) when nothing readable is left, or on a hand-edited cycle.
185
+ def group_pairs(registry, slug)
186
+ pairs = []
187
+ registry.expand(slug).each do |entry|
188
+ if File.directory?(entry.path)
189
+ ref_slugs[entry.path] = entry.slug
190
+ pairs << [ entry.slug, entry.path ]
191
+ else
192
+ skip_registered(entry)
193
+ end
194
+ end
195
+ return pairs unless pairs.empty?
196
+
197
+ @err.puts "error: @#{slug} resolves to no readable bundle (okf registry list)"
198
+ nil
199
+ rescue OKF::Error => e
200
+ @err.puts "error: #{e.message}"
201
+ nil
202
+ end
203
+
173
204
  # Search every bundle at once and merge the rankings, each row labeled with
174
205
  # its bundle's slug. The bundles go in as *one* corpus rather than one search
175
206
  # each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
@@ -24,7 +24,7 @@ module OKF
24
24
  require "okf/server/app"
25
25
  require "rack/deflater"
26
26
 
27
- options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false }
27
+ options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false, map: false }
28
28
  parser = OptionParser.new do |o|
29
29
  o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
30
30
  o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
@@ -32,10 +32,14 @@ module OKF
32
32
  o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
33
33
  o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
34
34
  o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
35
+ o.on("--map", "open in the Map view: concepts boxed by directory, links on selection") { options[:map] = true }
35
36
  o.on("--read-only", "serve the bundles list without its registry controls") { options[:read_only] = true }
36
37
  help_flag(o)
37
38
  end
38
- dirs = positional_dirs(parser, argv) or return 2
39
+ # expand_groups: `okf server @backend` fans a group out to its member
40
+ # bundles (single-bundle verbs reject a group; server is one of the two that
41
+ # take a set).
42
+ dirs = positional_dirs(parser, argv, expand_groups: true) or return 2
39
43
 
40
44
  # A flag that will have no effect in this mode gets a note, not silence.
41
45
  @err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
@@ -59,10 +63,16 @@ module OKF
59
63
  # Build the single-bundle Rack app and hand it to the runner (WEBrick by
60
64
  # default, injected so tests drive this without a socket).
61
65
  def run_server(folder, options)
62
- app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
66
+ # search_endpoint is named here rather than defaulted in App: the page
67
+ # resolves it against the URL the reader is on, and this is the layer that
68
+ # knows the app is mounted at the root. An embedding host mounting App
69
+ # elsewhere passes its own.
70
+ app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
71
+ layout: options[:layout], search_endpoint: "search", map: options[:map])
63
72
  # minimal: the banner wants a count, not bodies — and Folder#graph is not
64
73
  # memoized, so a full build here parses every concept a second time (the
65
74
  # App builds its own) purely to print one number.
75
+ app.warm_search
66
76
  count = folder.graph(minimal: true).nodes.size
67
77
  @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
68
78
  serve(app, options)
@@ -80,7 +90,7 @@ module OKF
80
90
  if dirs.empty?
81
91
  # A malformed registry raises OKF::Error, which `server` rescues into a
82
92
  # usage error — no guarded load needed on this path.
83
- reg = OKF::Registry.load
93
+ reg = open_registry
84
94
  # The hub's own loader, so the set it rebuilds after a browser-side
85
95
  # write is built exactly the way this one was.
86
96
  bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
@@ -91,7 +101,8 @@ module OKF
91
101
  # The hub keeps the registry so its /b/ manager can report on entries it
92
102
  # could not host — a folder deleted out from under one is the question
93
103
  # "where did my bundle go?", and only the registry can answer it.
94
- hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
104
+ hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options), map: options[:map])
105
+ hub.warm_search
95
106
  concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
96
107
  @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
97
108
  "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"