タスク管理 Doing List作った

タスク管理はどうしていますか?
http://lifehacking.jp/2008/03/doing-list/
を電子化してみたかった。紙とペンが無いときが自分には多いのだ。
org-modeはどうか。良いのだけれど可搬性がわるい。
DTM専用PCにはemacsなど入れたくない。
emacsでファイルを編集しているときはいちいちorg-agendaというのもいやな感じだ。
Google Tasksはどうか。非常に良いと思う。GoogleTask一択でよいとおもった。
だけれども作った。つくってみたかった。
sinatraをcgiで動かす。lisonalは非常に遅いので使い物にならないがローカルで動かすと許容できる速度感にはなる。

制作物

http://modeverv.a.lisonal.com/doinglist/doinglist.rb
thin版
http://modeverv.a.lisonal.com/doinglistP/
貼りつけてあるものとは中身が少し違う。5行分ぐらい。thin速い。

使い方

テキストフィールドに入力してエンターor追加をclickする。
Doing列にタスクが追加される
タスクをダブルクリックするとタスクのステータスが"closed"扱いになる。
裏側でサーバーにタスクは保存されているのでブラウザをとじても環境が変わってもOKだ。
タスクはDoing<=>Pendingの間でドラッグアンドドロップで移動が可能である。


一括してタスクを追加することができる。一括追加を押すとdivが現れるので
テキストエリアに入力して追加ボタンを押す。
一括追加におけるタスク間のデリミタは"\n"である。


タスクを実行した結果を見るにはアウトプットを押す。
preフィールドに実行結果が書き込まれて表示されるので利用してほしい。
"サーバーからTODOデータを削除"を押すとサーバー上からデータが削除される。

ソース

ソースはrbファイル,css,js,テンプレートファイルになる。
タスクのデータはxmlで管理される。
ソースの大部分はコピペである。

# doinglist.rb
#! /Users/seijiro/.rvm/rubies/ruby-1.9.2-p290/bin/ruby
# -*- coding: utf-8 -*-

#require 'rubygems'
require 'sinatra'
require 'json'
require 'nokogiri'
require 'cgi/util'
require 'digest'
require 'kconv'

# モデル xmlにする前にいったんTaskクラスで受け取ろう。
class Task
  attr_accessor :sn,:title,:timestamp

  def initialize( args={ } )
    @timestamp = args[:timestamp] || Time.now
    @title     = args[:title]      || "dammy"
    @sn        = args[:sn]         || Digest::MD5.hexdigest(title)
  end

  def stored_task?
    Task.get_ds.xml.xpath("//task[@sn='#{@sn}']").length > 0
  end

  def append_to_datastore
    fragment = <<-EOF
<task sn='#{@sn}' status='doing'>
  <title>#{Task.h(CGI.unescape(@title))}</title>
  <timestamp>#{@created_at}</timestamp>
