ditz 0.3 → 0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Changelog +9 -0
- data/README.txt +22 -11
- data/Rakefile +1 -15
- data/ReleaseNotes +29 -0
- data/bin/ditz +32 -20
- data/contrib/completion/_ditz.zsh +29 -0
- data/contrib/completion/ditz.bash +22 -0
- data/lib/ditz.rb +29 -2
- data/lib/hook.rb +9 -2
- data/lib/html.rb +36 -14
- data/lib/index.rhtml +24 -1
- data/lib/issue.rhtml +6 -1
- data/lib/issue_table.rhtml +6 -2
- data/lib/lowline.rb +1 -8
- data/lib/model-objects.rb +7 -2
- data/lib/model.rb +12 -4
- data/lib/operator.rb +81 -166
- data/lib/plugins/git.rb +101 -0
- data/lib/style.css +39 -3
- data/lib/unassigned.rhtml +1 -1
- data/lib/util.rb +5 -0
- data/lib/view.rb +16 -0
- data/lib/views.rb +136 -0
- metadata +22 -8
- data/bin/ditz-convert-from-monolith +0 -42
data/Changelog
CHANGED
@@ -1,3 +1,12 @@
|
|
1
|
+
== 0.4 / 2008-07-27
|
2
|
+
* bugfix: HOME environment variable now correctly detected on windows
|
3
|
+
* hooks loaded from both home directory and project directory
|
4
|
+
* added bash shell completion
|
5
|
+
* plugin architecture for tighter SCM integration, etc
|
6
|
+
* 'ditz grep' should also grep against comments, log messages, etc
|
7
|
+
* added man page
|
8
|
+
* removed ditz-convert-from-monolith
|
9
|
+
* lots of HTML output tweaking
|
1
10
|
== 0.3 / 2008-06-04
|
2
11
|
* readline support for all text entry
|
3
12
|
* hook system. Use ditz -l to see possible hooks.
|
data/README.txt
CHANGED
@@ -7,17 +7,28 @@ http://ditz.rubyforge.org
|
|
7
7
|
== DESCRIPTION
|
8
8
|
|
9
9
|
Ditz is a simple, light-weight distributed issue tracker designed to work with
|
10
|
-
distributed version control systems like darcs and
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
10
|
+
distributed version control systems like git, darcs, Mercurial, and Bazaar. It
|
11
|
+
can also be used with centralized systems like SVN.
|
12
|
+
|
13
|
+
Ditz maintains an issue database directory on disk, with files written in a
|
14
|
+
line-based and human-editable format. This directory can be kept under version
|
15
|
+
control, alongside project code.
|
16
|
+
|
17
|
+
There are different ways to use ditz:
|
18
|
+
|
19
|
+
1. Treat issue change the same as code change: include it as part of commits,
|
20
|
+
and merge it with changes from other developers. (Resolving conflicts in
|
21
|
+
the usual manner.)
|
22
|
+
2. Keep the issue database in the repository but in a separate branch. Issue
|
23
|
+
changes can be managed by your VCS, but is not tied directly to commits.
|
24
|
+
3. Keep the issue database separate and not under VCS at all.
|
25
|
+
|
26
|
+
Your particular usage will depend on what you want to get out of ditz.
|
16
27
|
|
17
28
|
Ditz provides a simple, console-based interface for creating and updating the
|
18
|
-
issue database
|
19
|
-
producing world-readable status pages. It offers no central public
|
20
|
-
bug submission.
|
29
|
+
issue database file, and some rudimentary HTML generation capabilities for
|
30
|
+
producing world-readable status pages. It currently offers no central public
|
31
|
+
method of bug submission.
|
21
32
|
|
22
33
|
== SYNOPSIS
|
23
34
|
|
@@ -30,7 +41,7 @@ bug submission.
|
|
30
41
|
|
31
42
|
# where am i?
|
32
43
|
4. ditz status
|
33
|
-
5. ditz todo
|
44
|
+
5. ditz todo (or simply "ditz")
|
34
45
|
|
35
46
|
# do work
|
36
47
|
6. write code
|
@@ -94,7 +105,7 @@ http://ditz.rubyforge.org/ditz/issue-0704dafe4aef96279364013aba177a0971d425cb.ht
|
|
94
105
|
|
95
106
|
== REQUIREMENTS
|
96
107
|
|
97
|
-
* trollop >= 1.
|
108
|
+
* trollop >= 1.8.2
|
98
109
|
|
99
110
|
== INSTALLATION
|
100
111
|
|
data/Rakefile
CHANGED
@@ -17,29 +17,15 @@ Hoe.new('ditz', Ditz::VERSION) do |p|
|
|
17
17
|
p.url = "http://ditz.rubyforge.org"
|
18
18
|
p.changes = p.paragraphs_of('Changelog', 0..0).join("\n\n")
|
19
19
|
p.email = "wmorgan-ditz@masanjin.net"
|
20
|
-
p.extra_deps = [['trollop', '>= 1.
|
20
|
+
p.extra_deps = [['trollop', '>= 1.8.2']]
|
21
21
|
end
|
22
22
|
|
23
23
|
WWW_FILES = FileList["www/*"] + %w(README.txt)
|
24
|
-
SCREENSHOTS = FileList["www/ss?.png"]
|
25
|
-
SCREENSHOTS_SMALL = []
|
26
|
-
SCREENSHOTS.each do |fn|
|
27
|
-
fn =~ /ss(\d+)\.png/
|
28
|
-
sfn = "www/ss#{$1}-small.png"
|
29
|
-
file sfn => [fn] do |t|
|
30
|
-
sh "cat #{fn} | pngtopnm | pnmscale -xysize 320 240 | pnmtopng > #{sfn}"
|
31
|
-
end
|
32
|
-
SCREENSHOTS_SMALL << sfn
|
33
|
-
end
|
34
24
|
|
35
25
|
task :upload_webpage => WWW_FILES do |t|
|
36
26
|
sh "rsync -essh -cavz #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/ditz/"
|
37
27
|
end
|
38
28
|
|
39
|
-
task :upload_webpage_images => (SCREENSHOTS + SCREENSHOTS_SMALL) do |t|
|
40
|
-
sh "rsync -essh -cavs #{t.prerequisites * ' '} wmorgan@rubyforge.org:/var/www/gforge-projects/ditz/"
|
41
|
-
end
|
42
|
-
|
43
29
|
task :upload_report do |t|
|
44
30
|
sh "ruby -Ilib bin/ditz html ditz"
|
45
31
|
sh "rsync -essh -cavz ditz wmorgan@rubyforge.org:/var/www/gforge-projects/ditz/"
|
data/ReleaseNotes
CHANGED
@@ -1,3 +1,32 @@
|
|
1
|
+
0.4
|
2
|
+
---
|
3
|
+
- Command-line completion scripts are now included for bash and zsh. To
|
4
|
+
activate, source the relevant file in $GEMDIR/ditz-0.4/contrib/completion/.
|
5
|
+
|
6
|
+
- Hooks can now be set on a per-project basis. Make a .ditz/hooks directory in
|
7
|
+
your project root and place them there. These will be loaded after any
|
8
|
+
hooks in ~/.ditz/hooks, so they can override or simply supplement.
|
9
|
+
|
10
|
+
- The plugin system is done. There's currently one plugin, for git integration.
|
11
|
+
To enable it, add the line "- git" in a .ditz-plugins file in your project
|
12
|
+
root. The git plugin currently has the following features:
|
13
|
+
|
14
|
+
- Issues can have a git branch assigned to them with "ditz set-branch".
|
15
|
+
- Git commit messages can have a Ditz-issue: header auto-filled if you
|
16
|
+
commit with "ditz commit <issue>" (i.e. instead of git commit).
|
17
|
+
- In both HTML and screen output, commits from the assigned branch, and
|
18
|
+
commits with the corresponding Ditz-issue: header in the log message,
|
19
|
+
will be listed for each issue.
|
20
|
+
|
21
|
+
Note that the plugin system is independent of the hook system. In order
|
22
|
+
to auto-add ditz files to the git index upon modification, you must set
|
23
|
+
up hooks. Example hooks for git are at:
|
24
|
+
http://hackety.org/2008/06/26/gitHooksForDitz.html
|
25
|
+
|
26
|
+
Also note that as soon as a feature branch is merged back into master, ditz
|
27
|
+
loses the ability to distinguish its commits. So the Ditz-issue: approach
|
28
|
+
is probably better if you want a long-term record.
|
29
|
+
|
1
30
|
0.3
|
2
31
|
---
|
3
32
|
Ditz now works from project subdirectories, and you can have a .ditz-config in
|
data/bin/ditz
CHANGED
@@ -19,19 +19,16 @@ require "ditz"
|
|
19
19
|
|
20
20
|
PROJECT_FN = "project.yaml"
|
21
21
|
CONFIG_FN = ".ditz-config"
|
22
|
+
PLUGIN_FN = ".ditz-plugins"
|
22
23
|
|
23
|
-
|
24
|
-
|
25
|
-
return start if (start + target).exist?
|
26
|
-
unless start.parent.realpath == start.realpath
|
27
|
-
find_dir_containing target, start.parent
|
28
|
-
end
|
29
|
-
end
|
24
|
+
config_dir = Ditz::find_dir_containing CONFIG_FN
|
25
|
+
plugin_dir = Ditz::find_dir_containing PLUGIN_FN
|
30
26
|
|
31
27
|
$opts = options do
|
32
28
|
version "ditz #{Ditz::VERSION}"
|
33
29
|
opt :issue_dir, "Issue database dir", :default => "bugs"
|
34
|
-
opt :config_file, "Configuration file", :default => File.join(
|
30
|
+
opt :config_file, "Configuration file", :default => File.join(config_dir || ".", CONFIG_FN)
|
31
|
+
opt :plugins_file, "Plugins file", :default => File.join(plugin_dir || ".", PLUGIN_FN)
|
35
32
|
opt :verbose, "Verbose output", :default => false
|
36
33
|
opt :no_comment, "Skip asking for a comment", :default => false
|
37
34
|
opt :list_hooks, "List all hooks and descriptions, and quit.", :short => 'l', :default => false
|
@@ -76,22 +73,36 @@ if $opts[:list_hooks]
|
|
76
73
|
exit 0
|
77
74
|
end
|
78
75
|
|
79
|
-
|
76
|
+
plugins = begin
|
77
|
+
Ditz::debug "loading plugins from #{$opts[:plugins_file]}"
|
78
|
+
YAML::load_file$opts[:plugins_file]
|
79
|
+
rescue SystemCallError => e
|
80
|
+
Ditz::debug "can't load plugins file: #{e.message}"
|
81
|
+
[]
|
82
|
+
end
|
83
|
+
|
84
|
+
plugins.each do |p|
|
85
|
+
fn = Ditz::find_ditz_file "plugins/#{p}.rb"
|
86
|
+
Ditz::debug "loading plugin #{p.inspect} from #{fn}"
|
87
|
+
load fn
|
88
|
+
end
|
89
|
+
|
80
90
|
config = begin
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
91
|
+
Ditz::debug "loading config from #{$opts[:config_file]}"
|
92
|
+
Ditz::Config.from $opts[:config_file]
|
93
|
+
rescue SystemCallError => e
|
94
|
+
if ARGV.member? "<options>"
|
95
|
+
## special case here. if we're asking for tab completion, and the config
|
96
|
+
## file doesn't exist, don't do the interactive building. just make a
|
97
|
+
## fake empty one and carry on.
|
98
|
+
Ditz::Config.new
|
85
99
|
else
|
86
|
-
|
87
|
-
Ditz::Config.from $opts[:config_file]
|
88
|
-
end
|
89
|
-
rescue SystemCallError, Ditz::ModelObject::ModelError => e
|
90
|
-
puts <<EOS
|
100
|
+
puts <<EOS
|
91
101
|
I wasn't able to find a configuration file #{$opts[:config_file]}.
|
92
102
|
We'll set it up right now.
|
93
103
|
EOS
|
94
|
-
|
104
|
+
Ditz::Config.create_interactively.save! $opts[:config_file]
|
105
|
+
end
|
95
106
|
end
|
96
107
|
|
97
108
|
cmd = ARGV.shift || "todo"
|
@@ -111,7 +122,7 @@ when "help"
|
|
111
122
|
exit
|
112
123
|
end
|
113
124
|
|
114
|
-
project_root = find_dir_containing(issue_dir + PROJECT_FN)
|
125
|
+
project_root = Ditz::find_dir_containing(issue_dir + PROJECT_FN)
|
115
126
|
die "No #{issue_dir} directory---use 'ditz init' to initialize" unless project_root
|
116
127
|
project_root += issue_dir
|
117
128
|
|
@@ -149,6 +160,7 @@ begin
|
|
149
160
|
rescue Ditz::Operator::Error => e
|
150
161
|
die e.message
|
151
162
|
rescue Errno::EPIPE, Interrupt
|
163
|
+
puts
|
152
164
|
exit 1
|
153
165
|
end
|
154
166
|
|
@@ -0,0 +1,29 @@
|
|
1
|
+
#compdef ditz
|
2
|
+
|
3
|
+
ME=ditz
|
4
|
+
COMMANDS=--commands
|
5
|
+
OPTIONS='<options>'
|
6
|
+
|
7
|
+
if (($CURRENT == 2)); then
|
8
|
+
# We're completing the first word after the tool: the command.
|
9
|
+
_wanted command expl "$ME command" \
|
10
|
+
compadd -- $( "$ME" "$COMMANDS" )
|
11
|
+
else
|
12
|
+
# Find the options/files/URL/etc. for the current command by using the tool itself.
|
13
|
+
case "${words[$CURRENT]}"; in
|
14
|
+
-*)
|
15
|
+
_wanted args expl "Arguments for $ME ${words[2]}" \
|
16
|
+
compadd -- $( "$ME" "${words[2]}" "$OPTIONS" ; _files )
|
17
|
+
;;
|
18
|
+
ht*|ft*)
|
19
|
+
_arguments '*:URL:_urls'
|
20
|
+
;;
|
21
|
+
/*|./*|\~*|../*)
|
22
|
+
_arguments '*:file:_files'
|
23
|
+
;;
|
24
|
+
*)
|
25
|
+
_wanted args expl "Arguments for $ME ${words[2]}" \
|
26
|
+
compadd -- $( "$ME" "${words[2]}" "$OPTIONS" )
|
27
|
+
;;
|
28
|
+
esac
|
29
|
+
fi
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# ditz bash completion
|
2
|
+
#
|
3
|
+
# author: Christian Garbs
|
4
|
+
#
|
5
|
+
# based on bzr.simple by Martin Pool
|
6
|
+
|
7
|
+
_ditz()
|
8
|
+
{
|
9
|
+
cur=${COMP_WORDS[COMP_CWORD]}
|
10
|
+
if [ $COMP_CWORD -eq 1 ]; then
|
11
|
+
COMPREPLY=( $( compgen -W "$(ditz --commands)" $cur ) )
|
12
|
+
elif [ $COMP_CWORD -eq 2 ]; then
|
13
|
+
cmd=${COMP_WORDS[1]}
|
14
|
+
COMPREPLY=( $( compgen -W "$(ditz "$cmd" '<options>' 2>/dev/null)" $cur ) )
|
15
|
+
elif [ $COMP_CWORD -eq 3 ]; then
|
16
|
+
cmd=${COMP_WORDS[1]}
|
17
|
+
parm1=${COMP_WORDS[2]}
|
18
|
+
COMPREPLY=( $( compgen -W "$(ditz "$cmd" "$parm1" '<options>' 2>/dev/null)" $cur ) )
|
19
|
+
fi
|
20
|
+
}
|
21
|
+
|
22
|
+
complete -F _ditz -o default ditz
|
data/lib/ditz.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
module Ditz
|
2
2
|
|
3
|
-
VERSION = "0.
|
3
|
+
VERSION = "0.4"
|
4
4
|
|
5
5
|
def debug s
|
6
6
|
puts "# #{s}" if $opts[:verbose]
|
@@ -14,7 +14,6 @@ end
|
|
14
14
|
def self.has_readline= val
|
15
15
|
@has_readline = val
|
16
16
|
end
|
17
|
-
end
|
18
17
|
|
19
18
|
begin
|
20
19
|
Ditz::has_readline = false
|
@@ -24,6 +23,34 @@ rescue LoadError
|
|
24
23
|
# do nothing
|
25
24
|
end
|
26
25
|
|
26
|
+
def home_dir
|
27
|
+
@home ||=
|
28
|
+
ENV["HOME"] || (ENV["HOMEDRIVE"] && ENV["HOMEPATH"] ? ENV["HOMEDRIVE"] + ENV["HOMEPATH"] : nil) || begin
|
29
|
+
$stderr.puts "warning: can't determine home directory, using '.'"
|
30
|
+
"."
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
## helper for recursive search
|
35
|
+
def find_dir_containing target, start=Pathname.new(".")
|
36
|
+
return start if (start + target).exist?
|
37
|
+
unless start.parent.realpath == start.realpath
|
38
|
+
find_dir_containing target, start.parent
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
## my brilliant solution to the 'gem datadir' problem
|
43
|
+
def find_ditz_file fn
|
44
|
+
dir = $:.find { |p| File.exist? File.expand_path(File.join(p, fn)) }
|
45
|
+
raise "can't find #{fn} in any load path" unless dir
|
46
|
+
File.expand_path File.join(dir, fn)
|
47
|
+
end
|
48
|
+
|
49
|
+
module_function :home_dir, :find_dir_containing, :find_ditz_file
|
50
|
+
end
|
51
|
+
|
27
52
|
require 'model-objects'
|
28
53
|
require 'operator'
|
54
|
+
require 'views'
|
29
55
|
require 'hook'
|
56
|
+
|
data/lib/hook.rb
CHANGED
@@ -50,8 +50,15 @@ EOS
|
|
50
50
|
|
51
51
|
def hooks_for name
|
52
52
|
if @blocks[name].nil? || @blocks[name].empty?
|
53
|
-
|
54
|
-
|
53
|
+
dirs = [Ditz::home_dir, Ditz::find_dir_containing(".ditz")].compact.map do |d|
|
54
|
+
File.join d, ".ditz", "hooks"
|
55
|
+
end
|
56
|
+
Ditz::debug "looking for hooks in #{dirs.join(" and ")}"
|
57
|
+
files = dirs.map { |d| Dir[File.join(d, "*.rb")] }.flatten
|
58
|
+
files.each do |fn|
|
59
|
+
Ditz::debug "loading hook file #{fn}"
|
60
|
+
load fn
|
61
|
+
end
|
55
62
|
end
|
56
63
|
|
57
64
|
@blocks[name] || []
|
data/lib/html.rb
CHANGED
@@ -5,17 +5,41 @@ module Ditz
|
|
5
5
|
## pass through any variables needed for template generation, and add a bunch
|
6
6
|
## of HTML formatting utility methods.
|
7
7
|
class ErbHtml
|
8
|
-
def initialize template_dir,
|
9
|
-
@template_name = template_name
|
8
|
+
def initialize template_dir, links, binding={}
|
10
9
|
@template_dir = template_dir
|
11
10
|
@links = links
|
12
|
-
@
|
11
|
+
@binding = binding
|
12
|
+
end
|
13
|
+
|
14
|
+
## return an ErbHtml object that has the current binding plus extra_binding merged in
|
15
|
+
def clone_for_binding extra_binding={}
|
16
|
+
extra_binding.empty? ? self : ErbHtml.new(@template_dir, @links, @binding.merge(extra_binding))
|
17
|
+
end
|
18
|
+
|
19
|
+
def render_template template_name, extra_binding={}
|
20
|
+
if extra_binding.empty?
|
21
|
+
@@erbs ||= {}
|
22
|
+
@@erbs[template_name] ||= ERB.new IO.read(File.join(@template_dir, "#{template_name}.rhtml"))
|
23
|
+
@@erbs[template_name].result binding
|
24
|
+
else
|
25
|
+
clone_for_binding(extra_binding).render_template template_name
|
26
|
+
end
|
27
|
+
end
|
13
28
|
|
14
|
-
|
15
|
-
|
29
|
+
def render_string s, extra_binding={}
|
30
|
+
if extra_binding.empty?
|
31
|
+
ERB.new(s).result binding
|
32
|
+
else
|
33
|
+
clone_for_binding(extra_binding).render_string s
|
34
|
+
end
|
16
35
|
end
|
17
36
|
|
37
|
+
###
|
38
|
+
### the following methods are meant to be called from the ERB itself
|
39
|
+
###
|
40
|
+
|
18
41
|
def h o; o.to_s.gsub("&", "&").gsub("<", "<").gsub(">", ">") end
|
42
|
+
def t o; o.strftime "%Y-%m-%d %H:%M %Z" end
|
19
43
|
def p o; "<p>" + h(o.to_s).gsub("\n\n", "</p><p>") + "</p>" end
|
20
44
|
def obscured_email e; h e.gsub(/@.*?(>|$)/, "@...\\1") end
|
21
45
|
def link_to o, name
|
@@ -24,23 +48,21 @@ class ErbHtml
|
|
24
48
|
raise ArgumentError, "no link for #{o.inspect}" unless dest
|
25
49
|
"<a href=\"#{dest}\">#{name}</a>"
|
26
50
|
end
|
51
|
+
def fancy_issue_link_for i
|
52
|
+
"<span class=\"issuestatus_#{i.status}\">" + link_to(i, "[#{i.title}]") + "</span>"
|
53
|
+
end
|
27
54
|
|
28
55
|
def link_issue_names project, s
|
29
56
|
project.issues.inject(s) do |s, i|
|
30
|
-
s.gsub(/\b#{i.name}\b/,
|
57
|
+
s.gsub(/\b#{i.name}\b/, fancy_issue_link_for(i))
|
31
58
|
end
|
32
59
|
end
|
33
60
|
|
34
|
-
|
35
|
-
|
36
|
-
end
|
61
|
+
## render a nested ERB
|
62
|
+
alias :render :render_template
|
37
63
|
|
38
64
|
def method_missing meth, *a
|
39
|
-
@
|
40
|
-
end
|
41
|
-
|
42
|
-
def to_s
|
43
|
-
@@erbs[@template_name].result binding
|
65
|
+
@binding.member?(meth) ? @binding[meth] : super
|
44
66
|
end
|
45
67
|
end
|
46
68
|
|
data/lib/index.rhtml
CHANGED
@@ -69,10 +69,13 @@
|
|
69
69
|
open_issues = project.group_issues(project.issues_for_component(c).select { |i| i.open? })
|
70
70
|
%>
|
71
71
|
<li>
|
72
|
-
<%= link_to c, c.name %>:
|
73
72
|
<% if open_issues.empty? %>
|
73
|
+
<span class="dimmed">
|
74
|
+
<%= link_to c, c.name %>:
|
74
75
|
no open issues.
|
76
|
+
</span>
|
75
77
|
<% else %>
|
78
|
+
<%= link_to c, c.name %>:
|
76
79
|
<%= open_issues.map { |n,is| n.to_s.pluralize is.size }.join(' and ') %> open.
|
77
80
|
<% end %>
|
78
81
|
</li>
|
@@ -80,6 +83,26 @@
|
|
80
83
|
</ul>
|
81
84
|
<% end %>
|
82
85
|
|
86
|
+
<h2>Recent activity</h2>
|
87
|
+
|
88
|
+
<table>
|
89
|
+
<% project.issues.map { |i| i.log_events.map { |e| [e, i] } }.
|
90
|
+
flatten_one_level.
|
91
|
+
sort_by { |e| e.first.first }.
|
92
|
+
reverse[0 ... 10].
|
93
|
+
each do |(date, who, what, comment), i| %>
|
94
|
+
<tr>
|
95
|
+
<td><%= date.pretty_date %></td>
|
96
|
+
<td class="issuename">
|
97
|
+
<%= link_issue_names project, h(i.name) %>
|
98
|
+
<%= what %> by <%= who.shortened_email %></td>
|
99
|
+
</tr>
|
100
|
+
<% if comment && comment =~ /\S/ %>
|
101
|
+
<tr><td></td><td><i><%= link_issue_names project, h(comment) %></i></td></tr>
|
102
|
+
<% end %>
|
103
|
+
<% end %>
|
104
|
+
</table>
|
105
|
+
|
83
106
|
<p class="footer">Generated by <a href="http://ditz.rubyforge.org/">ditz</a>.
|
84
107
|
|
85
108
|
</body>
|