brief 0.0.5 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (99) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +1 -1
  3. data/Gemfile.lock +71 -55
  4. data/README.md +149 -48
  5. data/Rakefile +15 -5
  6. data/bin/brief +15 -28
  7. data/brief.gemspec +17 -11
  8. data/examples/blog/brief.rb +54 -0
  9. data/examples/blog/docs/an-intro-to-brief.html.md +9 -0
  10. data/lib/.DS_Store +0 -0
  11. data/lib/brief/briefcase.rb +78 -0
  12. data/lib/brief/cli/change.rb +14 -0
  13. data/lib/brief/cli/init.rb +66 -0
  14. data/{spec/fixtures/generated/project_overview.json → lib/brief/cli/publish.rb} +0 -0
  15. data/lib/brief/cli/write.rb +0 -0
  16. data/lib/brief/configuration.rb +19 -115
  17. data/lib/brief/core_ext.rb +11 -0
  18. data/lib/brief/document/content_extractor.rb +33 -0
  19. data/lib/brief/document/front_matter.rb +20 -0
  20. data/lib/brief/document/rendering.rb +37 -0
  21. data/lib/brief/document.rb +43 -38
  22. data/lib/brief/document_mapper.rb +158 -0
  23. data/lib/brief/dsl.rb +42 -0
  24. data/lib/brief/model/definition.rb +117 -0
  25. data/lib/brief/model.rb +177 -0
  26. data/lib/brief/repository.rb +57 -0
  27. data/lib/brief/version.rb +1 -7
  28. data/lib/brief.rb +35 -40
  29. data/spec/fixtures/example/brief.rb +27 -0
  30. data/spec/fixtures/example/docs/concept.html.md +5 -0
  31. data/spec/fixtures/example/docs/epic.html.md +19 -0
  32. data/spec/fixtures/example/docs/persona.html.md +5 -0
  33. data/spec/fixtures/example/docs/release.html.md +7 -0
  34. data/spec/fixtures/example/docs/resource.html.md +5 -0
  35. data/spec/fixtures/example/docs/user_story.html.md +24 -0
  36. data/spec/fixtures/example/docs/wireframe.html.md +5 -0
  37. data/spec/fixtures/example/models/epic.rb +16 -0
  38. data/spec/lib/brief/briefcase_spec.rb +27 -0
  39. data/spec/lib/brief/document_spec.rb +14 -22
  40. data/spec/lib/brief/dsl_spec.rb +4 -15
  41. data/spec/lib/brief/model_spec.rb +84 -0
  42. data/spec/lib/brief/repository_spec.rb +60 -0
  43. data/spec/spec_helper.rb +12 -14
  44. data/spec/support/test_helpers.rb +0 -0
  45. data/tasks/brief/release.rake +0 -0
  46. metadata +120 -110
  47. data/.gitignore +0 -1
  48. data/Guardfile +0 -5
  49. data/examples/project_overview.md +0 -23
  50. data/lib/brief/cli/commands/config.rb +0 -40
  51. data/lib/brief/cli/commands/publish.rb +0 -27
  52. data/lib/brief/cli/commands/write.rb +0 -27
  53. data/lib/brief/formatters/base.rb +0 -12
  54. data/lib/brief/formatters/github_milestone_rollup.rb +0 -52
  55. data/lib/brief/git.rb +0 -19
  56. data/lib/brief/github/wiki.rb +0 -9
  57. data/lib/brief/github.rb +0 -141
  58. data/lib/brief/github_client/authentication.rb +0 -32
  59. data/lib/brief/github_client/client.rb +0 -86
  60. data/lib/brief/github_client/commands.rb +0 -5
  61. data/lib/brief/github_client/issue_labels.rb +0 -65
  62. data/lib/brief/github_client/issues.rb +0 -22
  63. data/lib/brief/github_client/milestone_issues.rb +0 -13
  64. data/lib/brief/github_client/organization_activity.rb +0 -9
  65. data/lib/brief/github_client/organization_issues.rb +0 -13
  66. data/lib/brief/github_client/organization_repositories.rb +0 -20
  67. data/lib/brief/github_client/organization_users.rb +0 -9
  68. data/lib/brief/github_client/repository_events.rb +0 -8
  69. data/lib/brief/github_client/repository_issue_events.rb +0 -9
  70. data/lib/brief/github_client/repository_issues.rb +0 -8
  71. data/lib/brief/github_client/repository_labels.rb +0 -18
  72. data/lib/brief/github_client/repository_milestones.rb +0 -9
  73. data/lib/brief/github_client/request.rb +0 -181
  74. data/lib/brief/github_client/request_wrapper.rb +0 -121
  75. data/lib/brief/github_client/response_object.rb +0 -50
  76. data/lib/brief/github_client/single_repository.rb +0 -9
  77. data/lib/brief/github_client/user_activity.rb +0 -16
  78. data/lib/brief/github_client/user_gists.rb +0 -9
  79. data/lib/brief/github_client/user_info.rb +0 -9
  80. data/lib/brief/github_client/user_issues.rb +0 -13
  81. data/lib/brief/github_client/user_organizations.rb +0 -9
  82. data/lib/brief/github_client/user_repositories.rb +0 -9
  83. data/lib/brief/github_client.rb +0 -43
  84. data/lib/brief/handlers/base.rb +0 -62
  85. data/lib/brief/handlers/github_issue.rb +0 -41
  86. data/lib/brief/handlers/github_milestone.rb +0 -37
  87. data/lib/brief/handlers/github_wiki.rb +0 -11
  88. data/lib/brief/line.rb +0 -69
  89. data/lib/brief/parser.rb +0 -354
  90. data/lib/brief/publisher/handler_manager.rb +0 -47
  91. data/lib/brief/publisher.rb +0 -142
  92. data/lib/brief/tree.rb +0 -43
  93. data/lib/core_ext.rb +0 -37
  94. data/spec/fixtures/front_end_tutorial.md +0 -33
  95. data/spec/fixtures/generator_dsl_example.rb +0 -22
  96. data/spec/fixtures/project_overview.md +0 -48
  97. data/spec/fixtures/sample.md +0 -19
  98. data/spec/lib/brief/line_spec.rb +0 -11
  99. data/spec/lib/brief/parser_spec.rb +0 -12
