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.
data/lib/okf/cli/index.rb CHANGED
@@ -5,9 +5,15 @@ module OKF
5
5
  # The progressive-disclosure map (spec §6): every directory that holds concepts
6
6
  # or carries an index.md, with its authored index body, a type/tag rollup, its
7
7
  # child directories, and — for a directory with no index.md — the listing
8
- # synthesized from the concepts there. The "orient before you read" view. `--area`
9
- # is repeatable (one or many directories; `root` is the bundle root); `--no-body`
10
- # drops the prose to a skeleton; advisory, exit 0.
8
+ # synthesized from the concepts there. The "orient before you read" view. `--dir`
9
+ # is repeatable and selects a directory *and its subtree* (`root` is the bundle
10
+ # root), `--depth N` bounds how far below the starting point that goes, and
11
+ # `--no-body` drops the prose to a skeleton; advisory, exit 0.
12
+ #
13
+ # The two narrowings are what make the map usable on a deep bundle: every
14
+ # directory is a section, so a few hundred concepts is a map nobody reads at
15
+ # once. `--depth 1` is the top of the tree, `--dir X --depth 1` is one branch
16
+ # of it, and the pair walks down a level at a time.
11
17
  class Index < Command
12
18
  def self.id
13
19
  :index
@@ -19,26 +25,47 @@ module OKF
19
25
 
20
26
  def self.help_rows
21
27
  [
22
- [ "index <dir|@slug> [--json] [--area A] [--no-body]", "the index map: dirs, their listings and rollups" ]
28
+ [ "index <dir|@slug> [--dir D] [--depth N] [--no-body]", "the index map: dirs, their listings and rollups" ]
23
29
  ]
24
30
  end
25
31
 
26
32
  def call(argv)
27
- options = { json: false, body: true, areas: nil }
33
+ options = { json: false, body: true, dirs: nil, areas: nil, depth: nil, ancestors: true }
28
34
  parser = OptionParser.new do |o|
29
- o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
35
+ o.banner = "Usage: okf index <dir|@slug> [--dir PATH] [--depth N] [--no-body] [--json]"
30
36
  json_flags(o, options, "emit the index map as JSON")
31
37
  projection_flags(o, options)
32
- o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
38
+ o.on("--dir PATH", "only this directory and the ones below it",
39
+ "(repeatable; `root` for the bundle root)") { |v| (options[:dirs] ||= []) << v }
40
+ depth_flag(o, options)
41
+ ancestors_flag(o, options)
42
+ o.on("--area AREA", "deprecated: use --dir (this directory exactly)") do |v|
43
+ (options[:areas] ||= []) << v
44
+ deprecated("--area", "--dir")
45
+ end
33
46
  o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
34
47
  help_flag(o)
35
48
  end
36
49
  dir = positional_dir(parser, argv) or return 2
50
+ bad_depth = depth_error(options)
51
+ return bad_depth if bad_depth
52
+ # --area is exact and names no starting point, so --depth has nothing to
53
+ # be relative *to*: the pair used to union the area with every directory
54
+ # at that depth from the root. Refusing beats answering with more.
55
+ #
56
+ # --dir is refused for the same reason and not a weaker one: one flag is
57
+ # exact and the other a prefix, so the pair came back with the area *and*
58
+ # the subtree — an answer to neither question, from a combination only
59
+ # someone mid-migration would type. A deprecated flag that quietly widens
60
+ # is worse than one that is merely old.
61
+ if options[:areas] && (options[:depth] || options[:dirs])
62
+ return usage_error("--area and #{options[:dirs] ? "--dir" : "--depth"} do not combine: use --dir")
63
+ end
37
64
 
38
65
  folder = OKF::Bundle::Folder.load(dir)
39
66
  report_skipped(folder)
40
67
  entries = folder.directory_index
41
- selected = select_directories(entries, options[:areas])
68
+ selected, chain = select_directories(entries, options)
42
69
  if options[:json]
43
70
  # --no-body is shorthand for --except body, so asking for the body by
44
71
  # name in the same breath is a contradiction. Letting --fields quietly
@@ -48,30 +75,44 @@ module OKF
48
75
  end
49
76
 
50
77
  options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
