# # my-sequel.rb # # show links to follow-up entries # # Copyright 2006 zunda and # NISHIMURA Takashi # # Permission is granted for use, copying, modification, distribution, # and distribution of modified versions of this work under the terms # of GPL version 2. # # Language resources can be found in the middle of thie file. # Please search a line with `language resource' # require 'pstore' unless defined?(ERB) require 'erb' end class MySequel include ERB::Util extend ERB::Util class Conf include ERB::Util Prefix = 'my_sequel.' unless @conf then def self::to_native(str) return str end else def self::to_native(str) @conf.to_native(str) end end def self::handler_escape(string) string.gsub(/\r/n, '').gsub(/&/n, '&').gsub(/"/n, '"').gsub(/>/n, '>').gsub(/').replace(/"/g, '"').replace(/&/g, '&'); } function uncheck(element) { document.getElementById(element.id+".reset").checked = false; } function restore(element) { var text_id = element.id.replace(/\.reset$/, "") if (element.checked) { document.getElementById(text_id).value = unescape(default_values[text_id]); } } _END end def initialize(conf_hash) @default_hash = conf_hash @conf_hash = Hash.new end # takes configuration from @options trusting the input def merge_hash(hash) @default_hash.each_key do |key| prefixed = Prefix + key.to_s @conf_hash[key] = hash[prefixed] if hash.has_key?(prefixed) end end # takes configuration from @cgi.params def merge_params(params) @default_hash.each_key do |key| keystr = key.to_s if params[keystr+'.reset'] and params[keystr+'.reset'][0] then @conf_hash.delete(key) elsif params[keystr] then @conf_hash[key] = params[keystr][0] end end end # returns current configuration def [](key) if @conf_hash.has_key?(key) then return @conf_hash[key] else return @default_hash[key][:default] end end # returns hash of configured values def to_conf_hash(target_hash) @default_hash.each_key do |key| target_hash.delete(Prefix + key.to_s) end @conf_hash.each_pair do |key, value| target_hash[Prefix + key.to_s] = value end end # returns an HTML sniplet for configuration interface def html(restore_default_label, mobile = false) return @default_hash.keys.sort_by{|k| @default_hash[k][:index]}.map{|k| idattr = mobile ? '' : %Q| id="#{h k.to_s}"| idattr_reset = mobile ? '' : %Q| id="#{h k.to_s}.reset"| uncheck = mobile ? '' : ' onfocus="uncheck(this)"' restore = mobile ? '' : ' onchange="restore(this)" onclick="restore(this)"' r = %Q|\t

#{h @default_hash[k][:title]}

\n| description = @default_hash[k][:description] r += %Q|\t

#{h description}

\n| if description and not mobile unless @default_hash[k][:textarea] r += %Q|\t

| else cols = 70 rows = 10 if @default_hash[k][:textarea].respond_to?(:[]) then cols = @default_hash[k][:textarea][:cols] || cols rows = @default_hash[k][:textarea][:rows] || rows end r += %Q|\t

| end name = "#{h k.to_s}.reset" r += %Q| - 

