okf 1.10.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.
@@ -64,13 +64,28 @@ module OKF
64
64
  end
65
65
  end
66
66
 
67
- # Human-readable "parent/dir" name — the default HTML title.
68
- # The bundle's display label, "parent/dir" — path arithmetic, no disk. It
69
- # is a class method so a caller that only wants the label (the registry
70
- # naming an entry) can have it without a Reader.read of every file.
67
+ # The bundle's display label — path arithmetic, no disk. It is a class
68
+ # method so a caller that only wants the label (the registry naming an
69
+ # entry) can have it without a Reader.read of every file.
70
+ #
71
+ # "parent/dir", because a bundle directory's own name is rarely unique
72
+ # enough to name it by — except when that name is `.okf`, the conventional
73
+ # container, and then the parent carries the whole answer on its own. A
74
+ # registry of eight projects is eight rows reading `…/.okf`, which is the
75
+ # one word that tells none of them apart; `repo/.okf` is read as "repo" by
76
+ # anyone looking at it anyway.
77
+ #
78
+ # A directory with no parent to borrow (`/.okf`) keeps its own name: the
79
+ # parent is `/`, which names nothing. That case used to compose into
80
+ # `//.okf`.
71
81
  def self.label(root)
72
82
  pathname = Pathname.new(root)
73
- "#{pathname.parent.basename}/#{pathname.basename}"
83
+ parent = pathname.parent.basename.to_s
84
+ base = pathname.basename.to_s
85
+ return base if [ "/", "." ].include?(parent)
86
+ return parent if base == ".okf"
87
+
88
+ "#{parent}/#{base}"
74
89
  end
75
90
 
76
91
  def name
@@ -324,7 +324,7 @@ module OKF
324
324
  end
325
325
 
326
326
  def concepts_in(dir)
327
- @concepts.select { |concept| File.dirname("#{concept.id}.md") == dir }
327
+ @concepts.select { |concept| OKF.dir_of(concept.id) == dir }
328
328
  end
329
329
 
330
330
  def index_path_for(dir)
@@ -12,8 +12,9 @@ module OKF
12
12
  #
13
13
  # Matching is by *token*: a term matches a whole word or a word it prefixes
14
14
  # ("dedup" reaches "deduplication"), and `fuzzy:` opts into typo tolerance.
15
- # The index is built per call — see .okf/capabilities/search.md for why that
16
- # ceiling stands and what lifts it.
15
+ # The index builds per call unless the caller holds a Search::Corpus, which
16
+ # builds it once through .prepare and reuses it — the server does, the
17
+ # one-shot CLI cannot. See .okf/capabilities/search.md.
17
18
  module Index
18
19
  CAPABILITIES = %i[fuzzy prefix].freeze
19
20
 
@@ -37,9 +38,18 @@ module OKF
37
38
  # can neither match nor be credited. The hit's `terms` are MiniFTS's
38
39
  # matched *document* terms — already lowercased, and present in the text
39
40
  # verbatim even when the query only prefixed them.
40
- def call(documents, terms, fields:, fuzzy: false, **_options)
41
+ # The expensive half, separated so a long-lived caller can hold it. A
42
+ # Corpus calls this once; a one-shot call still goes through #call and
43
+ # builds inline, which is why this is an addition and not a new
44
+ # requirement on the engine contract.
45
+ def prepare(documents)
41
46
  index = MiniFTS.new(fields: FIELDS, id_field: "key")
42
47
  index.add_all(documents)
48
+ index
49
+ end
50
+
51
+ def call(documents, terms, fields:, fuzzy: false, prepared: nil, **_options)
52
+ index = prepared || prepare(documents)
43
53
 
44
54
  options = { combine_with: "AND", prefix: true, boost: WEIGHTS, fields: fields }
45
55
  options[:fuzzy] = FUZZY_DISTANCE if fuzzy
@@ -186,12 +186,89 @@ module OKF
186
186
  new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
187
187
  end
188
188
 
