okf 1.8.0 → 1.10.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +615 -38
  3. data/README.md +109 -15
  4. data/lib/okf/bundle/folder.rb +20 -0
  5. data/lib/okf/bundle/search/index.rb +65 -0
  6. data/lib/okf/bundle/search/scan.rb +89 -0
  7. data/lib/okf/bundle/search.rb +262 -66
  8. data/lib/okf/bundle.rb +27 -3
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +495 -0
  11. data/lib/okf/cli/files.rb +68 -0
  12. data/lib/okf/cli/graph.rb +82 -0
  13. data/lib/okf/cli/index.rb +127 -0
  14. data/lib/okf/cli/lint.rb +139 -0
  15. data/lib/okf/cli/loose.rb +78 -0
  16. data/lib/okf/cli/registry.rb +229 -0
  17. data/lib/okf/cli/render.rb +66 -0
  18. data/lib/okf/cli/search.rb +285 -0
  19. data/lib/okf/cli/server.rb +179 -0
  20. data/lib/okf/cli/skill.rb +57 -0
  21. data/lib/okf/cli/stats.rb +88 -0
  22. data/lib/okf/cli/tags.rb +122 -0
  23. data/lib/okf/cli/types.rb +37 -0
  24. data/lib/okf/cli/validate.rb +66 -0
  25. data/lib/okf/cli.rb +418 -1633
  26. data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
  27. data/lib/okf/{server → render}/graph.rb +85 -9
  28. data/lib/okf/server/app.rb +17 -48
  29. data/lib/okf/server/hub/not_found.rb +663 -0
  30. data/lib/okf/server/hub.rb +504 -38
  31. data/lib/okf/skill/SKILL.md +41 -26
  32. data/lib/okf/skill/playbooks/consume.md +5 -3
  33. data/lib/okf/skill/playbooks/curate.md +3 -1
  34. data/lib/okf/skill/playbooks/maintain.md +4 -3
  35. data/lib/okf/skill/playbooks/menu.md +5 -0
  36. data/lib/okf/skill/playbooks/refine.md +92 -0
  37. data/lib/okf/skill/playbooks/search.md +47 -7
  38. data/lib/okf/skill/reference/authoring.md +3 -2
  39. data/lib/okf/skill/reference/cli.md +98 -21
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +8 -0
  42. metadata +37 -3
@@ -2,24 +2,97 @@
2
2
 
3
3
  module OKF
4
4
  class Bundle
5
- # Deterministic text retrieval over an in-memory bundle the browser page's
6
- # search brought server-side and extended to bodies. Terms are ANDed: every
7
- # term must hit at least one searched field, though not necessarily the same
8
- # one. A term is a case-insensitive substring, or a Ruby regular expression
9
- # with `regexp: true`. Matches rank by where they hit (a title hit outranks a
10
- # body hit) and carry one bounded context snippet, so answering "which concept
11
- # covers X?" costs a row, not a body read.
5
+ # Ranked text retrieval over one or more in-memory bundles. Terms are ANDed:
6
+ # every term must hit at least one searched field, though not necessarily the
7
+ # same one. Rows carry the fields each term hit, so a result stays explainable
8
+ # rather than being a bare relevance number.
12
9
  #
13
- # Deliberately not fuzzy: the consuming agent is the fuzzy layer synonyms
14
- # and vocabulary drift are judgment over the index map, not string distance.
10
+ # This class is a *facade*. It owns everything that defines what a result is
11
+ # the documents, the row and its key order, the snippet window, the final sort
12
+ # — and delegates only "which documents match, how well, and where" to an
13
+ # engine (Search::Index by default, Search::Scan for regexp). An engine that
14
+ # built its own rows could disagree about what a match is; this split makes
15
+ # that unrepresentable.
15
16
  #
16
- # Pure — no disk, no stdio. The CLI's `okf search` and any embedding app share
17
- # it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
17
+ # Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
18
+ # share it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
18
19
  class Search