@@ -0,0 +1,14 @@
1
+ command "change" do |c|
2
+ c.syntax = "brief change ATTRIBUTE [OPTIONS]"
3
+ c.description = "change attributes of brief documents"
4
+
5
+ c.option "--from", String, "Only apply when the attributes current value matches."
6
+ c.option "--to", String, "Only apply when the attributes current value matches."
7
+ c.option "--files", String, "The files you want to change"
8
+ c.option "--on", String, "alias for --files. The files you want to change"
9
+
10
+ c.action do |args, options|
11
+ puts "Args: #{ args.inspect }"
12
+ puts "Options: #{ options.inspect }"
13
+ end
14
+ end
@@ -0,0 +1,66 @@
1
+ command "init" do |c|
2
+ c.syntax = "brief init NAME [OPTIONS]"
3
+ c.description = "Create a new brief project, aka a briefcase"
4
+
5
+ c.option "--root", String, "The root folder for the new project."
6
+
7
+ c.action do |args, options|
8
+ options.default(:root => Dir.pwd())
9
+
10
+ if path = args.first
11
+ root = Pathname(options.root).join(path)
12
+ end
13
+
14
+ [root, root.join("models"), root.join("docs")].each do |p|
15
+ puts "== folder #{ p.basename } #{ '. exists' if p.exist? }"
16
+ FileUtils.mkdir_p(p) unless p.exist?
17
+ end
18
+
19
+
20
+ if root.join("brief.rb").exist?
21
+ say "== Briefcase config already exists. Skipping."
22
+ else
23
+ say "== Creating config file brief.rb"
24
+ root.join("brief.rb").open("w+") do |fh|
25
+ default_config = <<-EOF
26
+
27
+ config do
28
+ set(:models_path => Pathname(__FILE__).parent.join("models"))
29
+ end
30
+
31
+ define("Post") do
32
+ meta do
33
+ status
34
+ date DateTime, :default => lambda {|post, attr| post.document.created_at }
35
+ end
36
+
37
+ content do
38
+ title "h1"
39
+ has_many :subheadings, "h2"
40
+ end
41
+
42
+ helpers do
43
+ def publish(options={})
44
+ post.set(:status, "published")
45
+ post.save
46
+ end
47
+ end
48
+
49
+ on_status_change(:from => "draft", :to => "published") do |model|
50
+ # Do Something
51
+ # mail_service.send_html_email_campaign(model.to_html)
52
+ end
53
+ end
54
+
55
+ # brief publish posts /path/to/*.html.md
56
+ action "publish posts" do |briefcase, models, options|
57
+ models.each do |post|
58
+ post.publish(options)
59
+ end
60
+ end
61
+ EOF
62
+ fh.write(default_config)
63
+ end
64
+ end
65
+ end
66
+ end
File without changes
@@ -1,134 +1,38 @@
1
- require "singleton"
2
- require "json"
1
+ require 'singleton'
3
2
 