189
+ # The searchable text of one concept, by field. Here rather than on an
190
+ # instance because a Corpus builds documents with no query in hand.
191
+ def self.field_texts(concept)
192
+ {
193
+ "id" => concept.id,
194
+ "title" => concept.title.to_s,
195
+ "type" => concept.type.to_s,
196
+ "description" => concept.description.to_s,
197
+ "tags" => Array(concept.tags).join(" "),
198
+ "body" => concept.body
199
+ }
200
+ end
201
+
202
+ # A corpus prepared once and queried many times: the documents, the key →
203
+ # concept map, and each engine's built index.
204
+ #
205
+ # This is the asymmetry the engine choice was always argued from. A CLI
206
+ # process loads a bundle, asks one question and exits, so an index build has
207
+ # exactly one query to amortize over and the scan wins. A server is the
208
+ # other case — the build is ~95% of the index path's cost, and paying it per
209
+ # request made every search re-read the whole corpus. Held once, it is paid
210
+ # once.
211
+ #
212
+ # Pure: it holds concepts, never disk. Which is also the cost — the corpus
213
+ # is a snapshot, so a body edited after it was built is searchable only
214
+ # after the holder drops it. That matches the graph, which is memoized the
215
+ # same way and for the same reason.
216
+ class Corpus
217
+ attr_reader :bundles, :documents, :sources
218
+
219
+ def initialize(bundles)
220
+ @bundles = bundles
221
+ @documents = []
222
+ @sources = {}
223
+ @indexes = {}
224
+ bundles.each do |slug, bundle|
225
+ bundle.concepts.each do |concept|
226
+ key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
227
+ @sources[key] = [ slug, concept ]
228
+ @documents << Search.field_texts(concept).merge("key" => key)
229
+ end
230
+ end
231
+ end
232
+
233
+ # nil for an engine with nothing to prebuild — the scan reads raw text and
234
+ # has no index to hold — so the option only ever reaches one that declared
235
+ # it can. Memoized per engine id: two engines over one corpus is legal.
236
+ def index_for(engine)
237
+ return nil unless engine.respond_to?(:prepare)
238
+
239
+ @indexes[engine.id] ||= engine.prepare(@documents)
240
+ end
241
+ end
242
+
243
+ # Prepare a corpus for a long-lived caller. Hand the result back to .with
244
+ # for every query.
245
+ #
246
+ # `engine:` builds that engine's index *now* rather than on the first query.
247
+ # Without it the corpus holds only the documents, and the expensive half —
248
+ # the index — is still built lazily, which puts the whole cost on whoever
249
+ # searches first. A server knows its engine at boot, so it can pay there.
250
+ def self.prepare(bundles, engine: nil, engines: nil)
251
+ corpus = Corpus.new(bundles)
252
+ return corpus if OKF.blank?(engine)
253
+
254
+ corpus.index_for(engine_for([], engines: engines || self.engines, name: engine))
255
+ corpus
256
+ end
257
+
258
+ # Query a prepared corpus. Same rows as .across, without rebuilding what the
259
+ # corpus already holds.
260
+ def self.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
261
+ new(corpus.bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy,
262
+ engine: engine, engines: engines, corpus: corpus).results
263
+ end
264
+
189
265
  # Raises RegexpError on an invalid pattern with `regexp: true`, and
190
266
  # UnsupportedQuery when no engine can answer — the caller owns turning
191
267
  # either into a usage error. `engines:` overrides the registry, which is how
192
268
  # the "nothing qualifies" path stays reachable without an addon installed.
193
- def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
269
+ def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil)
194
270
  @bundles = bundles
271
+ @corpus = corpus
195
272
  @terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
196
273
  @fields = fields.nil? || fields.empty? ? FIELDS : fields
197
274
  @regexp = regexp
@@ -202,7 +279,7 @@ module OKF
202
279
  end
203
280
 
204
281
  # Ranked match rows, catalog-style identity plus where the terms hit:
205
- # [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
282
+ # [{ slug:, id:, title:, type:, dir:, area:, tags:, matched: [field, …], score:, snippet: }, …]
206
283
  # ordered by score descending, then slug, then id. `slug` is present only