</task>
EOF
    Task.get_ds.xml.css('tasks').first.add_child(fragment)
  end

  def done_task_datasource
    Task.get_ds.xml.xpath("//tasks/task[@sn='#{@sn}']").each do |node|
      node.attribute("status").value = "done"
      node.at("timestamp").remove
      node.add_child("<timestamp>#{Time.now}</timestamp>")
    end
  end

  class << self
    #データソースを取得する
    def get_ds
      @ds ||= DataStore.new
    end

    # 
    def get_tasks_from_ds(status="doing")
      tasks = []
      Task.get_ds.xml.xpath("//tasks/task[@status='#{status}']").each do |node|
        tasks << Task.new({
                            :title      => Task.h(node.css("title").text),
                            :sn         => node.attribute('sn').value,
                            :timestamp  => node.css("timestamp").text,
                          })
      end
      return tasks
    end
    
    def h(str)
      return str.
        gsub(/&/, "&amp;").
        gsub(/"/, "&quot;").
        gsub(/</, "&lt;").
        gsub(/>/, "&gt;")
    end

    def commit
      Task.get_ds.write
    end

    def dispose_xml
      old_filename = DataStore.filename
      new_filename = old_filename.gsub("\.xml","\_archive\_#{Time.new.to_i.to_s}\.xml")
      FileUtils.cp(old_filename,new_filename)
      ds = DataStore.new
      ds.xml.xpath("//tasks/task[@status='done']").each do |node|
        node.remove
      end
      ds.write
    rescue
      "ok"#no fileの場合は空振り上等。
    end
  end
end

# データソース
class DataStore
  attr_accessor :xml

  def initialize
    @filename = DataStore.filename
    unless File.exist?(@filename)
      File.open(@filename,"w"){|io| io.write("<tasks></tasks>") }
    end
    @xml = Nokogiri::XML( File.open(@filename) ,nil,'utf-8')
  end

  def write
    File.open(@filename,"w") { |io| io.write @xml.to_s }
  end

  class << self
    def filename
      "#{Dir::pwd}/data.xml"
    end
  end

  
end


# sinatra コントローラー
# config
configure do
  settings.run = false
end

get '/' do
  @title  = "Doing List"
  @tasks  = Task.get_tasks_from_ds("done")
  @tasks2 = Task.get_tasks_from_ds("doing")
  @tasks3 = []
  erb :index  
end

post '/append_server' do
  "Server OK. Worked with No Work " unless params["tasks"]
  
  require 'json'
  datas = JSON.parse(params["tasks"])
  
  write_flg = false
  datas.each do |k,v|
    t =  Task.new({
                    :title => v.gsub('#<<semicoron>>#',';'),
                    :sn    => k,
                  })
    unless t.stored_task?
      t.append_to_datastore
      write_flg = true
    end
  end
  Task.commit if write_flg
  "Server OK. Appended."
end


post '/done_server' do
  datas     = JSON.parse(params["tasks"])
  write_flg = false
  datas.each do |data|
    t = Task.new({:sn => data["sn"]})
    if t.stored_task?
      t.done_task_datasource
      write_flg = true
    end
  end
  Task.commit if write_flg
  "Server Done sn=#{datas[0]['sn']} OK. "
end

post '/dispose_xml' do
  Task.dispose_xml
  "Server OK dispose xml."
end

Rack::Handler::CGI.run Sinatra::Application
// doinglist.js
/* js for doing list */
function escapeHTML(str) {
    return str.replace(/[&"<>]/g, function(c) {
                           return {
                               "&": "&amp;",
                               '"': "&quot;",
                               "<": "&lt;",
                               ">": "&gt;"
                           }[c];
                       });
}

var _server = {
    append : function(data,mode){
        this._data_pool[data["sn"]] = data["title"];
        if(mode === 'single'){
            this.bulk_end();
        }
    },
    bulk_end : function(){
        var pdata = JSON.stringify(this._data_pool);
        this._append_server(pdata);
        this._data_pool = {};
    },
    _data_pool : {},
    _append_server : function(pdata){
        $.ajax({  
                   type: "POST",
                   url: "doinglist.rb/append_server",
                   data: "tasks="+pdata,
                   success: function(msg){
                       console.log( "Data Saved: " + msg );
                   }
               });    
    },
    done : function(data){
        var qdata = JSON.stringify([data]);
        this._done_server(qdata);
    },
    _done_server : function(pdata){
        $.ajax({  
                   type: "POST",
                   url: "doinglist.rb/done_server",
                   data: "tasks="+pdata,
                   success: function(msg){
                       console.log( "Data Saved: " + msg );
                   }
               });    
    },
    dispose_xml : function(){
        console.log("_server:dispose_xml");
        $.ajax({  
                   type: "POST",
                   url: "doinglist.rb/dispose_xml",
                   data: "",
                   success: function(msg){
                       console.log( "Data Saved: " + msg );
                   }
               });    
    }
};

/* タスクを追加する */
function _appender(title,mode){
    console.log(title);
    console.log( title.replace(/\;/g,'\\;') );
    if(title.match(/^\s*$/)){ return; }
    var sn = CybozuLabs.MD5.calc(title + new Date());
    var c_date = Math.round(new Date());
    var targ = " <li id=\"jquery-ui-sortable-item-<%= t.sn %>\" \n \
         class=\"ui-state-default\"  \
         ondblclick=\"done_task(this);\" > \
        <span> \
          <span class=\"title\"><%= t.title %></span> \
          <span class=\"cdate right\"><%= t.timestamp.to_s %></span> \
        </span> \
    </li> ".
        replace('<%= t.sn %>',sn).
        replace('<%= t.title %>',escapeHTML(title)).
        replace('<%= t.timestamp.to_s %>',c_date);
    $("#jquery-ui-sortable-center").append($(targ));
    _server.append({ 'sn' : sn , 'title' : 
         title.replace(/;/g,'#<<semicoron>>#')

 },mode);
}

/* タスク修了 */
function done_task(targ){
    var parent_div_id = $(targ).closest('ul').attr("id");

    if(parent_div_id == "jquery-ui-sortable-center"){
        // タイムスタンプを変更
        var stamp = new Date();
        $(targ).find(".cdate").first().html( "" + new Date() );
        //move
        $("#jquery-ui-sortable-left").append(targ);
        //coookie
        $(targ).css({ 'background' : 'white' } );
        var updateArray = jQuery("#jquery-ui-sortable-center").sortable('toArray').join( ',' );
        jQuery.cookies.set ("jquery-ui-sortable-center",updateArray,{ expires: 1 } );
        // server
        var sn = $(targ).attr("id").replace('jquery-ui-sortable-item-','');
        _server.done({'sn': sn });
    }
}
/* タスクの出力を書きだす */
function print_statistics(){
    var str = "結果\n";
    console.log(str);
    $("#jquery-ui-sortable-left").
        find("li").
        each(function(){
                 str += $(this).find('.title').first().html() + "\t";
                 str += $(this).find('.cdate').first().html();
                 str += "\n";
             });
    console.log(str);
    $("#statistics").html(str);
}
/* ページ上の doneを非表示 */
function remove_page_done_li_data(){
  $("#jquery-ui-sortable-left").find("li").
      each(function(){ $(this).css({ "display" : "none" }); });
}
/* ボタンからキックされる */ 
function server_remove_xml(){
  remove_page_done_li_data();
  console.log("server_remove_xml");
  _server.dispose_xml();
  _fout_s();
  return false;
}

/* タスク追加 formからキックされる */
function append_task(){
    var title = $('#task_title').val();
    _appender(title,'single');
    $('#task_title').val('');
}

/* UI */
function _fout(){$("#floatWindow").fadeOut('fast');return false;}
function _fin(){$("#floatWindow").fadeIn('fast');return false;}
function _fout_s(){$("#floatWindow2").fadeOut('fast');return false;}
function _fin_s(){$("#floatWindow2").fadeIn('fast');print_statistics();return false;}

/* text_areaをパースする */
function _textarea_parser(data){
    // 今は\nのみ
    var titles = data.split("\n");
    return titles;
}

/* タスク追加 text_areaから */
function bulk_input(){
    var data = $("#bulk_text_area").val();
    var titles = _textarea_parser(data);
    for(var i=0;i<titles.length;i++){
        _appender(titles[i],'bulk');
    }
    _server.bulk_end();
    _fout();
    $("#bulk_text_area").val('');
    return false;
}

/* init */
jQuery( 
    function() {
        sorter = function(name){
            jQuery('#'+ name).sortable({ 
                                           connectWith: '.sortable' ,
                                           cursor: 'move',
                                           opacity: 0.7,
                                           placeholder: 'ui-state-highlight'
                                       } );
            //            jQuery('#'+ name).disableSelection();
            jQuery('#'+ name).
                sortable(
                    {
                        update: function(event,ui) {
                            var updateArray = jQuery('#'+ name).sortable('toArray').join( ',' );
                            jQuery.cookies.set (name,updateArray,{ expires: 1 } );
                        }
                    } );
            if( jQuery.cookies.get(name) ){
                var cookieValue = jQuery.cookies.get(name).split( ',' ).reverse();
                jQuery.each(
                    cookieValue,
                    function( index, value ){ 
                        jQuery( '#' + value ).prependTo( '#' + name );
                    });
            }
        };
        sorter('jquery-ui-sortable-center');
        sorter('jquery-ui-sortable-right');

        /* window 表示 */
        $("#fin").click(_fin);
        $("#fout").click(_fout);
        $("#fin_s").click(_fin_s);
        $("#fout_s").click(_fout_s);

        /* ui design */
        $(".ui-state-default").mouseover(
          function(){ $(this).css({'background':'lightyellow'});  }
        );
        $(".ui-state-default").mouseout(
          function(){ $(this).css({'background':'white'});  }
        );
    } );
/* custom.css */

* { margin:0px;padding:0px}

body {
   font-size:12px;
   line-height:1.5;
}

h1 {
    font-size:3.0em;
    padding-left:0.6em;
}
pre {
  width:100%;
  font-size:20px;
}    
/* layout */
header {
      position: fixed;
      left: 0;
      top: 0;
      width: 100%;
      background-color: lightgreen;
      z-index: 100;
}
#wrapper {
    margin:65px 0px 0px 0px;
}
#now,#after,#done{
    margin-bottom:30px;
}    
.gridl { width: 20%;float:left;}
.gridc { width: 50%;float:left;}
.gridr { width: 30%;float:left;}
footer {
    position: fixed;
    left: 0;
    bottom: 0;
    clear:both;
    width: 100%;
    background-color: lightgreen;
    z-index: 100;
    text-align:right;
}
.left{float:left}    
.right{float:right}    
.clear{clear:both}    

