okf 1.11.0 → 1.13.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 +193 -1
- data/README.md +77 -428
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/reader.rb +39 -16
- data/lib/okf/bundle/search.rb +5 -5
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +37 -17
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +139 -19
- data/lib/okf/cli/dirs.rb +1 -1
- data/lib/okf/cli/files.rb +1 -1
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.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 +53 -8
- data/lib/okf/cli/server.rb +9 -5
- data/lib/okf/cli/stats.rb +8 -8
- data/lib/okf/cli/tags.rb +2 -2
- data/lib/okf/cli.rb +3 -3
- data/lib/okf/concept/file.rb +17 -2
- data/lib/okf/path.rb +17 -3
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +211 -7
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/safe_read.rb +50 -0
- data/lib/okf/server/app.rb +13 -3
- data/lib/okf/server/hub.rb +5 -3
- data/lib/okf/skill/SKILL.md +5 -1
- data/lib/okf/skill/playbooks/menu.md +2 -1
- data/lib/okf/skill/playbooks/refine.md +26 -0
- data/lib/okf/skill/reference/cli.md +81 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +2 -0
- metadata +4 -3
- data/CODE_OF_CONDUCT.md +0 -10
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
|
@@ -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
|
|
@@ -178,16 +209,30 @@ module OKF
|
|
|
178
209
|
#
|
|
179
210
|
# Filters stay per-bundle — they are per-folder questions — so they apply to
|
|
180
211
|
# the merged rows by (slug, id) afterwards.
|
|
212
|
+
#
|
|
213
|
+
# The one thing that is *not* a per-folder question is what `--dir root`
|
|
214
|
+
# means. The alias yields to a directory that really carries the name, so
|
|
215
|
+
# resolving it inside this loop made one flag mean two things in one
|
|
216
|
+
# ranking: the `root/` subtree where a bundle has one, the bundle root
|
|
217
|
+
# where it does not, merged with nothing in the output saying so. The
|
|
218
|
+
# served set answers it once, and a bundle without the directory then
|
|
219
|
+
# matches nothing — which is what `--dir` already does everywhere for a
|
|
220
|
+
# directory a bundle lacks.
|
|
181
221
|
def multi_search(pairs, terms, options)
|
|
182
|
-
|
|
183
|
-
keeps = {}
|
|
184
|
-
total = 0
|
|
185
|
-
pairs.each do |slug, dir|
|
|
222
|
+
folders = pairs.map do |slug, dir|
|
|
186
223
|
folder = OKF::Bundle::Folder.load(dir)
|
|
187
224
|
report_skipped(folder)
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
225
|
+
[ slug, folder ]
|
|
226
|
+
end
|
|
227
|
+
total = folders.reduce(0) { |sum, (_, folder)| sum + folder.bundle.concepts.size }
|
|
228
|
+
bundles = folders.map { |slug, folder| [ slug, folder.bundle ] }
|
|
229
|
+
# The served set's directories, only when a flag will consult them —
|
|
230
|
+
# the alias is resolved once across the whole run (see filter_ids),
|
|
231
|
+
# and an unfiltered search never pays the walk.
|
|
232
|
+
dirs = options[:dir] || options[:area] ? folders.flat_map { |_, folder| folder.directories }.uniq : nil
|
|
233
|
+
keeps = {}
|
|
234
|
+
folders.each do |slug, folder|
|
|
235
|
+
keep = filter_ids(folder, options, dirs)
|
|
191
236
|
keeps[slug] = keep unless keep.nil?
|
|
192
237
|
end
|
|
193
238
|
rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
|
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])
|
|
@@ -64,7 +68,7 @@ module OKF
|
|
|
64
68
|
# knows the app is mounted at the root. An embedding host mounting App
|
|
65
69
|
# elsewhere passes its own.
|
|
66
70
|
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
|
|
67
|
-
layout: options[:layout], search_endpoint: "search")
|
|
71
|
+
layout: options[:layout], search_endpoint: "search", map: options[:map])
|
|
68
72
|
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
69
73
|
# memoized, so a full build here parses every concept a second time (the
|
|
70
74
|
# App builds its own) purely to print one number.
|
|
@@ -86,7 +90,7 @@ module OKF
|
|
|
86
90
|
if dirs.empty?
|
|
87
91
|
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
88
92
|
# usage error — no guarded load needed on this path.
|
|
89
|
-
reg =
|
|
93
|
+
reg = open_registry
|
|
90
94
|
# The hub's own loader, so the set it rebuilds after a browser-side
|
|
91
95
|
# write is built exactly the way this one was.
|
|
92
96
|
bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
|
|
@@ -97,7 +101,7 @@ module OKF
|
|
|
97
101
|
# The hub keeps the registry so its /b/ manager can report on entries it
|
|
98
102
|
# could not host — a folder deleted out from under one is the question
|
|
99
103
|
# "where did my bundle go?", and only the registry can answer it.
|
|
100
|
-
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])
|
|
101
105
|
hub.warm_search
|
|
102
106
|
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
103
107
|
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
data/lib/okf/cli/stats.rb
CHANGED
|
@@ -41,18 +41,18 @@ module OKF
|
|
|
41
41
|
graph = folder.graph(minimal: true)
|
|
42
42
|
entries = folder.catalog
|
|
43
43
|
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
44
|
-
|
|
44
|
+
by_top_dir = entries.group_by { |entry| entry[:top_dir] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
45
45
|
by_dir = directory_counts(folder)
|
|
46
46
|
{
|
|
47
47
|
concepts: entries.size,
|
|
48
48
|
dirs: by_dir.size,
|
|
49
|
-
|
|
49
|
+
top_dirs: by_top_dir.size,
|
|
50
50
|
types: by_type.size,
|
|
51
51
|
cross_links: graph.edges.size,
|
|
52
52
|
tags: graph.tag_index.size,
|
|
53
53
|
by_type: by_type,
|
|
54
54
|
by_dir: by_dir,
|
|
55
|
-
|
|
55
|
+
by_top_dir: by_top_dir
|
|
56
56
|
}
|
|
57
57
|
end
|
|
58
58
|
|
|
@@ -83,9 +83,9 @@ module OKF
|
|
|
83
83
|
@out.puts " cross-links #{stats[:cross_links]}"
|
|
84
84
|
@out.puts " distinct tags #{stats[:tags]}"
|
|
85
85
|
print_stat_breakdown("By type", stats[:by_type])
|
|
86
|
-
# One grouping word in the human view: `
|
|
87
|
-
#
|
|
88
|
-
#
|
|
86
|
+
# One grouping word in the human view: `by_top_dir` stays in --json (the
|
|
87
|
+
# first-segment rollup) but a screen that printed both it and `by_dir`
|
|
88
|
+
# would double up on one idea, so the human view shows the full-path cut.
|
|
89
89
|
print_stat_breakdown("By dir", stats[:by_dir]) { |label| dir_label(label) }
|
|
90
90
|
end
|
|
91
91
|
|
|
@@ -101,9 +101,9 @@ module OKF
|
|
|
101
101
|
|
|
102
102
|
def print_stats_json(dir, stats)
|
|
103
103
|
emit_json(bundle_head(dir).merge(
|
|
104
|
-
"concepts" => stats[:concepts], "dirs" => stats[:dirs], "
|
|
104
|
+
"concepts" => stats[:concepts], "dirs" => stats[:dirs], "top_dirs" => stats[:top_dirs],
|
|
105
105
|
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
106
|
-
"by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "
|
|
106
|
+
"by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_top_dir" => stats[:by_top_dir]
|
|
107
107
|
))
|
|
108
108
|
end
|
|
109
109
|
end
|
data/lib/okf/cli/tags.rb
CHANGED
|
@@ -61,7 +61,7 @@ module OKF
|
|
|
61
61
|
# makes a tag's spread — local to one group, or cutting across several —
|
|
62
62
|
# readable without cross-referencing the groups by hand.
|
|
63
63
|
def tag_groups(tag_index, folder, options)
|
|
64
|
-
by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
|
|
64
|
+
by_id = filter_entries(folder.catalog, options, dir_scope(folder, options)).map { |entry| [ entry[:id], entry ] }.to_h
|
|
65
65
|
groups = {}
|
|
66
66
|
totals = Hash.new(0)
|
|
67
67
|
tag_index.each do |tag, ids|
|
|
@@ -93,7 +93,7 @@ module OKF
|
|
|
93
93
|
case dim
|
|
94
94
|
when :type then entry_type(entry)
|
|
95
95
|
when :dir then entry[:dir]
|
|
96
|
-
else entry[:
|
|
96
|
+
else entry[:top_dir]
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
data/lib/okf/cli.rb
CHANGED
|
@@ -32,14 +32,14 @@ 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 dir
|
|
35
|
+
"matches" => %w[id title type dir top_dir 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 dir
|
|
42
|
-
"concepts" => %w[id title type description tags timestamp status backlog_ref dir
|
|
41
|
+
"matches_by_ref" => %w[slug id title type dir top_dir tags matched score snippet],
|
|
42
|
+
"concepts" => %w[id title type description tags timestamp status backlog_ref dir top_dir links_out links_in],
|
|
43
43
|
"files" => %w[path id dir type title description],
|
|
44
44
|
"directories" => %w[dir ancestor index_path present synthesized count types tags subdirs body listing],
|
|
45
45
|
"dirs" => %w[dir ancestor count subtree subdirs],
|
data/lib/okf/concept/file.rb
CHANGED
|
@@ -14,6 +14,13 @@ module OKF
|
|
|
14
14
|
#
|
|
15
15
|
# NOTE: this class is named File, which shadows Ruby's File inside the
|
|
16
16
|
# OKF::Concept namespace — every filesystem call here uses ::File explicitly.
|
|
17
|
+
#
|
|
18
|
+
# absolute_path guards the *name* lexically (Path.join_under!), which is all a
|
|
19
|
+
# write needs — the file may not exist yet. A read has more to prove: the file
|
|
20
|
+
# is on disk now, so it may be a symlink whose name is inside the root but
|
|
21
|
+
# whose target is not, and File.expand_path does not resolve links. So #read
|
|
22
|
+
# goes through SafeRead, which realpath-resolves and refuses a target outside
|
|
23
|
+
# the root, closing the same escape Bundle::Reader closes on the bulk read.
|
|
17
24
|
class File
|
|
18
25
|
attr_reader :root, :path, :concept
|
|
19
26
|
|
|
@@ -53,11 +60,19 @@ module OKF
|
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def reload
|
|
56
|
-
|
|
57
|
-
frontmatter, body = Markdown::Frontmatter.parse(content)
|
|
63
|
+
frontmatter, body = Markdown::Frontmatter.parse(read)
|
|
58
64
|
@concept = Concept.new(path: @path, frontmatter: frontmatter, body: body)
|
|
59
65
|
self
|
|
60
66
|
end
|
|
67
|
+
|
|
68
|
+
# The file's own bytes, refused if the resolved target escapes the root by
|
|
69
|
+
# symlink. This is the guarded read a caller that wants the raw markdown
|
|
70
|
+
# (not a re-serialized `concept.to_markdown`) must use instead of reading
|
|
71
|
+
# #absolute_path itself — that path guards the *name* lexically, which a
|
|
72
|
+
# write needs but a read does not, since the file exists and may be a link.
|
|
73
|
+
def read
|
|
74
|
+
SafeRead.read!(@root, absolute_path)
|
|
75
|
+
end
|
|
61
76
|
end
|
|
62
77
|
end
|
|
63
78
|
end
|
data/lib/okf/path.rb
CHANGED
|
@@ -24,11 +24,25 @@ module OKF
|
|
|
24
24
|
relative = normalize_relative!(path)
|
|
25
25
|
expanded_root = File.expand_path(root.to_s)
|
|
26
26
|
expanded_path = File.expand_path(File.join(expanded_root, relative))
|
|
27
|
-
|
|
28
|
-
raise Error, "path escapes bundle root"
|
|
29
|
-
end
|
|
27
|
+
raise Error, "path escapes bundle root" unless under?(expanded_root, expanded_path)
|
|
30
28
|
|
|
31
29
|
expanded_path
|
|
32
30
|
end
|
|
31
|
+
|
|
32
|
+
# Is +path+ the root itself or a descendant of it? Pure string containment
|
|
33
|
+
# (no disk access), so it works on both lexical paths (File.expand_path) and
|
|
34
|
+
# symlink-resolved ones (File.realpath) — the shell resolves, this decides.
|
|
35
|
+
# Both arguments must already be absolute and normalized the same way.
|
|
36
|
+
#
|
|
37
|
+
# The prefix guards against a sibling passing as a child ("/foo" is not under
|
|
38
|
+
# "/food"), and reuses the root itself as the prefix when the root already
|
|
39
|
+
# ends in the separator — i.e. the filesystem root "/", whose children would
|
|
40
|
+
# otherwise be tested against "//" and every one rejected.
|
|
41
|
+
def self.under?(root, path)
|
|
42
|
+
return true if path == root
|
|
43
|
+
|
|
44
|
+
prefix = root.end_with?(File::SEPARATOR) ? root : "#{root}#{File::SEPARATOR}"
|
|
45
|
+
path.start_with?(prefix)
|
|
46
|
+
end
|
|
33
47
|
end
|
|
34
48
|
end
|