207
284
  # when searching across bundles. No terms means no matches.
208
285
  def results
@@ -242,6 +319,7 @@ module OKF
242
319
  # what it can act on, so there is nothing left for it to ignore.
243
320
  def engine_options(chosen)
244
321
  options = { fields: @fields }
322
+ options[:prepared] = @corpus.index_for(chosen) if @corpus
245
323
  ROUTABLE.each do |capability|
246
324
  options[capability] = requested[capability] if chosen.capabilities.include?(capability)
247
325
  end
@@ -255,6 +333,13 @@ module OKF
255
333
  # Every concept as an indexable document, keyed uniquely across bundles.
256
334
  # @sources keeps the way back, so the index stores no fields of its own.
257
335
  def documents
336
+ # The corpus already walked every concept and kept the map that turns a
337
+ # hit back into a row; taking its sources is what makes that reuse whole.
338
+ if @corpus
339
+ @sources = @corpus.sources
340
+ return @corpus.documents
341
+ end
342
+
258
343
  docs = []
259
344
  @bundles.each do |slug, bundle|
260
345
  bundle.concepts.each do |concept|
@@ -269,14 +354,7 @@ module OKF
269
354
  # { field => original-case text } for every searchable field. The index reads
270
355
  # all of them; `fields:` narrows the search, not the document.
271
356
  def field_texts(concept)
272
- {
273
- "id" => concept.id,
274
- "title" => concept.title.to_s,
275
- "type" => concept.type.to_s,
276
- "description" => concept.description.to_s,
277
- "tags" => Array(concept.tags).join(" "),
278
- "body" => concept.body
279
- }
357
+ Search.field_texts(concept)
280
358
  end
281
359
 
282
360
  # `slug` leads the row so a merged result reads bundle-first, and drops
@@ -288,6 +366,7 @@ module OKF
288
366
  id: concept.id,
289
367
  title: (concept.title || concept.id).to_s,
290
368
  type: concept.type.to_s,
369
+ dir: OKF.dir_of(concept.id),
291
370
  area: area_of(concept.id),
292
371
  tags: Array(concept.tags).map(&:to_s),
293
372
  matched: matched,
@@ -342,7 +421,8 @@ module OKF
342
421
  end
343
422
  end
344
423
 
345
- # A concept's top-level area, mirroring the catalog's definition.
424
+ # A concept's top-level area, mirroring the catalog's definition. Deprecated
425
+ # in favour of OKF.dir_of, which keeps the levels this one throws away.
346
426
  def area_of(id)
347
427
  id.include?("/") ? id.split("/").first : "(root)"
348
428
  end
data/lib/okf/bundle.rb CHANGED
@@ -118,7 +118,7 @@ module OKF
118
118
  timestamp: concept.timestamp&.to_s,
119
119
  status: concept.frontmatter["status"]&.to_s,
120
120
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
- dir: File.dirname("#{id}.md"),
121
+ dir: OKF.dir_of(id),
122
122
  area: area_of(id),
123
123
  links_out: out_degree[id],
124
124
  links_in: in_degree[id]
@@ -22,7 +22,7 @@ module OKF
22
22
  def call(argv)
23
23
  options = { json: false }
24
24
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
25
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
26
26
  json_flags(o, options, "emit the catalog as JSON")
27
27
  projection_flags(o, options)
28
28
  filter_flags(o, options, :type, :area, :tag)
@@ -118,13 +118,15 @@ module OKF
118
118
  # reproduced on the CLI so an agent can read the same knowledge without one.
119
119
  # Each prints a scannable human view by default and machine JSON with --json;
120
120
  # all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
121
- # and (with `types`) narrow through the same --type/--area/--tag filters the
121
+ # and (with `types`) narrow through the same --type/--dir/--tag filters the
122
122
  # server UI offers, so browser and CLI can answer the same questions.
123
123
  #
124
- # ── their shared --type/--area/--tag narrowing ──
124
+ # ── their shared --type/--dir/--tag narrowing ──
125
125
  # Each view takes the filters orthogonal to it (tags can't filter by tag).
