/ 最近 .rdf 追記 編集 設定 本棚

脳log[20071208] カテゴリ表示を最新表示、月表示モード互換に変更



2007年12月08日 (土) 知らなかった: ヘボン = Hepburn = ヘップバーン

[tDiary]カテゴリ表示を最新表示、月表示モード互換に変更

10月22日に書いたように「カテゴリ」表示モードは「最新」「月」表示と見た目が違って違和感があるので、カテゴリ表示を最新表示と月表示に近づけてみた。(この日記で実働中)。

 追記

  • 2007-12-17 不完全なキャッシュが作成される問題があったので、「4 tdiary/categorizedio.rb」を修正しました。
  • 2007-12-28 セクションURLが変わってしまう問題があったので、「4 tdiary/categorizedio.rb」を修正しました。
  • 2008-04-17 カテゴリインデックスのパスの求め方を最新の tDiaryと同じものにしました。(「4 tdiary/categorizedio.rb」。結果は以前の CGI.escapeを使ったものと同じ。20080417p01)
  • 2008-04-18 共通のコードを TDiaryView#load_categorizedioにまとめました。(「3 tdiary.rb」)

 方針

新しく表示モード(TDiaryViewを継承したクラス)を作るのは手間だし、TDiaryLatestや TDiaryMonthからのコピペばっかりになることが想像できるので IOのラッパを作って TDiaryLatestや TDiaryMonthを騙すことに。

 CategorizedIO

transactionメソッドを引っかけて元々の IO(DefaultIOか PStoreIO)が渡してきた diariesから特定のカテゴリを持たないセクションを取り除いて呼び出し元に渡す。

transactionの呼び出し元に不完全な日記データが渡る関係上、日記の変更は捨てる。(読み出し専用)

 変更・追加したファイル

  1. .htaccess
  2. index.rb
  3. tdiary.rb
  4. tdiary/categorizedio.rb
  5. plugin/00default.rb
  6. misc/plugin/category.rb

 1 .htaccess

個々の日記のURLを ?date=yyyymmdd から yyyymmdd.html に、mod_rewriteを使って書き換えてる場合、RewriteRuleの最後に [QSA] (query string append)フラグを付けて書き換えた URLにクエリストリングをくっつけてもらう必要がある。クエリストリングはカテゴリを指定するのに使う。mod_rewriteを使っていない場合は .htaccessの変更は必要ない。

RewriteEngine on
RewriteBase /ds14050/diary
RewriteRule ^([0-9]+)(-[0-9]+)?\.html$ index.rb?date=$1$2 [QSA]

 2 index.rb

カテゴリの指定に categoryという CGIパラメータを使うので、最新表示にカテゴリフィルタをかけたときの URLが index.rb?category=カテゴリ になり、最後の else まで落ちて最新表示が選択される前に、従来のカテゴリ表示が選択されてしまう。これまでのカテゴリ表示は使わないので該当する二行をコメントアウトする。

		if @cgi.valid?( 'comment' ) then
			……
		elsif @cgi.valid?( 'date' )
			……
#		elsif @cgi.valid?( 'category' )
#			tdiary = TDiary::TDiaryCategoryView::new( @cgi, "category.rhtml", conf )
		elsif @cgi.valid?( 'search' )
			……
		else
			……
		end

 3 tdiary.rb

騙すのは TDiaryLatestと TDiaryMonth。それぞれの initialize()で日記データを読み出す前に、@ioを置き換えるコードを追加する。CategorizedIOは読み出し専用なのでこれ以後 日記の変更はできなくなるが、どちらも日記の変更はしていない(と思う)ので影響はない。

また categoryパラメータが与えられたときにキャッシュを無効にするために cache_file() も変更している。

	class TDiaryView < TDiaryBase

