okf 1.11.0 → 1.13.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.
@@ -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
@@ -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
@@ -178,16 +209,30 @@ module OKF
178
209
  #
179
210
  # Filters stay per-bundle — they are per-folder questions — so they apply to
180
211
  # the merged rows by (slug, id) afterwards.
212
+ #
213
+ # The one thing that is *not* a per-folder question is what `--dir root`
214
+ # means. The alias yields to a directory that really carries the name, so
215
+ # resolving it inside this loop made one flag mean two things in one
216
+ # ranking: the `root/` subtree where a bundle has one, the bundle root
217
+ # where it does not, merged with nothing in the output saying so. The
218
+ # served set answers it once, and a bundle without the directory then
219
+ # matches nothing — which is what `--dir` already does everywhere for a
220
+ # directory a bundle lacks.
181
221
  def multi_search(pairs, terms, options)
182
- bundles = []
183
- keeps = {}
184
- total = 0
185
- pairs.each do |slug, dir|
222
+ folders = pairs.map do |slug, dir|
186
223
  folder = OKF::Bundle::Folder.load(dir)
187
224
  report_skipped(folder)
188
- total += folder.bundle.concepts.size
189
- bundles << [ slug, folder.bundle ]
190
- keep = filter_ids(folder, options)
225
+ [ slug, folder ]
226
+ end
227
+ total = folders.reduce(0) { |sum, (_, folder)| sum + folder.bundle.concepts.size }
228
+ bundles = folders.map { |slug, folder| [ slug, folder.bundle ] }
229
+ # The served set's directories, only when a flag will consult them —
230
+ # the alias is resolved once across the whole run (see filter_ids),
231
+ # and an unfiltered search never pays the walk.
232
+ dirs = options[:dir] || options[:area] ? folders.flat_map { |_, folder| folder.directories }.uniq : nil
233
+ keeps = {}
234
+ folders.each do |slug, folder|
235
+ keep = filter_ids(folder, options, dirs)
191
236
  keeps[slug] = keep unless keep.nil?
192
237
  end
193
238
  rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
@@ -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])
@@ -64,7 +68,7 @@ module OKF
64
68
  # knows the app is mounted at the root. An embedding host mounting App
65
69
  # elsewhere passes its own.
66
70
  app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
67
- layout: options[:layout], search_endpoint: "search")
71
+ layout: options[:layout], search_endpoint: "search", map: options[:map])
68
72
  # minimal: the banner wants a count, not bodies — and Folder#graph is not
69
73
  # memoized, so a full build here parses every concept a second time (the
70
74
  # App builds its own) purely to print one number.
@@ -86,7 +90,7 @@ module OKF
86
90
  if dirs.empty?
87
91
  # A malformed registry raises OKF::Error, which `server` rescues into a
88
92
  # usage error — no guarded load needed on this path.
89
- reg = OKF::Registry.load
93
+ reg = open_registry
90
94
  # The hub's own loader, so the set it rebuilds after a browser-side
91
95
  # write is built exactly the way this one was.
92
96
  bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
@@ -97,7 +101,7 @@ module OKF
97
101
  # The hub keeps the registry so its /b/ manager can report on entries it
98
102
  # could not host — a folder deleted out from under one is the question
99
103
  # "where did my bundle go?", and only the registry can answer it.
100
- 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])
101
105
  hub.warm_search
102
106
  concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
103
107
  @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
data/lib/okf/cli/stats.rb CHANGED
@@ -41,18 +41,18 @@ module OKF
41
41
  graph = folder.graph(minimal: true)
42
42
  entries = folder.catalog
43
43
  by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
44
- by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
44
+ by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
45
45
  by_dir = directory_counts(folder)
46
46
  {
47
47
  concepts: entries.size,
48
48
  dirs: by_dir.size,
49
- areas: by_area.size,
49
+ top_dirs: by_top_dir.size,
50
50
  types: by_type.size,
51
51
  cross_links: graph.edges.size,
52
52
  tags: graph.tag_index.size,
53
53
  by_type: by_type,
54
54
  by_dir: by_dir,
55
- by_area: by_area
55
+ by_top_dir: by_top_dir
56
56
  }
57
57
  end
58
58
 
@@ -83,9 +83,9 @@ module OKF
83
83
  @out.puts " cross-links #{stats[:cross_links]}"
84
84
  @out.puts " distinct tags #{stats[:tags]}"
85
85
  print_stat_breakdown("By type", stats[:by_type])
86
- # One grouping word in the human view: `by_area` stays in --json for the
87
- # deprecation window, but a screen that printed both would be teaching the
88
- # vocabulary the rest of this change is retiring.
86
+ # One grouping word in the human view: `by_top_dir` stays in --json (the
87
+ # first-segment rollup) but a screen that printed both it and `by_dir`
88
+ # would double up on one idea, so the human view shows the full-path cut.
89
89
  print_stat_breakdown("By dir", stats[:by_dir]) { |label| dir_label(label) }
90
90
  end
91
91
 
@@ -101,9 +101,9 @@ module OKF
101
101
 
102
102
  def print_stats_json(dir, stats)
103
103
  emit_json(bundle_head(dir).merge(
104
- "concepts" => stats[:concepts], "dirs" => stats[:dirs], "areas" => stats[:areas],
104
+ "concepts" => stats[:concepts], "dirs" => stats[:dirs], "top_dirs" => stats[:top_dirs],
105
105
  "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
106
- "by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_area" => stats[:by_area]
106
+ "by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_top_dir" => stats[:by_top_dir]
107
107
  ))