4
3
  module Brief
5
4
  class Configuration
6
5
  include Singleton
7
6
 
8
- DefaultSettings = {
9
- github_username: "",
10
- github_api_token: ""
11
- }
12
-
13
- def self.method_missing meth, *args, &block
7
+ def self.method_missing(meth, *args, &block)
14
8
  if instance.respond_to?(meth)
15
- return instance.send meth, *args, &block
16
- end
17
-
18
- nil
19
- end
20
-
21
- def initialize!
22
- FileUtils.mkdir_p home_config_path.dirname
23
- end
24
-
25
- def method_missing meth, *args, &block
26
- if current.has_key?(meth.to_s)
27
- return current.fetch(meth)
28
- end
29
-
30
- super
31
- end
32
-
33
- def current_github_repository
34
- value = current.github_repository
35
-
36
- return value if value
37
-
38
- if value.nil?
39
- if guess = Brief::Git.current_github_repository
40
- set "github_repository", guess
41
- return guess
42
- end
9
+ instance.send(meth, *args, &block)
10
+ else
11
+ super
43
12
  end
44
-
45
- current.github_repository
46
13
  end
47
14
 
48
15
  def current
49
- @current ||= begin
50
- home_config.merge(cwd_config)
51
- end
16
+ @current ||= {
17
+ docs_path: "docs",
18
+ models_path: "models"
19
+ }.to_mash
52
20
  end
53
21
 
54
- def current_config
55
- cwd_config_path.exist? ? cwd_config : home_config
22
+ def set(attribute, value=nil)
23
+ current[attribute] = value
24
+ self
56
25
  end
57
26
 
58
- def set setting, value, persist=true
59
- current_config[setting] = value
60
- save! if persist == true
61
- value
62
- end
63
-
64
- def unset setting, persist=true
65
- current_config.delete(setting)
66
- save! if persist == true
67
- end
68
-
69
- def current
70
- Hashie::Mash.new(home_config.merge(cwd_config).merge(applied_config))
71
- end
72
-
73
- def apply_config_from_path path
74
- path = Pathname(path)
75
- parsed = JSON.parse(path.read) rescue {}
76
- applied_config.merge!(parsed)
77
- nil
78
- end
79
-
80
- def save!
81
- save_home_config
82
- save_cwd_config
83
- end
84
-
85
- def save_cwd_config
86
- return nil unless cwd_config_path.exist?
87
-
88
- File.open(cwd_config_path,'w+') do |fh|
89
- fh.write JSON.generate(cwd_config.to_hash)
27
+ def method_missing(meth, *args, &block)
28
+ if current.respond_to?(meth) && current.key?(meth)
29
+ current.send(meth, *args, &block)
30
+ else
31
+ # swallow invalid method calls in production
32
+ super if ENV['BRIEF_DEBUG_MODE']
33
+ nil
90
34
  end
91
35
  end
92
36
 