51
- return print_index_map_json(dir, selected, options)
78
+ return print_index_map_json(dir, selected, chain, options)
52
79
  end
53
- print_index_map(dir, selected, options[:body])
80
+ print_index_map(dir, selected, chain, options[:body])
54
81
  0
55
82
  end
56
83
 
57
84
  private
58
85
 
59
- # Narrow the map to the named directories/areas — case-insensitive, `root`
60
- # matching the bundle root (".") so no shell quoting is needed. No --area passed
61
- # keeps the whole map.
62
- def select_directories(entries, areas)
63
- return entries if areas.nil? || areas.empty?
64
-
65
- wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
66
- entries.select { |entry| wanted.include?(entry[:dir].downcase) }
86
+ # Narrow the map — case-insensitive, `root` matching the bundle root (".").
87
+ # --dir takes the named directory *and its subtree* and --depth bounds how
88
+ # far below the starting point that reaches, both through the shared
89
+ # select_dirs so the whole CLI answers "which directories?" one way. The
90
+ # deprecated --area keeps its old exact match beside them, because a
91
+ # deprecated flag that quietly widens is worse than one that is merely old.
92
+ # Nothing passed keeps the whole map.
93
+ def select_directories(entries, options)
94
+ areas = Array(options[:areas]).map { |area| fold_dir(area) }
95
+ scoped = !options[:dirs].nil? || !options[:depth].nil?
96
+ return [ entries, [] ] if areas.empty? && !scoped
97
+
98
+ all_dirs = entries.map { |entry| entry[:dir] }
99
+ wanted = scoped ? select_dirs(all_dirs, options) : []
100
+ chain = ancestor_dirs(options, all_dirs) - wanted
101
+ selected = entries.select do |entry|
102
+ areas.include?(fold(entry[:dir])) || wanted.include?(entry[:dir]) || chain.include?(entry[:dir])
103
+ end
104
+ [ selected, chain ]
67
105
  end
68
106
 
69
- def print_index_map(dir, entries, body)
107
+ def print_index_map(dir, entries, chain, body)
70
108
  noun = entries.size == 1 ? "directory" : "directories"
71
109
  @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
72
110
  entries.each do |entry|
73
111
  @out.puts
74
- @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
112
+ # ↑ marks a row the reader did not ask for: it is here to place the
113
+ # branch, not to answer about it.
114
+ up = chain.include?(entry[:dir]) ? "↑ " : ""
115
+ @out.puts " #{up}#{index_dir_label(entry)}#{index_dir_meta(entry)}"
75
116
  subdirs = entry[:subdirs]
76
117
  @out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
77
118
  if entry[:present]
@@ -83,7 +124,7 @@ module OKF
83
124
  end
84
125
 
85
126
  def index_dir_label(entry)
86
- base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
127
+ base = dir_label(entry[:dir], slash: true)
87
128
  entry[:present] ? base : "#{base} (no index.md)"
88
129
  end
89
130
 
@@ -107,13 +148,14 @@ module OKF
107
148
  end
108
149
  end
109
150
 
110
- def print_index_map_json(dir, entries, options)
111
- emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
151
+ def print_index_map_json(dir, entries, chain, options)
152
+ rows = entries.map { |entry| index_map_entry_json(entry, chain.include?(entry[:dir])) }
153
+ emit_list_json(dir, "directories", rows, options)
112
154
  end
113
155
 
114
- def index_map_entry_json(entry)
156
+ def index_map_entry_json(entry, ancestor)
115
157
  {
116
- "dir" => entry[:dir], "index_path" => entry[:index_path],
158
+ "dir" => entry[:dir], "ancestor" => ancestor, "index_path" => entry[:index_path],
117
159
  "present" => entry[:present], "synthesized" => entry[:synthesized],
118
160
  "count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
119
161
  "subdirs" => entry[:subdirs], "body" => entry[:body],
data/lib/okf/cli/loose.rb CHANGED
@@ -44,7 +44,7 @@ module OKF
44
44
  def loose_files(graph)
45
45
  titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
46
46
  graph.unlinked_ids
47
- .map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
47
+ .map { |id| { id: id, title: titles[id], dir: OKF.dir_of(id) } }
48
48
  .sort_by { |file| file[:id] }
49
49
  end
50
50
 
@@ -58,7 +58,7 @@ module OKF
58
58
  files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
59
59
  width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
60
60
  @out.puts
61
- @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
61
+ @out.puts " #{dir_label(folder, slash: true)}"
62
62
  group.each do |file|
63
63
  @out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
64
64
  end
@@ -34,7 +34,7 @@ module OKF
34
34
  def call(argv)
35
35
  options = { json: false, regexp: false, fuzzy: false, engine: nil }
36
36
  parser = OptionParser.new do |o|
37
- o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
37
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--dir D] [--tag T] [--json]"
38
38
  search_engine_note(o)