20
+ # Raised when the query needs something the engine cannot do — either the
21
+ # one that was named, or any that is available. Carries structured data
22
+ # rather than a finished sentence, because the shell says "--regexp" where
23
+ # the core says ":regexp"; the CLI formats it and exits 2.
24
+ class UnsupportedQuery < OKF::Error
25
+ attr_reader :missing, :engine
26
+
27
+ def initialize(missing, engine: nil)
28
+ @missing = missing
29
+ @engine = engine
30
+ super(build_message(missing, engine))
31
+ end
32
+
33
+ private
34
+
35
+ def build_message(missing, engine)
36
+ return "no search engine is available" if missing.empty?
37
+
38
+ offered = missing.map { |name| ":#{name}" }.join(", ")
39
+ engine.nil? ? "no available search engine offers #{offered}" : "engine #{engine} does not offer #{offered}"
40
+ end
41
+ end
42
+
43
+ # Raised when `--engine` names something that is not on offer. An engine
44
+ # registered but reporting `available? == false` is absent from the list for
45
+ # the same reason it is absent from routing: it cannot answer. A future
46
+ # addon whose native build failed will want a kinder message than this one.
47
+ class UnknownEngine < OKF::Error
48
+ attr_reader :name, :available
49
+
50
+ def initialize(name, available)
51
+ @name = name
52
+ @available = available
53
+ super("unknown search engine: #{name} (available: #{available.join(", ")})")
54
+ end
55
+ end
56
+
57
+ # The **declarable** vocabulary: what an engine may claim about itself.
58
+ # Frozen so an engine declaring `:regex` is refused at registration rather
59
+ # than silently never selected — a typo in an addon would otherwise present
60
+ # as "my engine is ignored".
61
+ #
62
+ # `:prefix` lives here and *not* in ROUTABLE on purpose. Nothing asks for
63
+ # its absence, so it selects nothing; what it does is document that this
64
+ # engine grows a term to the tokens it prefixes, which an FTS5 engine may
65
+ # not do by default. Declarative, and honest about being declarative.
66
+ CAPABILITIES = %i[regexp fuzzy prefix].freeze
67
+
68
+ # The **routable** subset: the capabilities a query can actually require,
69
+ # and therefore the only ones that pick an engine. Kept distinct from
70
+ # CAPABILITIES because a capability nothing selects on, filed among the ones
71
+ # that do, is documentation posing as code.
72
+ #
73
+ # Each entry is also the option name the facade hands an engine that
74
+ # declares it — see #engine_options, which is what keeps a meaningful
75
+ # option from reaching an engine that would quietly drop it.
76
+ ROUTABLE = %i[regexp fuzzy].freeze
77
+
78
+ # Chosen when the query requires nothing in particular, which is the
79
+ # overwhelming majority of searches.
80
+ #
81
+ # The scan, not the index, because a one-shot CLI builds an index, asks one
82
+ # question and exits — a build with a single query to amortize it over.
83
+ # Measured end to end: 3.00s vs 0.24s at 1,000 concepts, 0.83s vs 0.18s at
84
+ # 250, and the gap widens with the bundle. Raw-text matching also carries no
85
+ # tokenizer, so the terms that are glued to symbols and therefore
86
+ # unreachable by token (`minifts`, $OKF_HOME) stay findable by default.
87
+ #
88
+ # What it gives up is BM25+ ranking, reachable with `--engine index` — and
89
+ # that is also the engine the browser page runs, so the two rank alike only
90
+ # when the index is named. See .okf/design/search-engines.md.
91
+ DEFAULT_ENGINE = :scan
92
+
19
93
  # The searchable fields with their rank weight, strongest signal first.