93
- def save_home_config
94
- File.open(home_config_path,'w+') do |fh|
95
- fh.write JSON.generate(home_config.to_hash)
96
- end
97
- end
98
-
99
- # Applied config is configuration values passed in context
100
- # usually from the cli, but also in the unit tests
101
- def applied_config
102
- @applied_config ||= {}
103
- end
104
-
105
- def cwd_config
106
- @cwd_config ||= begin
107
- (cwd_config_path.exist? rescue false) ? JSON.parse(cwd_config_path.read) : {}
108
- rescue
109
- {}
110
- end
111
- end
112
-
113
- def home_config
114
- @home_config ||= begin
115
- (home_config_path.exist? rescue false) ? JSON.parse(home_config_path.read) : {}
116
- rescue
117
- {}
118
- end
119
- end
120
-
121
- def home_folder
122
- Pathname(ENV['HOME']).join('.brief')
123
- end
124
-
125
- def home_config_path
126
- Pathname(ENV['HOME']).join('.brief','config.json')
127
- end
128
-
129
- def cwd_config_path
130
- Pathname(Dir.pwd).join('.briefrc')
131
- end
132
37
  end
133
38
  end
134
-
@@ -0,0 +1,11 @@
1
+ class Hash
2
+ def to_mash
3
+ Hashie::Mash.new(self)
4
+ end
5
+ end
6
+
7
+ class String
8
+ def to_pathname
9
+ Pathname(self)
10
+ end
11
+ end
@@ -0,0 +1,33 @@
1
+ module Brief
2
+ class Document::ContentExtractor
3
+ def initialize(model_type, document)
4
+ @model_type = model_type
5
+ @document = document
6
+ end
7
+
8
+ def document
9
+ @document
10
+ end
11
+
12
+ def model_class
13
+ Brief::Model.for_type(@model_type)
14
+ end
15
+
16
+ def attribute_set
17
+ model_class.definition.content_schema.attributes
18
+ end
19
+
20
+ def respond_to?(meth)
21
+ attribute_set.key?(meth) || super
22
+ end
23
+
24
+ def method_missing(meth, *args, &block)
25
+ if settings = attribute_set.fetch(meth, nil)
26
+ if settings.args.length == 1 && settings.args.first.is_a?(String)
27
+ selector = settings.args.first
28
+ document.css(selector).try(:text)
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
1
+ module Brief
2
+ class Document
3
+ module FrontMatter
4
+ extend ActiveSupport::Concern
5
+
6
+ def frontmatter
7
+ @frontmatter || load_frontmatter
8
+ end
9
+
10
+ protected
11
+
12
+ def load_frontmatter
13
+ if content =~ /^(---\s*\n.*?\n?)^(---\s*$\n?)/m
14
+ self.content = content[($1.size + $2.size)..-1]
15
+ @frontmatter = YAML.load($1).to_mash
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,37 @@
1
+ module Brief
2
+ class Document
3
+ module Rendering
4
+ extend ActiveSupport::Concern
5
+
6
+ def to_html
7
+ self.class.renderer.render(content)
8
+ end
9
+
10
+ def css(*args, &block)
11
+ parser.send(:css, *args, &block)
12
+ end
13
+
14
+ def at(*args, &block)
15
+ parser.send(:at, *args, &block)
16
+ end
17
+
18
+ def parser
19
+ @parser ||= Nokogiri::HTML.fragment(to_html)
20
+ end
21
+
22
+ module ClassMethods
23
+ def renderer
24
+ @renderer ||= begin
25
+ r = ::Redcarpet::Render::HTML.new(:tables => true,
26
+ :autolink => true,
27
+ :gh_blockcode => true,
28
+ :fenced_code_blocks => true,
29
+ :footnotes => true)
30
+
31
+ ::Redcarpet::Markdown.new(r)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -1,68 +1,73 @@
1
-
2
1
  module Brief
3
2
  class Document
4
- attr_reader :content, :options
3
+ include Brief::Document::Rendering
4
+ include Brief::Document::FrontMatter
5
+
6
+ attr_accessor :path, :content, :frontmatter
5
7
 
6
- def initialize(options)
8
+ def initialize(path, options={})
9
+ @path = Pathname(path)
7
10
  @options = options
8
11
 
9
- case
10
- when options.is_a?(String)
11
- @content = options
12
- when options.is_a?(Pathname)
13
- @content = options.read
14
- when options.is_a?(Hash)
15
- @content = options[:path].read
12
+ if self.path.exist?
13
+ content
14
+ load_frontmatter
16
15
  end
16
+
17
+ self.model_class.try(:models).try(:<<, to_model) unless model_instance_registered?
17
18
  end
18
19
 