39
39
  json_flags(o, options, "emit the matches as JSON")
40
40
  projection_flags(o, options)
@@ -59,10 +59,16 @@ module OKF
59
59
  # Build the single-bundle Rack app and hand it to the runner (WEBrick by
60
60
  # default, injected so tests drive this without a socket).
61
61
  def run_server(folder, options)
62
- app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
62
+ # search_endpoint is named here rather than defaulted in App: the page
63
+ # resolves it against the URL the reader is on, and this is the layer that
64
+ # knows the app is mounted at the root. An embedding host mounting App
65
+ # elsewhere passes its own.
66
+ app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
67
+ layout: options[:layout], search_endpoint: "search")
63
68
  # minimal: the banner wants a count, not bodies — and Folder#graph is not
64
69
  # memoized, so a full build here parses every concept a second time (the
65
70
  # App builds its own) purely to print one number.
71
+ app.warm_search
66
72
  count = folder.graph(minimal: true).nodes.size
67
73
  @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
68
74
  serve(app, options)
@@ -92,6 +98,7 @@ module OKF
92
98
  # could not host — a folder deleted out from under one is the question
93
99
  # "where did my bundle go?", and only the registry can answer it.
94
100
  hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
101
+ hub.warm_search
95
102
  concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
96
103
  @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
97
104
  "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
data/lib/okf/cli/stats.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  module OKF
4
4
  class CLI
5
- # Bundle rollups — concepts, types, areas, links, tags — in one screen.
5
+ # Bundle rollups — concepts, dirs, types, links, tags — in one screen.
6
6
  class Stats < Command
7
7
  def self.id
8
8
  :stats
@@ -14,7 +14,7 @@ module OKF
14
14
 
15
15
  def self.help_rows
16
16
  [
17
- [ "stats <dir|@slug> [--json]", "bundle rollups (concepts, types, areas, links, tags)" ]
17
+ [ "stats <dir|@slug> [--json]", "bundle rollups (concepts, dirs, types, links, tags)" ]
18
18
  ]
19
19
  end
20
20
 
@@ -42,43 +42,68 @@ module OKF
42
42
  entries = folder.catalog
43
43
  by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
44
44
  by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
45
+ by_dir = directory_counts(folder)
45
46
  {
46
47
  concepts: entries.size,
48
+ dirs: by_dir.size,
47
49
  areas: by_area.size,
48
50
  types: by_type.size,
49
51
  cross_links: graph.edges.size,
50
52
  tags: graph.tag_index.size,
51
53
  by_type: by_type,
54
+ by_dir: by_dir,
52
55
  by_area: by_area
53
56
  }
54
57
  end
55
58
 
59
+ # Every directory the bundle has, with the concepts that live *directly* in
60
+ # it. Read off Bundle#directory_index — the same map `okf dirs` lists and
61
+ # `--dir` is answered against — rather than off the catalog, which knows
62
+ # only the directories that happen to hold a concept. Grouping the catalog
63
+ # made `stats` and `dirs` report different totals for one bundle, and left
64
+ # an addressable directory out of by_dir entirely: `--dir deeply` answers,
65
+ # but nothing in `stats` said `deeply` was there to ask about.
66
+ #
67
+ # A directory holding nothing directly therefore appears at 0. That is the
68
+ # honest reading — it is the same zero `okf dirs` prints in its Concepts
69
+ # column — and it keeps `dirs` equal to `by_dir.size`. Ties break by path so
70
+ # the order is total, not whatever the sort happened to leave.
71
+ def directory_counts(folder)
72
+ folder.directory_index
73
+ .map { |entry| [ entry[:dir], entry[:count] ] }
74
+ .sort_by { |dir, count| [ -count, dir ] }.to_h
75
+ end
76
+
56
77
  def print_stats(dir, stats)
