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.
@@ -42,6 +42,10 @@ module OKF
42
42
  @bundle.graph(minimal: minimal, body: body)
43
43
  end
44
44
 
45
+ def skeleton
46
+ @bundle.skeleton
47
+ end
48
+
45
49
  def catalog
46
50
  @bundle.catalog
47
51
  end
@@ -64,13 +68,28 @@ module OKF
64
68
  end
65
69
  end
66
70
 
67
- # Human-readable "parent/dir" namethe 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.
71
+ # The bundle's display label path arithmetic, no disk. It is a class
72
+ # method so a caller that only wants the label (the registry naming an
73
+ # entry) can have it without a Reader.read of every file.
74
+ #
75
+ # "parent/dir", because a bundle directory's own name is rarely unique
76
+ # enough to name it by — except when that name is `.okf`, the conventional
77
+ # container, and then the parent carries the whole answer on its own. A
78
+ # registry of eight projects is eight rows reading `…/.okf`, which is the
79
+ # one word that tells none of them apart; `repo/.okf` is read as "repo" by
80
+ # anyone looking at it anyway.
81
+ #
82
+ # A directory with no parent to borrow (`/.okf`) keeps its own name: the
83
+ # parent is `/`, which names nothing. That case used to compose into
84
+ # `//.okf`.
71
85
  def self.label(root)
72
86
  pathname = Pathname.new(root)
73
- "#{pathname.parent.basename}/#{pathname.basename}"
87
+ parent = pathname.parent.basename.to_s
88
+ base = pathname.basename.to_s
89
+ return base if [ "/", "." ].include?(parent)
90
+ return parent if base == ".okf"
91
+
92
+ "#{parent}/#{base}"
74
93
  end
75
94
 
76
95
  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:, top_dir:, 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,7 +366,8 @@ module OKF
288
366
  id: concept.id,
289
367
  title: (concept.title || concept.id).to_s,
290
368
  type: concept.type.to_s,
291
- area: area_of(concept.id),
369
+ dir: OKF.dir_of(concept.id),
370
+ top_dir: top_dir_of(concept.id),
292
371
  tags: Array(concept.tags).map(&:to_s),
293
372
  matched: matched,
294
373
  score: score.round(4),
@@ -342,8 +421,9 @@ module OKF
342
421
  end
343
422
  end
344
423
 
345
- # A concept's top-level area, mirroring the catalog's definition.
346
- def area_of(id)
424
+ # A concept's top-level dir, mirroring the catalog's definition — the first
425
+ # path segment. OKF.dir_of keeps the levels this one rolls up.
426
+ def top_dir_of(id)
347
427
  id.include?("/") ? id.split("/").first : "(root)"
348
428
  end