(skip)

		def load_categorizedio( baseio ) # CategorizedIO is an IO wrapper and is readonly.
			return baseio if not @conf or not @cgi
			require 'tdiary/categorizedio'
			category_dir = File.join(@conf.data_path, 'category')
			categories = ( @cgi.params['category'] || [] )
			return CategorizedIO.new( baseio, category_dir, categories )
		end
	end

	#
	# class TDiaryDay
	#  show day mode view
	#
	class TDiaryDay < TDiaryView

(skip)

	class TDiaryMonth < TDiaryView
		def initialize( cgi, rhtml, conf )
			super

			@io = load_categorizedio( @io )

			begin

(skip)

	protected
		def cache_file( prefix )
			@cgi.valid?( 'category' ) ? nil : "#{prefix}#{@rhtml.sub( /month/, @date.strftime( '%Y%m' ) ).sub( /\.rhtml$/, '.rb' )}"
		end
	end # class TDiaryMonth

(skip)

	class TDiaryLatest < TDiaryView
		def initialize( cgi, rhtml, conf )
			super

			@io = load_categorizedio( @io )

(snip)

		def cache_file( prefix )
			if @cgi.params['date'][0] or @cgi.valid?( 'category' ) then
				nil
			else
				"#{prefix}#{@rhtml.sub( /\.rhtml$/, '.rb' )}"
			end
		end

(skip)

	end # class TDiaryLatest

 4 tdiary/categorizedio.rb

新規追加ファイル。貼り付けるには厳しい量だな。貼るけど。

既存の IOのメソッドを置き換えるのは transaction() と calendar() の二つ。

transaction() は該当するカテゴリを持たないセクションをはじく機能を持つ。

calendar() は該当するカテゴリを持つセクション の存在する年月のみを返す。calendarを作成するために category.rbプラグインが作成するキャッシュを利用するのでキャッシュが壊れていたり不完全だったりすると影響を受ける。

 予想される問題点

HOWTO-make-plugin.html には TDiary::Plugin@yearsには全日記の年月データが含まれていると明記されているので、@yearsの元になる calendarを上書きすると category.rbや squeeze.rbなど独自に日記を読み込むプラグインが期待通り動かない可能性がある。

@io.transactionで TDiary::TDiaryBase::DIRTY_NONEを返しても DefaultIOがデータを更新することがある。キャッシュが存在しない場合がそう。yieldを呼び出した後で、CategorizedIOがセクションを削除したりした diariesを元にキャッシュを作成してしまう。今は tdiary/defaultio.rbを書き換えて yieldを呼び出す前に必要ならキャッシュを作成してしまって、yieldの戻り値が DIRTY_NONE以外だった場合は、もう一度キャッシュを作成しなおすようにしているが、単純に tdiary/caretorizedio.rbで DefaultIO#transactionを breakで抜けてもいい。 DefaultIO#transactionを抜けてから diariesをいじるようにして解決済み。

表示しないセクションを削除してしまうとセクションナンバーが変わってしまい、それに伴って URLも変わってしまうので混乱を招くもとになっていた。 12月28日に tdiary/categorizedio.rbを修正して解決済み。

require 'delegate'
require 'pstore'