126
- # Matching is case-insensitive and exact; a concept at the bundle root lives in
127
- # the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
126
+ # Matching is case-insensitive; --type and --tag are exact, --dir is a prefix
127
+ # over the whole path (see #under_dir?). The bundle root is `.`, spellable
128
+ # `root` so no shell quoting is needed. --area is --dir's deprecated
129
+ # predecessor and keeps its old first-segment-only behavior.
128
130
 
129
131
  # The shared back half of `tags` and `types`: load, narrow, print.
130
132
  def print_inverted_index(dir, label, key, plural, options)
@@ -177,7 +179,14 @@ module OKF
177
179
 
178
180
  def filter_flags(parser, options, *keys)
179
181
  parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
180
- parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
182
+ if keys.include?(:area)
183
+ parser.on("--dir PATH", "only concepts in this directory or below it",
184
+ "(`root` — or `.` — for the bundle root)") { |v| options[:dir] = v }
185
+ parser.on("--area AREA", "deprecated: use --dir (matches the first path segment only)") do |v|
186
+ options[:area] = v
187
+ deprecated("--area", "--dir")
188
+ end
189
+ end
181
190
  parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
182
191
  end
183
192
 
@@ -185,19 +194,172 @@ module OKF
185
194
  entries.select do |entry|
186
195
  (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
187
196
  (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
197
+ (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
188
198
  (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
189
199
  end
190
200
  end
191
201
 
202
+ # The one rule --dir is built on: a dir names itself and everything beneath
203
+ # it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
204
+ # needs no special case at all — nothing starts with "./", so the root
205
+ # selects only what lives directly in it.
206
+ def under_dir?(entry_dir, wanted)
207
+ entry = fold(entry_dir)
208
+ path = fold_dir(wanted)
209
+ entry == path || entry.start_with?("#{path}/")
210
+ end
211
+
192
212
  def fold(value)
193
213
  value.to_s.downcase
194
214
  end
195
215
 
196
216
  def fold_area(value)
197
- folded = fold(value)
217
+ folded = trim_slash(fold(value))
198
218
  folded == "root" ? "(root)" : folded
199
219
  end
200
220
 
221
+ # `.` is the stored spelling of the root everywhere; `root` is the one a
222
+ # shell needs no quoting for, and the only reason the two exist.
223
+ def fold_dir(value)
224
+ folded = trim_slash(fold(value))
225
+ folded.empty? || folded == "root" ? "." : folded
226
+ end
227
+
228
+ # The human views print a directory with the slash that says it is one —
229
+ # `tables/`, `docs/api/` — so the flag has to accept the label the CLI
230
+ # itself just printed. Without this, pasting a row back into --dir matched
231
+ # nothing and exited 0: an empty answer that reads like a real one.
232
+ def trim_slash(value)
233
+ value.sub(%r{/+\z}, "")
234
+ end
235
+
236
+ # The inverse of fold_dir: `.` is the stored spelling of the root and
237
+ # "(root)" the human one, and every grouped view keeps that split so a
238
+ # table and its --json never disagree about which spelling is the data.
239
+ # `slash:` adds the trailing slash the listing views use to say "directory"
240
+ # — the same one fold_dir now accepts back.
241
+ def dir_label(dir, slash: false)
242
+ return "(root)" if [ ".", "(root)" ].include?(dir)
243
+
244
+ slash ? "#{dir}/" : dir
245
+ end
246
+
247
+ # How many path segments deep a directory sits. The root is 0.
248
+ def dir_depth(dir)
249
+ dir == "." ? 0 : dir.count("/") + 1
250
+ end
251
+
252
+ # --depth N: how many directory levels below the *starting point* to keep,
253
+ # where the starting point is each --dir when one is given and the bundle
254
+ # root otherwise. Relative rather than absolute on purpose: `--dir a/b
255
+ # --depth 1` reads "a/b and one level under it" without the caller first
256
+ # working out how deep a/b already is — and the two flags then compose the
257
+ # way a reader descending a tree actually moves.
258
+ def depth_flag(parser, options)
259
+ parser.on("--depth N", "keep only this many directory levels below the",
260
+ "starting point (--dir when given, else the bundle root)") { |v| options[:depth] = v }
261
+ end
262
+
263
+ # Checked here rather than with OptionParser's Integer coercion, which
264
+ # accepts "-1" and "0x2" and reports in its own words. Returns the exit
265
+ # status to hand back, or nil when the value is fine.
266
+ def depth_error(options)
267
+ raw = options[:depth]
268
+ return nil if raw.nil? || raw.to_s =~ /\A\d+\z/
269
+
270
+ usage_error("--depth takes a whole number of levels (got #{raw.inspect})")
271
+ end
272
+
273
+ # The chain from the bundle root down to each --dir, so a branch is never
274
+ # shown adrift. On by default in the *directory* views (`index`, `dirs`):
275
+ # the map's job there is orientation, and a subtree printed with nothing
276
+ # above it has dropped the authored context that says what it is — the root
277
+ # index.md's prose first among it. Off with --no-ancestors, which restores
278
+ # the subtree alone.
279
+ #
280
+ # Deliberately not offered on the concept filters (search/catalog/files/…):
281
+ # there --dir narrows *concepts*, and a concept in `a/` is simply not in
282
+ # `a/b`. Same flag, one meaning, because it is asked about two different
283
+ # kinds of row.
284
+ def ancestors_flag(parser, options)
285
+ parser.on("--[no-]ancestors", "with --dir, also show the chain up to the root",
286
+ "so the branch is placed (default: yes)") { |v| options[:ancestors] = v }
287
+ end
288
+
289
+ # Every proper ancestor of each --dir, root included. Empty unless --dir
290
+ # named something below the root: with no --dir the whole bundle is already
291
+ # the starting point, and `--dir .` has nothing above it.
292
+ #
293
+ # `known` is the map's own directory list, and a base outside it contributes
294
+ # no chain. Without that check `--dir typo` came back with the root — a
295
+ # chain to a place that does not exist, which reads as a partial answer to
296
+ # a query that in fact matched nothing.
297
+ #
298
+ # The deprecated --area gains no chain either: it is exact, and a deprecated
299
+ # flag that quietly answers with more than it used to is worse than one that
300
+ # is merely old.
301
+ # Matching folds case, but a row is found by its *stored* spelling, so the
302
+ # chain is walked folded and handed back in the map's own words. Returning
303
+ # the folded string instead dropped every ancestor a bundle spelled with a
304
+ # capital — the rows are selected with `include?`, which does not fold.
305
+ def ancestor_dirs(options, known)
306
+ return [] unless options[:ancestors]
307
+
308
+ stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
309
+ Array(options[:dirs]).each_with_object([]) do |path, out|
310
+ base = fold_dir(path)
311
+ next unless stored.key?(base)
312
+
313
+ current = dir_parent(base)
314
+ while current
315
+ out << stored.fetch(current, current)
316
+ current = dir_parent(current)
317
+ end
318
+ end.uniq
319
+ end
320
+
321
+ # nil above the root, so the walk above terminates on it rather than on ".".
322
+ def dir_parent(dir)
323
+ return nil if dir == "."
324
+
325
+ slash = dir.rindex("/")
326
+ slash ? dir[0, slash] : "."
327
+ end
328
+
329
+ # The directories a --dir/--depth pair selects, out of the map's own
330
+ # ordered list. Neither flag given keeps everything — these narrow a view,
331
+ # they do not define one. The ancestor chain is unioned on top by the
332
+ # caller, which is also what tells a row apart from context.
333
+ def select_dirs(dirs, options)
334
+ bases = Array(options[:dirs]).map { |path| fold_dir(path) }
335
+ depth = options[:depth]&.to_i
336
+ return dirs if bases.empty? && depth.nil?
337
+ # No --dir means the whole bundle is the starting point, which is *not*
338
+ # `--dir .`: that one selects the root alone, by the same prefix rule
339
+ # everything else here uses.
340
+ return dirs.select { |dir| dir_depth(dir) <= depth } if bases.empty?
341
+
342
+ dirs.select do |dir|
343
+ bases.any? do |base|
344
+ # --depth bounds the *descent*; the chain above is the ascent, and the
345
+ # two are separate axes. That is what keeps `--depth 0` meaning "the
346
+ # named directory alone" even while its chain is printed with it.
347
+ under_dir?(dir, base) && (depth.nil? || dir_depth(dir) - dir_depth(base) <= depth)
348
+ end
349
+ end
350
+ end
351
+
352
+ # A deprecated spelling still does what it always did — never silently
353
+ # something else — and says so once per run, on stderr so a --json
354
+ # consumer's stdout stays a clean machine substrate.
355
+ def deprecated(what, instead)
356
+ @deprecated ||= {}
357
+ return if @deprecated[what]
358
+
359
+ @deprecated[what] = true
360
+ @err.puts "warning: #{what} is deprecated, use #{instead}"
361
+ end
362
+
201
363
  # Turn an inverted index ({ value => [id, …] }) into display rows ordered by
202
364
  # count, narrowed to the concepts the active filters select; rows the narrowing
203
365
  # empties drop. With no filters the index passes through whole.
@@ -212,7 +374,7 @@ module OKF
212
374
  # The ids the filters select, resolved through the catalog metadata — or nil
213
375
  # when no filter is active, meaning keep everything.
214
376
  def filter_ids(folder, options)
215
- return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
377
+ return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
216
378
 
217
379
  filter_entries(folder.catalog, options).map { |entry| entry[:id] }
218
380
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The bundle's directories — its clusters — and how many concepts live
6
+ # directly in each. The shape view: `index` reads a directory's contents,
7
+ # `dirs` reads the layout they hang off, which is the question `--dir` is
8
+ # answered against.
9
+ #
10
+ # `count` is *direct*, never cumulative: a dir's number is what lives in it,
11
+ # so the column sums to the bundle's concept count and an empty intermediate
12
+ # dir reads as the zero it is. `subtree` is the other half of that honesty —
13
+ # what `--dir <that row>` would return — because a direct count alone cannot
14
+ # say where the mass is once `--depth` truncates the listing: on a deep
15
+ # bundle the top-level rows are then all zeroes.
16
+ #
17
+ # Presentation only — every number comes off Bundle#directory_index, the same
18
+ # source `okf index` and the server's Index panel read. Advisory: exit 0.
19
+ class Dirs < Command
20
+ def self.id
21
+ :dirs
22
+ end
23
+
24
+ def self.group
25
+ :read
26
+ end
27
+
28
+ def self.help_rows
29
+ [
30
+ [ "dirs <dir|@slug> [--json] [--dir D] [--depth N]", "list the bundle's dirs (clusters) and their concept counts" ]
31
+ ]
32
+ end
33
+
34
+ def call(argv)
35
+ options = { json: false, dirs: nil, depth: nil, ancestors: true }
36
+ parser = OptionParser.new do |o|
37
+ o.banner = "Usage: okf dirs <dir|@slug> [--dir PATH] [--depth N] [--json]"
38
+ json_flags(o, options, "emit the dirs as JSON")
39
+ projection_flags(o, options)
40
+ o.on("--dir PATH", "only this directory and the ones below it",
41
+ "(repeatable; `root` for the bundle root)") { |v| (options[:dirs] ||= []) << v }
42
+ depth_flag(o, options)
43
+ ancestors_flag(o, options)
44
+ help_flag(o)
45
+ end
46
+ dir = positional_dir(parser, argv) or return 2
47
+ bad_depth = depth_error(options)
48
+ return bad_depth if bad_depth
49
+
50
+ folder = OKF::Bundle::Folder.load(dir)
51
+ report_skipped(folder)
52
+ rows = select_rows(folder.directory_index, options)
53
+ return emit_list_json(dir, "dirs", rows, options, "total" => total(rows)) if options[:json]
54
+
55
+ print_dirs(dir, rows)
56
+ 0
57
+ end
58
+
59
+ private
60
+
61
+ # The subtree counts come off the *whole* map, before any narrowing — a
62
+ # truncated view still has to report the real weight hanging below a row,
63
+ # which is the only reason the column exists.
64
+ def select_rows(entries, options)
65
+ subtree = subtree_counts(entries)
66
+ all_dirs = entries.map { |entry| entry[:dir] }
67
+ wanted = select_dirs(all_dirs, options)
68
+ chain = ancestor_dirs(options, all_dirs) - wanted
69
+ entries.select { |entry| wanted.include?(entry[:dir]) || chain.include?(entry[:dir]) }.map do |entry|
70
+ { "dir" => entry[:dir], "ancestor" => chain.include?(entry[:dir]), "count" => entry[:count],
71
+ "subtree" => subtree[entry[:dir]], "subdirs" => entry[:subdirs] }
72
+ end
73
+ end
74
+
75
+ # Per dir, the concepts at or below it — defined as exactly what `--dir` on
76
+ # that row selects, so the number on the row and the flag can never
77
+ # disagree. Which is also why the root's subtree is its own direct count:
78
+ # `.` is a prefix of nothing, the same rule `--dir .` is built on.
79
+ def subtree_counts(entries)
80
+ entries.each_with_object({}) do |entry, out|
81
+ out[entry[:dir]] = entries.reduce(0) do |sum, other|
82
+ under_dir?(other[:dir], entry[:dir]) ? sum + other[:count] : sum
83
+ end
84
+ end
85
+ end
86
+
87
+ # The chain is context, not the answer, so it stays out of the total —
88
+ # which is what keeps a row's `subtree` equal to the total `--dir` on that
89
+ # row returns. `count` in the envelope is rows printed, chain included,
90
+ # because that is what it has always meant: how many rows came back.
91
+ def total(rows)
92
+ rows.reject { |row| row["ancestor"] }.map { |row| row["count"] }.reduce(0, :+)
93
+ end
94
+
95
+ def print_dirs(dir, rows)
96
+ @out.puts "Dirs — #{bundle_label(dir)}"
97
+ @out.puts
98
+ labels = rows.map { |row| "#{"↑ " if row["ancestor"]}#{dir_label(row["dir"])}" }
99
+ # The second column earns its place only where a dir actually nests. On a
100
+ # flat bundle it would repeat the first one down the page.
101
+ nested = rows.any? { |row| row["subtree"] != row["count"] }
102
+ unless rows.empty?
103
+ width = [ 3, *labels.map(&:length) ].max
104
+ @out.puts " #{"Dir".ljust(width)} Concepts#{" Subtree" if nested}"
105
+ rows.each_with_index do |row, i|
106
+ line = " #{labels[i].ljust(width)} #{row["count"].to_s.rjust(8)}"
107
+ line += " #{row["subtree"].to_s.rjust(7)}" if nested
108
+ @out.puts line
109
+ end
110
+ @out.puts
111
+ end
112
+ @out.puts " #{rows.size} #{pluralize(rows.size, "dir")} · #{total(rows)} #{pluralize(total(rows), "concept")}"
113
+ end
114
+ end
115
+
116
+ register(Dirs)
117
+ end
118
+ end
data/lib/okf/cli/files.rb CHANGED
@@ -22,7 +22,7 @@ module OKF
22
22
  def call(argv)
23
23
  options = { json: false }
24
24
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
25
+ o.banner = "Usage: okf files <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
26
26
  json_flags(o, options, "emit the file tree as JSON")
27
27
  projection_flags(o, options)
28
28
  filter_flags(o, options, :type, :area, :tag)
@@ -47,7 +47,7 @@ module OKF
47
47
  entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
48
48
  width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
49
49
  @out.puts
50
- @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
50
+ @out.puts " #{dir_label(folder, slash: true)}"
51
51
  group.each do |entry|
52
52
  @out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
53
53
  end