\n| r }.join end # Javascript hash literal for default values def default_js_hash r = "default_values = {\n" r += @default_hash.keys.sort_by{|k| @default_hash[k][:index]}.map{|k| %Q|\t"#{h k}": "#{Conf::handler_escape(@default_hash[k][:default])}"| }.join(",\n") r += "\n};\n" return r end def handler_block return <<"_END" _END end end # CSS sniplet for sequels def self::css(inner_css) unless inner_css.strip.empty? return <<"_END" \t _END else return '' end end # cache directory for this plguin def self::cache_dir(cache_path) return File.join(cache_path, 'my_sequel') end # cache file for a month: #{yyyy}/#{yyyymm}.#{src or dst}.dat def self::cache_file(cache_path, anchor, direction) return File.join(MySequel.cache_dir(cache_path), MySequel.year(anchor), "#{MySequel.month(anchor)}.#{direction}.dat") end # unique for each month def self::cache_key(anchor) return MySequel.month(anchor) end # for each cache key for dates def self::each_cache_key(dates) dates.map{|ymd| MySequel.cache_key(ymd)}.uniq.each do |cache_file| yield(cache_file) end end # yyyy def self::year(anchor) return anchor.scan(/\d{4,4}/)[0] end # yyyymm def self::month(anchor) return anchor.scan(/\d{6,6}/)[0] end # yyyymmdd def self::date(anchor) if anchor.respond_to?(:localtime) return anchor.localtime.strftime("%Y%m%d") else return anchor.scan(/\d{8,8}/)[0] end end # add an entry to Array value of hash, making new Array if needed def self::push_to_hash(hash, key, element) unless hash.has_key?(key) hash[key] = Array.new begin hash[key].taint rescue SecurityError end end hash[key] << element hash end def initialize(cache_path) @link_srcs = Hash.new.taint # key:dst anchor value:Array of src anchors @current_dsts = Hash.new.taint # key:src anchor value:Array of dst anchors @cached_dsts = Hash.new.taint # for restore_dsts and clean_srcs @vanished_dsts = Hash.new.taint # key:src date value:Array of dst anchors @cache_path = cache_path end def restore(dates) restore_srcs(dates) restore_dsts(dates) end # HTML sniplet for sequels def html(dst_anchor, date_format, label) anchors = srcs(dst_anchor) if anchors and not anchors.empty? then r = %Q|
#{h label}| r += anchors.map{|src_anchor| yield(src_anchor, Time.local(*(src_anchor.scan(/(\d{4,4})(\d\d)(\d\d)/)[0])).strftime(date_format)) }.join(', ') r += "
\n" return r else return '' end end # Array of source anchors for a destination anchor, nil if none def srcs(dst_anchor) a = @link_srcs[dst_anchor] return nil if not a or a.empty? return a.uniq.sort end # starts a day - get ready to scan the diary for the section def clean_dsts(date) datestr = MySequel.date(date) @current_dsts.keys.each do |src_anchor| next unless MySequel.date(src_anchor) == datestr @current_dsts[src_anchor] = Array.new begin @current_dsts[src_anchor].taint rescue SecurityError end end end # adds a link def add(src_anchor, dst_anchor) MySequel.push_to_hash(@link_srcs, dst_anchor, src_anchor) MySequel.push_to_hash(@current_dsts, src_anchor, dst_anchor) end # detect vanished links def clean_srcs (@cached_dsts.keys + @current_dsts.keys).uniq.each do |src_anchor| if @cached_dsts[src_anchor] then if @current_dsts[src_anchor] then @vanished_dsts[src_anchor] = @cached_dsts[src_anchor] - @current_dsts[src_anchor] else @vanished_dsts[src_anchor] = @cached_dsts[src_anchor] end end @cached_dsts[src_anchor] = @current_dsts[src_anchor].dup end end # restores cached data for a month # calls the block for each root giving key and value def each_cached(anchor, direction) path = MySequel.cache_file(@cache_path, anchor, direction) begin PStore.new(path).transaction(true) do |db| db.roots.each do |cached_anchor| yield(cached_anchor, db[cached_anchor]) end end rescue TypeError # corrupted PStore data File.unlink(path) rescue PStore::Error # corrupted PStore data begin File.unlink(path) rescue Errno::ENOENT end rescue Errno::ENOENT # no cache yet end end private :each_cached # restores cached sources for a month def restore_srcs(dates) @srcs_loaded ||= Hash.new MySequel.each_cache_key(dates) do |cache_key| unless @srcs_loaded[cache_key] then each_cached(cache_key, 'src') do |anchor, array| unless @link_srcs.has_key?(anchor) @link_srcs[anchor] = array.taint else @link_srcs[anchor] += array.taint end end @srcs_loaded[cache_key] = true end end end # restores cached destinations def restore_dsts(dates) @dsts_loaded ||= Hash.new MySequel.each_cache_key(dates) do |cache_key| unless @dsts_loaded[cache_key] then each_cached(cache_key, 'dst') do |anchor, array| array.taint @cached_dsts[anchor] = array @current_dsts[anchor] = array.dup end @dsts_loaded[cache_key] = true end end end # hash for storing cache # key: path to cache # value: Hash # key: anchor # value: compacted and uniqed Array of anchor on the other side of link def hash_for_cache(link_hash, direction) r = Hash.new link_hash.each_pair do |pivot_anchor, anchor_array| c = anchor_array.compact.uniq path = MySequel.cache_file(@cache_path, pivot_anchor, direction) r[path] ||= Hash.new r[path][pivot_anchor] = c end return r end private :hash_for_cache # stores the data def store(cache_hash) cache_hash.each_pair do |path, h| d = File.dirname(path) Dir.mkdir(d) unless File.exist?(d) PStore.new(path).transaction do |db| h.each_pair do |k, v| unless v.empty? then db[k] = v else db.delete(k) end end end end end private :store # commits on-memory results to files def commit d = MySequel.cache_dir(@cache_path) Dir.mkdir(d) unless File.exist?(d) restore_srcs(@link_srcs.keys) restore_srcs(@vanished_dsts.values.flatten) @vanished_dsts.each_pair do |src_anchor, dst_anchors| dst_anchors.uniq.each do |dst_anchor| @link_srcs[dst_anchor].reject!{|anchor| anchor == src_anchor} end end store(hash_for_cache(@link_srcs, 'src')) store(hash_for_cache(@current_dsts, 'dst')) @vanished_dsts = Hash.new.taint end end unless __FILE__ == $0 then # register this plguin to tDiary # language resource and configuration @my_sequel_plugin_name ||= 'Link to follow ups' @my_sequel_description ||= <<_END

