ragerender 0.1.1

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.
@@ -0,0 +1,132 @@
1
+ require 'jekyll/generator'
2
+ require 'jekyll/drops/document_drop'
3
+
4
+ # Add default values for the 'unchapter' which is used to hold all comics that
5
+ # don't have a chapter.
6
+ Jekyll::Hooks.register :site, :after_init do |site|
7
+ site.config['defaults'].prepend({
8
+ 'scope' => {
9
+ 'path' => '_chapters/0.html',
10
+ 'type' => 'chapters',
11
+ },
12
+ 'values' => {
13
+ 'title' => 'Unchaptered',
14
+ 'description' => 'These comic pages are not part of any chapter',
15
+ },
16
+ })
17
+ end
18
+
19
+ Jekyll::Hooks.register :chapters, :pre_render do |chapter, payload|
20
+ payload.merge! RageRender::ChapterDrop.new(chapter).to_liquid
21
+ payload.merge! RageRender::ArchiveDrop.new(chapter).to_liquid
22
+ end
23
+
24
+ module RageRender
25
+ # Create chapter objects for any chapters listed in comics but that don't
26
+ # currently have an explicit page created.
27
+ class ChapterFromComicsGenerator < Jekyll::Generator
28
+ priority :high
29
+
30
+ def generate site
31
+ required = Set.new(site.collections['comics'].docs.map {|c| c.data['chapter'] }.reject(&:nil?))
32
+ existing = Set.new(site.collections['chapters'].docs.map {|c| c.data['slug'] })
33
+ missing = required - existing
34
+ missing.each do |slug|
35
+ filename = Pathname.new(site.collections['chapters'].relative_directory).join("#{slug}.html")
36
+ chapter = Jekyll::Document.new(filename.to_path, site: site, collection: site.collections['chapters'])
37
+ chapter.send(:merge_defaults)
38
+ chapter.data['slug'] ||= slug
39
+ chapter.data['title'] ||= slug
40
+ chapter.content = nil
41
+ site.collections['chapters'].docs << chapter
42
+ end
43
+ end
44
+ end
45
+
46
+ # Set the default cover for any chapters that don't have one to be the first
47
+ # page of the first comic in that chapter.
48
+ class DefaultCoverSetter < Jekyll::Generator
49
+ priority :lowest
50
+
51
+ def generate site
52
+ site.collections['chapters'].docs.each do |chapter|
53
+ chapter.data['image'] ||= default_cover(chapter)
54
+ end
55
+ end
56
+
57
+ def default_cover chapter
58
+ Pathname.new('/').join(first_comic(chapter).data['image']).to_path
59
+ end
60
+
61
+ def first_comic chapter
62
+ chapter.site.collections['comics'].docs.select {|c| c.data['chapter'] == chapter.data['slug'] }.first
63
+ end
64
+ end
65
+
66
+ # Values to pass to the archive layout when rendering a chapter.
67
+ class ChapterDrop < Jekyll::Drops::DocumentDrop
68
+ COVER_MAX_HEIGHT = 420
69
+ COVER_MAX_WIDTH = 300
70
+
71
+ PAGINATION_FIELDS = %w[ chaptername chapterdescription ]
72
+
73
+ delegate_method_as :data, :fallback_data
74
+ extend NamedDataDelegator
75
+ extend Forwardable
76
+
77
+ def_data_delegator :title, :chaptername
78
+ def_data_delegator :description, :chapterdescription
79
+ def_delegator :@obj, :url, :chapterarchiveurl
80
+
81
+ def cover
82
+ cover_obj.url
83
+ end
84
+
85
+ def cover_width_small
86
+ if (cover_height.to_f / COVER_MAX_HEIGHT) > (cover_width.to_f / COVER_MAX_WIDTH)
87
+ (cover_height_small * cover_width) / cover_height
88
+ else
89
+ [COVER_MAX_WIDTH, cover_width].min
90
+ end
91
+ end
92
+
93
+ def cover_height_small
94
+ if (cover_height.to_f / COVER_MAX_HEIGHT) > (cover_width.to_f / COVER_MAX_WIDTH)
95
+ [COVER_MAX_HEIGHT, cover_height].min
96
+ else
97
+ (cover_width_small * cover_height) / cover_width
98
+ end
99
+ end
100
+
101
+ def firstcomicinchapter
102
+ first_comic&.url
103
+ end
104
+
105
+ def to_liquid
106
+ super.reject do |k, v|
107
+ Jekyll::Drops::DocumentDrop::NESTED_OBJECT_FIELD_BLACKLIST.include? k
108
+ end.to_h
109
+ end
110
+
111
+ private
112
+ def cover_width
113
+ cover_obj.data['width'] ||= Dimensions.width cover_obj.path
114
+ end
115
+
116
+ def cover_height
117
+ cover_obj.data['height'] ||= Dimensions.height cover_obj.path
118
+ end
119
+
120
+ def cover_obj
121
+ @cover_obj ||= @obj.site.static_files.detect {|f| f.relative_path == cover_relative_path }
122
+ end
123
+
124
+ def cover_relative_path
125
+ Pathname.new('/').join(@obj.data['image']).to_s
126
+ end
127
+
128
+ def first_comic
129
+ @obj.site.collections['comics'].docs.select {|c| c.data['chapter'] == @obj.data['slug'] }.first
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,230 @@
1
+ require 'jekyll/generator'
2
+ require 'jekyll/document'
3
+ require 'jekyll/drops/document_drop'
4
+ require_relative '../date_formats'
5
+ require_relative 'named_data_delegator'
6
+
7
+ Jekyll::Hooks.register :comics, :pre_render do |page, payload|
8
+ payload.merge! RageRender::ComicDrop.new(page).to_liquid
9
+ end
10
+
11
+ module RageRender
12
+ SPECIAL_COMIC_SLUGS = %w{frontpage index}
13
+
14
+ # Creates comics for each file found in the 'images' directory
15
+ # that does not already have an associated comic object.
16
+ class ComicFromImageGenerator < Jekyll::Generator
17
+ priority :highest
18
+
19
+ def generate site
20
+ images = site.static_files.select {|f| f.relative_path.start_with? '/images' }.map {|f| [f.basename, f] }.to_h
21
+ comics = site.collections['comics'].docs.map {|c| [c.basename_without_ext, c] }.to_h
22
+ missing = Set.new(images.keys) - Set.new(comics.keys)
23
+ missing -= Set.new(comics.map {|k, c| c.data['image'] }.reject(&:nil?).map {|img| File.basename(img, '.*') })
24
+ missing.each do |slug|
25
+ comic = Jekyll::Document.new(images[slug].relative_path, site: site, collection: site.collections['comics'])
26
+ comic.send(:merge_defaults)
27
+ comic.data['slug'] = slug
28
+ comic.data['title'] = slug
29
+ comic.data['date'] = images[slug].modified_time
30
+ comic.data['image'] = images[slug].relative_path
31
+ comic.content = nil
32
+ site.collections['comics'].docs << comic
33
+ end
34
+ end
35
+ end
36
+
37
+ # The index for the comics collection is always the latest comic.
38
+ class LatestComicGenerator < Jekyll::Generator
39
+ priority :lowest
40
+
41
+ def generate site
42
+ comics = site.collections['comics']
43
+ index = comics.docs.last.dup
44
+ index.instance_variable_set(:"@data", index.data.dup)
45
+ index.data['slug'] = 'index'
46
+ comics.docs << index
47
+ end
48
+ end
49
+
50
+ class DefaultImageSetter < Jekyll::Generator
51
+ priority :normal
52
+
53
+ def generate site
54
+ site.collections['comics'].docs.each do |comic|
55
+ comic.data['image'] ||= default_image_path(comic)
56
+ end
57
+ end
58
+
59
+ def default_image_path comic
60
+ "images/#{comic.data['slug']}.jpg"
61
+ end
62
+ end
63
+
64
+ # If the image for this comic was inside a subdirectory, set that subdirectory
65
+ # name to be the chapter slug for this comic, if one is not already set.
66
+ class ChapterFromDirectorySetter < Jekyll::Generator
67
+ priority :low
68
+
69
+ def generate site
70
+ site.collections['comics'].docs.each do |comic|
71
+ components = Pathname.new(comic.data['image']).descend.reduce([]) {|acc, path| acc << path.basename }
72
+ chapter_slug = components.drop_while {|path| path.root? || path.to_s == 'images' }[...-1].first
73
+ comic.data['chapter'] ||= chapter_slug.to_s unless chapter_slug.nil?
74
+ end
75
+ end
76
+ end
77
+
78
+ class ComicDrop < Jekyll::Drops::DocumentDrop
79
+ extend NamedDataDelegator
80
+
81
+ PAGINATION_FIELDS = %w[ comicurl comictitle posttime ]
82
+
83
+ delegate_method_as :id, :comicid
84
+ def_data_delegator :title, :comictitle
85
+ def_delegator :@obj, :url, :comicurl
86
+ data_delegator 'rating'
87
+ data_delegator 'votecount'
88
+
89
+ def posttime
90
+ comicfury_date(@obj.date)
91
+ end
92
+
93
+ def usechapters
94
+ all_comics.any? {|comic| comic.data.include? 'chapter' }
95
+ end
96
+
97
+ def haschapter
98
+ @obj.data.include? 'chapter'
99
+ end
100
+
101
+ def chaptername
102
+ chapter.data['title']
103
+ end
104
+
105
+ def chapterlink
106
+ chapter.url
107
+ end
108
+
109
+ def dropdown
110
+ all_comics.each_with_object([]) do |c, dropdown|
111
+ new_group = dropdown.last.nil? ? true : dropdown.last['grouplabel'] != c.data['chapter']
112
+ if new_group && !dropdown.last.nil? && dropdown.last['title'] == c.data['chapter']
113
+ dropdown.last['endgroup'] = true
114
+ end
115
+
116
+ in_this_chapter = @obj.data['chapter'] == c.data['chapter']
117
+ if in_this_chapter
118
+ dropdown << {
119
+ 'is_selected' => @obj == c,
120
+ 'is_disabled' => false,
121
+ 'title' => c.data['title'],
122
+ 'grouplabel' => c.data['chapter'],
123
+ 'newgroup' => new_group,
124
+ 'endgroup' => false,
125
+ 'url' => c.url,
126
+ }
127
+ elsif new_group
128
+ dropdown << {
129
+ 'is_selected' => false,
130
+ 'is_disabled' => false,
131
+ 'title' => c.data['chapter'],
132
+ 'grouplabel' => c.data['chapter'],
133
+ 'newgroup' => false,
134
+ 'endgroup' => false,
135
+ 'url' => c.url, # navigating to chapter just goes to first page
136
+ }
137
+ end
138
+ end
139
+ end
140
+
141
+ def authornotes
142
+ @obj.data['authornotes'] || [{
143
+ 'is_reply' => false,
144
+ 'comment' => @obj.content,
145
+ 'isguest' => false,
146
+ 'avatar' => nil,
147
+ 'authorname' => @obj.data['author'],
148
+ 'commentanchor' => "comment-#{@obj.date.strftime('%s')}",
149
+ 'posttime' => comicfury_date(@obj.date),
150
+ 'profilelink' => nil, # TODO
151
+ }]
152
+ end
153
+
154
+ def custom
155
+ @obj.data.fetch('custom', {}).reject do |k, v|
156
+ v.nil? || (v.respond_to?(:empty?) && v.empty?)
157
+ end
158
+ end
159
+
160
+ def isfirstcomic
161
+ all_comics.first == @obj
162
+ end
163
+
164
+ def islastcomic
165
+ all_comics.last == @obj
166
+ end
167
+
168
+ def prevcomic
169
+ @obj.previous_doc&.url
170
+ end
171
+
172
+ def nextcomic
173
+ @obj.next_doc&.url
174
+ end
175
+
176
+ def comicimageurl
177
+ File.join (@obj.site.baseurl || ''), image_relative_path
178
+ end
179
+
180
+ def comicwidth
181
+ image_obj.data['width'] ||= Dimensions.width image_path
182
+ end
183
+
184
+ def comicheight
185
+ image_obj.data['height'] ||= Dimensions.height image_path
186
+ end
187
+
188
+ # An HTML tag to print for the comic image. If there is a future image, then
189
+ # this is also a link to the next comic page.
190
+ def comicimage
191
+ linkopen = nextcomic ? <<~HTML : ''
192
+ <a href="#{nextcomic}">
193
+ HTML
194
+ image = <<~HTML
195
+ <img id="comicimage" src="#{comicimageurl}" width="#{comicwidth}" height="#{comicheight}">
196
+ HTML
197
+ linkclose = nextcomic ? <<~HTML : ''
198
+ </a>
199
+ HTML
200
+ [linkopen, image, linkclose].join
201
+ end
202
+
203
+ def to_liquid
204
+ super.reject do |k, v|
205
+ Jekyll::Drops::DocumentDrop::NESTED_OBJECT_FIELD_BLACKLIST.include? k
206
+ end.to_h
207
+ end
208
+
209
+ private
210
+ def all_comics
211
+ @obj.collection.docs.reject {|c| SPECIAL_COMIC_SLUGS.include? c.data['slug'] }
212
+ end
213
+
214
+ def chapter
215
+ @obj.site.collections['chapters'].docs.detect {|c| c.data['slug'] == @obj.data['chapter'] }
216
+ end
217
+
218
+ def image_obj
219
+ @image_obj ||= @obj.site.static_files.detect {|f| f.relative_path == image_relative_path }
220
+ end
221
+
222
+ def image_path
223
+ image_obj.path
224
+ end
225
+
226
+ def image_relative_path
227
+ Pathname.new('/').join(@obj.data['image']).to_s
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'named_data_delegator'
2
+
3
+ Jekyll::Hooks.register :pages, :pre_render do |page, payload|
4
+ if page.data['layout'] == 'error-page'
5
+ payload.merge! RageRender::ErrorDrop.new(page).to_liquid
6
+ end
7
+ end
8
+
9
+ module RageRender
10
+ class ErrorDrop < Jekyll::Drops::Drop
11
+ private delegate_method_as :data, :fallback_data
12
+ extend NamedDataDelegator
13
+ extend Forwardable
14
+
15
+ def_data_delegator :title, :errortitle
16
+ def_delegator :@obj, :content, :errormessage
17
+ end
18
+ end
@@ -0,0 +1,9 @@
1
+ module RageRender
2
+ module NamedDataDelegator
3
+ def def_data_delegator key, aliaz
4
+ define_method(aliaz.to_sym) do
5
+ @obj.data[key.to_s]
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ require 'jekyll/hooks'
2
+ require 'jekyll/drops/drop'
3
+ require_relative 'comics'
4
+ require_relative 'blog_archive'
5
+
6
+ # Pass the right variables to overview pages.
7
+ Jekyll::Hooks.register :pages, :pre_render do |page, payload|
8
+ if page.data['layout'] == 'overview'
9
+ payload.merge! RageRender::ComicDrop.new(page.site.collections['comics'].docs.last).to_liquid
10
+ payload.merge! RageRender::OverviewDrop.new(page).to_liquid
11
+ end
12
+ end
13
+
14
+ module RageRender
15
+ class OverviewDrop < Jekyll::Drops::Drop
16
+ private delegate_method_as :data, :fallback_data
17
+
18
+ def latestblogs
19
+ @obj.site.posts.docs[-5..].map {|post| RageRender::PaginatedBlogDrop.new(post) }
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,29 @@
1
+ module RageRender
2
+ def self.duplicate_page page
3
+ Jekyll::Page.new(
4
+ page.site,
5
+ page.instance_variable_get(:"@base"),
6
+ page.instance_variable_get(:"@dir"),
7
+ page.name,
8
+ )
9
+ end
10
+
11
+ module PaginationGenerator
12
+ def handle_page page
13
+ end
14
+
15
+ def generate site
16
+ archive = source_page site
17
+ archive.data['number'] = 1
18
+
19
+ num_pages(site).times.each do |number|
20
+ paged_archive = RageRender.duplicate_page archive
21
+ paged_archive.data['permalink'] = permalink.gsub(/:number/, (number + 1).to_s)
22
+ paged_archive.data['number'] = number + 1
23
+ Jekyll.logger.debug 'Paginating:', paged_archive.data['permalink']
24
+ site.pages << paged_archive
25
+ handle_page paged_archive
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,37 @@
1
+ Jekyll::Hooks.register :pages, :pre_render do |page, payload|
2
+ if page.data['layout'] == 'search'
3
+ payload.merge! RageRender::SearchDrop.new(page).to_liquid
4
+ end
5
+ end
6
+
7
+ module RageRender
8
+ class SearchDrop < Jekyll::Drops::Drop
9
+ private delegate_method_as :data, :fallback_data
10
+ data_delegator 'searchterm'
11
+
12
+ def searched
13
+ !searchterm.nil?
14
+ end
15
+
16
+ def searchresults
17
+ return [] unless searched
18
+ @results ||= @obj.site.collections['comics'].docs.select do |comic|
19
+ [
20
+ *comic.data.fetch('tags', []),
21
+ comic.content,
22
+ *comic.data.fetch('authornotes', []).flat_map {|n| n['comment'] },
23
+ ].map(&:downcase).any? {|c| c.include?(searchterm.downcase) }
24
+ end.map.each_with_index do |comic, index|
25
+ drop = ComicDrop.new(comic)
26
+ {
27
+ 'number' => index + 1,
28
+ **ComicDrop::PAGINATION_FIELDS.map {|f| [f.to_s, drop[f]] }.to_h,
29
+ }
30
+ end
31
+ end
32
+
33
+ def foundresults
34
+ searchresults.any?
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,185 @@
1
+ require 'etc'
2
+ require 'stringio'
3
+ require 'jekyll'
4
+ require 'dimensions'
5
+ require_relative 'language'
6
+ require_relative 'to_liquid'
7
+ require_relative 'date_formats'
8
+ require_relative 'jekyll/archive'
9
+ require_relative 'jekyll/blog'
10
+ require_relative 'jekyll/blog_archive'
11
+ require_relative 'jekyll/comics'
12
+ require_relative 'jekyll/chapter'
13
+ require_relative 'jekyll/overview'
14
+ require_relative 'jekyll/error'
15
+ require_relative 'jekyll/search'
16
+ require_relative 'jekyll/named_data_delegator'
17
+
18
+ def setup_collection site, label, permalink, **kwargs
19
+ site.config['collections'][label.to_s] = {
20
+ 'output' => true,
21
+ 'permalink' => permalink,
22
+ }
23
+
24
+ site.config['defaults'].prepend({
25
+ 'scope' => {
26
+ 'path' => '',
27
+ 'type' => label.to_s,
28
+ },
29
+ 'values' => {
30
+ 'permalink' => permalink,
31
+ **kwargs.map do |k, v|
32
+ [k.to_s, v]
33
+ end.to_h,
34
+ },
35
+ })
36
+ end
37
+
38
+ Jekyll::Hooks.register :site, :after_init do |site|
39
+ # This is obviously quite naughty for many reasons,
40
+ # but it's the only way to get the theme selected
41
+ # without requiring the user to write a config file
42
+ site.config['theme'] ||= 'ragerender'
43
+ site.config['title'] ||= File.basename(site.source)
44
+ site.config['search'] ||= true
45
+ site.config = site.config
46
+
47
+ setup_collection site, :comics, '/:collection/:slug/', layout: 'comic-page', chapter: '0'
48
+ setup_collection site, :posts, '/blogarchive/:slug/', layout: 'blog-display'
49
+ setup_collection site, :chapters, '/archive/:slug/', layout: 'archive'
50
+
51
+ site.config['defaults'].push({
52
+ 'scope' => {
53
+ 'path' => '',
54
+ },
55
+ 'values' => {
56
+ 'author' => Etc.getlogin,
57
+ }
58
+ })
59
+ end
60
+
61
+ Jekyll::Hooks.register :site, :post_read do |site|
62
+ site.layouts.each do |(name, layout)|
63
+ layout.data['layout'] = 'overall' unless name == 'overall'
64
+ layout.content = RageRender.to_liquid(RageRender::Language.parse(StringIO.new(layout.content))).join
65
+ end
66
+ end
67
+
68
+ # The index for the site can be set by configuration
69
+ class RageRender::FrontpageGenerator < Jekyll::Generator
70
+ priority :lowest
71
+
72
+ def generate site
73
+ comics = site.collections['comics']
74
+ frontpage = site.config.fetch('frontpage', 'latest')
75
+ index = case frontpage
76
+ when 'latest'
77
+ collection = site.collections['comics'].docs
78
+ comics.docs.last
79
+ when 'first'
80
+ collection = site.collections['comics'].docs
81
+ comics.docs.first
82
+ when 'chapter'
83
+ collection = site.collections['comics'].docs
84
+ chapter = comics.docs.last.data['chapter']
85
+ comics.docs.detect {|c| c.data['chapter'] == chapter }
86
+ when 'overview'
87
+ collection = site.pages
88
+ site.pages.detect {|p| p.data["permalink"] == '/overview/index.html' }
89
+ else
90
+ collection = site.pages
91
+ site.pages.detect {|p| p.data["slug"] == frontpage }
92
+ end.dup
93
+ index.instance_variable_set(:"@destination", {site.dest => File.join(site.dest, 'index.html')})
94
+ index.instance_variable_set(:"@data", index.data.dup)
95
+ index.data['slug'] = 'frontpage'
96
+ collection << index
97
+ end
98
+ end
99
+
100
+ Jekyll::Hooks.register :documents, :pre_render do |doc, payload|
101
+ payload.merge! RageRender::WebcomicDrop.new(doc).to_liquid
102
+ end
103
+
104
+ Jekyll::Hooks.register :pages, :pre_render do |page, payload|
105
+ payload.merge! RageRender::WebcomicDrop.new(page).to_liquid
106
+ end
107
+
108
+ class RageRender::WebcomicDrop < Jekyll::Drops::Drop
109
+ extend Forwardable
110
+ extend RageRender::NamedDataDelegator
111
+
112
+ def self.def_config_delegator source, target
113
+ define_method(target) { @obj.site.config[source.to_s] }
114
+ end
115
+
116
+ def_config_delegator :title, :webcomicname
117
+ def_config_delegator :description, :webcomicslogan
118
+ def_config_delegator :search, :searchon
119
+ %w{bannerads allowratings showpermalinks showcomments allowcomments}.each do |var|
120
+ def_config_delegator var, var
121
+ end
122
+
123
+ def webcomicurl
124
+ @obj.site.baseurl
125
+ end
126
+
127
+ def lastupdatedmy
128
+ Time.now.strftime('%d/%m/%Y')
129
+ end
130
+
131
+ def copyrights
132
+ @obj.site.config['copyrights'].gsub('[year]', Date.today.year.to_s)
133
+ end
134
+
135
+ def banner
136
+ Pathname.new(@obj.site.baseurl || '/').join(@obj.site.config['banner'] || '').to_path
137
+ end
138
+
139
+ def webcomicavatar
140
+ Pathname.new(@obj.site.baseurl || '/').join(@obj.site.config['webcomicavatar'] || '').to_path
141
+ end
142
+
143
+ def hasblogs
144
+ @obj.site.posts.docs.any?
145
+ end
146
+
147
+ def hidefromhost
148
+ false
149
+ end
150
+
151
+ def extrapages
152
+ @obj.site.pages.reject {|page| page.data['hidden'] }.map do |page|
153
+ {'link' => page.url, 'title' => page.data['title']}
154
+ end
155
+ end
156
+
157
+ def cfscriptcode
158
+ <<~HTML
159
+ <script type="text/javascript">
160
+ function jumpTo(place) { window.location = place; }
161
+ </script>
162
+ HTML
163
+ end
164
+
165
+ def css
166
+ css_files = @obj.site.static_files.select {|f| f.extname == '.css'}.map(&:path).to_a
167
+ css_files << Pathname.new(@obj.site.theme.includes_path).join('layout.css') unless css_files.any?
168
+ css_files.map {|f| File.read f }.join
169
+ end
170
+
171
+ delegate_method_as :url, :permalink
172
+ def_data_delegator :title, :pagetitle
173
+
174
+ def iscomicpage
175
+ @obj.type == :comics
176
+ end
177
+
178
+ def isextrapage
179
+ @obj.type == :pages && @obj.data['hidden'] != true
180
+ end
181
+
182
+ def fallback_data
183
+ {}
184
+ end
185
+ end