20
- # A concept's score sums the weights of the fields that matched; hitting a
21
- # field twice does not stack. Tags match against the space-joined list,
22
- # mirroring the server page's haystack.
94
+ # In the index engine these ride as MiniFTS per-field `boost`; the scan
95
+ # sums the weights of the fields that matched instead.
23
96
  WEIGHTS = {
24
97
  "title" => 5,
25
98
  "id" => 4,
@@ -38,53 +111,165 @@ module OKF
38
111
  # Characters of context kept on each side of the first matched term.
39
112
  SNIPPET_RADIUS = 44
40
113
 
41
- def self.call(bundle, terms, fields: nil, regexp: false)
42
- new(bundle, terms, fields: fields, regexp: regexp).results
114
+ # Edit distance as a fraction of term length, under `fuzzy: true` — the
115
+ # same 0.2 the browser page passes, so both forgive the same typos.
116
+ FUZZY_DISTANCE = 0.2
117
+
118
+ # The unique document key is "<slug>\0<id>": ids are only unique *within* a
119
+ # bundle, and a merge that collided two bundles' same-named concepts would
120
+ # silently drop one.
121
+ KEY_SEPARATOR = "\0"
122
+
123
+ # Append-only and idempotent by id: a second registration of an id already
124
+ # present is a no-op, so a double `require` cannot double the registry and
125
+ # an addon cannot quietly displace a built-in. Deliberately the same shape
126
+ # as the Linter's planned register hook — two extension points, one idiom.
127
+ def self.register(engine)
128
+ rogue = engine.capabilities - CAPABILITIES
129
+ raise ArgumentError, "unknown search capability: #{rogue.join(", ")}" unless rogue.empty?
130
+
131
+ @engines ||= []
132
+ @engines << engine unless @engines.any? { |registered| registered.id == engine.id }
133
+ engine
134
+ end
135
+
136
+ # A frozen snapshot in registration order. Frozen because the registry is
137
+ # only meant to grow through .register, where the vocabulary is checked.
138
+ def self.engines
139
+ (@engines ||= []).dup.freeze
140
+ end
141
+
142
+ # The router. Naming an engine is an override, not a hint: it is how a
143
+ # caller reaches semantics no capability flag asks for — `--engine scan`
144
+ # means "match raw text", which the flags cannot express because there is
145
+ # nothing to *require*. A named engine that cannot do what was also asked
146
+ # is an error rather than a silent fallback, since falling back would answer
147
+ # a different question than the one that was posed.
148
+ #
149
+ # Unnamed, the default engine leads, then registration order; the first
150
+ # available engine offering *every* required capability answers. Partition
151
+ # rather than sort_by, because sort_by is not stable and registration order
152
+ # is the tie-break.
153
+ def self.engine_for(required, engines: self.engines, name: nil)
154
+ available = engines.select(&:available?)
155
+ return named_engine(name, required, available) unless OKF.blank?(name)
156
+
157
+ default, rest = available.partition { |engine| engine.id == DEFAULT_ENGINE }
158
+ found = (default + rest).find { |engine| (required - engine.capabilities).empty? }
159
+ return found if found
160
+
161
+ raise UnsupportedQuery, required
162
+ end
163
+
164
+ def self.named_engine(name, required, available)
165
+ wanted = name.to_s.downcase
166
+ found = available.find { |engine| engine.id.to_s == wanted }
167
+ raise UnknownEngine.new(name, available.map(&:id)) if found.nil?
168
+
169
+ missing = required - found.capabilities
170
+ raise UnsupportedQuery.new(missing, engine: found.id) unless missing.empty?
171
+
172
+ found
173
+ end
174
+ private_class_method :named_engine
175
+
176
+ def self.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
177
+ new([ [ nil, bundle ] ], terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
178
+ end
179
+
180
+ # Several bundles as [ slug, bundle ] pairs, ranked into one list with every
181
+ # row labeled by its slug. They share **one** index on purpose: BM25 weighs a
182
+ # term by how rare it is in the corpus, so per-bundle indexes would score the
183
+ # same match differently depending on which bundle it came from. One index
184
+ # makes one corpus, and the merged ranking is comparable by construction.
185
+ def self.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
186
+ new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
43
187
  end
44
188
 
45
- # Raises RegexpError on an invalid pattern with `regexp: true` — the caller
46
- # owns turning that into a usage error.
47
- def initialize(bundle, terms, fields: nil, regexp: false)
48
- @bundle = bundle
49
- raw = Array(terms).reject { |term| OKF.blank?(term) }
50
- @matchers = raw.map { |term| regexp ? Regexp.new(term.to_s, Regexp::IGNORECASE) : term.to_s.downcase }
189
+ # Raises RegexpError on an invalid pattern with `regexp: true`, and
190
+ # UnsupportedQuery when no engine can answer — the caller owns turning
191
+ # either into a usage error. `engines:` overrides the registry, which is how
192
+ # 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)
194
+ @bundles = bundles
195
+ @terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
51
196
  @fields = fields.nil? || fields.empty? ? FIELDS : fields
197
+ @regexp = regexp
198
+ @fuzzy = fuzzy
199
+ @engine = engine
200
+ @engines = engines
201
+ @sources = {}
52
202
  end
53
203
 
54
204
  # Ranked match rows, catalog-style identity plus where the terms hit:
55
- # [{ id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
56
- # ordered by score descending, then id. No terms means no matches.
205
+ # [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
206
+ # ordered by score descending, then slug, then id. `slug` is present only
207
+ # when searching across bundles. No terms means no matches.
57
208
  def results
58
- return [] if @matchers.empty?
209
+ return [] if @terms.empty?
59
210
 
60
- @bundle.concepts
61
- .map { |concept| match(concept) }
62
- .compact
63
- .sort_by { |row| [ -row[:score], row[:id] ] }
211
+ chosen = engine
212
+ rows = chosen.call(documents, @terms, **engine_options(chosen)).map do |hit|
213
+ slug, concept = @sources[hit[:key]]
214
+ row(slug, concept, hit[:matched], hit[:score], hit[:terms])
215
+ end
216
+ rows.sort_by { |row| [ -row[:score], row[:slug].to_s, row[:id] ] }
64
217
  end
65
218
 
66
219
  private
67
220
 
68
- def match(concept)
69
- texts = searchable_texts(concept)
70
- matched = matched_fields(texts)
71
- return nil if matched.nil?
221
+ # The engine is chosen by what the query needs, not by a flag naming one.
222
+ # `-e` unambiguously means "regexp semantics", so it routes on its own and
223
+ # says nothing about it — there is no --engine flag to reconcile with.
224
+ def engine
225
+ Search.engine_for(required_capabilities, engines: @engines || Search.engines, name: @engine)
226
+ end
72
227
 
73
- {
74
- id: concept.id,
75
- title: (concept.title || concept.id).to_s,
76
- type: concept.type.to_s,
77
- area: area_of(concept.id),
78
- tags: Array(concept.tags).map(&:to_s),
79
- matched: matched,
80
- score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
81
- snippet: snippet(texts, matched)
82
- }
228
+ # What the query requires, in the routable vocabulary. `:prefix` never
229
+ # appears: it is declarable, not routable — nothing asks for its absence.
230
+ def required_capabilities
231
+ ROUTABLE.select { |capability| requested[capability] }
232
+ end
233
+
234
+ # `fields:` always, plus exactly the routable options the chosen engine
235
+ # declared it understands.
236
+ #
237
+ # The facade used to hand every engine every option and trust it to ignore
238
+ # what it could not use. Routing makes that harmless in practice — a fuzzy
239
+ # query only ever reaches a :fuzzy engine — but "harmless because something
240
+ # else prevents it" is precisely how an option comes to be dropped in
241
+ # silence the day that something else changes. An engine now receives only
242
+ # what it can act on, so there is nothing left for it to ignore.
243
+ def engine_options(chosen)
244
+ options = { fields: @fields }
245
+ ROUTABLE.each do |capability|
246
+ options[capability] = requested[capability] if chosen.capabilities.include?(capability)
247
+ end
248
+ options
249
+ end
250
+
251
+ def requested
252
+ @requested ||= { regexp: @regexp, fuzzy: @fuzzy }
253
+ end
254
+
255
+ # Every concept as an indexable document, keyed uniquely across bundles.
256
+ # @sources keeps the way back, so the index stores no fields of its own.
257
+ def documents
258
+ docs = []
259
+ @bundles.each do |slug, bundle|
260
+ bundle.concepts.each do |concept|
261
+ key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
262
+ @sources[key] = [ slug, concept ]
263
+ docs << field_texts(concept).merge("key" => key)
264
+ end
265
+ end
266
+ docs
83
267
  end
84
268
 
85
- # { field => original-case text } for the fields this search reads.
86
- def searchable_texts(concept)
87
- texts = {
269
+ # { field => original-case text } for every searchable field. The index reads
270
+ # all of them; `fields:` narrows the search, not the document.
271
+ def field_texts(concept)
272
+ {
88
273
  "id" => concept.id,
89
274
  "title" => concept.title.to_s,
90
275
  "type" => concept.type.to_s,
@@ -92,35 +277,46 @@ module OKF
92
277
  "tags" => Array(concept.tags).join(" "),
93
278
  "body" => concept.body
94
279
  }
95
- texts.each_with_object({}) do |(field, text), acc|
96
- acc[field] = text if @fields.include?(field)
97
- end
98
- end
99
-
100
- # The union of fields any term hit, in WEIGHTS order — or nil when some term
101
- # hit nothing (terms are ANDed).
102
- def matched_fields(texts)
103
- hits = @matchers.map do |matcher|
104
- fields = texts.keys.select { |field| hit?(matcher, texts[field]) }
105
- return nil if fields.empty?
106
-
107
- fields
108
- end
109
- FIELDS.select { |field| hits.flatten.include?(field) }
110
280
  end
111
281
 
112
- def hit?(matcher, text)
113
- matcher.is_a?(Regexp) ? matcher.match?(text) : text.downcase.include?(matcher)
282
+ # `slug` leads the row so a merged result reads bundle-first, and drops
283
+ # entirely for a single bundle, which has no slug to carry.
284
+ def row(slug, concept, matched, score, terms)
285
+ texts = field_texts(concept)
286
+ built = {
287
+ slug: slug,
288
+ id: concept.id,
289
+ title: (concept.title || concept.id).to_s,
290
+ type: concept.type.to_s,
291
+ area: area_of(concept.id),
292
+ tags: Array(concept.tags).map(&:to_s),
293
+ matched: matched,
294
+ score: score.round(4),
295
+ snippet: snippet(texts, matched, terms)
296
+ }
297
+ built.delete(:slug) if slug.nil?
298
+ built
114
299
  end
115
300
 
116
301
  # One bounded context window around the first term that hit the strongest
117
302
  # snippet-worthy field; "" when the match needs no context (id/title/type/tags).
118
- def snippet(texts, matched)
303
+ def snippet(texts, matched, terms)
119
304
  field = SNIPPET_FIELDS.find { |candidate| matched.include?(candidate) }
120
305
  return "" if field.nil?
121
306
 
122
- matcher = @matchers.find { |candidate| hit?(candidate, texts[field]) }
123
- context(texts[field], matcher)
307
+ matcher = snippet_matcher(texts[field], terms)
308
+ matcher.nil? ? "" : context(texts[field], matcher)
309
+ end
310
+
311
+ # What to point the window at: the first of the engine's reported matchers
312
+ # that this text actually contains. Engine-agnostic on purpose — the scan
313
+ # reports compiled patterns, the index reports lowercased document terms,
314
+ # and both are things `locate` can find again in the flattened text.
315
+ def snippet_matcher(text, terms)
316
+ down = text.downcase
317
+ Array(terms).find do |term|
318
+ term.is_a?(Regexp) ? term.match?(text) : down.include?(term)
319
+ end
124
320
  end
125
321
 
126
322
  def context(text, matcher)
data/lib/okf/bundle.rb CHANGED
@@ -111,7 +111,7 @@ module OKF
111
111
  id = concept.id
112
112
  {
113
113
  id: id,
114
- title: (concept.title || id).to_s,
114
+ title: OKF.blank?(concept.title) ? File.basename(id) : concept.title.to_s,
115
115
  type: concept.type.to_s,
116
116
  description: concept.description.to_s,
117
117
  tags: Array(concept.tags).map(&:to_s),
@@ -119,13 +119,31 @@ module OKF
119
119
  status: concept.frontmatter["status"]&.to_s,
120
120
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
121
  dir: File.dirname("#{id}.md"),
122
- area: id.include?("/") ? id.split("/").first : "(root)",
122
+ area: area_of(id),
123
123
  links_out: out_degree[id],
124
124
  links_in: in_degree[id]
125
125
  }
126
126
  end.sort_by { |entry| entry[:id] }
127
127
  end
128
128
 
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.
135
+ def hubs
136
+ inbound = {}
137
+ graph(minimal: true).edges.each do |edge|
138
+ (inbound[edge[:target]] ||= Hash.new(0))[area_of(edge[:source])] += 1
139
+ end
140
+
141
+ 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 }
144
+ end.sort_by { |row| [ -row[:inbound], row[:id] ] }
145
+ end
146
+
129
147
  # The progressive-disclosure map (spec §6): one entry per directory that holds
130
148
  # concepts or carries an index.md, sorted with the root (".") first. Each entry
131
149
  # gives the authored index body (frontmatter stripped) when an index.md is
@@ -159,7 +177,7 @@ module OKF
159
177
  listing: here.map do |concept|
160
178
  {
161
179
  id: concept.id,
162
- title: (concept.title || concept.id).to_s,
180
+ title: OKF.blank?(concept.title) ? File.basename(concept.id) : concept.title.to_s,
163
181
  description: concept.description.to_s,
164
182
  type: concept.type.to_s,
165
183
  tags: Array(concept.tags).map(&:to_s)
@@ -171,6 +189,12 @@ module OKF
171
189
 
172
190
  private
173
191
 
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)
195
+ id.include?("/") ? id.split("/").first : "(root)"
196
+ end
197
+
174
198
  # Every directory to show: those holding concepts or an index.md, plus each of
175
199
  # their ancestors up to the root, so the subdir tree stays connected even when
176
200
  # an intermediate directory holds nothing directly. Sorted with "." first.
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
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.
7
+ class Catalog < Command
8
+ def self.id
9
+ :catalog
10
+ end
11
+
12
+ def self.group
13
+ :read
14
+ end
15
+
16
+ def self.help_rows
17
+ [
18
+ [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by area" ]
19
+ ]
20
+ end
21
+
22
+ def call(argv)
23
+ options = { json: false }
24
+ parser = OptionParser.new do |o|
25
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
26
+ json_flags(o, options, "emit the catalog as JSON")
27
+ projection_flags(o, options)
28
+ filter_flags(o, options, :type, :area, :tag)
29
+ help_flag(o)
30
+ end
31
+ dir = positional_dir(parser, argv) or return 2
32
+
33
+ folder = OKF::Bundle::Folder.load(dir)
34
+ report_skipped(folder)
35
+ entries = folder.catalog
36
+ selected = filter_entries(entries, options)
37
+ return print_catalog_json(dir, selected, options) if options[:json]
38
+
39
+ print_catalog(dir, selected, entries.size)
40
+ 0
41
+ end
42
+
43
+ private
44
+
45
+ def print_catalog(dir, entries, total)
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|
48
+ @out.puts
49
+ @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
50
+ group.each do |entry|
51
+ links = entry[:links_out] + entry[:links_in]
52
+ meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
53
+ @out.puts " #{entry[:title]} · #{meta}"
54
+ @out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
55
+ end
56
+ end
57
+ end
58
+
59
+ def print_catalog_json(dir, entries, options)
60
+ emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
61
+ end
62
+ end
63
+
64
+ register(Catalog)
65
+ end
66
+ end