108
108
  end
109
109
  end
data/lib/okf/cli/tags.rb CHANGED
@@ -61,7 +61,7 @@ module OKF
61
61
  # makes a tag's spread — local to one group, or cutting across several —
62
62
  # readable without cross-referencing the groups by hand.
63
63
  def tag_groups(tag_index, folder, options)
64
- by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
64
+ by_id = filter_entries(folder.catalog, options, dir_scope(folder, options)).map { |entry| [ entry[:id], entry ] }.to_h
65
65
  groups = {}
66
66
  totals = Hash.new(0)
67
67
  tag_index.each do |tag, ids|
@@ -93,7 +93,7 @@ module OKF
93
93
  case dim
94
94
  when :type then entry_type(entry)
95
95
  when :dir then entry[:dir]
96
- else entry[:area]
96
+ else entry[:top_dir]
97
97
  end
98
98
  end
99
99
 
data/lib/okf/cli.rb CHANGED
@@ -32,14 +32,14 @@ module OKF
32
32
  # Declared in emission order, so the "available:" list a typo prints reads
33
33
  # the same as the rows themselves.
34
34
  ROW_FIELDS = {
35
- "matches" => %w[id title type dir area tags matched score snippet],
35
+ "matches" => %w[id title type dir top_dir tags matched score snippet],
36
36
  # Registry mode labels every row with the bundle it came from; a plain-dir
37
37
  # search has one bundle and no slug to carry. Two shapes, because the typo
38
38
  # guard checks against the *declared* one — a single shape covering both
39
39
  # would let `--fields slug` pass on a search whose rows have none, and hand
40
40
  # back an empty object per match under a count that says otherwise.
41
- "matches_by_ref" => %w[slug id title type dir area tags matched score snippet],
42
- "concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
41
+ "matches_by_ref" => %w[slug id title type dir top_dir tags matched score snippet],
42
+ "concepts" => %w[id title type description tags timestamp status backlog_ref dir top_dir links_out links_in],
43
43
  "files" => %w[path id dir type title description],
44
44
  "directories" => %w[dir ancestor index_path present synthesized count types tags subdirs body listing],
45
45
  "dirs" => %w[dir ancestor count subtree subdirs],
@@ -14,6 +14,13 @@ module OKF
14
14
  #
15
15
  # NOTE: this class is named File, which shadows Ruby's File inside the
16
16
  # OKF::Concept namespace — every filesystem call here uses ::File explicitly.
17
+ #
18
+ # absolute_path guards the *name* lexically (Path.join_under!), which is all a
19
+ # write needs — the file may not exist yet. A read has more to prove: the file
20
+ # is on disk now, so it may be a symlink whose name is inside the root but
21
+ # whose target is not, and File.expand_path does not resolve links. So #read
22
+ # goes through SafeRead, which realpath-resolves and refuses a target outside
23
+ # the root, closing the same escape Bundle::Reader closes on the bulk read.
17
24
  class File
18
25
  attr_reader :root, :path, :concept
19
26
 
@@ -53,11 +60,19 @@ module OKF
53
60
  end
54
61
 
55
62
  def reload
56
- content = ::File.read(absolute_path, encoding: "UTF-8")
57
- frontmatter, body = Markdown::Frontmatter.parse(content)
63
+ frontmatter, body = Markdown::Frontmatter.parse(read)
58
64
  @concept = Concept.new(path: @path, frontmatter: frontmatter, body: body)
59
65
  self
60
66
  end
67
+
68
+ # The file's own bytes, refused if the resolved target escapes the root by
69
+ # symlink. This is the guarded read a caller that wants the raw markdown
70
+ # (not a re-serialized `concept.to_markdown`) must use instead of reading
71
+ # #absolute_path itself — that path guards the *name* lexically, which a
72
+ # write needs but a read does not, since the file exists and may be a link.
73
+ def read
74
+ SafeRead.read!(@root, absolute_path)
75
+ end
61
76
  end
62
77
  end
63
78
  end
data/lib/okf/path.rb CHANGED
@@ -24,11 +24,25 @@ module OKF
24
24
  relative = normalize_relative!(path)
25
25
  expanded_root = File.expand_path(root.to_s)
26
26
  expanded_path = File.expand_path(File.join(expanded_root, relative))
27
- unless expanded_path == expanded_root || expanded_path.start_with?("#{expanded_root}#{File::SEPARATOR}")
28
- raise Error, "path escapes bundle root"
29
- end
27
+ raise Error, "path escapes bundle root" unless under?(expanded_root, expanded_path)
30
28
 
31
29
  expanded_path
32
30
  end
31
+
32
+ # Is +path+ the root itself or a descendant of it? Pure string containment
33
+ # (no disk access), so it works on both lexical paths (File.expand_path) and
34
+ # symlink-resolved ones (File.realpath) — the shell resolves, this decides.
35
+ # Both arguments must already be absolute and normalized the same way.
36
+ #
37
+ # The prefix guards against a sibling passing as a child ("/foo" is not under
38
+ # "/food"), and reuses the root itself as the prefix when the root already
39
+ # ends in the separator — i.e. the filesystem root "/", whose children would
40
+ # otherwise be tested against "//" and every one rejected.
41
+ def self.under?(root, path)
42
+ return true if path == root
43
+
44
+ prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
45
+ path.start_with?(prefix)
46
+ end
33
47
  end
34
48
  end