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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +294 -0
- data/README.md +310 -445
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +93 -13
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +19 -14
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +241 -14
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +33 -2
- data/lib/okf/cli/server.rb +16 -5
- data/lib/okf/cli/stats.rb +36 -11
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +10 -6
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +504 -103
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/server/app.rb +74 -3
- data/lib/okf/server/hub.rb +40 -30
- data/lib/okf/skill/SKILL.md +17 -10
- 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 +3 -2
- data/lib/okf/skill/playbooks/refine.md +30 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +171 -32
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +10 -0
- metadata +21 -8
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. `--
|
|
9
|
-
# is repeatable
|
|
10
|
-
#
|
|
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> [--
|
|
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> [--
|
|
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("--
|
|
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
|
|
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
|
|
60
|
-
#
|
|
61
|
-
#
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
data/lib/okf/cli/registry.rb
CHANGED
|
@@ -10,7 +10,7 @@ module OKF
|
|
|
10
10
|
class Registry < Command
|
|
11
11
|
# The `registry` umbrella's subcommands — the dispatch, and the words a
|
|
12
12
|
# flag-first invocation is checked against.
|
|
13
|
-
SUBCOMMANDS = %w[set del list default rename].freeze
|
|
13
|
+
SUBCOMMANDS = %w[init set del list default rename group ungroup].freeze
|
|
14
14
|
|
|
15
15
|
def self.id
|
|
16
16
|
:registry
|
|
@@ -22,11 +22,14 @@ module OKF
|
|
|
22
22
|
|
|
23
23
|
def self.help_rows
|
|
24
24
|
[
|
|
25
|
+
[ "registry init", "create a project-local .okf-registry.json (nearest one wins)" ],
|
|
25
26
|
[ "registry list [--json]", "list registered bundles (* marks the default)" ],
|
|
26
27
|
[ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
|
|
27
|
-
[ "registry del <dir|@slug>", "remove a bundle from the registry" ],
|
|
28
|
+
[ "registry del <dir|@slug>", "remove a bundle or group from the registry" ],
|
|
28
29
|
[ "registry default <@slug>", "move a bundle to the front (the default)" ],
|
|
29
|
-
[ "registry rename <@slug> <new>", "rename a
|
|
30
|
+
[ "registry rename <@slug> <new>", "rename a bundle or group (<new> is a new name, not a ref)" ],
|
|
31
|
+
[ "registry group <slug> <@member…>", "create a group, or add members (search/server can target @slug)" ],
|
|
32
|
+
[ "registry ungroup <slug> <@member…>", "remove members from a group (emptying it deletes it)" ]
|
|
30
33
|
]
|
|
31
34
|
end
|
|
32
35
|
|
|
@@ -35,11 +38,14 @@ module OKF
|
|
|
35
38
|
|
|
36
39
|
sub = argv.first
|
|
37
40
|
case sub
|
|
41
|
+
when "init" then registry_init(argv.drop(1))
|
|
38
42
|
when "set" then registry_set(argv.drop(1))
|
|
39
43
|
when "del" then registry_del(argv.drop(1))
|
|
40
44
|
when "list" then registry_list(argv.drop(1))
|
|
41
45
|
when "default" then registry_default(argv.drop(1))
|
|
42
46
|
when "rename" then registry_rename(argv.drop(1))
|
|
47
|
+
when "group" then registry_group(argv.drop(1))
|
|
48
|
+
when "ungroup" then registry_ungroup(argv.drop(1))
|
|
43
49
|
else
|
|
44
50
|
# A bare word that isn't a known subcommand is a typo (`registry remove x`
|
|
45
51
|
# must not silently render the list and read as success).
|
|
@@ -61,6 +67,38 @@ module OKF
|
|
|
61
67
|
|
|
62
68
|
private
|
|
63
69
|
|
|
70
|
+
# Create a project-local .okf-registry.json in the current directory. Once it
|
|
71
|
+
# exists, discovery finds it (walking up from cwd) and every registry op —
|
|
72
|
+
# and every @ref — resolves through it instead of the global $OKF_HOME one.
|
|
73
|
+
# init only writes the empty file; `registry set` fills it. Refuses to clobber
|
|
74
|
+
# an existing local registry, and notes a parent one it would shadow.
|
|
75
|
+
def registry_init(argv)
|
|
76
|
+
parser = OptionParser.new do |o|
|
|
77
|
+
o.banner = "Usage: okf registry init"
|
|
78
|
+
help_flag(o)
|
|
79
|
+
end
|
|
80
|
+
parser.parse!(argv)
|
|
81
|
+
no_extras?(argv) or return 2
|
|
82
|
+
|
|
83
|
+
target = File.join(Dir.pwd, OKF::Registry::LOCAL_FILE)
|
|
84
|
+
display = "./#{OKF::Registry::LOCAL_FILE}"
|
|
85
|
+
return usage_error("already initialized: #{display}") if File.exist?(target)
|
|
86
|
+
|
|
87
|
+
# The parent it would shadow, if any — a courtesy, not a barrier: nested
|
|
88
|
+
# registries resolve nearest-first, so creating one here is legitimate.
|
|
89
|
+
parent = OKF::Registry.discover(File.dirname(Dir.pwd))
|
|
90
|
+
@err.puts "note: a parent registry at #{parent} — the nearest one wins" if parent
|
|
91
|
+
|
|
92
|
+
OKF::Registry.new(target).save
|
|
93
|
+
@out.puts "initialized #{display}"
|
|
94
|
+
0
|
|
95
|
+
rescue OptionParser::ParseError => e
|
|
96
|
+
@err.puts e.message
|
|
97
|
+
2
|
|
98
|
+
rescue OKF::Error => e
|
|
99
|
+
usage_error(e.message)
|
|
100
|
+
end
|
|
101
|
+
|
|
64
102
|
# Add a bundle to the persistent registry (so a later bare `okf server` finds
|
|
65
103
|
# it), or update one already there. The entry is keyed by the bundle's path: a
|
|
66
104
|
# path already registered refreshes its title in place, and --as renames it. A
|
|
@@ -78,7 +116,7 @@ module OKF
|
|
|
78
116
|
# positional through `positional`, which does not check.
|
|
79
117
|
dir = positional_dir(parser, argv) or return 2
|
|
80
118
|
|
|
81
|
-
reg =
|
|
119
|
+
reg = open_registry
|
|
82
120
|
# Said before the upsert: after it, an update is indistinguishable from an
|
|
83
121
|
# add, and "registered" for what was a rename reads as a duplicate entry.
|
|
84
122
|
known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
|
|
@@ -104,7 +142,7 @@ module OKF
|
|
|
104
142
|
slug = positional(parser, argv) or return 2
|
|
105
143
|
no_extras?(argv) or return 2
|
|
106
144
|
|
|
107
|
-
reg =
|
|
145
|
+
reg = open_registry
|
|
108
146
|
slug = registry_slug(slug, reg) or return 2
|
|
109
147
|
removed = reg.remove(slug)
|
|
110
148
|
return usage_error("no such bundle: #{slug}") unless removed
|
|
@@ -131,8 +169,11 @@ module OKF
|
|
|
131
169
|
end
|
|
132
170
|
no_extras?(argv) or return 2
|
|
133
171
|
|
|
134
|
-
reg =
|
|
135
|
-
|
|
172
|
+
reg = open_registry
|
|
173
|
+
if options[:json]
|
|
174
|
+
groups = { "groups" => reg.groups_listing.map { |row| stringify(row) } }
|
|
175
|
+
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options, groups)
|
|
176
|
+
end
|
|
136
177
|
|
|
137
178
|
print_registry(reg)
|
|
138
179
|
0
|
|
@@ -153,7 +194,7 @@ module OKF
|
|
|
153
194
|
slug = positional(parser, argv) or return 2
|
|
154
195
|
no_extras?(argv) or return 2
|
|
155
196
|
|
|
156
|
-
reg =
|
|
197
|
+
reg = open_registry
|
|
157
198
|
slug = registry_slug(slug, reg) or return 2
|
|
158
199
|
reg.default = slug
|
|
159
200
|
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
@@ -196,7 +237,7 @@ module OKF
|
|
|
196
237
|
end
|
|
197
238
|
no_extras?(argv) or return 2
|
|
198
239
|
|
|
199
|
-
reg =
|
|
240
|
+
reg = open_registry
|
|
200
241
|
# The old name may be a ref; the new one is a name being minted, never one.
|
|
201
242
|
old_slug = registry_slug(old_slug, reg) or return 2
|
|
202
243
|
entry = reg.rename(old_slug, new_slug)
|
|
@@ -211,15 +252,111 @@ module OKF
|
|
|
211
252
|
usage_error(e.message)
|
|
212
253
|
end
|
|
213
254
|
|
|
255
|
+
# Create a group, or add members to one. Members are bundle or group slugs,
|
|
256
|
+
# bare or as @refs; the model normalizes, unions, checks each names something,
|
|
257
|
+
# and refuses a cycle. Only `search`/`server` can then target @slug.
|
|
258
|
+
def registry_group(argv)
|
|
259
|
+
parser = OptionParser.new do |o|
|
|
260
|
+
o.banner = "Usage: okf registry group <slug> <@member…>"
|
|
261
|
+
help_flag(o)
|
|
262
|
+
end
|
|
263
|
+
parser.parse!(argv)
|
|
264
|
+
slug = argv.shift
|
|
265
|
+
if slug.nil? || argv.empty?
|
|
266
|
+
@err.puts parser.banner
|
|
267
|
+
return 2
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
reg = open_registry
|
|
271
|
+
group = reg.set_group(slug, argv)
|
|
272
|
+
count = reg.expand(group.slug).size
|
|
273
|
+
@out.puts "grouped #{group.slug} → #{group.members.map { |m| "@#{m}" }.join(", ")} " \
|
|
274
|
+
"(#{count} #{pluralize(count, "bundle")})"
|
|
275
|
+
0
|
|
276
|
+
rescue OptionParser::ParseError => e
|
|
277
|
+
@err.puts e.message
|
|
278
|
+
2
|
|
279
|
+
rescue OKF::Error => e
|
|
280
|
+
usage_error(e.message)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# Remove members from a group. Emptying it deletes the group — an empty group
|
|
284
|
+
# resolves to nothing, so it is not worth keeping.
|
|
285
|
+
def registry_ungroup(argv)
|
|
286
|
+
parser = OptionParser.new do |o|
|
|
287
|
+
o.banner = "Usage: okf registry ungroup <slug> <@member…>"
|
|
288
|
+
help_flag(o)
|
|
289
|
+
end
|
|
290
|
+
parser.parse!(argv)
|
|
291
|
+
slug = argv.shift
|
|
292
|
+
if slug.nil? || argv.empty?
|
|
293
|
+
@err.puts parser.banner
|
|
294
|
+
return 2
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
reg = open_registry
|
|
298
|
+
removed, emptied = reg.unset_group_members(slug, argv)
|
|
299
|
+
name = OKF::Registry.normalize(slug)
|
|
300
|
+
if emptied
|
|
301
|
+
@out.puts "removed empty group #{name}"
|
|
302
|
+
elsif removed.empty?
|
|
303
|
+
@out.puts "no members removed from #{name} (none of #{argv.join(", ")} were in it)"
|
|
304
|
+
else
|
|
305
|
+
@out.puts "ungrouped #{removed.map { |m| "@#{m}" }.join(", ")} from #{name}"
|
|
306
|
+
end
|
|
307
|
+
0
|
|
308
|
+
rescue OptionParser::ParseError => e
|
|
309
|
+
@err.puts e.message
|
|
310
|
+
2
|
|
311
|
+
rescue OKF::Error => e
|
|
312
|
+
usage_error(e.message)
|
|
313
|
+
end
|
|
314
|
+
|
|
214
315
|
def print_registry(reg)
|
|
215
|
-
|
|
316
|
+
# A header only when a project-local registry is in play — the case where
|
|
317
|
+
# "which registry am I looking at?" is a real question. The global $OKF_HOME
|
|
318
|
+
# one is the default, so it stays headerless (and the JSON envelope names
|
|
319
|
+
# the file for a script either way).
|
|
320
|
+
@out.puts "registry: #{registry_display(reg)}" if local_registry?(reg)
|
|
321
|
+
groups = reg.groups_listing
|
|
322
|
+
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty? && groups.empty?
|
|
216
323
|
|
|
217
324
|
rows = reg.listing
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
325
|
+
unless rows.empty?
|
|
326
|
+
width = rows.map { |row| row[:slug].length }.max
|
|
327
|
+
rows.each do |row|
|
|
328
|
+
marker = row[:default] ? "*" : " "
|
|
329
|
+
missing = row[:missing] ? " (missing)" : ""
|
|
330
|
+
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
331
|
+
end
|
|
332
|
+
end
|
|
333
|
+
print_groups(groups, rows) unless groups.empty?
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Whether this registry was discovered as a project-local file rather than
|
|
337
|
+
# read from $OKF_HOME — the basename settles it (only a local one is named
|
|
338
|
+
# .okf-registry.json).
|
|
339
|
+
def local_registry?(reg)
|
|
340
|
+
File.basename(reg.path) == OKF::Registry::LOCAL_FILE
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# How to name the local registry in the header: `./` when it sits in cwd
|
|
344
|
+
# (the common case, a bare `init` here), its absolute path when discovery
|
|
345
|
+
# walked up to an ancestor.
|
|
346
|
+
def registry_display(reg)
|
|
347
|
+
File.dirname(reg.path) == Dir.pwd ? "./#{OKF::Registry::LOCAL_FILE}" : reg.path
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# The groups section under the bundle listing: one row per group, its members
|
|
351
|
+
# and how many bundles it resolves to (a hand-edited cycle shows `(cycle)`).
|
|
352
|
+
def print_groups(groups, rows)
|
|
353
|
+
@out.puts "" unless rows.empty?
|
|
354
|
+
@out.puts "groups:"
|
|
355
|
+
width = groups.map { |group| group[:slug].length }.max
|
|
356
|
+
groups.each do |group|
|
|
357
|
+
members = group[:members].map { |m| "@#{m}" }.join(", ")
|
|
358
|
+
count = group[:resolved].nil? ? "cycle" : "#{group[:resolved]} #{pluralize(group[:resolved], "bundle")}"
|
|
359
|
+
@out.puts " #{group[:slug].ljust(width)} #{members} (#{count})"
|
|
223
360
|
end
|
|
224
361
|
end
|
|
225
362
|
end
|
data/lib/okf/cli/render.rb
CHANGED
|
@@ -23,20 +23,21 @@ module OKF
|
|
|
23
23
|
def call(argv)
|
|
24
24
|
require "okf/render/graph"
|
|
25
25
|
|
|
26
|
-
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
26
|
+
options = { output: nil, title: nil, link: nil, layout: "cose", map: false }
|
|
27
27
|
parser = OptionParser.new do |o|
|
|
28
28
|
o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
|
|
29
29
|
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
30
30
|
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
31
31
|
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
32
32
|
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
33
|
+
o.on("--map", "open in the Map view: concepts boxed by directory, links on selection") { options[:map] = true }
|
|
33
34
|
help_flag(o)
|
|
34
35
|
end
|
|
35
36
|
dir = positional_dir(parser, argv) or return 2
|
|
36
37
|
|
|
37
38
|
folder = OKF::Bundle::Folder.load(dir)
|
|
38
39
|
report_skipped(folder)
|
|
39
|
-
html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
|
|
40
|
+
html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout], map: options[:map])
|
|
40
41
|
if options[:output]
|
|
41
42
|
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
42
43
|
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
data/lib/okf/cli/search.rb
CHANGED
|
@@ -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] [--
|
|
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)
|
|
@@ -158,8 +158,17 @@ module OKF
|
|
|
158
158
|
pairs
|
|
159
159
|
end
|
|
160
160
|
|
|
161
|
-
# One @ref as
|
|
161
|
+
# One @ref as [[slug, dir], …]: a group fans out to its readable member
|
|
162
|
+
# bundles, a plain @slug is the single-element pair it always was. nil after
|
|
163
|
+
# reporting. `ref_targets` dedupes across refs, and #expand within a group, so
|
|
164
|
+
# `@backend @okf` (okf ∈ backend) still searches okf once.
|
|
162
165
|
def ref_pair(ref)
|
|
166
|
+
registry = load_registry
|
|
167
|
+
return nil unless registry
|
|
168
|
+
|
|
169
|
+
slug = OKF::Registry.normalize(ref[1..-1])
|
|
170
|
+
return group_pairs(registry, slug) if !slug.empty? && registry.group?(slug)
|
|
171
|
+
|
|
163
172
|
path = resolve_registered(ref)
|
|
164
173
|
unless path
|
|
165
174
|
# Only an unknown slug is plausibly a mistyped term — a broken registry
|
|
@@ -170,6 +179,28 @@ module OKF
|
|
|
170
179
|
[ [ ref_slugs[path], path ] ]
|
|
171
180
|
end
|
|
172
181
|
|
|
182
|
+
# A group's readable member bundles as [slug, dir] pairs, skipping vanished
|
|
183
|
+
# ones with a note (as `@all` does) and labelling each leaf by its own slug.
|
|
184
|
+
# nil (reported) when nothing readable is left, or on a hand-edited cycle.
|
|
185
|
+
def group_pairs(registry, slug)
|
|
186
|
+
pairs = []
|
|
187
|
+
registry.expand(slug).each do |entry|
|
|
188
|
+
if File.directory?(entry.path)
|
|
189
|
+
ref_slugs[entry.path] = entry.slug
|
|
190
|
+
pairs << [ entry.slug, entry.path ]
|
|
191
|
+
else
|
|
192
|
+
skip_registered(entry)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
return pairs unless pairs.empty?
|
|
196
|
+
|
|
197
|
+
@err.puts "error: @#{slug} resolves to no readable bundle (okf registry list)"
|
|
198
|
+
nil
|
|
199
|
+
rescue OKF::Error => e
|
|
200
|
+
@err.puts "error: #{e.message}"
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
173
204
|
# Search every bundle at once and merge the rankings, each row labeled with
|
|
174
205
|
# its bundle's slug. The bundles go in as *one* corpus rather than one search
|
|
175
206
|
# each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
|
data/lib/okf/cli/server.rb
CHANGED
|
@@ -24,7 +24,7 @@ module OKF
|
|
|
24
24
|
require "okf/server/app"
|
|
25
25
|
require "rack/deflater"
|
|
26
26
|
|
|
27
|
-
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false }
|
|
27
|
+
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false, map: false }
|
|
28
28
|
parser = OptionParser.new do |o|
|
|
29
29
|
o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
30
30
|
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
@@ -32,10 +32,14 @@ module OKF
|
|
|
32
32
|
o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
33
33
|
o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
|
|
34
34
|
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
35
|
+
o.on("--map", "open in the Map view: concepts boxed by directory, links on selection") { options[:map] = true }
|
|
35
36
|
o.on("--read-only", "serve the bundles list without its registry controls") { options[:read_only] = true }
|
|
36
37
|
help_flag(o)
|
|
37
38
|
end
|
|
38
|
-
|
|
39
|
+
# expand_groups: `okf server @backend` fans a group out to its member
|
|
40
|
+
# bundles (single-bundle verbs reject a group; server is one of the two that
|
|
41
|
+
# take a set).
|
|
42
|
+
dirs = positional_dirs(parser, argv, expand_groups: true) or return 2
|
|
39
43
|
|
|
40
44
|
# A flag that will have no effect in this mode gets a note, not silence.
|
|
41
45
|
@err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
|
|
@@ -59,10 +63,16 @@ module OKF
|
|
|
59
63
|
# Build the single-bundle Rack app and hand it to the runner (WEBrick by
|
|
60
64
|
# default, injected so tests drive this without a socket).
|
|
61
65
|
def run_server(folder, options)
|
|
62
|
-
|
|
66
|
+
# search_endpoint is named here rather than defaulted in App: the page
|
|
67
|
+
# resolves it against the URL the reader is on, and this is the layer that
|
|
68
|
+
# knows the app is mounted at the root. An embedding host mounting App
|
|
69
|
+
# elsewhere passes its own.
|
|
70
|
+
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
|
|
71
|
+
layout: options[:layout], search_endpoint: "search", map: options[:map])
|
|
63
72
|
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
64
73
|
# memoized, so a full build here parses every concept a second time (the
|
|
65
74
|
# App builds its own) purely to print one number.
|
|
75
|
+
app.warm_search
|
|
66
76
|
count = folder.graph(minimal: true).nodes.size
|
|
67
77
|
@out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
68
78
|
serve(app, options)
|
|
@@ -80,7 +90,7 @@ module OKF
|
|
|
80
90
|
if dirs.empty?
|
|
81
91
|
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
82
92
|
# usage error — no guarded load needed on this path.
|
|
83
|
-
reg =
|
|
93
|
+
reg = open_registry
|
|
84
94
|
# The hub's own loader, so the set it rebuilds after a browser-side
|
|
85
95
|
# write is built exactly the way this one was.
|
|
86
96
|
bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
|
|
@@ -91,7 +101,8 @@ module OKF
|
|
|
91
101
|
# The hub keeps the registry so its /b/ manager can report on entries it
|
|
92
102
|
# could not host — a folder deleted out from under one is the question
|
|
93
103
|
# "where did my bundle go?", and only the registry can answer it.
|
|
94
|
-
hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
|
|
104
|
+
hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options), map: options[:map])
|
|
105
|
+
hub.warm_search
|
|
95
106
|
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
96
107
|
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
97
108
|
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|