module TDiary

	class CategorizedIO < SimpleDelegator

		def initialize(baseio, category_dir, categories=nil)
			super(baseio);
			@dir = category_dir;
			set_categories(categories);
		end

		def set_categories(categories)
			@categories = (categories || []);
			@calendar = nil; # update calendar
		end

		def categorized?
			return(@categories and !@categories.empty?);
		end

		def calendar
			return __getobj__.calendar unless categorized?;
			@calendar = category_calendar unless @calendar;
			return @calendar;
		end

		# 読み出し専用。ブロック引数を変更しても元々の IO(DefaultIO or PStoreIO)には届きません。
		def transaction(date, &block)
			if(not categorized?)
				return __getobj__.transaction(date, &block);
			elsif(! block)
				# nothing to do.
			elsif(in_calendar?(date))
				diaries = nil;
				__getobj__.transaction(date){|diar_es|
					diaries = diar_es;
					TDiaryBase::DIRTY_NONE;
				}
				if(diaries)
					filtered_diaries = category_filter(diaries);
					dirty = yield(filtered_diaries);
				end
			else
				yield({});
			end
		end

	private

		def category_filter(diaries)
			filtered = {};
			diaries.each{|ymd, diary|
				next unless diary.categorizable?;
				diary_is_empty = true;
				diary.each_section{|section|
					if((@categories - section.categories).empty?)
						diary_is_empty = false;
					else
						hide_section(section);
					end
				}
				filtered[ymd] = diary unless diary_is_empty;
			}
			return filtered;
		end

		def hide_section(section)
			section.extend(HiddenSection);
		end

		def in_calendar?(date)
			y, m = "000#{date.year}"[-4,4], "0#{date.month}"[-2,2];
			cal = self.calendar;
			return(cal.has_key?(y) && cal[y].include?(m));
		end

		# returns intersection of all @categories' calendar.
		def category_calendar
			years = __getobj__.calendar;
			categories = (@categories || []);
			ymdp_array = nil;
			categorize(categories, years).each{|ctgr, ctgr_cache|
				a = [];
				ctgr_cache.each{|ymd, day_cache|
					day_cache.each{|cache| section_index, = *cache;
						a.push(ymd+'p'+section_index.to_s);
					}
				};
				ymdp_array = ymdp_array.nil? ? a : (ymdp_array & a);
				break if ymdp_array.empty?;
			};
			category_calendar = {};
			ymdp_array.each{|ymdp| y, m = ymdp[0,4], ymdp[4,2];
				months = (category_calendar[y] ||= []);
				months.push(m) unless months.member?(m);
			}
			return category_calendar;
		end

	# following code is from plugin/category.rb (--;) !DRY

		#
		# categorize sections of category of years
		#
		# {"category" => {"yyyymmdd" => [[idx, title, excerpt], ...], ...}, ...}
		#
		def categorize(category, years)
			categories = category - ['ALL']
			if categories.empty?
				categories = restore_categories
			else
				categories &= restore_categories
			end

			categorized = {}
			categories.each do |c|
				PStore.new(cache_file(c)).transaction do |db|
					categorized[c] = db['category']
					db.abort
				end
				categorized[c].keys.each do |ymd|
					y, m = ymd[0,4], ymd[4,2]
					if years[y].nil? or !years[y].include?(m)
						categorized[c].delete(ymd)
					end
				end
				categorized.delete(c) if categorized[c].empty?
			end

			categorized
		end

		#
		# restore category names
		# ["category1", "category2", ...]
		#
		def restore_categories
			list = nil
			PStore.new(cache_file).transaction do |db|
				list = db['category'] if db.root?('category')
				db.abort
			end
			list || []
		end

		def cache_file(category = nil)
			if category
				"#{@dir}/#{ERB::Util.u( category ).gsub(/%20/,'+')}".untaint
			else
				"#{@dir}/category_list"
			end
		end
	end

	module HiddenSection
		def html4(date, idx, opt)
			return <<-"HTML";
				<% section_enter_proc( Time::at( #{date.to_i} ) )%>
				<% section_leave_proc( Time::at( #{date.to_i} ) )%>
			HTML
		end
		def chtml(date, idx, opt)
			return html4(date, idx, opt);
		end
		def subtitle_to_html
			return '';
		end
		def body_to_html
			return '';
		end
		def stripped_subtitle_to_html
			return '';
		end
	end
end

 5 plugin/00default.rb

ナビリンクとカレンダーを categoryパラメータ対応に。c_anchor() がカテゴリに対応した anchor()で、この後で category.rbに追加する。

def navi_user_latest
	anchor = method(respond_to?(:c_anchor) ? :c_anchor : :anchor)
	result = ''
	result << navi_item( "#{@index}#{anchor.call( @conf['ndays.next'] + '-' + @conf.latest_limit.to_s )}", navi_next_ndays ) if @conf['ndays.next'] and not bot?
	result << navi_item( @index, navi_latest ) if @cgi.params['date'][0]
	result << navi_item( "#{@index}#{anchor.call( @conf['ndays.prev'] + '-' + @conf.latest_limit.to_s )}", navi_prev_ndays ) if @conf['ndays.prev'] and not bot?
	result
end
def navi_user_month

	……

	anchor = method(respond_to?(:c_anchor) ? :c_anchor : :anchor)

	result = ''
	result << navi_item( "#{@index}#{anchor.call( prev_month )}", navi_prev_month ) if prev_month and not bot?
	result << navi_item( @index, navi_latest )
	result << navi_item( "#{@index}#{anchor.call( next_month )}", navi_next_month ) if next_month and not bot?
	result
end
def calendar
	result = %Q[<div class="calendar">\n]
	@years.keys.sort.each do |year|
		result << %Q[<div class="year">#{year}|]
		@years[year.to_s].sort.each do |month|
			m = "#{year}#{month}"
			if(respond_to? :c_anchor)
				result << %Q[<a href="#{@index}#{c_anchor m}">#{month}</a>|]
			else
				result << %Q[<a href="#{@index}#{anchor m}">#{month}</a>|]
			end
		end
		result << "</div>\n"
	end
	result << "</div>"
end

 6 misc/plugin/category.rb

c_anchor プラグインメソッドを追加。カテゴリパラメータに対応した anchorメソッド。

def c_anchor(s)
	a = anchor(s)
	return a unless @category_info
	a << (a.index('?') ? ';' : '?')
	a << @category_info.query_string
	a.chomp!(';'); a.chomp!('?')
	return a
end

#
# misc
#

既存の make_anchor()メソッドの前半からクエリストリング作成部分を query_string()メソッドとして切り出し。上の c_anchor()で利用するために。

	def make_anchor(label = nil)
		if label
			case mode
			when :year
				label = label.gsub(/\$1/, @year)
			when :month, :quarter, :half
				label = label.gsub(/\$2/, @month)
				label = label.gsub(/\$1/, @year || '*')
			end
		else
			label = @category.map {|c| CGI.escapeHTML(c)}.join(':')
		end
		%Q|<a href="#{@conf.index}?#{query_string}">#{label}</a>|
	end

	def query_string
		qs = @category.map {|c| "category=#{CGI.escape(c)}"}.join(';')
		qs << ";year=#{@year}" if @year
		qs << ";month=#{@month}" if @month
		qs
	end

Category::Infoを毎回作るのが面倒なのでインスタンス変数として保持。

@categories = @category_cache.restore_categories
@category_info = Category::Info.new(@cgi, @years, @conf)
if @mode == 'categoryview'
	@categorized = @category_cache.categorize(@category_info.category, @category_info.years)
end

 まだやってないこと

  • カテゴリフィルタを外すリンク。やりました@2007-12-11。
  • フィルタがかかっていることの明示。やりました@2007-12-11。
  • カテゴリリストのソート (文字コード順でなく、最終更新日とエントリ数によるもの < タグクラウドじゃん。文字の大きさと濃さを変えてさ) やりました@2007-12-14 ページ上部に表示中

 思いついたこと

検索結果の表示も同じ方法でできる。スコア順に表示したりはできないけど、特定の語を含む日記を時系列順に閲覧することはできる。ワードフィルタ? IOの三段重ねでワードフィルタとカテゴリフィルタを両方適用することもできるね。

やってみた@2007-12-18。→http://vvvvvv.sakura.ne.jp/ds14050/diary/?word=windows

単語のハイライトとかナビゲーションリンクの対応とかカレンダーをどうするかとかは一切放置。本当にやってみただけ。word=WORD1というパラメータを付ければ「月別」「最新」「カテゴリ」表示のどれでもフィルタリングできるはず。

2008年の 1月頃から約 1年間、上記のリンク先が Internal Server Errorになっていたもよう。なんたる無様。それに気付くきっかけが連日のコメントスパムというのがなんともはや。