# $Id: ndiary-lib.rb,v 1.51 2003/12/10 16:41:49 not Exp $ # 2003/11/3-12/15 ”÷C³ NDIARY_VERSION = 'nDiary version 0.9.4' REQUIRED_RUBY_VER = '1.6' RUBY_INFO = "ruby #{RUBY_VERSION} (#{RUBY_RELEASE_DATE}) [#{RUBY_PLATFORM}]" require 'lib/config' require 'lib/string' require 'lib/dateformat' require 'lib/find' class NDiaryError < StandardError; end class NDiaryFatalError < NDiaryError; end module DiaryModule attr_reader :logDirectory attr_reader :outputDirectory attr_accessor :userPluginDirectory #typo_fix def typo_fix @diarys = @diaries @allDiarys = @allDiaries end def isXHTML if @isxhtml.nil? then unless @isxhtml = @config['OUTPUT_XHTML'] then begin open(@logDirectory + @skelton){ |f| if /]+XHTML/im =~ f.gets('" : "" end def tag_hr(str = '') str = ' ' + str unless str.empty? isXHTML ? "" : "" end def setlogDirectory(dir) # dir = File::expand_path(dir) dir << '/' unless dir[-1,1] == '/' unless File::directory?(dir) then raise NDiaryFatalError, "'#{dir}' not exist." end @logDirectory = dir if @logDirectory == @outputDirectory then raise NDiaryFatalError, "LOG_DIRECTORY and OUTPUT_DIRECTORY should not be the same directory." end end def setoutputDirectory(dir) # dir = File::expand_path(dir) dir << '/' unless dir[-1,1] == '/' unless File::directory?(dir) then raise NDiaryFatalError, "'#{dir}' not exist." end @outputDirectory = dir if @logDirectory == @outputDirectory then raise NDiaryFatalError, "LOG_DIRECTORY and OUTPUT_DIRECTORY should not be the same directory." end end def setmonthlyFilename(str) return if str.to_s.empty? if str.index('%0m') and str.index('%Y') then @monthlyFilename = str else raise NDiaryFatalError, "MONTHLY_FILENAME wrong." end end def logDirectory=(dir); setlogDirectory(dir); end def outputDirectory=(dir); setoutputDirectory(dir); end def monthlyFilename=(filename); setmonthlyFilename(filename); end def first_date_of_month(month) date = '' for dd in "01".."31" if @allDiaries.key?(month + dd) then date = month + dd break end end date end # convet date to monthly filename def date2monthlyfilename(date) date = first_date_of_month(date) if date.size == 6 unless date.empty? then yyyy = date[0..3] mm = date[4..5] dd = date[6..7] @monthlyFilename.gsub('%Y', yyyy).gsub('%0m', mm).gsub(/^(.+)(\.[^\/]+)$/,"\\1#{@monthPartNames[dd.to_i]}\\2") else '' end end def date2fileanchor(date) (isXHTML ? 'd' : '' ) + date[6..7] end # convert date to relative link string def date2monthlyfilelink(date, anchor = nil) date = first_date_of_month(date) if date.size == 6 unless date.empty? then filename = date2monthlyfilename(date) if anchor then filename << '#' filename << date2fileanchor(date) end '../' * @filename.scan(%r"[^/]+/").size + filename else '' end end # Load Plug-in def loadplugin(line) #typo_fix typo_fix line.gsub!(//i){ argv = [] argv = $1.gsub('>','>').gsub('<','<').split(/\?/s) plugname = argv.shift plugname = plugname + '.rb' opt = {} argv.each{ |str| str = str.gsub('&question;','?').gsub('&','&') key, val = str.split(/=/, 2) opt[key.downcase] = val } if $DEBUG then if @userPluginDirectory and File::readable?(@userPluginDirectory + plugname) then puts @userPluginDirectory + plugname load @userPluginDirectory + plugname else puts 'plugin/' + plugname load 'plugin/' + plugname end plug(opt) else begin if @userPluginDirectory and File::readable?(@userPluginDirectory + plugname) then load @userPluginDirectory + plugname else load 'plugin/' + plugname end plug(opt) rescue LoadError $stderr.puts "Plug-in not found: #{plugname}" "" rescue Exception $stderr.puts "Plug-in error: #{plugname} -> #{$!.class}: #{$!.message}\n(#{$@.join})\n[#{@filename}]" "" end end } end end require 'lib/topic' class Filter attr_reader :diary def initialize(filterlist = [], ufdir = nil) @filterlist = filterlist @filterlist = [] if @filterlist.nil? @filterlist.delete_if{ |filter| begin if ufdir then ufdir.chomp!('/') if File::readable?(ufdir + '/' + filter + '.rb') then require ufdir + '/' + filter + '.rb' puts 'load: ' + ufdir + '/' + filter + '.rb' if $DEBUG else require 'filter/' + filter + '.rb' puts 'load: filter/' + filter + '.rb' if $DEBUG end else require 'filter/' + filter + '.rb' puts 'load: filter/' + filter + '.rb' if $DEBUG end false rescue LoadError $stderr.puts "Filter '#{filter}.rb' not found." true end } end def parentdiary=(diary) @diary = diary end def filter(str, type) ret = false @filterlist.each{ |filter| begin ret = true if send(filter, str, type) == :THROUGH rescue NameError $stderr.puts %Q!Filter error: '#{filter}' -> #{$!.class}: #{$!.message}\n(#{$@.join})\n[#{@filename}]! @filterlist.delete(filter) end } return ret end end class Diary require 'cgi' require 'ftools' include DiaryModule attr_accessor :latestFilename attr_reader :monthlyFilename attr_accessor :monthPartNames attr_accessor :showVersion attr_accessor :format attr_accessor :months attr_accessor :date attr_accessor :header attr_accessor :topicChar attr_accessor :outputDirectory attr_writer :tag_date attr_writer :tag_topic attr_accessor :paragraphAnchor attr_accessor :anchorEachTopic attr_accessor :lf2br attr_accessor :getDateByTimestamp attr_accessor :splithtml attr_accessor :skelton attr_accessor :outputKcode attr_reader :config attr_reader :filter attr_reader :months attr_reader :diaries attr_reader :diary attr_reader :allDiaries attr_reader :filename #typo fix attr_reader :diarys attr_reader :allDiarys alias_method("allDiarys", "allDiaries") alias_method("diarys", "diaries") def initialize @diaries = [] @diary = self @showVersion = true @logDirectory = '' @outputDirectory = '' @allDiaries = {} @months = [] @format = '%b.%d,%Y (%a)' @header = '¡' @topicChar = '›œ ¡žŸ¢£¤¥™š' @lf2br = true @outputKcode = 1 @splithtml = '' @tag_date = 'h4' @tag_topic = 'h5' @topicSortReverse = false @now = Time::now @filter = nil @latestFilename = 'index.html' @monthlyFilename = '%Y%0m.html' @filename = '' @isxhtml = nil @userPluginDirectory = nil end def config=(conf) @config = conf @showVersion = @config['SHOW_VERSION'] if @config.key?('SHOW_VERSION') @latestFilename = @config['LATEST_FILENAME'] if @config.key?('LATEST_FILENAME') @outputKcode = @config['OUTPUT_KCODE'] if @config.key?('OUTPUT_KCODE') @topicChar = @config['TOPIC_CHAR'] if @config.key?('TOPIC_CHAR') @paragraphAnchor = @config['PARAGRAPH_ANCHOR'] if @config.key?('PARAGRAPH_ANCHOR') @anchorEachTopic = @config['ANCHOR_EACH_TOPIC'] if @config.key?('ANCHOR_EACH_TOPIC') @lf2br = @config['LF2BR'] if @config.key?('LF2BR') @topic_sentence_link = @config['TOPIC_SENTENCE_LINK'] if @config.key?('TOPIC_SENTENCE_LINK') if @config['USER_PLUGIN_DIRECTORY'] then @userPluginDirectory = @config['USER_PLUGIN_DIRECTORY'].chomp('/') + '/' end setlogDirectory(@config['LOG_DIRECTORY']) setoutputDirectory(@config['OUTPUT_DIRECTORY']) setmonthlyFilename(@config['MONTHLY_FILENAME']) end def tag_date if @tag_date.to_s.empty? then 'h4' else @tag_date end end def tag_topic if @tag_topic.to_s.empty? then 'h5' else @tag_topic end end def filter=(f) @filter = f @filter.parentdiary = self end # search diary file def getDiaries(diary = nil) if diary.kind_of?(Diary) then @allDiaries.update(diary.allDiaries) @months = diary.months else Dir::find(@logDirectory){ |file| next unless /\/(\d{8})(\.diary|\.dia)$/i =~ file date = File::basename(file, $2) unless @allDiaries.key?(date) then @allDiaries[date] = file else raise NDiaryFatalError, "#{date}'s diary duplicated." end } @allDiaries.keys.each{ |date| @months << date[0..5] } for year in "1990"..Time::now.strftime("%Y") for month in '01'..'12' if File::exist?(@outputDirectory + @monthlyFilename.gsub(/%Y/, year).gsub(/%0m/, month)) then @months << year + month end end end @months = @months.uniq.sort end end alias getDiarys getDiaries private def makeParagraph(str) return str if @filter.filter(str, :PRE) if str[0,3] == "<<\n" then return str if @filter.filter(str, :HEREDOC) str = str[3...str.size] str.gsub!(/\n/,"\n\t") str = "\t#{str}\n\n" @filter.filter(str, :AFTER_HEREDOC) return str elsif /\ACode:(?: (-.*))?\n/i =~ str then str = $' param = $1 return str if @filter.filter(str, :CODE) t = /t(\d+)/i =~ param ? $1.to_i : 4 str = CGI::escapeHTML(str) if /q/i =~ param str = str.expandtabs(t) str = %Q!\t
\n#{str}
\n\n! @filter.filter(str, :AFTER_CODE) return str elsif /\AQuote:(?: (-.*))?\n/i =~ str then str = $' param = $1 return str if @filter.filter(str, :QUOTE) t = /t(\d+)/i =~ param ? $1.to_i : 4 str = CGI::escapeHTML(str) if /q/i =~ param if /l[^-]/i =~ param or (@lf2br != false and /l-/i !~ param) then str.gsub!(/\n/, tag_br + "\n") end str = str.expandtabs(t) str = %Q!\t
\n#{str}
\n\n! @filter.filter(str, :AFTER_QUOTE) return str elsif /^[^\t]/ !~ str then return str if @filter.filter(str, :BLOCKQUOTE) str.delete!("\t") str.gsub!(/\n/, tag_br + "\n\t\t") unless @lf2br == false str = "\t