57
78
  @out.puts "Stats — #{bundle_label(dir)}"
58
79
  @out.puts
59
80
  @out.puts " concepts #{stats[:concepts]}"
60
- @out.puts " areas #{stats[:areas]}"
81
+ @out.puts " dirs #{stats[:dirs]}"
61
82
  @out.puts " concept types #{stats[:types]}"
62
83
  @out.puts " cross-links #{stats[:cross_links]}"
63
84
  @out.puts " distinct tags #{stats[:tags]}"
64
85
  print_stat_breakdown("By type", stats[:by_type])
65
- print_stat_breakdown("By area", stats[:by_area])
86
+ # One grouping word in the human view: `by_area` stays in --json for the
87
+ # deprecation window, but a screen that printed both would be teaching the
88
+ # vocabulary the rest of this change is retiring.
89
+ print_stat_breakdown("By dir", stats[:by_dir]) { |label| dir_label(label) }
66
90
  end
67
91
 
68
92
  def print_stat_breakdown(title, counts)
69
93
  return if counts.empty?
70
94
 
71
- width = counts.keys.map(&:length).max
95
+ labels = counts.keys.map { |key| block_given? ? yield(key) : key }
96
+ width = labels.map(&:length).max
72
97
  @out.puts
73
98
  @out.puts " #{title}"
74
- counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
99
+ counts.each_with_index { |(_, count), i| @out.puts " #{labels[i].ljust(width)} #{count}" }
75
100
  end
76
101
 
77
102
  def print_stats_json(dir, stats)
78
103
  emit_json(bundle_head(dir).merge(
79
- "concepts" => stats[:concepts], "areas" => stats[:areas],
104
+ "concepts" => stats[:concepts], "dirs" => stats[:dirs], "areas" => stats[:areas],
80
105
  "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
81
- "by_type" => stats[:by_type], "by_area" => stats[:by_area]
106
+ "by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_area" => stats[:by_area]
82
107
  ))
83
108
  end
84
109
  end
data/lib/okf/cli/tags.rb CHANGED
@@ -23,9 +23,12 @@ module OKF
23
23
  def call(argv)
24
24
  options = { json: false, by: nil }
25
25
  parser = OptionParser.new do |o|
26
- o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
26
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--json]"
27
27
  json_flags(o, options, "emit the tag index as JSON")
28
- o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
28
+ o.on("--by DIM", %w[type dir area], "group the tags by a concept dimension (type | dir)") do |v|
29
+ options[:by] = v.to_sym
30
+ deprecated("--by area", "--by dir") if options[:by] == :area
31
+ end
29
32
  filter_flags(o, options, :type, :area)
30
33
  help_flag(o)
31
34
  end
@@ -38,10 +41,10 @@ module OKF
38
41
 
39
42
  private
40
43
 
41
- # `tags --by type|area`: the tag index re-cut per concept type or top-level
42
- # area, with within-group counts — the curation view. A tag confined to one
44
+ # `tags --by type|dir`: the tag index re-cut per concept type or directory,
45
+ # with within-group counts — the curation view. A tag confined to one
43
46
  # group at count 1 is scattered; one recurring across groups is connective.
44
- # The --type/--area filters narrow the concepts first, then the grouping cuts.
47
+ # The --type/--dir filters narrow the concepts first, then the grouping cuts.
45
48
  def grouped_tags(dir, options)
46
49
  folder = OKF::Bundle::Folder.load(dir)
47
50
  report_skipped(folder)
@@ -66,7 +69,7 @@ module OKF
66
69
  entry = by_id[id]
67
70
  next if entry.nil?
68
71
 
69
- key = options[:by] == :type ? entry_type(entry) : entry[:area]
72
+ key = group_key(entry, options[:by])
70
73
  ((groups[key] ||= {})[tag] ||= []) << id
71
74
  totals[tag] += 1
72
75
  end
@@ -83,10 +86,29 @@ module OKF
83
86
  OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
84
87
  end
85
88
 
