textus 0.4.0 → 0.5.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 +64 -1
- data/README.md +13 -11
- data/SPEC.md +13 -9
- data/docs/architecture.md +63 -28
- data/lib/textus/audit_log.rb +46 -11
- data/lib/textus/builder.rb +3 -3
- data/lib/textus/builtin_actions.rb +5 -5
- data/lib/textus/cli/accept.rb +13 -0
- data/lib/textus/cli/action.rb +51 -0
- data/lib/textus/cli/build.rb +11 -0
- data/lib/textus/cli/delete.rb +14 -0
- data/lib/textus/cli/deprecated_alias.rb +31 -0
- data/lib/textus/cli/deps.rb +10 -0
- data/lib/textus/cli/doctor.rb +13 -0
- data/lib/textus/cli/extension_group.rb +9 -0
- data/lib/textus/cli/extensions.rb +49 -0
- data/lib/textus/cli/get.rb +10 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/init.rb +12 -0
- data/lib/textus/cli/intro.rb +9 -0
- data/lib/textus/cli/key_group.rb +10 -0
- data/lib/textus/cli/list.rb +12 -0
- data/lib/textus/cli/migrate.rb +41 -0
- data/lib/textus/cli/migrate_keys.rb +19 -0
- data/lib/textus/cli/mv.rb +20 -0
- data/lib/textus/cli/published.rb +9 -0
- data/lib/textus/cli/put.rb +48 -0
- data/lib/textus/cli/rdeps.rb +10 -0
- data/lib/textus/cli/refresh.rb +13 -0
- data/lib/textus/cli/schema.rb +10 -0
- data/lib/textus/cli/schema_diff.rb +15 -0
- data/lib/textus/cli/schema_group.rb +33 -0
- data/lib/textus/cli/schema_init.rb +19 -0
- data/lib/textus/cli/schema_migrate.rb +19 -0
- data/lib/textus/cli/stale.rb +12 -0
- data/lib/textus/cli/uid.rb +15 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli/where.rb +10 -0
- data/lib/textus/cli.rb +65 -387
- data/lib/textus/doctor.rb +64 -33
- data/lib/textus/entry/json.rb +6 -4
- data/lib/textus/entry/markdown.rb +4 -4
- data/lib/textus/entry/text.rb +3 -3
- data/lib/textus/entry/yaml.rb +6 -4
- data/lib/textus/entry.rb +2 -2
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/init.rb +1 -1
- data/lib/textus/intro.rb +2 -2
- data/lib/textus/manifest.rb +11 -221
- data/lib/textus/manifest_entry.rb +185 -0
- data/lib/textus/migrate_v2.rb +27 -0
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +3 -3
- data/lib/textus/refresh.rb +7 -7
- data/lib/textus/schema_tools.rb +8 -8
- data/lib/textus/store/events.rb +31 -0
- data/lib/textus/store/mover.rb +118 -0
- data/lib/textus/store/staleness.rb +142 -0
- data/lib/textus/store/validator.rb +53 -0
- data/lib/textus/store.rb +49 -354
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +38 -0
- metadata +38 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class DoctorVerb < Verb
|
|
4
|
+
option :checks, "--check=NAME"
|
|
5
|
+
|
|
6
|
+
def call(store)
|
|
7
|
+
check_list = checks&.split(",")&.map(&:strip)
|
|
8
|
+
res = Textus::Doctor.run(store, checks: check_list)
|
|
9
|
+
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Extensions < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "extensions"
|
|
7
|
+
def self.replacement_path = "extension list"
|
|
8
|
+
|
|
9
|
+
option :kind, "--kind=K"
|
|
10
|
+
|
|
11
|
+
def call(store) # rubocop:disable Metrics/AbcSize
|
|
12
|
+
# When invoked as the flat alias `textus extensions list`, the positional "list"
|
|
13
|
+
# is still present. When invoked via `textus extension list` (group), it has been
|
|
14
|
+
# consumed by the group dispatcher — both are valid.
|
|
15
|
+
subcommand = positional.first
|
|
16
|
+
if subcommand
|
|
17
|
+
raise UsageError.new("extensions requires 'list'") unless subcommand == "list"
|
|
18
|
+
|
|
19
|
+
positional.shift
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
rows = []
|
|
23
|
+
rows += store.registry.action_names.map { |n| { "kind" => "action", "name" => n.to_s } }
|
|
24
|
+
rows += store.registry.doctor_check_names.map { |n| { "kind" => "doctor_check", "name" => n.to_s } }
|
|
25
|
+
rows += store.registry.reducer_names.map { |n| { "kind" => "reducer", "name" => n.to_s } }
|
|
26
|
+
store.registry.hook_events.each do |evt|
|
|
27
|
+
store.registry.hooks(evt).each do |h|
|
|
28
|
+
rows << { "kind" => "hook", "event" => evt.to_s, "name" => h[:name].to_s }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
store.manifest.entries.each do |e|
|
|
32
|
+
e.events.each do |evt, defs|
|
|
33
|
+
Array(defs).each do |defn|
|
|
34
|
+
next unless defn["exec"]
|
|
35
|
+
|
|
36
|
+
rows << {
|
|
37
|
+
"kind" => "hook", "event" => evt.to_s, "exec" => defn["exec"],
|
|
38
|
+
"key" => e.key, "as" => defn["as"] || "script"
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
rows.select! { |r| r["kind"] == kind } if kind
|
|
44
|
+
|
|
45
|
+
emit({ "extensions" => rows })
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Group < Verb
|
|
4
|
+
class << self
|
|
5
|
+
def subcommands
|
|
6
|
+
@subcommands ||= {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def cli_name
|
|
10
|
+
@cli_name || raise("subclass must define cli_name")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_writer :cli_name
|
|
14
|
+
|
|
15
|
+
def inherited(subclass)
|
|
16
|
+
super
|
|
17
|
+
subclass.instance_variable_set(:@subcommands, {})
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def needs_store?
|
|
21
|
+
# Delegate to the matched subcommand at parse time; default true.
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def parse(argv)
|
|
27
|
+
subname = argv.shift
|
|
28
|
+
if subname.nil?
|
|
29
|
+
raise UsageError.new(
|
|
30
|
+
"#{self.class.cli_name} requires a subcommand: #{self.class.subcommands.keys.join(", ")}",
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@sub_klass = self.class.subcommands[subname]
|
|
35
|
+
unless @sub_klass
|
|
36
|
+
raise UsageError.new(
|
|
37
|
+
"unknown #{self.class.cli_name} subcommand '#{subname}'. " \
|
|
38
|
+
"Valid: #{self.class.subcommands.keys.join(", ")}",
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
43
|
+
@sub.parse(argv)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call(store)
|
|
47
|
+
@sub.call(@sub_klass.needs_store? ? store : nil)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Migrate < Verb
|
|
4
|
+
# Does not need a store — the target manifest may still be textus/1.
|
|
5
|
+
def self.needs_store? = false
|
|
6
|
+
|
|
7
|
+
def call(_store)
|
|
8
|
+
target = positional.shift or raise UsageError.new("migrate requires a target (e.g. 'v2')")
|
|
9
|
+
raise UsageError.new("unknown migration target: #{target.inspect}; supported: v2") unless target == "v2"
|
|
10
|
+
|
|
11
|
+
# Locate the .textus directory the same way Store.discover does.
|
|
12
|
+
root = find_textus_root
|
|
13
|
+
emit(Textus::MigrateV2.run(root))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def find_textus_root
|
|
19
|
+
explicit = ENV.fetch("TEXTUS_ROOT", nil)
|
|
20
|
+
if explicit
|
|
21
|
+
abs = File.expand_path(explicit)
|
|
22
|
+
return abs if File.directory?(abs)
|
|
23
|
+
|
|
24
|
+
raise IoError.new("no textus store at #{abs}")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
dir = File.expand_path(@cwd)
|
|
28
|
+
loop do
|
|
29
|
+
candidate = File.join(dir, ".textus")
|
|
30
|
+
return candidate if File.directory?(candidate) && File.exist?(File.join(candidate, "manifest.yaml"))
|
|
31
|
+
|
|
32
|
+
parent = File.dirname(dir)
|
|
33
|
+
break if parent == dir
|
|
34
|
+
|
|
35
|
+
dir = parent
|
|
36
|
+
end
|
|
37
|
+
raise IoError.new("no .textus directory found from #{@cwd}")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class MigrateKeysVerb < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "migrate-keys"
|
|
7
|
+
def self.replacement_path = "key migrate"
|
|
8
|
+
|
|
9
|
+
option :write, "--write"
|
|
10
|
+
option :dry_run, "--dry-run"
|
|
11
|
+
|
|
12
|
+
def call(store)
|
|
13
|
+
effective_write = write && !dry_run
|
|
14
|
+
res = Textus::MigrateKeys.run(store, write: effective_write || false)
|
|
15
|
+
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Mv < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "mv"
|
|
7
|
+
def self.replacement_path = "key mv"
|
|
8
|
+
|
|
9
|
+
option :as_flag, "--as=ROLE"
|
|
10
|
+
option :dry_run, "--dry-run"
|
|
11
|
+
|
|
12
|
+
def call(store)
|
|
13
|
+
old_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
14
|
+
new_key = positional.shift or raise UsageError.new("mv requires <old-key> <new-key>")
|
|
15
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
16
|
+
emit(store.mv(old_key, new_key, as: role, dry_run: dry_run || false))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Put < Verb
|
|
4
|
+
option :as_flag, "--as=ROLE"
|
|
5
|
+
option :use_stdin, "--stdin"
|
|
6
|
+
option :action_name, "--action=NAME"
|
|
7
|
+
|
|
8
|
+
def call(store) # rubocop:disable Metrics/AbcSize
|
|
9
|
+
key = positional.shift or raise UsageError.new("put requires a key")
|
|
10
|
+
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
11
|
+
|
|
12
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
13
|
+
|
|
14
|
+
raw = @stdin.read
|
|
15
|
+
payload =
|
|
16
|
+
if action_name
|
|
17
|
+
callable = store.registry.action(action_name)
|
|
18
|
+
result =
|
|
19
|
+
begin
|
|
20
|
+
Timeout.timeout(Textus::Refresh::ACTION_TIMEOUT_SECONDS) do
|
|
21
|
+
callable.call(config: { "bytes" => raw }, store: Textus::StoreView.new(store), args: {})
|
|
22
|
+
end
|
|
23
|
+
rescue Timeout::Error
|
|
24
|
+
raise UsageError.new(
|
|
25
|
+
"action '#{action_name}' exceeded #{Textus::Refresh::ACTION_TIMEOUT_SECONDS}s timeout",
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
basename = key.split(".").last
|
|
29
|
+
{
|
|
30
|
+
"_meta" => {
|
|
31
|
+
"name" => basename,
|
|
32
|
+
"last_refreshed_at" => Time.now.utc.iso8601,
|
|
33
|
+
"actioned_with" => action_name,
|
|
34
|
+
}.merge(result[:_meta] || result["_meta"] || result[:frontmatter] || result["frontmatter"] || {}),
|
|
35
|
+
"body" => result[:body] || result["body"] || "",
|
|
36
|
+
}
|
|
37
|
+
else
|
|
38
|
+
JSON.parse(raw)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
meta = payload["_meta"] || payload["frontmatter"] || {}
|
|
42
|
+
body = payload["body"] || ""
|
|
43
|
+
if_etag = payload["if_etag"]
|
|
44
|
+
emit(store.put(key, meta: meta, body: body, if_etag: if_etag, as: role))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class RefreshVerb < Verb
|
|
4
|
+
option :as_flag, "--as=ROLE"
|
|
5
|
+
|
|
6
|
+
def call(store)
|
|
7
|
+
key = positional.shift or raise UsageError.new("refresh requires a key")
|
|
8
|
+
role = Role.resolve(flag: as_flag, env: ENV, root: store.root)
|
|
9
|
+
emit(Textus::Refresh.call(store, key, as: role))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class SchemaDiff < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "schema-diff"
|
|
7
|
+
def self.replacement_path = "schema diff"
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
name = positional.shift or raise UsageError.new("schema-diff NAME")
|
|
11
|
+
emit(Textus::SchemaTools.diff(store, name: name))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class SchemaGroup < Group
|
|
4
|
+
self.cli_name = "schema"
|
|
5
|
+
subcommands["show"] = SchemaVerb
|
|
6
|
+
subcommands["init"] = SchemaInit
|
|
7
|
+
subcommands["diff"] = SchemaDiff
|
|
8
|
+
subcommands["migrate"] = SchemaMigrate
|
|
9
|
+
|
|
10
|
+
# Back-compat: `textus schema KEY` (dotted key, no subcommand word).
|
|
11
|
+
# If the first positional looks like a dotted key, treat it as `schema show KEY`.
|
|
12
|
+
def parse(argv)
|
|
13
|
+
first = argv.first
|
|
14
|
+
if first && dotted_key?(first)
|
|
15
|
+
@stderr.puts(
|
|
16
|
+
"textus: 'schema KEY' is deprecated; use 'textus schema show KEY' instead. Removed in 0.6.",
|
|
17
|
+
)
|
|
18
|
+
argv.unshift("show")
|
|
19
|
+
end
|
|
20
|
+
super
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def dotted_key?(token)
|
|
26
|
+
return false if token.start_with?("-")
|
|
27
|
+
return false unless token.include?(".")
|
|
28
|
+
|
|
29
|
+
token.split(".").all? { |seg| seg.match?(Manifest::KEY_SEGMENT) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class SchemaInit < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "schema-init"
|
|
7
|
+
def self.replacement_path = "schema init"
|
|
8
|
+
|
|
9
|
+
option :from_key, "--from=KEY"
|
|
10
|
+
|
|
11
|
+
def call(store)
|
|
12
|
+
name = positional.shift or raise UsageError.new("schema-init NAME")
|
|
13
|
+
raise UsageError.new("schema-init requires --from=KEY") unless from_key
|
|
14
|
+
|
|
15
|
+
emit(Textus::SchemaTools.init(store, name: name, from: from_key))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class SchemaMigrate < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "schema-migrate"
|
|
7
|
+
def self.replacement_path = "schema migrate"
|
|
8
|
+
|
|
9
|
+
option :rename, "--rename=O:N"
|
|
10
|
+
|
|
11
|
+
def call(store)
|
|
12
|
+
name = positional.shift or raise UsageError.new("schema-migrate NAME")
|
|
13
|
+
raise UsageError.new("schema-migrate requires --rename=OLD:NEW") unless rename
|
|
14
|
+
|
|
15
|
+
emit(Textus::SchemaTools.migrate(store, name: name, rename: rename))
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Uid < Verb
|
|
4
|
+
prepend DeprecatedAliasMixin
|
|
5
|
+
|
|
6
|
+
def self.deprecated_name = "uid"
|
|
7
|
+
def self.replacement_path = "key uid"
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
key = positional.shift or raise UsageError.new("uid requires a key")
|
|
11
|
+
emit({ "key" => key, "uid" => store.uid(key) })
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class CLI
|
|
6
|
+
# Subclasses must implement #call(store) and return an integer exit code.
|
|
7
|
+
# Use #emit(obj) for normal JSON output (returns 0).
|
|
8
|
+
# Subclasses that don't need a Textus store (e.g. Init) override
|
|
9
|
+
# `.needs_store?` to return false; dispatch will pass nil instead.
|
|
10
|
+
class Verb
|
|
11
|
+
class << self
|
|
12
|
+
def option(name, optspec)
|
|
13
|
+
options << [name, optspec]
|
|
14
|
+
attr_accessor(name)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def options
|
|
18
|
+
@options ||= []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def needs_store?
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def inherited(subclass)
|
|
26
|
+
super
|
|
27
|
+
subclass.instance_variable_set(:@options, [])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(stdin:, stdout:, stderr:, cwd: nil)
|
|
32
|
+
@stdin = stdin
|
|
33
|
+
@stdout = stdout
|
|
34
|
+
@stderr = stderr
|
|
35
|
+
@cwd = cwd
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse(argv)
|
|
39
|
+
fmt = "json"
|
|
40
|
+
OptionParser.new do |o|
|
|
41
|
+
self.class.options.each do |name, optspec|
|
|
42
|
+
o.on(optspec) { |v| public_send(:"#{name}=", v) }
|
|
43
|
+
end
|
|
44
|
+
o.on("--format=FMT") { |v| fmt = v }
|
|
45
|
+
end.permute!(argv)
|
|
46
|
+
raise UsageError.new("only --format=json is supported in v1") unless fmt == "json"
|
|
47
|
+
|
|
48
|
+
@positional = argv.dup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :positional
|
|
52
|
+
|
|
53
|
+
# Hashes get "protocol" => PROTOCOL prepended unless they already
|
|
54
|
+
# carry one (Store envelopes do). Caller's value wins on collision.
|
|
55
|
+
def emit(obj, exit_code: 0)
|
|
56
|
+
payload = obj.is_a?(Hash) ? { "protocol" => PROTOCOL }.merge(obj) : obj
|
|
57
|
+
@stdout.puts(JSON.generate(payload))
|
|
58
|
+
exit_code
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|