Shows links to follow-up entries, which have `my' link to the entry in the past.

Do not forget to push the OK button to store the changes.

_END @my_sequel_label_conf ||= 'Link label' @my_sequel_label ||= 'Follow up: ' @my_sequel_restore_default_label ||= 'Restore default' @my_sequel_default_hash ||= { :label => { :title => 'Link label', :default => 'Follow up: ', :description => 'Prefix for links to the follow-ups', :index => 1, }, :date_format => { :title => 'Link format', :default => @date_format, :description => 'Time format of links to the follow-ups. Sequences of % and a charactor are converted as follows: "%Y" to year, "%m" to month in number, "%b" to short name of month, "%B" to full name of month, "%d" to day of month, "%a" to short name of day of week, and "%A" to full name of day of week, for the follow-up.', :index => 2, }, :inner_css => { :title => 'CSS', :default => <<'_END', font-size: 75%; text-align: right; margin: 0px; _END :description => 'CSS for the links. The followoing is applied to div.sequel.', :index => 3, :textarea => {:rows => 5}, }, } @my_sequel_conf = MySequel::Conf.new(@my_sequel_default_hash) @my_sequel_conf.merge_hash(@options) # configuration interface add_conf_proc( 'my-sequel', @my_sequel_plugin_name ) do if @mode == 'saveconf' then @my_sequel_conf.merge_params(@cgi.params) @my_sequel_conf.to_conf_hash(@conf) end <<"_HTML" #{@my_sequel_conf.handler_block}

#{@my_sequel_plugin_name}

#{@my_sequel_description} #{@my_sequel_conf.html(@my_sequel_restore_default_label, @conf.mobile_agent?).chomp} _HTML end @my_sequel = MySequel.new(@cache_path) @my_sequel_active = false # activate this plugin if header procs are called # - This avoids being called from makerss.rb add_header_proc do if not @conf.bot? and not @conf.mobile_agent? then @my_sequel_active = true @my_sequel.restore(@diaries.keys) MySequel.css(@my_sequel_conf[:inner_css]) end end # preparation for a day add_body_enter_proc do |date| if @my_sequel_active then if date then @my_sequel_date = MySequel.date(date) @my_sequel.clean_dsts(@my_sequel_date) else @my_sequel_date = nil end end '' end # preparation for a section add_section_enter_proc do |date, index| if @my_sequel_active and @my_sequel_date then @my_sequel_anchor = "#{@my_sequel_date}#p#{'%02d' % index}" end '' end # plugin function to be called from within sections alias :my_sequel_orig_my :my unless defined?(my_sequel_orig_my) def my(*args) if @my_sequel_active and @my_sequel_date and @my_sequel_anchor and @mode != 'preview' then dst_date, frag = args[0].scan(/(\d{8,8})(?:[^\d]*)(?:#?p(\d+))?$/)[0] if dst_date and dst_date < @my_sequel_date then dst_anchor = "#{dst_date}#{frag ? "#p%02d" % frag.to_i : ''}" @my_sequel.add(@my_sequel_anchor, dst_anchor) end end my_sequel_orig_my(*args) end # show sequels when leaving a section add_section_leave_proc do r = '' if @my_sequel_active and @my_sequel_date and @my_sequel_anchor and not @conf.bot? and not @conf.mobile_agent? then r = @my_sequel.html(@my_sequel_anchor, @my_sequel_conf[:date_format], @my_sequel_conf[:label]){|src_anchor, anchor_str| my_sequel_orig_my(src_anchor, anchor_str) } end @my_sequel_anchor = nil r end # show sequels when leaving a day add_body_leave_proc do r = '' if @my_sequel_active and @my_sequel_date then if not @conf.bot? and not @conf.mobile_agent? then r = @my_sequel.html(@my_sequel_anchor, @my_sequel_conf[:date_format], @my_sequel_conf[:label]){|src_anchor, anchor_str| my_sequel_orig_my(src_anchor, anchor_str) } end end @my_sequel_date = nil r end # commit changes add_footer_proc do if @my_sequel_active then @my_sequel.clean_srcs @my_sequel.commit end '' end else # test cases for updates of links: run this file as a Ruby script require 'test/unit' require 'tmpdir' class TestMySequel < Test::Unit::TestCase OrigLinks = { '20061231#p01' => '20061101#p01', '20061231#p02' => '20061001#p01', '20061130#p01' => '20061001#p01', } def setup @cache_path = File.join(Dir.tmpdir, "#{__FILE__}-#{$$}") Dir.mkdir(@cache_path) @orig_src_dates = OrigLinks.keys.map{|a| MySequel.date(a)} @orig_dst_dates = OrigLinks.values.map{|a| MySequel.date(a)} orig = MySequel.new(@cache_path) orig.restore(@orig_src_dates) @orig_src_dates.each do |date| orig.clean_dsts(Time.local(*(date.scan(/(\d{4,4})(\d\d)(\d\d)/)[0]))) end OrigLinks.each_pair do |src, dst| orig.add(src, dst) end orig.clean_srcs orig.commit end def testsrcs # confirms setup really cached the OrigLinks cached = MySequel.new(@cache_path) cached.restore(@orig_dst_dates) OrigLinks.values.uniq.each do |dst| srcs = OrigLinks.find_all{|e| dst == e[1]}.map{|e| e[0]} assert_equal(srcs.sort, cached.srcs(dst).sort) end end def testadd # confirms addition of a day # write the diary for 2007-01-01 cached = MySequel.new(@cache_path) cached.restore(@orig_dst_dates) cached.clean_dsts(Time.local(2007,1,1)) cached.add('20070101#p01', '20060101#p01') assert_equal(['20070101#p01'], cached.srcs('20060101#p01')) cached.clean_srcs cached.commit # display the diary for 2006-01-01 cached = MySequel.new(@cache_path) cached.restore(['20060101']) assert_equal(['20070101#p01'], cached.srcs('20060101#p01')) # confirmation of other links testsrcs end def testadd_two_months # http://zunda.freeshell.org/d/20070122.html#c01 # write the diary for 2007-02-15 cached = MySequel.new(@cache_path) cached.restore('20070215') cached.clean_dsts(Time.local(2007,2,15)) cached.add('20070215#p01', '20070115#p01') cached.clean_srcs cached.commit # write the diary for 2007-03-10 cached = MySequel.new(@cache_path) cached.restore('20070310') cached.clean_dsts(Time.local(2007,3,10)) cached.add('20070310#p01', '20070115#p01') cached.clean_srcs cached.commit # display the diary on 2007-01-15 cached = MySequel.new(@cache_path) cached.restore('20070115') assert_equal(['20070215#p01', '20070310#p01'], cached.srcs('20070115#p01')) end def testedit # confirms edition of a link # edit the diary for 2006-11-31 cached = MySequel.new(@cache_path) cached.restore(@orig_dst_dates) cached.clean_dsts(Time.local(2006,11,30)) cached.add('20061130#p01', '20060901#p01') cached.clean_srcs cached.commit # display the diary on 2006-09-01 cached = MySequel.new(@cache_path) cached.restore(['20060901']) assert_equal(['20061130#p01'], cached.srcs('20060901#p01')) # display the diary on 2006-10-01 cached.restore(['20061001']) assert_equal(['20061231#p02'], cached.srcs('20061001#p01')) end def testdelete # confirms deletion of a link # edit the diary for 2006-11-31 cached = MySequel.new(@cache_path) cached.restore(@orig_dst_dates) cached.clean_dsts(Time.local(2006,11,30)) cached.clean_srcs cached.commit # display the diary on 2006-10-01 cached.restore(['20061001']) assert_equal(['20061231#p02'], cached.srcs('20061001#p01')) end def teardown FileUtils.rmtree(@cache_path) end end class TestMySequelCss < Test::Unit::TestCase def test_usual assert_equal(<<"_TARGET", MySequel::css(<<'_INNER')) \t _TARGET hogehoge: _INNER end def test_empty assert_equal('', MySequel::css('')) end def test_space assert_equal('', MySequel::css(' ')) end def test_crlf assert_equal('', MySequel::css("\r\n")) end end class TestMySequelConf < Test::Unit::TestCase include ERB::Util def setup @defaults = { :label => { :title => 'Link label', :default => 'default label', :index => 1, }, :format => { :title => 'Date format*', :description => 'Format of the dates of the link', :default => '', :index => 2, }, :textarea => { :title => 'Test to show text area', :default => "a\nb\ncc", :index => 3, :textarea => true, }, :textarea_with_size => { :title => 'Test to show text area', :default => "a\nb\ncc", :index => 4, :textarea => {:rows => 2}, } } @my_sequel_conf = MySequel::Conf.new(@defaults) end def testdefaults # retrieve default configuration assert_equal('default label', @my_sequel_conf[:label]) end def testmerge options = {'my_sequel.label' => 'configured label'} @my_sequel_conf.merge_hash(options) assert_equal('configured label', @my_sequel_conf[:label]) end def testparams options = {'label' => ['configured label']} @my_sequel_conf.merge_params(options) assert_equal('configured label', @my_sequel_conf[:label]) end def testparams_with_empty_array options = {'label' => ['configured label'], 'label.reset' => []} @my_sequel_conf.merge_params(options) assert_equal('configured label', @my_sequel_conf[:label]) end def testtohash testmerge conf_hash = {'dummy' => 'dummy'} @my_sequel_conf.to_conf_hash(conf_hash) assert_equal({'my_sequel.label' => 'configured label', 'dummy' => 'dummy'}, conf_hash) end def testparams_with_empty testmerge options = {'label' => ['']} @my_sequel_conf.merge_params(options) assert_equal('', @my_sequel_conf[:label]) end def testparams_with_reset testmerge options = {'label' => ['any value'], 'label.reset' => 't'} @my_sequel_conf.merge_params(options) assert_equal('default label', @my_sequel_conf[:label]) end def test_delete_confhash options = {'my_sequel.label' => 'configured label'} @my_sequel_conf.merge_hash(options) params = {'label' => ['any value'],'label.reset' => 't'} @my_sequel_conf.merge_params(params) @my_sequel_conf.to_conf_hash(options) assert(!options.has_key?('my_sequel.label')) end def testparams_with_nil testmerge options = {'label' => nil} @my_sequel_conf.merge_params(options) assert_equal('configured label', @my_sequel_conf[:label]) end def testparams_with_nokey testmerge options = {} @my_sequel_conf.merge_params(options) assert_equal('configured label', @my_sequel_conf[:label]) end def testconfhtml target = <<_HTML

#{h @defaults[:label][:title]}

- Restore default:

#{h @defaults[:format][:title]}

#{h @defaults[:format][:description]}

- Restore default:

#{h @defaults[:textarea][:title]}

- Restore default:

#{h @defaults[:textarea_with_size][:title]}

- Restore default:

_HTML assert_equal(target, @my_sequel_conf.html(' - Restore default:')) end def testconfhtml_mobile target = <<_HTML

#{h @defaults[:label][:title]}

- Restore default:

#{h @defaults[:format][:title]}

- Restore default:

#{h @defaults[:textarea][:title]}

- Restore default:

#{@defaults[:textarea_with_size][:title]}

- Restore default:

_HTML assert_equal(target, @my_sequel_conf.html(' - Restore default:', true)) end end end