19
- def write using
20
- using.run_before_hooks(:write) do
21
- write
22
- end
20
+ def content
21
+ @content ||= path.read
23
22
  end
24
23
 
25
- def publish using=nil
26
- using ||= publisher
24
+ def extract_content(*args)
25
+ options = args.extract_options!
26
+ args = options[:args] if options.is_a?(Hash) && options.key?(:args)
27
27
 
28
- document = self
29
- using.run_before_hooks(:publish) do
30
- process(document)
28
+ case
29
+ when args.length == 1 && args.first.is_a?(String)
30
+ css(args.first).try(:text).to_s
31
31
  end
32
32
  end
33
33
 
34
- def publisher
35
- @publisher ||= Brief::Publisher.find(options[:publisher] || "default")
34
+ def data
35
+ frontmatter
36
36
  end
37
37
 
38
- def method_missing meth, *args, &block
39
- if parser.respond_to?(meth)
40
- return parser.send(meth, *args, &block)
41
- end
42
-
43
- super
38
+ def extension
39
+ path.extname
44
40
  end
45
41
 
46
- def checksum
47
- @checksum ||= Digest::MD5.hexdigest(content)
42
+ def to_model
43
+ model_class.new(data.to_hash.merge(path: path, document: self)) if model_class
48
44
  end
49
45
 
50
- def parser
51
- @parser ||= Brief::Parser.new(content, checksum: checksum)
46
+ def model_class
47
+ @model_class || ((data && data.type) && Brief::Model.for_type(data.type))
52
48
  end
53
49
 
54
-
55
- def settings
56
- parser.front_matter
50
+ # Each model class tracks the instances of the models created
51
+ # and ensures that there is a 1-1 relationship between a document path
52
+ # and the model.
53
+ def model_instance_registered?
54
+ self.model_class && self.model_class.models.any? do |model|
55
+ model.path == self.path
56
+ end
57
57
  end
58
58
 
59
- def elements
60
- parser.send(:elements)
59
+ def respond_to?(method)
60
+ super || data.respond_to?(method) || data.key?(method)
61
61
  end
62
62
 
63
- def tree
64
- parser.send(:tree)
63
+ def method_missing(meth, *args, &block)
64
+ if data.respond_to?(meth)
65
+ data.send(meth, *args, &block)
66
+ else
67
+ super
68
+ end
65
69
  end
66
70
 
67
71
  end
68
72
  end