349
429
  end
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class Bundle
5
+ # The graph reduced to what a reader can hold in their head. Pure — built from
6
+ # an OKF::Bundle::Graph's nodes and edges, does no I/O, and decides nothing
7
+ # about how any of it is drawn.
8
+ #
9
+ # A dense bundle is not dense in the way a hub-and-spoke picture is. Measured
10
+ # on a 47-concept bundle with 227 links: the top hub takes 13 inbound and the
11
+ # median takes 4, so there is no 80/20 to exploit — dropping two thirds of the
12
+ # concepts still leaves 53 edges. The density lives *between directories*:
13
+ # 173 of those 227 links (76%) cross a directory boundary, and they collapse
14
+ # into 50 directory-to-directory arcs of which the top ten carry half the mass.
15
+ # That is the reduction worth drawing, and it is why #arcs exists at all.
16
+ #
17
+ # ── the two things it produces, and who reads them ──
18
+ #
19
+ # dirs + arcs the reduction, as counts: one row per directory, one
20
+ # weighted arc per ordered pair. Printed by
21
+ # `okf graph --traffic`, with cohesion derived from them.
22
+ # edges every link with the cut it survives (`keep_at`). Nothing
23
+ # prints these — they are what lets the graph page lay a
24
+ # large bundle out on its strongest links first.
25
+ #
26
+ # Both are emitted *unthresholded*, and #suggested_cut names where to cut
27
+ # rather than cutting, so a caller narrows the picture without this class
28
+ # having to know what a picture is.
29
+ #
30
+ # Directories come off the concept *id* (OKF.dir_of), not the file path, so
31
+ # this agrees with #catalog, #hubs and the `--dir` filter. Bundle#directory_index
32
+ # groups by path instead — deliberately, since an index.md is a physical
33
+ # listing — so the two disagree for a concept whose frontmatter `id` moves it.
34
+ # This side follows the id because the edges do.
35
+ class Skeleton
36
+ attr_reader :dirs, :arcs, :edges
37
+
38
+ def self.build(bundle)
39
+ graph = bundle.graph(minimal: true)
40
+ ids = graph.nodes.map { |node| node[:id] }
41
+ pairs = graph.edges.map { |edge| [ edge[:source], edge[:target] ] }
42
+ dir_by_id = ids.map { |id| [ id, OKF.dir_of(id) ] }.to_h
43
+
44
+ new(
45
+ dirs: dirs_for(dir_by_id, pairs),
46
+ arcs: arcs_for(dir_by_id, pairs),
47
+ edges: edges_for(pairs, neighbours_for(ids, pairs))
48
+ )
49
+ end
50
+
51
+ # ── the cuts (see above) ──
52
+
53
+ def self.arcs_above(arcs, weight)
54
+ arcs.select { |arc| arc[:weight] >= weight }
55
+ end
56
+
57
+ def self.edges_within(edges, keep_at)
58
+ edges.select { |edge| edge[:keep_at] <= keep_at }
59
+ end
60
+
61
+ # ── the directories ──
62
+
63
+ # One row per directory that holds a concept, plus every ancestor up to the
64
+ # root, so the tree stays connected through a directory that holds nothing
65
+ # directly. `count` is direct and `subtree` is at-or-below — the same pair
66
+ # `okf dirs` prints, and for the same reason: a direct count alone cannot
67
+ # say where the mass is once the listing is cut off at a depth.
68
+ def self.dirs_for(dir_by_id, pairs)
69
+ direct = Hash.new(0)
70
+ dir_by_id.each_value { |dir| direct[dir] += 1 }
71
+ internal = Hash.new(0)
72
+ pairs.each do |source, target|
73
+ from = dir_by_id[source]
74
+ internal[from] += 1 if from == dir_by_id[target]
75
+ end
76
+
77
+ every_dir(direct.keys).map do |dir|
78
+ { dir: dir, parent: parent_of(dir), count: direct[dir],
79
+ subtree: direct.reduce(0) { |sum, (other, n)| under?(other, dir) ? sum + n : sum },
80
+ internal: internal[dir] }
81
+ end
82
+ end
83
+
84
+ # The cross-directory link mass, aggregated. Directed and never a self-arc:
85
+ # a directory's internal links are carried on its own row (`internal`),
86
+ # because an arc from a box to itself is a loop the eye has to untangle to
87
+ # learn a number the box could simply have carried.
88
+ def self.arcs_for(dir_by_id, pairs)
89
+ weights = Hash.new(0)
90
+ pairs.each do |source, target|
91
+ from = dir_by_id[source]
92
+ to = dir_by_id[target]
93
+ weights[[ from, to ]] += 1 unless from == to
94
+ end
95
+
96
+ weights.map { |(from, to), weight| { source: from, target: to, weight: weight } }
97
+ .sort_by { |arc| [ -arc[:weight], arc[:source], arc[:target] ] }
98
+ end
99
+
100
+ # ── where to cut ──
101
+
102
+ # The arc cut that leaves a readable picture, chosen from the *shape* of
103
+ # the bundle rather than fixed. A fixed cut cannot work: measured at
104
+ # weight 3 across ten bundles it left 2 arcs on one and 136 on another —
105
+ # too tight to be a picture at one end, no reduction at all at the other.
106
+ #
107
+ # What stays constant when a bundle grows is not the arc count but the
108
+ # arcs *per box*: a node-link diagram reads at roughly one to two edges
109
+ # per node regardless of size. So the target is 1.5 arcs per directory,
110
+ # and the cut is whatever weight delivers it — 22 arcs over 13 directories
111
+ # on one bundle, 191 over 95 on another, both about the same density.
112
+ #
113
+ # The floor of 8 is for the small end, where 1.5-per-box would cut a
114
+ # ten-arc bundle down to something that no longer shows how it is joined
115
+ # up. Ties are kept rather than broken, so the result is *at least* the
116
+ # target — the alternative is dropping one of two arcs that weigh the
117
+ # same, which is an arbitrary choice presented as a threshold.
118
+ def self.suggested_cut(arcs, dir_count)
119
+ target = [ (dir_count * 1.5).ceil, 8 ].max
120
+ return 1 if arcs.empty? || arcs.size <= target
121
+
122
+ arcs[target - 1][:weight]
123
+ end
124
+
125
+ # ── the edges, each with the cut it survives ──
126
+
127
+ # The local-degree sparsifier (Lindner et al.): every concept keeps its own
128
+ # most-connected neighbours, and an edge survives if *either* end kept it.
129
+ # The union is the point — it is what stops a sparsifier from stranding the
130
+ # quiet half of the bundle, which a global "drop the weakest edges" rule
131
+ # does immediately.
132
+ #
133
+ # Not the disparity filter, the usual name in this territory: that one reads
134
+ # an edge's weight against its endpoint's total, and every link here weighs
135
+ # exactly 1, which makes every proportion identical and the filter a coin
136
+ # toss. Weighted-graph tools do not transfer to an unweighted graph just
137
+ # because both are graphs.
138
+ #
139
+ # `keep_at` is the smallest cut (0–100) at which the edge appears, so the
140
+ # consumer's whole job is `keep_at <= n`. An edge to a node's single most
141
+ # connected neighbour keeps at 0 and is therefore never cut away: the
142
+ # skeleton always spans every linked concept.
143
+ def self.edges_for(pairs, neighbours)
144
+ order = ordered_neighbours(neighbours)
145
+ pairs.map do |source, target|
146
+ { source: source, target: target,
147
+ keep_at: [ keep_at(order, neighbours, source, target),
148
+ keep_at(order, neighbours, target, source) ].min }
149
+ end
150
+ end
151
+
152
+ # Where `other` sits in `id`'s neighbours (1 = most connected), turned into
153
+ # the smallest cut that reaches it. Position 1 needs no budget at all; past
154
+ # that, a cut of n keeps ceil(degree ** n/100) neighbours, so the answer is
155
+ # the least n satisfying that — solved directly rather than searched, and
156
+ # pinned against the definition it came from in the unit test.
157
+ def self.keep_at(order, neighbours, id, other)
158
+ position = order[id].index(other) + 1
159
+ return 0 if position == 1
160
+
161
+ degree = neighbours[id].size
162
+ (100 * Math.log(position - 1) / Math.log(degree)).floor + 1
163
+ end
164
+
165
+ def self.ordered_neighbours(neighbours)
166
+ neighbours.each_with_object({}) do |(id, set), out|
167
+ out[id] = set.sort_by { |other| [ -neighbours[other].size, other ] }
168
+ end
169
+ end
170
+
171
+ # Undirected neighbour sets. A link is structure regardless of which end
172
+ # authored it, and a concept every page cites but that cites nothing back
173
+ # is as central as one that does the citing.
174
+ def self.neighbours_for(ids, pairs)
175
+ sets = ids.map { |id| [ id, Set.new ] }.to_h
176
+ pairs.each do |source, target|
177
+ sets[source] << target if sets.key?(source) && sets.key?(target)
178
+ sets[target] << source if sets.key?(source) && sets.key?(target)
179
+ end
180
+ sets
181
+ end
182
+
183
+ # ── path arithmetic (pure; the bundle root is ".") ──
184
+
185
+ def self.every_dir(dirs)
186
+ seen = {}
187
+ dirs.each do |dir|
188
+ current = dir
189
+ loop do
190
+ seen[current] = true
191
+ break if current == "."
192
+
193
+ current = parent_of(current)
194
+ end
195
+ end
196
+ seen.keys.sort_by { |dir| dir == "." ? "" : dir }
197
+ end
198
+
199
+ def self.parent_of(dir)
200
+ return nil if dir == "."
201
+
202
+ File.dirname(dir)
203
+ end
204
+
205
+ # At or below — the same rule `--dir` is answered against, so a row's
206
+ # `subtree` and what `--dir <that row>` returns can never disagree.
207
+ def self.under?(dir, ancestor)
208
+ return true if ancestor == "."
209
+
210
+ dir == ancestor || dir.start_with?("#{ancestor}/")
211
+ end
212
+
213
+ def initialize(dirs:, arcs:, edges:)
214
+ @dirs = dirs
215
+ @arcs = arcs
216
+ @edges = edges
217
+ end
218
+
219
+ def suggested_cut
220
+ @suggested_cut ||= self.class.suggested_cut(arcs, dirs.size)
221
+ end
222
+
223
+ # The `keep_at` of each edge in +edges+, in *its* order — so a caller
224
+ # holding an OKF::Bundle::Graph can line the two up. Matched on the pair
225
+ # rather than by index: both lists derive from the same graph and so are
226
+ # already parallel today, and an index-aligned read would go wrong in
227
+ # silence on the day one of them stops being built that way.
228
+ def cuts_for(graph_edges)
229
+ by_pair = edges.each_with_object({}) { |edge, out| out[[ edge[:source], edge[:target] ]] = edge[:keep_at] }
230
+ graph_edges.map { |edge| by_pair[[ edge[:source], edge[:target] ]] || 0 }
231
+ end
232
+
233
+ # What the drawn views read. `edges` stays out: it is per-link data whose
234
+ # only consumer needs it at boot, before any fetch could answer — so it
235
+ # rides inline with the graph instead (see OKF::Render::Graph#edge_cuts_json).
236
+ def to_h
237
+ { dirs: dirs, arcs: arcs, suggested_cut: suggested_cut }
238
+ end
239
+ end
240
+ end
241
+ end
data/lib/okf/bundle.rb CHANGED
@@ -95,6 +95,10 @@ module OKF
95
95
  Graph.build(self, minimal: minimal, body: body)