89
+ # The group a concept falls in, in its *stored* spelling — `.` for the root
90
+ # under --by dir, never "(root)". The human label is applied at print time,
91
+ # so the JSON and the table cannot disagree about which one is the data.
92
+ def group_key(entry, dim)
93
+ case dim
94
+ when :type then entry_type(entry)
95
+ when :dir then entry[:dir]
96
+ else entry[:area]
97
+ end
98
+ end
99
+
100
+ # `.` prints "(root)" bare; every other dir carries the trailing slash that
101
+ # says it is one. The deprecated --by area already stores "(root)" itself.
102
+ def group_label(key, dim)
103
+ return key if dim == :type
104
+
105
+ dir_label(key, slash: true)
106
+ end
107
+
86
108
  def print_grouped_tags(dir, dim, groups, titles)
87
109
  @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
88
110
  groups.each do |key, rows|
89
- label = dim == :area && key != "(root)" ? "#{key}/" : key
111
+ label = group_label(key, dim)
90
112
  @out.puts
91
113
  @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
92
114
  width = rows.map { |row| row[:tag].length }.max || 0
data/lib/okf/cli/types.rb CHANGED
@@ -21,7 +21,7 @@ module OKF
21
21
  def call(argv)
22
22
  options = { json: false }
23
23
  parser = OptionParser.new do |o|
24
- o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
24
+ o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--json]"
25
25
  json_flags(o, options, "emit the type index as JSON")
26
26
  filter_flags(o, options, :area, :tag)
27
27
  help_flag(o)
data/lib/okf/cli.rb CHANGED
@@ -32,16 +32,17 @@ module OKF
32
32
  # Declared in emission order, so the "available:" list a typo prints reads
33
33
  # the same as the rows themselves.
34
34
  ROW_FIELDS = {
35
- "matches" => %w[id title type area tags matched score snippet],
35
+ "matches" => %w[id title type dir area tags matched score snippet],
36
36
  # Registry mode labels every row with the bundle it came from; a plain-dir
37
37
  # search has one bundle and no slug to carry. Two shapes, because the typo
38
38
  # guard checks against the *declared* one — a single shape covering both
39
39
  # would let `--fields slug` pass on a search whose rows have none, and hand
40
40
  # back an empty object per match under a count that says otherwise.
41
- "matches_by_ref" => %w[slug id title type area tags matched score snippet],
41
+ "matches_by_ref" => %w[slug id title type dir area tags matched score snippet],
42
42
  "concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
43
43
  "files" => %w[path id dir type title description],
44
- "directories" => %w[dir index_path present synthesized count types tags subdirs body listing],
44
+ "directories" => %w[dir ancestor index_path present synthesized count types tags subdirs body listing],
45
+ "dirs" => %w[dir ancestor count subtree subdirs],
45
46
  "bundles" => %w[slug title dir mount default missing]
46
47
  }.freeze
47
48
 
@@ -103,9 +104,11 @@ module OKF
103
104
  search spans bundles: several leading @slugs, or @all for every registered one
104
105
  (@all skips a bundle whose directory is gone; a named @slug insists on it).
105
106
 
106
- [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
107
+ [filters] narrow a view to matching concepts: --type TYPE, --dir PATH, --tag TAG
107
108
  (each view takes the ones orthogonal to it; matching is case-insensitive).
108
- tags --by DIM regroups the tags per concept dimension — type or area — with
109
+ --dir takes a directory and everything below it; `root` (or `.`) is the bundle
110
+ root. It replaces --area, which still works, warns, and matches one segment.
111
+ tags --by DIM regroups the tags per concept dimension — type or dir — with
109
112
  within-group counts, the view for curating a tag vocabulary.
110
113
  --json emits compact JSON (the machine substrate); add --pretty to indent it.
111
114
  --fields / --except project the JSON to the properties you want (search/index/catalog/files).
@@ -497,6 +500,7 @@ require "okf/cli/loose"
497
500
  require "okf/cli/validate"
498
501
  require "okf/cli/search"
499
502
  require "okf/cli/index"
503
+ require "okf/cli/dirs"
500
504
  require "okf/cli/stats"
501
505
  require "okf/cli/types"
502
506
  require "okf/cli/tags"