yunomuのブログ

酒とゲームと上から目線

Redmineのプロジェクトの並びが気に入らない その3

まだやってました。でも大枠は完成って感じです。

ちょっと前ですが、redmine_projects_sorterをver.0.0.2にしました。0.1.0とかでもよかったかなー。
projects/indexだけじゃなくガントチャート他のプロジェクトの並び順制御にも対応しました。名前の辞書順だけですけど。
配布元:https://github.com/yunomu/redmine_projects_sorter

やったことなど

Redmineのバージョンは1.3.1です。

前回作ったプラグインは、projects/indexでのプロジェクトの並び順の制御はできていたけど、ガントチャートなどは従来のままでした。まあprojects_helperをいじっただけだからそりゃそうなんだけど。
で、やっぱりガントチャートもキレイに並び替えてくれないと使いものにならない。

ということで、ガントチャートの表示部分のデータがどこからどんな形できてるのかを追いかけてみました。

まずガントチャート画面のパスが"/projects/プロジェクト識別子/issues/gantt"なので、route.rbからそれっぽいところを探す。
config/route.rb

 92   map.with_options :controller => 'gantts', :action => 'show' do |gantts_routes|
 93     gantts_routes.connect '/projects/:project_id/issues/gantt'
 94     gantts_routes.connect '/projects/:project_id/issues/gantt.:format'
 95     gantts_routes.connect '/issues/gantt.:format'
 96   end

これっぽい。Railsルールでは、これはparams[:project_id]にプロジェクト識別子を入れてgantts/showを呼んでいる。

gantts/showのviewを見る。
app/views/gantts/show.html.erb

 74 <div class="gantt_subjects">
 75 <%= @gantt.subjects.html_safe %>
 76 </div>

ここで表示してるっぽい。

じゃあ@gantt.subjectsはどこからきてるのか。とりあえずコントローラから辿る。
app/controllers/gantts_controller.rb

 18 class GanttsController < ApplicationController
(略)
 33   def show
 34     @gantt = Redmine::Helpers::Gantt.new(params)
 35     @gantt.project = @project
 36     retrieve_query
 37     @query.group_by = nil
 38     @gantt.query = @query if @query.valid?
 39
 40     basename = (@project ? "#{@project.identifier}-" : '') + 'gantt'
 41
 42     respond_to do |format|
 43       format.html { render :action => "show", :layout => !request.xhr? }
 44       format.png  { send_data(@gantt.to_image, :disposition => 'inline', :type => 'image/png', :filename => "#{basename}.png") } if @gantt.respond_to?('to_image')
 45       format.pdf  { send_data(@gantt.to_pdf, :type => 'application/pdf', :filename => "#{basename}.pdf") }
 46     end
 47   end

34行目から、@ganttはそもそもRedmine::Helper::Ganttのインスタンスらしい。
このクラスはlib以下にあったので、subjectsメソッドを見てみる。
lib/redmine/helpers/gantt.rb

 18 module Redmine
 19   module Helpers
 20     # Simple class to handle gantt chart data
 21     class Gantt
(略)
120       # Renders the subjects of the Gantt chart, the left side.
121       def subjects(options={})
122         render(options.merge(:only => :subjects)) unless @subjects_rendered
123         @subjects
124       end

renderを呼んだ後に@subjectsを返してる。
@subjectsの出所を細かく追っていくと、とりあえずinitializeでは空文字が入れられているけど、実態はrenderで作ってるっぽい。

というのを追いかけてる途中で見つけた。
lib/redmine/helpers/gantt.rb

174       def render(options={})
(略)
182         Project.project_tree(projects) do |project, level|
183           options[:indent] = indent + level * options[:indent_increment]
184           render_project(project, options)
185           break if abort?
186         end

あれ、もしかしてsubjects関係なくここで書いてるんじゃない?
Project.project_treeでツリーを作って、levelでインデントを決めてrender_projectだから、たぶんこれ。読むべきはProject.project_treeだった。

app/models/project.rb

 18 class Project < ActiveRecord::Base
(略)
653   # Yields the given block for each project with its level in the tree
654   def self.project_tree(projects, &block)
655     ancestors = []
656     projects.sort_by(&:lft).each do |project|
657       while (ancestors.any? && !project.is_descendant_of?(ancestors.last))
658         ancestors.pop
659       end
660       yield project, ancestors.size
661       ancestors << project
662     end
663   end

has_many多すぎだろ……というのはさておき、
ここでlftでソート(line:656)して、レベルを添えてブロックに渡してる(line:660)。
というわけでこのメソッドを前みたいに書き換えてやればよさそう。
っていうかprojects/indexでもこれ使えよ! まあそれはいいや。HTMLのリスト系タグの入れ子構造とインデントでのツリー表現の両立は意外に大変よね。

ということでちょっとここでまたアルゴリズムを考えるターン。
lftでソートされたリストを読んで、projectとlevelの組のリストを返す関数が欲しい。んだけど、ソート済みのツリー構造を作るところまでは既に書いているのでそれを流用することにして、ツリーをレベルと要素の組のリストに変換する関数を書けばよさそう。
試しにまたHaskellで書いたのがこれ。

flatten :: [Tree] -> [(Node, Int)]
flatten t = f' t 0
            where
              f' :: [Tree] -> Int -> [(Node, Int)]
              f' []             _ = []
              f' (T (n, cs):ts) d = (n, d):f' cs (d+1) ++ f' ts d

ついでに、これをインデント付きで表示する関数がこれ。

out :: [(Node, Int)] -> IO ()
out []                 = return ()
out ((node, level):ns) = do
    indent level
    printNodeLn node
    out ns
  where
    indent :: Int -> IO ()
    indent 0 = return ()
    indent l = do putChar ' '
                  indent (l-1)

あ、なんかすっきり? というかどう考えても前のよりこっちの方が簡単だぞ。

Rubyで書き直すとこんな感じ。

def flatten(ts, d = 0)
  return [] if ts.size == 0
  n, cs = ts.shift
  [[n, d]] + flatten(cs, d+1) + flatten(ts, d)
end

割とそのまま書けた。これを使って、Project.project_treeを上書きしてやればいい。特異メソッドだからちょっとめんどかったので無理やりな感じで実装してしまいましたが。

class << Project
  include Redmine::Plugins::ProjectsSorter::ProjectPatch::ClassMethods

  def project_tree(projects, &block)
    flatten(sortTree(construct(projects), :name)).each do |p, l|
      yield p, l
    end
  end
end

これで、ガントチャートのviewでもプロジェクトが名前順に並びました。
ソートのカラム指定とかソート用カラムを足すとかは、まあ当分いいや。

続き:redmine_projects_sorter ver.1.0.0 - yunomuのブログ
前の:Redmineのプロジェクトの並びが気に入らないという話その2 Redmineプラグイン試作 - yunomuのブログ