\n\t\t#{str}\n\t

\n\n" @filter.filter(str, :AFTER_BLOCKQUOTE) return str elsif /^\t/ =~ str then return str if @filter.filter(str, :DL) str.gsub!(/^/,"\t\t
") str.gsub!(/^\t\t
\t/,"\t\t\t
") str.gsub!(/^(\t\t
.+)$/,"\\1
") str.gsub!(/^(\t\t\t
.+)$/,"\\1
") str = "\t
\n#{str}\n\t
\n\n" @filter.filter(str, :AFTER_DL) return str elsif @topicChar.index(str.split(//)[0]) and (/\n/ !~ str) then return str if @filter.filter(str, :TOPIC) th, ts = str.split(//,2) @cntTopic += 1 @cntParagraph = 0 if @anchorEachTopic == true if self.kind_of?(PastDiary) then @topics[@dd] = [] unless @topics.key?(@dd) @topics[@dd] << str.gsub(/<[^>]+>/, '') end name = '' if self.kind_of?(PastDiary) name = (isXHTML ? "id" : "name") + %Q!="#{date2fileanchor(@date)}_t#{@cntTopic}" ! end if @topic_sentence_link then links = [] ts.gsub!(/([^<]+)<\/a>/){ links << $& $1 } link = '' if links.size.nonzero? then link = %Q! (ŽQÆURL: #{links.join(' / ')})! end str = %Q!\t<#{tag_topic} class="topic">#{th}#{ts}#{link}\n\n! else str = %Q!\t<#{tag_topic}>#{th}#{ts}\n\n! end @filter.filter(str, :AFTER_TOPIC) return str elsif /\A[\+E]/ =~ str then return str if @filter.filter(str, :UL) str.gsub!(/\n([^\+E].+)$/,"#{tag_br}\\1") unless @lf2br == false str.gsub!(/(?:\n|^)[\+E](.+)(?!^[\+E])/,"\n\t\t
  • \\1
  • ") str = "\t
      #{str}\n\t
    \n\n" @filter.filter(str, :AFTER_UL) return str elsif !str.strip.empty? then return str if @filter.filter(str, :P) @cntParagraph += 1 str.gsub!(/\n/, tag_br + "\n\t") unless @lf2br == false anchor = '' if @anchorEachTopic then anchor = "_t#{@cntTopic}_#{@cntParagraph}" else anchor = "_#{@cntParagraph}" end if @paragraphAnchor then name = '' if self.kind_of?(PastDiary) name = (isXHTML ? "id" : "name") + %Q!="#{date2fileanchor(@date)}#{anchor}" ! end str = %Q!\t

    #{@header}\n\t#{str}

    \n\n! elsif self.kind_of?(PastDiary) str = %Q!\t

    #{str}

    \n\n! else str = %Q!\t

    #{str}

    \n\n! end @filter.filter(str, :AFTER_P) return str else return '' end end def tohtml(date) body = '' @cntParagraph = @cntTopic = 0 filename = @allDiaries[date] @date = date @yyyy = @date[0..3] @mm = @date[4..5] @dd = @date[6..7] objDate = Time::local(@yyyy, @mm, @dd) # Read Diary File text = '' open(filename){ |file| text = file.read.gsub(/\r\n/,"\n") } text = text.tosjis bodys = [[]] # Filter :ONEDAY if @filter.filter(text, :ONEDAY) then body << text else text.each("\n\n"){ |line| line.sub!(/^\n$/,"") line.chomp! line.chomp! line.delete!("\C-z") next if line.empty? str = makeParagraph(line) bodys[@cntTopic] = [] if bodys[@cntTopic].nil? bodys[@cntTopic] << str } end t0 = bodys.shift if t0.size.nonzero? then body << %Q!\t
    \n\n#{t0.join}\t
    \n\n! end bodys.reverse! if @topicSortReverse bodys.each{ |block| body << %Q!\t
    \n\n! body << block[0] body << %Q!\t
    \n\n! body << block[1..-1].join body << "\t
    \n\n" body << "\t
    \n\n" } body = %Q!
    \n\n! + body + "
    \n\n" name = '' if self.kind_of?(PastDiary) then name = (isXHTML ? "id" : "name") + %Q!="#{date2fileanchor(@date)}" ! end date_anchor = objDate.strftime2(%Q!<#{tag_date} class="date">#{@format}\n!) @filter.filter(date_anchor, :DATE_ANCHOR) body = date_anchor + body # Filter :AFTER_ONEDAY @filter.filter(body, :AFTER_ONEDAY) splithtml = objDate.strftime2(@splithtml) splithtml = eval('%Q(' + splithtml + ')') body << splithtml + "\n\n" unless @splithtml.empty? body = %Q!
    \n#{body}\n
    \n\n! return body end def writeHTML # filename = File::expand_path(@filename, @outputDirectory) filename = @outputDirectory + @filename @outputKcode = 1 if (@outputKcode < 1 or @outputKcode > 4) begin body = '' @diaries.each{ |date| body << tohtml(date) } File::makedirs(File::dirname(filename)) skelton = '' open(@logDirectory + @skelton) { |f| skelton = f.read.tosjis } skelton.sub!(//i){ if @showVersion then str = %Q!\n
    Generated by ! str << %Q!! str << NDIARY_VERSION << "
    \n#{tag_hr}\n" else str = "\n\n" end lm = @now.dup.gmtime str << lm.strftime("") str << body str << "\n\n" } loadplugin(skelton) @filter.filter(skelton, :HTML) open(filename, "w"){ |f| f.print skelton.kconv(@outputKcode) } print "make '#{filename}'\n" rescue Errno::ENOENT raise NDiaryFatalError, "skelton file '#{@logDirectory}#{@skelton}' not found." rescue Exception raise NDiaryFatalError, "#{$!.backtrace}: #{$!.message}(#{$!.class})" end end end class LastDiary < Diary attr_accessor :maxDiaries attr_accessor :topicSortReverse alias maxDiarys maxDiaries def initialize super() @maxDiaries = 5 @topicSortReverse = false end alias maxDiarys maxDiaries def config=(conf) super @maxDiaries = @config['MAX_DIARY'] if @config.key?('MAX_DIARY') @skelton = @config['SKELTON_LATEST'] if @config.key?('SKELTON_LATEST') @splithtml = @config['SPLIT_LATEST'] if @config.key?('SPLIT_LATEST') @format = @config['DATEFORMAT_LATEST'] if @config.key?('DATEFORMAT_LATEST') @header = @config['HEADER_LATEST'] if @config.key?('HEADER_LATEST') @tag_date = @config['TAG_DATE_LATEST'] if @config.key?('TAG_DATE_LATEST') @tag_topic = @config['TAG_TOPIC_LATEST'] if @config.key?('TAG_TOPIC_LATEST') @topicSortReverse = @config['TOPIC_SORT_REVERSE'] end def tohtml(date) body = super if date == @diaries[0] then body = "\n\n#{body}\n\n" end return body end def makeHTML @filter.parentdiary = self @diaries = @allDiaries.keys.sort.reverse[0...@maxDiaries] lastDate = @diaries[0] @objLastDate = Time::local(lastDate[0..3], lastDate[4..5], lastDate[6..7]) @filename = @latestFilename writeHTML end end class PastDiary < Diary attr_reader :targetMonth def initialize super() @targetMonth = '' @topics = {} end def config=(conf) super @format = @config['DATEFORMAT_PASTDAYS'] if @config.key?('DATEFORMAT_PASTDAYS') @header = @config['HEADER_PASTDAYS'] if @config.key?('HEADER_PASTDAYS') @skelton = @config['SKELTON_PASTDAYS'] if @config.key?('SKELTON_PASTDAYS') @splithtml = @config['SPLIT_PASTDAYS'] if @config.key?('SPLIT_PASTDAYS') @tag_date = @config['TAG_DATE_PASTDAYS'] if @config.key?('TAG_DATE_PASTDAYS') @tag_topic = @config['TAG_TOPIC_PASTDAYS'] if @config.key?('TAG_TOPIC_PASTDAYS') end def targetMonth=(month) @topics.clear @targetMonth = month end def makeHTML @filter.parentdiary = self diaries = @allDiaries.keys.sort diaries.delete_if{ |file| file.index(@targetMonth) == nil } if diaries.empty? then $stderr.puts @targetMonth + ": not found .diary files." return end @objTargetMonth = Time::local(@targetMonth[0..3], @targetMonth[4..5]) partdiaries = {} diaries.each{ |date| day = date[6..7].to_i unless partdiaries.key?(@monthPartNames[day]) then partdiaries[@monthPartNames[day]] = [] end partdiaries[@monthPartNames[day]] << date } partdiaries.each{|part, ds| @diaries = ds.sort @filename = date2monthlyfilename(@diaries[0]) writeHTML } # Topics List open(@logDirectory + @targetMonth + '.topic', "w"){ |file| @topics.keys.sort.each{ |dd| file.puts @targetMonth + dd + "\t" + @topics[dd].join("\t") } } unless @topics.empty? end end # typo fix class Config alias set_original set def set(key, val) key = case key when 'LASTEST_FILENAME' 'LATEST_FILENAME' when 'SKELTON_LASTEST' 'SKELTON_LATEST' when 'SPLIT_LASTEST' 'SPLIT_LATEST' when 'DATEFORMAT_LASTEST' 'DATEFORMAT_LATEST' when 'HEADER_LASTEST' 'HEADER_LATEST' when 'TAG_DATE_LASTEST' 'TAG_DATE_LATEST' when 'TAG_TOPIC_LASTEST' 'TAG_TOPIC_LATEST' else key end set_original(key, val) end end #/////////////////////////////////////////////////////////////////// # # Main Routine # class NDiary def initialize @config = Config::new @filter = @pastdiary = @lastdiary = nil @years = [] if RUBY_VERSION < REQUIRED_RUBY_VER then raise NDiaryFatalError, "this script require ruby #{REQUIRED_RUBY_VER} or later." end end def loadconfig(configfile) unless File::readable?(configfile) then raise NDiaryFatalError, "config file '#{configfile}' not found." end @config.load(configfile) @config.setarray('TOPIC', 'FILTER') @filter = Filter::new(@config['FILTER'], @config['USER_FILTER_DIRECTORY']) @config['MONTH_PART_NAMES'] = [] unless @config['MONTH_PART_NAMES'].kind_of?(Array) end def setconfig(key, value = nil) if value.nil? then @config[key] = true else begin @config[key] = eval(value.tosjis) rescue ScriptError @config[key] = value.tosjis end end end def initialize_diary monthPartNames = [''] * 32 monthPartNames[1,0] = @config['MONTH_PART_NAMES'] monthPartNames = monthPartNames[0..31] @pastdiary = PastDiary::new @pastdiary.config = @config @pastdiary.monthPartNames = monthPartNames @pastdiary.filter = @filter @pastdiary.getDiaries if @pastdiary.allDiaries.keys.nil? or @pastdiary.allDiaries.keys.size < 1 then raise NDiaryFatalError, "no .diary file under #{pastDiary.logDirectory}." end @lastdiary = LastDiary::new @lastdiary.config = @config @lastdiary.monthPartNames = monthPartNames @lastdiary.filter = @filter @lastdiary.getDiaries(@pastdiary) end def makepastdiary(month = nil) initialize_diary if @pastdiary.nil? if month == :ALL then for m in @pastdiary.months @pastdiary.targetMonth = m @pastdiary.makeHTML @years << m[0..3] end elsif month.nil? then months = @pastdiary.allDiaries.keys.sort.reverse[0,@config['MAX_DIARY']] months.collect!{ |m| m[0..5] }.uniq.each{ |m| @pastdiary.targetMonth = m @pastdiary.makeHTML @years << m[0..3] } else @pastdiary.targetMonth = month @pastdiary.makeHTML @years << month[0..3] end @years.uniq! end def makelastdiary initialize_diary if @lastdiary.nil? @lastdiary.makeHTML end def maketopiclist if !@config['TOPIC'].nil? and @config['TOPIC'].size > 0 then t = Topics::new t.topicDateFormat = @config['TOPIC_DATE_FORMAT'] t.topicMonthFormat = @config['TOPIC_MONTH_FORMAT'] t.diary = @pastdiary @config['TOPIC'].each{ |topic| filename, regexp, pattern, replace = topic.split(/\t/) t.skelton = filename t.regexp = regexp ? regexp : '' t.pattern = pattern ? pattern : '' t.replace = replace ? replace : '' t.writeHTML(@pastdiary.allDiaries.keys, true, @years) } end end end def main print "\n-- #{NDIARY_VERSION} --\n\n" now = Time::now begin ndiary = NDiary::new # Load Configuration if /.+\.conf$/i =~ ARGV[0] or /\.ndiaryrc$/ =~ ARGV[0] then configfile = ARGV.shift else configfile = ENV['HOME'].to_s + File::Separator + ".ndiaryrc"; unless File::readable? configfile then configfile = 'ndiary.conf' end end ndiary.loadconfig(configfile) # commandline option while ARGV[0].to_s[0,1] == "-" do opt = ARGV.shift.dup opt.sub!(/^\-/, '') key, value = opt.split('=', 2) ndiary.setconfig(key, value) end # make diary html file if ARGV.size.zero? then ndiary.makepastdiary ndiary.makelastdiary elsif /^now$/ =~ ARGV[0] then #2003.12.15‰ü‘¢•”•ªB ndiary.makelastdiary #ƒIƒvƒVƒ‡ƒ“‚̒ljÁB elsif /^(\d{6})$/ =~ ARGV[0] then ndiary.makepastdiary($1) elsif /^(\d{4})$/ =~ ARGV[0] then for m in '01'..'12' ndiary.makepastdiary($1 + m) end elsif /^(\d{6})\-(\d{6})$/ =~ ARGV[0] then ym1, ym2 = $1, $2 if ym1 > ym2 then ym1, ym2 = ym2, ym1 end y1, m1 = ym1[0..3], ym1[4..5] y2 ,m2 = ym2[0..3], ym2[4..5] for y in y1..y2 for m in '01'..'12' next if (y == y1 and m < m1) break if (y == y2 and m > m2) ndiary.makepastdiary(y + m) end end elsif ARGV[0].downcase == 'all' then ndiary.makepastdiary(:ALL) ndiary.makelastdiary else $stderr.puts "\n** Bad Parameter: '#{ARGV[0]}'\n" exit end # make topic list ndiary.maketopiclist puts "\nfinish. (#{Time::now - now}sec.)" rescue NDiaryFatalError $stderr.puts %Q!#{$!.message}\n(#{$!.class}: #{$@.join("\n")})\n! end end