96
96
  end
97
97
 
98
+ def skeleton
99
+ Skeleton.build(self)
100
+ end
101
+
98
102
  # Rich per-concept metadata the catalog / files / stats consumers want but the
99
103
  # lean graph omits — the descriptive frontmatter fields plus in/out link degree
100
104
  # taken from the graph edges. Pure: derived from the concepts and their links,
@@ -118,29 +122,29 @@ module OKF
118
122
  timestamp: concept.timestamp&.to_s,
119
123
  status: concept.frontmatter["status"]&.to_s,
120
124
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
- dir: File.dirname("#{id}.md"),
122
- area: area_of(id),
125
+ dir: OKF.dir_of(id),
126
+ top_dir: top_dir_of(id),
123
127
  links_out: out_degree[id],
124
128
  links_in: in_degree[id]
125
129
  }
126
130
  end.sort_by { |entry| entry[:id] }
127
131
  end
128
132
 
129
- # Concepts ranked by inbound link degree, each with the areas its inbound
130
- # links come from — the evidence for "is this hub well-homed?": a hub whose
131
- # inbound majority is foreign to its own area is a move candidate, one with
132
- # a single dominant foreign area already names its better home. Only
133
- # concepts with at least one inbound link appear. Pure: derived from the
134
- # graph edges. Shared by the `okf graph --hubs` view.
133
+ # Concepts ranked by inbound link degree, each with the top-level dirs its
134
+ # inbound links come from — the evidence for "is this hub well-homed?": a hub
135
+ # whose inbound majority is foreign to its own top-level dir is a move
136
+ # candidate, one with a single dominant foreign dir already names its better
137
+ # home. Only concepts with at least one inbound link appear. Pure: derived
138
+ # from the graph edges. Shared by the `okf graph --hubs` view.
135
139
  def hubs