/* design */
h1 {
  float:left;
}
h2{ 
  text-align:center;
}
.roundborder10 {
      border-radius: 10px;
      -webkit-border-radius: 10px;
      -moz-border-radius: 10px;
      border:1px solid;
}
#jquery-ui-sortable-left,
#jquery-ui-sortable-center,
#jquery-ui-sortable-right {
    clear:both;
    list-style-type: none;
    margin: 0;
    padding: 0;
    min-height:2em;
}

#jquery-ui-sortable-left li ,
#jquery-ui-sortable-center li ,
#jquery-ui-sortable-right li {
    margin:0 1em 0.5em 1em;
    padding: 0.1em;
    padding-left: 1em; 
    padding-right:1em;
    background:white;
    cursor: move;
    overflow:auto;
}
#jquery-ui-sortable-center li ,
#jquery-ui-sortable-right li {
    border-radius: 5px;
    -webkit-border-radius: 5px;
    -moz-border-radius: 5px;
    border:1px solid;
}
footer p{
    margin:5px 5px 5px 5px;
}    
#floatWindow {
    background:white;
    display:none;
    position:absolute;
    top:0;
    z-index:200;
    width:50%;
}
#floatWindow2 {
    width:50%;
    background:white;
    display:none;
    position:absolute;
    top:0;
    z-index:200;
    width:50%;
}
#taskdiv{
    margin:5px 1em 0 1em;
    float:right;
}
.text_field {
  background: none repeat scroll 0 0 lightgray;
  border-color: -moz-use-text-color -moz-use-text-color #FFF8E8;
  border-radius: 4px 4px 4px 4px;
  border-style: none none solid;
  border-width: medium medium 2px;
  box-shadow: 0 3px 3px #706751 inset;
  color: #453000;
  font-family: Helvetica,Arial,sans-serif;
  font-size: 24px;
  margin: 5px 5px 5px 5px;
  padding: 4px;
}
.bulk_area{ width:97%;}
.submit_button {
    background: darkgreen;
    border: 1px solid;
    border-radius: 4px 4px 4px 4px;
    box-shadow: 0 1px 1px darkgray inset, 0 1px 1px gray;
    color: #FFFFFF;
    cursor: pointer;
    display: block;
    font-family: Helvetica,Arial,sans-serif;
    font-size: 16px;
    font-weight: bold;
    margin: 5px 5px 5px 5px;
    padding: 8px;
    text-shadow: 0 -1px 1px #250300;
}
.cdate{ font-size:0.5em; }
.title{ font-size:0.5em; text-align:left;}
#jquery-ui-sortable-left li .cdate{ display:block; }
#jquery-ui-sortable-center li .cdate{ display:none; }
#jquery-ui-sortable-right li .cdate{ display:none; }
#jquery-ui-sortable-left li .title{ font-size:0.5em; }
#jquery-ui-sortable-center li .title{ font-size:1.4em; }
#jquery-ui-sortable-center li:first-child .title{ font-size:3.4em; }
#jquery-ui-sortable-left li .title{ font-size:0.5em; }
.ui-state-highlight { height: 6em; background:lightblue;}
# タスクのデータはxmlで管理される。
<?xml version="1.0" encoding="utf-8"?>
<tasks>
<task sn="90367bcdc321412d8346ece0d25aec7b" status="done">
  <title>リファクタリング:html/css</title>
  <timestamp>2011-09-19 00:43:05 +0900</timestamp>