73
+
@@ -0,0 +1,158 @@
1
+ module Brief::DocumentMapper
2
+ OPERATOR_MAPPING = {
3
+ 'equal' => :==,
4
+ 'eq' => :==,
5
+ 'neq' => :!=,
6
+ 'not_equal' => :!=,
7
+ 'gt' => :>,
8
+ 'gte' => :>=,
9
+ 'in' => :in?,
10
+ 'include' => :include?,
11
+ 'lt' => :<,
12
+ 'lte' => :<=
13
+ }
14
+
15
+ VALID_OPERATORS = OPERATOR_MAPPING.keys
16
+
17
+ class Selector
18
+ attr_reader :attribute, :operator
19
+
20
+ def initialize(opts = {})
21
+ unless Brief::DocumentMapper::VALID_OPERATORS.include?(opts[:operator])
22
+ raise 'Operator not supported'
23
+ end
24
+
25
+ @attribute, @operator = opts[:attribute], opts[:operator]
26
+ end
27
+ end
28
+
29
+ class Query
30
+ attr_reader :model
31
+
32
+ def initialize(model)
33
+ @model = model
34
+ @where = {}
35
+ end
36
+
37
+ def where(constraints_hash)
38
+ selector_hash = constraints_hash.reject { |key, value| !key.is_a? Selector }
39
+ symbol_hash = constraints_hash.reject { |key, value| key.is_a? Selector }
40
+ symbol_hash.each do |attribute, value|
41
+ selector = Selector.new(:attribute => attribute, :operator => 'equal')
42
+ selector_hash.update({ selector => value })
43
+ end
44
+ @where.merge! selector_hash
45
+ self
46
+ end
47
+
48
+ def order_by(field)
49
+ @order_by = field.is_a?(Symbol) ? {field => :asc} : field
50
+ self
51
+ end
52
+
53
+ def offset(number)
54
+ @offset = number
55
+ self
56
+ end
57
+
58
+ def limit(number)
59
+ @limit = number
60
+ self
61
+ end
62
+
63
+ def first
64
+ self.all.first
65
+ end
66
+
67
+ def last
68
+ self.all.last
69
+ end
70
+
71
+ def run_query
72
+ if query_is_empty?
73
+ select
74
+ else
75
+ model.select do |obj|
76
+ match = true
77
+
78
+ @where.each do |selector, value|
79
+ if obj.respond_to?(selector.attribute)
80
+ test_value = obj.send(selector.attribute)
81
+ operator = OPERATOR_MAPPING[selector.operator]
82
+ match = false unless test_value.send(operator, value)
83
+ else
84
+ match = false
85
+ end
86
+ end
87
+
88
+ match
89
+ end
90
+ end
91
+ end
92
+
93
+ def all
94
+ results = run_query
95
+
96
+ if @order_by
97
+ order_by_attr = @order_by.keys.first
98
+ direction = @order_by.values.first
99
+
100
+ results.select! do |result|
101
+ result.respond_to?(order_by_attr)
102
+ end
103
+
104
+ results.sort_by! do |result|
105
+ result.send(order_by_attr)
106
+ end
107
+
108
+ results.reverse! if direction == :desc
109
+ end
110
+
111
+ if @offset.present?
112
+ results = results.last([results.size - @offset, 0].max)
113
+ end
114
+
115
+ if @limit.present?
116
+ results = results.first(@limit)
117
+ end
118
+
119
+ results
120
+ end
121
+
122
+ def to_a
123
+ all
124
+ end
125
+
126
+ def query_is_empty?
127
+ @where.empty? && @limit.nil? && @order_by.nil?
128
+ end
129
+
130
+ def inspect
131
+ "Query: #{ @where.map {|k,v| "#{k.attribute} #{k.operator} #{v}" }}"
132
+ end
133
+
134
+ def method_missing(meth, *args, &block)
135
+ if all.respond_to?(meth)
136
+ all.send(meth, *args, &block)
137
+ else
138
+ super
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ class Symbol
145
+ Brief::DocumentMapper::VALID_OPERATORS.each do |operator|
146
+ class_eval <<-OPERATORS
147
+ def #{operator}
148
+ Brief::DocumentMapper::Selector.new(:attribute => self, :operator => '#{operator}')
149
+ end
150
+ OPERATORS
151
+ end
152
+
153
+ unless method_defined?(:"<=>")
154
+ def <=>(other)
155
+ self.to_s <=> other.to_s
156
+ end
157
+ end
158
+ end
data/lib/brief/dsl.rb CHANGED
@@ -0,0 +1,42 @@
1
+ module Brief
2
+ module DSL
3
+ extend ActiveSupport::Concern
4
+
5
+ def config(options={}, &block)
6
+ Brief::Configuration.instance.load_options(options) unless options.nil? || options.empty?
7
+ Brief::Configuration.instance.instance_eval(&block) if block_given?
8
+ end
9
+
10
+ def define(*args, &block)
11
+ definition = Brief::Model::Definition.new(args.first, args.extract_options!)
12
+ definition.instance_eval(&block) if block_given?
13
+ definition.validate!
14
+ end
15
+
16
+ def action(identifier, options={}, &block)
17
+ Object.class.class_eval do
18
+ command "#{identifier}" do |c|
19
+ c.syntax = "brief #{identifier}"
20
+ c.description = "run the #{identifier} command"
21
+
22
+ c.action do |args, opts|
23
+ briefcase = Brief.case
24
+
25
+ path_args = args.select {|arg| arg.is_a?(String) && arg.match(/\.md$/) }
26
+
27
+ path_args.select! do |arg|
28
+ path = briefcase.repository.root.join(arg)
29
+ path.exist?
30
+ end
31
+
32
+ path_args.map! {|p| briefcase.repository.root.join(p) }
33
+
34
+ models = path_args.map {|path| Brief::Document.new(path) }.map(&:to_model)
35
+
36
+ block.call(Brief.case, models, opts)
37
+ end
38
+ end rescue nil
39
+ end
40
+ end
41
+ end
42
+ end