ditz 0.1
Sign up to get free protection for your applications and to get access to all the features.
- data/Changelog +2 -0
- data/README.txt +117 -0
- data/Rakefile +48 -0
- data/bin/ditz +77 -0
- data/lib/component.rhtml +18 -0
- data/lib/ditz.rb +13 -0
- data/lib/html.rb +41 -0
- data/lib/index.rhtml +83 -0
- data/lib/issue.rhtml +106 -0
- data/lib/issue_table.rhtml +29 -0
- data/lib/lowline.rb +150 -0
- data/lib/model-objects.rb +265 -0
- data/lib/model.rb +137 -0
- data/lib/operator.rb +408 -0
- data/lib/release.rhtml +67 -0
- data/lib/style.css +91 -0
- data/lib/unassigned.rhtml +27 -0
- data/lib/util.rb +33 -0
- metadata +79 -0
@@ -0,0 +1,29 @@
|
|
1
|
+
<table>
|
2
|
+
<% issues.sort_by { |i| i.sort_order }.each do |i| %>
|
3
|
+
<tr>
|
4
|
+
<td class="issuestatus_<%= i.status %>">
|
5
|
+
<%= i.status_string %><% if i.closed? %>: <%= i.disposition_string %><% end %>
|
6
|
+
</td>
|
7
|
+
<td class="issuename">
|
8
|
+
<%= link_to i, i.title %>
|
9
|
+
<%= i.bug? ? '(bug)' : '' %>
|
10
|
+
</td>
|
11
|
+
<% if show_release %>
|
12
|
+
<td class="issuerelease">
|
13
|
+
<% if i.release %>
|
14
|
+
<% r = project.release_for i.release %>
|
15
|
+
in <%= link_to r, "release #{i.release}" %>
|
16
|
+
(<%= r.status %>)
|
17
|
+
<% else %>
|
18
|
+
<% end %>
|
19
|
+
</td>
|
20
|
+
<% end %>
|
21
|
+
<% if show_component %>
|
22
|
+
<td class="issuecomponent">
|
23
|
+
component <%= link_to project.component_for(i.component), i.component %>
|
24
|
+
</td>
|
25
|
+
<% end %>
|
26
|
+
</tr>
|
27
|
+
<% end %>
|
28
|
+
</table>
|
29
|
+
|
data/lib/lowline.rb
ADDED
@@ -0,0 +1,150 @@
|
|
1
|
+
require 'util'
|
2
|
+
|
3
|
+
class Numeric
|
4
|
+
def to_pretty_s
|
5
|
+
if self < 20
|
6
|
+
%w(no one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen)[self]
|
7
|
+
else
|
8
|
+
to_s
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
class String
|
14
|
+
def ucfirst; self[0..0].upcase + self[1..-1] end
|
15
|
+
def dcfirst; self[0..0].downcase + self[1..-1] end
|
16
|
+
def blank?; self =~ /\A\s*\z/ end
|
17
|
+
def underline; self + "\n" + ("-" * self.length) end
|
18
|
+
def multiline prefix=""; blank? ? "" : "\n" + self.gsub(/^/, prefix) end
|
19
|
+
def pluralize n; n.to_pretty_s + " " + (n == 1 ? self : self + "s") end # oh yeah
|
20
|
+
end
|
21
|
+
|
22
|
+
class Array
|
23
|
+
def listify prefix=""
|
24
|
+
return "" if empty?
|
25
|
+
"\n" +
|
26
|
+
map_with_index { |x, i| x.to_s.gsub(/^/, "#{prefix}#{i + 1}. ") }.
|
27
|
+
join("\n")
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
class Time
|
32
|
+
def pretty; strftime "%c" end
|
33
|
+
def pretty_date; strftime "%Y-%m-%d" end
|
34
|
+
def ago
|
35
|
+
diff = (Time.now - self).to_i.abs
|
36
|
+
if diff < 60
|
37
|
+
"second".pluralize diff
|
38
|
+
elsif diff < 60*60*3
|
39
|
+
"minute".pluralize(diff / 60)
|
40
|
+
elsif diff < 60*60*24*3
|
41
|
+
"hour".pluralize(diff / (60*60))
|
42
|
+
elsif diff < 60*60*24*7*2
|
43
|
+
"day".pluralize(diff / (60*60*24))
|
44
|
+
elsif diff < 60*60*24*7*8
|
45
|
+
"week".pluralize(diff / (60*60*24*7))
|
46
|
+
elsif diff < 60*60*24*7*52
|
47
|
+
"month".pluralize(diff / (60*60*24*7*4))
|
48
|
+
else
|
49
|
+
"year".pluralize(diff / (60*60*24*7*52))
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
module Lowline
|
55
|
+
def ask q, opts={}
|
56
|
+
default_s = case opts[:default]
|
57
|
+
when nil; nil
|
58
|
+
when ""; " (enter for none)"
|
59
|
+
else; " (enter for #{opts[:default].inspect})"
|
60
|
+
end
|
61
|
+
|
62
|
+
tail = case q
|
63
|
+
when /[:?]$/; " "
|
64
|
+
when /[:?]\s+$/; ""
|
65
|
+
else; ": "
|
66
|
+
end
|
67
|
+
|
68
|
+
while true
|
69
|
+
print [q, default_s, tail].compact.join
|
70
|
+
ans = gets.strip
|
71
|
+
if opts[:default]
|
72
|
+
ans = opts[:default] if ans.blank?
|
73
|
+
else
|
74
|
+
next if ans.blank? && !opts[:empty_ok]
|
75
|
+
end
|
76
|
+
break ans unless (opts[:restrict] && ans !~ opts[:restrict])
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def ask_multiline q
|
81
|
+
puts "#{q} (ctrl-d or . by itself to stop):"
|
82
|
+
ans = ""
|
83
|
+
while true
|
84
|
+
print "> "
|
85
|
+
line = gets
|
86
|
+
break if line =~ /^\.$/ || line.nil?
|
87
|
+
ans << line.strip + "\n"
|
88
|
+
end
|
89
|
+
ans.sub(/\n+$/, "")
|
90
|
+
end
|
91
|
+
|
92
|
+
def ask_yon q
|
93
|
+
while true
|
94
|
+
print "#{q} (y/n): "
|
95
|
+
a = gets.strip
|
96
|
+
break a if a =~ /^[yn]$/i
|
97
|
+
end =~ /y/i
|
98
|
+
end
|
99
|
+
|
100
|
+
def ask_for_many plural_name, name=nil
|
101
|
+
name ||= plural_name.gsub(/s$/, "")
|
102
|
+
stuff = []
|
103
|
+
|
104
|
+
while true
|
105
|
+
puts
|
106
|
+
puts "Current #{plural_name}:"
|
107
|
+
if stuff.empty?
|
108
|
+
puts "None!"
|
109
|
+
else
|
110
|
+
stuff.each_with_index { |c, i| puts " #{i + 1}) #{c}" }
|
111
|
+
end
|
112
|
+
puts
|
113
|
+
ans = ask "(A)dd #{name}, (r)emove #{name}, or (d)one"
|
114
|
+
case ans
|
115
|
+
when "a", "A"
|
116
|
+
ans = ask "#{name.ucfirst} name", ""
|
117
|
+
stuff << ans unless ans =~ /^\s*$/
|
118
|
+
when "r", "R"
|
119
|
+
ans = ask "Remove which component? (1--#{stuff.size})"
|
120
|
+
stuff.delete_at(ans.to_i - 1) if ans
|
121
|
+
when "d", "D"
|
122
|
+
break
|
123
|
+
end
|
124
|
+
end
|
125
|
+
stuff
|
126
|
+
end
|
127
|
+
|
128
|
+
def ask_for_selection stuff, name, to_string=:to_s
|
129
|
+
puts "Choose a #{name}:"
|
130
|
+
stuff.each_with_index do |c, i|
|
131
|
+
pretty = case to_string
|
132
|
+
when Symbol
|
133
|
+
c.send to_string
|
134
|
+
when Proc
|
135
|
+
to_string.call c
|
136
|
+
else
|
137
|
+
raise ArgumentError, "unknown to_string argument type; expecting Proc or Symbol"
|
138
|
+
end
|
139
|
+
puts " #{i + 1}) #{pretty}"
|
140
|
+
end
|
141
|
+
|
142
|
+
j = while true
|
143
|
+
i = ask "#{name.ucfirst} (1--#{stuff.size})"
|
144
|
+
break i.to_i if i && (1 .. stuff.size).member?(i.to_i)
|
145
|
+
end
|
146
|
+
|
147
|
+
stuff[j - 1]
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
@@ -0,0 +1,265 @@
|
|
1
|
+
require 'model'
|
2
|
+
|
3
|
+
module Ditz
|
4
|
+
|
5
|
+
class Component < ModelObject
|
6
|
+
field :name
|
7
|
+
def name_prefix; @name.gsub(/\s+/, "-").downcase end
|
8
|
+
end
|
9
|
+
|
10
|
+
class Release < ModelObject
|
11
|
+
class Error < StandardError; end
|
12
|
+
|
13
|
+
field :name
|
14
|
+
field :status, :default => :unreleased, :ask => false
|
15
|
+
field :release_time, :ask => false
|
16
|
+
changes_are_logged
|
17
|
+
|
18
|
+
def released?; self.status == :released end
|
19
|
+
def unreleased?; !released? end
|
20
|
+
|
21
|
+
def issues_from project; project.issues.select { |i| i.release == name } end
|
22
|
+
|
23
|
+
def release! project, who, comment
|
24
|
+
raise Error, "already released" if released?
|
25
|
+
|
26
|
+
issues = issues_from project
|
27
|
+
bad = issues.find { |i| i.open? }
|
28
|
+
raise Error, "open issue #{bad.name} must be reassigned" if bad
|
29
|
+
|
30
|
+
self.release_time = Time.now
|
31
|
+
self.status = :released
|
32
|
+
log "released", who, comment
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
class Project < ModelObject
|
37
|
+
class Error < StandardError; end
|
38
|
+
|
39
|
+
field :name, :default_generator => lambda { File.basename(Dir.pwd) }
|
40
|
+
field :version, :default => Ditz::VERSION, :ask => false
|
41
|
+
field :issues, :multi => true, :ask => false
|
42
|
+
field :components, :multi => true, :generator => :get_components
|
43
|
+
field :releases, :multi => true, :ask => false
|
44
|
+
|
45
|
+
def get_components
|
46
|
+
puts <<EOS
|
47
|
+
Issues can be tracked across the project as a whole, or the project can be
|
48
|
+
split into components, and issues tracked separately for each component.
|
49
|
+
EOS
|
50
|
+
use_components = ask_yon "Track issues separately for different components?"
|
51
|
+
comp_names = use_components ? ask_for_many("components") : []
|
52
|
+
|
53
|
+
([name] + comp_names).uniq.map { |n| Component.create_interactively :with => { :name => n } }
|
54
|
+
end
|
55
|
+
|
56
|
+
def issue_for issue_name
|
57
|
+
issues.find { |i| i.name == issue_name } or
|
58
|
+
raise Error, "has no issue with name #{issue_name.inspect}"
|
59
|
+
end
|
60
|
+
|
61
|
+
def component_for component_name
|
62
|
+
components.find { |i| i.name == component_name } or
|
63
|
+
raise Error, "has no component with name #{component_name.inspect}"
|
64
|
+
end
|
65
|
+
|
66
|
+
def release_for release_name
|
67
|
+
releases.find { |i| i.name == release_name } or
|
68
|
+
raise Error, "has no release with name #{release_name.inspect}"
|
69
|
+
end
|
70
|
+
|
71
|
+
def issues_for_release release
|
72
|
+
issues.select { |i| i.release == release.name }
|
73
|
+
end
|
74
|
+
|
75
|
+
def issues_for_component component
|
76
|
+
issues.select { |i| i.component == component.name }
|
77
|
+
end
|
78
|
+
|
79
|
+
def unassigned_issues
|
80
|
+
issues.select { |i| i.release.nil? }
|
81
|
+
end
|
82
|
+
|
83
|
+
def assign_issue_names!
|
84
|
+
prefixes = components.map { |c| [c.name, c.name.gsub(/^\s+/, "-").downcase] }.to_h
|
85
|
+
ids = components.map { |c| [c.name, 0] }.to_h
|
86
|
+
issues.each do |i|
|
87
|
+
i.name = "#{prefixes[i.component]}-#{ids[i.component] += 1}"
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
def validate!
|
92
|
+
if(dup = components.map { |c| c.name }.first_duplicate)
|
93
|
+
raise Error, "more than one component named #{dup.inspect}"
|
94
|
+
elsif(dup = releases.map { |r| r.name }.first_duplicate)
|
95
|
+
raise Error, "more than one release named #{dup.inspect}"
|
96
|
+
end
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
class Issue < ModelObject
|
101
|
+
class Error < StandardError; end
|
102
|
+
|
103
|
+
field :title
|
104
|
+
field :desc, :prompt => "Description", :multiline => true
|
105
|
+
field :type, :generator => :get_type
|
106
|
+
field :component, :generator => :get_component
|
107
|
+
field :release, :generator => :get_release
|
108
|
+
field :reporter, :prompt => "Issue creator", :default_generator => lambda { |config, proj| config.user }
|
109
|
+
field :status, :ask => false, :default => :unstarted
|
110
|
+
field :disposition, :ask => false
|
111
|
+
field :creation_time, :ask => false, :generator => lambda { Time.now }
|
112
|
+
field :references, :ask => false, :multi => true
|
113
|
+
field :id, :ask => false, :generator => :make_id
|
114
|
+
changes_are_logged
|
115
|
+
|
116
|
+
attr_accessor :name
|
117
|
+
|
118
|
+
STATUS_SORT_ORDER = { :unstarted => 2, :paused => 1, :in_progress => 0, :closed => 3 }
|
119
|
+
STATUS_WIDGET = { :unstarted => "_", :in_progress => ">", :paused => "=", :closed => "x" }
|
120
|
+
DISPOSITIONS = [ :fixed, :wontfix, :reorg ]
|
121
|
+
TYPES = [ :bugfix, :feature ]
|
122
|
+
STATUSES = STATUS_WIDGET.keys
|
123
|
+
|
124
|
+
STATUS_STRINGS = { :in_progress => "in progress", :wontfix => "won't fix" }
|
125
|
+
DISPOSITION_STRINGS = { :wontfix => "won't fix", :reorg => "reorganized" }
|
126
|
+
|
127
|
+
def before_serialize project
|
128
|
+
self.desc = project.issues.inject(desc) do |s, i|
|
129
|
+
s.gsub(/\b#{i.name}\b/, "{issue #{i.id}}")
|
130
|
+
end
|
131
|
+
end
|
132
|
+
|
133
|
+
def interpolated_desc issues
|
134
|
+
issues.inject(desc) do |s, i|
|
135
|
+
s.gsub(/\{issue #{i.id}\}/, block_given? ? yield(i) : i.name)
|
136
|
+
end.gsub(/\{issue \w+\}/, "[unknown issue]")
|
137
|
+
end
|
138
|
+
|
139
|
+
## make a unique id
|
140
|
+
def make_id config, project
|
141
|
+
SHA1.hexdigest [Time.now, rand, creation_time, reporter, title, desc].join("\n")
|
142
|
+
end
|
143
|
+
|
144
|
+
def sort_order; [STATUS_SORT_ORDER[@status], creation_time] end
|
145
|
+
def status_widget; STATUS_WIDGET[@status] end
|
146
|
+
|
147
|
+
def status_string; STATUS_STRINGS[status] || status.to_s end
|
148
|
+
def disposition_string; DISPOSITION_STRINGS[disposition] || disposition.to_s end
|
149
|
+
|
150
|
+
def closed?; status == :closed end
|
151
|
+
def open?; !closed? end
|
152
|
+
def in_progress?; status == :in_progress end
|
153
|
+
def bug?; type == :bugfix end
|
154
|
+
def feature?; type == :feature end
|
155
|
+
|
156
|
+
def start_work who, comment; change_status :in_progress, who, comment end
|
157
|
+
def stop_work who, comment
|
158
|
+
raise Error, "unstarted" unless self.status == :in_progress
|
159
|
+
change_status :paused, who, comment
|
160
|
+
end
|
161
|
+
|
162
|
+
def close disp, who, comment
|
163
|
+
raise Error, "unknown disposition #{disp}" unless DISPOSITIONS.member? disp
|
164
|
+
log "closed issue with disposition #{disp}", who, comment
|
165
|
+
self.status = :closed
|
166
|
+
self.disposition = disp
|
167
|
+
end
|
168
|
+
|
169
|
+
def change_status to, who, comment
|
170
|
+
raise Error, "unknown status #{to}" unless STATUSES.member? to
|
171
|
+
raise Error, "already marked as #{to}" if status == to
|
172
|
+
log "changed status from #{@status} to #{to}", who, comment
|
173
|
+
self.status = to
|
174
|
+
end
|
175
|
+
private :change_status
|
176
|
+
|
177
|
+
def change hash, who, comment
|
178
|
+
what = []
|
179
|
+
if title != hash[:title]
|
180
|
+
what << "changed title"
|
181
|
+
self.title = hash[:title]
|
182
|
+
end
|
183
|
+
|
184
|
+
if desc != hash[:description]
|
185
|
+
what << "changed description"
|
186
|
+
self.desc = hash[:description]
|
187
|
+
end
|
188
|
+
|
189
|
+
if reporter != hash[:reporter]
|
190
|
+
what << "changed reporter"
|
191
|
+
self.reporter = hash[:reporter]
|
192
|
+
end
|
193
|
+
|
194
|
+
unless what.empty?
|
195
|
+
log what.join(", "), who, comment
|
196
|
+
true
|
197
|
+
end
|
198
|
+
end
|
199
|
+
|
200
|
+
def assign_to_release release, who, comment
|
201
|
+
log "assigned to release #{release.name} from #{self.release || 'unassigned'}", who, comment
|
202
|
+
self.release = release.name
|
203
|
+
end
|
204
|
+
|
205
|
+
def unassign who, comment
|
206
|
+
raise Error, "not assigned to a release" unless release
|
207
|
+
log "unassigned from release #{release}", who, comment
|
208
|
+
self.release = nil
|
209
|
+
end
|
210
|
+
|
211
|
+
def get_type config, project
|
212
|
+
ask "Is this a (b)ugfix or a (f)eature?", :restrict => /^[bf]$/
|
213
|
+
type == "b" ? :bugfix : :feature
|
214
|
+
end
|
215
|
+
|
216
|
+
def get_component config, project
|
217
|
+
if project.components.size == 1
|
218
|
+
project.components.first
|
219
|
+
else
|
220
|
+
ask_for_selection project.components, "component", :name
|
221
|
+
end.name
|
222
|
+
end
|
223
|
+
|
224
|
+
def get_release config, project
|
225
|
+
releases = project.releases.select { |r| r.unreleased? }
|
226
|
+
if !releases.empty? && ask_yon("Assign to a release now?")
|
227
|
+
if releases.size == 1
|
228
|
+
r = releases.first
|
229
|
+
puts "Assigning to release #{r.name}."
|
230
|
+
r
|
231
|
+
else
|
232
|
+
ask_for_selection releases, "release", :name
|
233
|
+
end.name
|
234
|
+
end
|
235
|
+
end
|
236
|
+
|
237
|
+
def get_reporter config, project
|
238
|
+
reporter = ask "Creator", :default => config.user
|
239
|
+
end
|
240
|
+
end
|
241
|
+
|
242
|
+
class Config < ModelObject
|
243
|
+
field :name, :prompt => "Your name", :default_generator => :get_default_name
|
244
|
+
field :email, :prompt => "Your email address", :default_generator => :get_default_email
|
245
|
+
|
246
|
+
def user; "#{name} <#{email}>" end
|
247
|
+
|
248
|
+
def get_default_name
|
249
|
+
require 'etc'
|
250
|
+
|
251
|
+
name = Etc.getpwnam(ENV["USER"]).gecos.split(/,/).first
|
252
|
+
end
|
253
|
+
|
254
|
+
def get_default_email
|
255
|
+
require 'socket'
|
256
|
+
email = ENV["USER"] + "@" +
|
257
|
+
begin
|
258
|
+
Socket.gethostbyname(Socket.gethostname).first
|
259
|
+
rescue SocketError
|
260
|
+
Socket.gethostname
|
261
|
+
end
|
262
|
+
end
|
263
|
+
end
|
264
|
+
|
265
|
+
end
|
data/lib/model.rb
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require "lowline"; include Lowline
|
3
|
+
require "util"
|
4
|
+
require 'sha1'
|
5
|
+
|
6
|
+
module Ditz
|
7
|
+
|
8
|
+
class ModelObject
|
9
|
+
class ModelError < StandardError; end
|
10
|
+
|
11
|
+
## yamlability
|
12
|
+
def self.yaml_domain; "ditz.rubyforge.org,2008-03-06" end
|
13
|
+
def self.yaml_other_thing; name.split('::').last.dcfirst end
|
14
|
+
def to_yaml_type; "!#{self.class.yaml_domain}/#{self.class.yaml_other_thing}" end
|
15
|
+
def to_yaml_properties; self.class.fields.map { |f| "@#{f.to_s}" } end
|
16
|
+
def self.inherited subclass
|
17
|
+
YAML.add_domain_type(yaml_domain, subclass.yaml_other_thing) do |type, val|
|
18
|
+
YAML.object_maker(subclass, val)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
def before_serialize(*a); end
|
22
|
+
def after_deserialize(*a); end
|
23
|
+
|
24
|
+
def self.field name, opts={}
|
25
|
+
@fields ||= [] # can't use a hash because want to preserve field order when serialized
|
26
|
+
raise ModelError, "field with name #{name} already defined" if @fields.any? { |k, v| k == name }
|
27
|
+
@fields << [name, opts]
|
28
|
+
|
29
|
+
attr_reader name
|
30
|
+
if opts[:multi]
|
31
|
+
single_name = name.to_s.sub(/s$/, "") # oh yeah
|
32
|
+
define_method "add_#{single_name}" do |obj|
|
33
|
+
array = self.instance_variable_get("@#{name}")
|
34
|
+
raise ModelError, "already has a #{single_name} with name #{obj.name.inspect}" if obj.respond_to?(:name) && array.any? { |o| o.name == obj.name }
|
35
|
+
changed!
|
36
|
+
array << obj
|
37
|
+
end
|
38
|
+
|
39
|
+
define_method "drop_#{single_name}" do |obj|
|
40
|
+
return unless self.instance_variable_get("@#{name}").delete obj
|
41
|
+
changed!
|
42
|
+
obj
|
43
|
+
end
|
44
|
+
end
|
45
|
+
define_method "#{name}=" do |o|
|
46
|
+
changed!
|
47
|
+
instance_variable_set "@#{name}", o
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.fields; @fields.map { |name, opts| name } end
|
52
|
+
|
53
|
+
def self.changes_are_logged
|
54
|
+
define_method(:changes_are_logged?) { true }
|
55
|
+
field :log_events, :multi => true, :ask => false
|
56
|
+
end
|
57
|
+
|
58
|
+
def self.from fn
|
59
|
+
returning YAML::load_file(fn) do |o|
|
60
|
+
raise ModelError, "error loading from yaml file #{fn.inspect}: expected a #{self}, got a #{o.class}" unless o.class == self
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
## depth-first search on all reachable ModelObjects. fuck yeah.
|
65
|
+
def each_modelobject
|
66
|
+
seen = {}
|
67
|
+
to_see = [self]
|
68
|
+
until to_see.empty?
|
69
|
+
cur = to_see.pop
|
70
|
+
seen[cur] = true
|
71
|
+
yield cur
|
72
|
+
cur.class.fields.each do |f|
|
73
|
+
val = cur.send(f)
|
74
|
+
next if seen[val]
|
75
|
+
if val.is_a?(ModelObject)
|
76
|
+
to_see.push val
|
77
|
+
elsif val.is_a?(Array)
|
78
|
+
to_see += val.select { |v| v.is_a?(ModelObject) }
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
def save! fn
|
85
|
+
Ditz::debug "saving configuration to #{fn}"
|
86
|
+
FileUtils.mv fn, "#{fn}~", :force => true rescue nil
|
87
|
+
File.open(fn, "w") { |f| f.puts to_yaml }
|
88
|
+
end
|
89
|
+
|
90
|
+
def log what, who, comment
|
91
|
+
add_log_event([Time.now, who, what, comment])
|
92
|
+
self
|
93
|
+
end
|
94
|
+
|
95
|
+
def initialize
|
96
|
+
@changed = false
|
97
|
+
@log_events = []
|
98
|
+
end
|
99
|
+
|
100
|
+
def changed?; @changed end
|
101
|
+
def changed!; @changed = true end
|
102
|
+
|
103
|
+
def self.create_interactively opts={}
|
104
|
+
o = self.new
|
105
|
+
args = opts[:args] || []
|
106
|
+
@fields.each do |name, field_opts|
|
107
|
+
val = if opts[:with] && opts[:with][name]
|
108
|
+
opts[:with][name]
|
109
|
+
elsif field_opts[:generator].is_a? Proc
|
110
|
+
field_opts[:generator].call(*args)
|
111
|
+
elsif field_opts[:generator]
|
112
|
+
o.send field_opts[:generator], *args
|
113
|
+
elsif field_opts[:ask] == false # nil counts as true here
|
114
|
+
field_opts[:default] || (field_opts[:multi] ? [] : nil)
|
115
|
+
else
|
116
|
+
q = field_opts[:prompt] || name.to_s.ucfirst
|
117
|
+
if field_opts[:multiline]
|
118
|
+
ask_multiline q
|
119
|
+
else
|
120
|
+
default = if field_opts[:default_generator].is_a? Proc
|
121
|
+
field_opts[:default_generator].call(*args)
|
122
|
+
elsif field_opts[:default_generator]
|
123
|
+
o.send field_opts[:default_generator], *args
|
124
|
+
elsif field_opts[:default]
|
125
|
+
field_opts[:default]
|
126
|
+
end
|
127
|
+
|
128
|
+
ask q, :default => default
|
129
|
+
end
|
130
|
+
end
|
131
|
+
o.send("#{name}=", val)
|
132
|
+
end
|
133
|
+
o
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
end
|