</task>
<task sn="d4eebf3bc73b1d544d21993bec989f2d" status="done">
  <title>タスク2</title>
  <timestamp>2011-09-19 00:43:33 +0900</timestamp>
</task>
</tasks>
<!-- view ファイル -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title><%= @title %></title>
    <link rel="icon" href="http://ja.gravatar.com/userimage/14611836/d5caef2a5366cf647fc8fba3430e5854.png" type="image/png">
    <!--[if lt IE 9]>
    <script src="http://html5shiv.googlecode.com/svn/trunk/html5.js"></script>
    <![endif]-->
    <link rel="stylesheet" href="custom.css?<%=Time.now.to_i.to_s%>" type="text/css" media="screen" />
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>    
    <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jqueryui/1.8.16/jquery-ui.min.js"></script>
    <script type="text/javascript" src="http://cookies.googlecode.com/svn/trunk/jquery.cookies.js"></script>
    <script type="text/javascript" src="http://labs.cybozu.co.jp/blog/mitsunari/2007/07/24/js/md5.js"></script>
    <script type="text/javascript" src="doinglist.js?<%=Time.now.to_i.to_s%>"></script>
  </head>
  <body>
    <header>
      <h1><%= @title %></h1>
      <div id="taskdiv" >
        <form onsubmit="append_task();return false;" class="left">
          <input type="text" id="task_title" class="text_field left" size="24" />
          <input type="submit"  class="submit_button left" value="追加" />
        </form>
        <div style="float:right">
          <input type="button" id="fin" class="submit_button left" value="bulk追加" />
          <input type="button" id="fin_s" class="submit_button left" value="アウトプット" />
        </div>
      </div>
    </header>

    <div id="wrapper">
      <div id="done" class="gridl">
        <h2>Closed</h2>
        <ul id="jquery-ui-sortable-left" class="">
          <% @tasks.each do |t|%>
            <li id="jquery-ui-sortable-item-<%= t.sn %>"
                class="ui-state-default"
                ondblclick="done_task(this);">
              <span>
                <span class="title"><%= t.title %></span>
                <span class="cdate right"><%= t.timestamp.to_s %></span>
              </span>
            </li>
          <% end %>
        </ul>
      </div>
      
      <div id="now" class="gridc">
        <h2>Doing</h2>
        <ul id="jquery-ui-sortable-center" class="sortable">
          <% @tasks2.each do |t|%>
            <li id="jquery-ui-sortable-item-<%= t.sn %>"
                class="ui-state-default"
                ondblclick="done_task(this);">
              <span>
                <span class="title"><%= t.title %></span>
                <span class="cdate right"><%= t.timestamp.to_s %></span>
              </span>
            </li>
          <% end %>
        </ul>      
      </div>
      
      <div id="after" class="gridr">
        <h2>Pending</h2>
        <ul id="jquery-ui-sortable-right" class="sortable">
          <% @tasks3.each do |t|%>
            <li id="jquery-ui-sortable-item-<%= t.sn %>"
                class="ui-state-default"
                ondblclick="done_task(this);">
              <span>
                <span class="title"><%= t.title %></span>
                <span class="cdate right"><%= t.timestamp.to_s %></span>
              </span>
            </li>
          <% end %>
        </ul>
      </div>

      <div id="floatWindow">
        <div><input type="button" id="fout" class="submit_button right" value="閉じる" /></div>
        <div class="clear"><textarea id="bulk_text_area" class="text_field bulk_area" value="" ></textarea></div>
        <div><input type="button" id="fout" class="submit_button right" value="追加" onclick="bulk_input();"/></div>
      </div>

      <div id="floatWindow2">
        <div><input type="button" id="fout_s" class="submit_button right" value="閉じる" /></div>
        <div class="clear"><pre id="statistics">&nbsp;</pre></div>
        <div><input type="button" id="server_remove_xml" class="submit_button right" value="サーバーからTODOデータを削除(destructive)" onclick="server_remove_xml();return false;"/></div>
      </div>

    </div>
    
    <footer>
      <p>modeverv@gmai.com</p>
    </footer>
    
  </body>
</html>