136
140
  inbound = {}
137
141
  graph(minimal: true).edges.each do |edge|
138
- (inbound[edge[:target]] ||= Hash.new(0))[area_of(edge[:source])] += 1
142
+ (inbound[edge[:target]] ||= Hash.new(0))[top_dir_of(edge[:source])] += 1
139
143
  end
140
144
 
141
145
  inbound.map do |id, sources|
142
- by_area = sources.sort_by { |area, count| [ -count, area ] }.to_h
143
- { id: id, area: area_of(id), inbound: by_area.values.reduce(0, :+), by_area: by_area }
146
+ by_top_dir = sources.sort_by { |top_dir, count| [ -count, top_dir ] }.to_h
147
+ { id: id, top_dir: top_dir_of(id), inbound: by_top_dir.values.reduce(0, :+), by_top_dir: by_top_dir }
144
148
  end.sort_by { |row| [ -row[:inbound], row[:id] ] }
145
149
  end
146
150
 
@@ -189,9 +193,10 @@ module OKF
189
193
 
190
194
  private
191
195
 
192
- # A concept's top-level area, derived from its id — the same derivation the
193
- # catalog exposes, so every grouped view labels the bundle root "(root)".
194
- def area_of(id)
196
+ # A concept's top-level dir, derived from its id — the first path segment, the
197
+ # same derivation the catalog exposes, so every grouped view labels the bundle
198
+ # root "(root)". OKF.dir_of keeps the levels this one rolls up.
199
+ def top_dir_of(id)
195
200
  id.include?("/") ? id.split("/").first : "(root)"
196
201
  end
197
202
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module OKF
4
4
  class CLI
5
- # Every concept with its metadata, grouped by area. The widest of the read
6
- # views, and the one the others narrow down from.
5
+ # Every concept with its metadata, grouped by top-level dir. The widest of the
6
+ # read views, and the one the others narrow down from.
7
7
  class Catalog < Command
8
8
  def self.id
9
9
  :catalog
@@ -15,14 +15,14 @@ module OKF
15
15
 
16
16
  def self.help_rows
17
17
  [
18
- [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by area" ]
18
+ [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by top-level dir" ]
19
19
  ]
20
20
  end
21
21
 
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)
@@ -44,9 +44,9 @@ module OKF
44
44
 
45
45
  def print_catalog(dir, entries, total)
46
46
  @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
47
- entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
47
+ entries.group_by { |entry| entry[:top_dir] }.sort_by(&:first).each do |top_dir, group|
48
48
  @out.puts
49
- @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
49
+ @out.puts " #{top_dir == "(root)" ? "(root)" : "#{top_dir}/"} (#{group.size})"
50
50
  group.each do |entry|
51
51
  links = entry[:links_out] + entry[:links_in]
52
52
  meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")