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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +203 -0
- data/README.md +238 -413
- data/lib/okf/bundle/folder.rb +20 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +1 -1
- data/lib/okf/cli/catalog.rb +1 -1
- data/lib/okf/cli/command.rb +169 -7
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/search.rb +1 -1
- data/lib/okf/cli/server.rb +8 -1
- data/lib/okf/cli/stats.rb +33 -8
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +9 -5
- data/lib/okf/render/graph/template.html.erb +293 -96
- data/lib/okf/server/app.rb +61 -0
- data/lib/okf/server/hub.rb +35 -27
- data/lib/okf/skill/SKILL.md +12 -9
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +1 -1
- data/lib/okf/skill/playbooks/refine.md +4 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +100 -18
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +20 -8
data/lib/okf/bundle/folder.rb
CHANGED
|
@@ -64,13 +64,28 @@ module OKF
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/okf/bundle/linter.rb
CHANGED
|
@@ -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
|
|
16
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -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:
|
|
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]
|
data/lib/okf/cli/catalog.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 catalog <dir|@slug> [--type T] [--
|
|
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)
|
data/lib/okf/cli/command.rb
CHANGED
|
@@ -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/--
|
|
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/--
|
|
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
|
|
127
|
-
# the
|
|
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
|
-
|
|
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
|
data/lib/okf/cli/dirs.rb
ADDED
|
@@ -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] [--
|
|
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
|
|
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
|