ditz 0.1
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 +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
|