typingpool 0.7.0 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. data/LICENSE +20 -0
  2. data/README.markdown +452 -0
  3. data/lib/typingpool/amazon/hit/assignment/empty.rb +19 -0
  4. data/lib/typingpool/amazon/hit/assignment.rb +43 -0
  5. data/lib/typingpool/amazon/hit/full/fromsearchhits.rb +44 -0
  6. data/lib/typingpool/amazon/hit/full.rb +105 -0
  7. data/lib/typingpool/amazon/hit.rb +458 -0
  8. data/lib/typingpool/amazon/question.rb +45 -0
  9. data/lib/typingpool/amazon.rb +3 -677
  10. data/lib/typingpool/app/cli/formatter.rb +16 -0
  11. data/lib/typingpool/app/cli.rb +64 -0
  12. data/lib/typingpool/app/friendlyexceptions.rb +34 -0
  13. data/lib/typingpool/app.rb +2 -97
  14. data/lib/typingpool/config/root.rb +114 -0
  15. data/lib/typingpool/config.rb +13 -119
  16. data/lib/typingpool/filer/audio.rb +84 -0
  17. data/lib/typingpool/filer/csv.rb +57 -0
  18. data/lib/typingpool/filer/dir.rb +76 -0
  19. data/lib/typingpool/filer/files/audio.rb +63 -0
  20. data/lib/typingpool/filer/files.rb +55 -0
  21. data/lib/typingpool/filer.rb +4 -313
  22. data/lib/typingpool/project/local.rb +117 -0
  23. data/lib/typingpool/project/remote/s3.rb +135 -0
  24. data/lib/typingpool/project/remote/sftp.rb +100 -0
  25. data/lib/typingpool/project/remote.rb +65 -0
  26. data/lib/typingpool/project.rb +2 -396
  27. data/lib/typingpool/template/assignment.rb +17 -0
  28. data/lib/typingpool/template/env.rb +77 -0
  29. data/lib/typingpool/template.rb +2 -87
  30. data/lib/typingpool/test/script.rb +310 -0
  31. data/lib/typingpool/test.rb +1 -306
  32. data/lib/typingpool/transcript/chunk.rb +129 -0
  33. data/lib/typingpool/transcript.rb +1 -125
  34. data/lib/typingpool/utility/castable.rb +65 -0
  35. data/lib/typingpool/utility.rb +1 -61
  36. data/test/test_integration_script_6_tp_finish.rb +1 -0
  37. metadata +135 -81
@@ -0,0 +1,16 @@
1
+ module Typingpool
2
+ module App
3
+ module CLI
4
+ module Formatter
5
+ require 'highline/import'
6
+ def cli_bold(text)
7
+ HighLine.color(text, :bold)
8
+ end
9
+
10
+ def cli_reverse(text)
11
+ HighLine.color(text, :reverse)
12
+ end
13
+ end #Formatter
14
+ end #CLI
15
+ end #App
16
+ end #Typingpool
@@ -0,0 +1,64 @@
1
+ module Typingpool
2
+ module App
3
+ module CLI
4
+ class << self
5
+ include App::FriendlyExceptions
6
+
7
+ #Optionally takes an ostensible path to a config file, as passed
8
+ #as a command-line option. Checks to make sure the file exists;
9
+ #returns nil if does not, returns a Config instance if it
10
+ #does. If no path is passed, the default config file is returned
11
+ #(as retrieved by Config.file with no args).
12
+ def config_from_arg(arg=nil)
13
+ if arg
14
+ path = File.expand_path(arg)
15
+ return unless File.exists?(path) && File.file?(path)
16
+ Config.file(path)
17
+ else
18
+ Config.file
19
+ end #if option
20
+ end
21
+
22
+ #Outputs a friendly explanation of the --help option for
23
+ #appending to script usage banners.
24
+ def help_arg_explanation
25
+ "`#{File.basename($PROGRAM_NAME)} --help` for more information."
26
+ end
27
+
28
+ #Converts a user arg into a Project instance, setting up or
29
+ #consulting a Config along the way.
30
+ # ==== Params
31
+ # [arg] A user-supplied argument specifying either an absolute
32
+ # path to a Project folder (Project#local) or the
33
+ # name of a project folder within
34
+ # [config]#transcripts.
35
+ # [config] A Config instance. If [arg] is an absolute path,
36
+ # will be modified -- Config#itranscripts will be
37
+ # changed to match the implied transcripts dir.
38
+ # ==== Errors
39
+ # Will abort with a friendly message on any errors.
40
+ # ==== Returns
41
+ # A Project instance.
42
+ def project_from_arg_and_config(arg, config)
43
+ path = if (File.exists?(arg) && File.directory?(arg))
44
+ config.transcripts = File.dirname(arg)
45
+ arg
46
+ else
47
+ abort "No 'transcripts' dir specified in your config file and '#{arg}' is not a valid path" unless config.transcripts
48
+ path = File.join(config.transcripts, arg)
49
+ abort "No such project '#{arg}' in dir '#{config.transcripts}'" unless File.exists? path
50
+ abort "'#{arg}' is not a directory at '#{path}'" unless File.directory? path
51
+ path
52
+ end
53
+ project = with_friendly_exceptions('project name', File.basename(path)) do
54
+ Typingpool::Project.new(File.basename(path), config)
55
+ end
56
+ abort "Not a project directory at '#{path}'" unless project.local
57
+ project
58
+ end
59
+
60
+ end #class << self
61
+ require 'typingpool/app/cli/formatter'
62
+ end #CLI
63
+ end #App
64
+ end #Typingpool
@@ -0,0 +1,34 @@
1
+ module Typingpool
2
+ module App
3
+ module FriendlyExceptions
4
+
5
+ #Massages terse exceptions from our model layer into a
6
+ #human-friendly message suitable for an abort message from a
7
+ #command-line script.
8
+ # ==== Params
9
+ # [name] A string used to refer to the input. For example
10
+ # 'project title' or '--config argument'. Used in the
11
+ # goodbye message.
12
+ # [*input] One or more values. The user input that will cause
13
+ # any exceptions. Used in the goodbye message.
14
+ # [&block] The block to execute and monitor for
15
+ # exceptions. Will be passed [*input].
16
+ # ==== Errors
17
+ # Will abort with a friendly message on any exception of the
18
+ # type Typingpool::Error::Argument.
19
+ # ==== Returns
20
+ # The return value of &block.
21
+ def with_friendly_exceptions(name, *input)
22
+ begin
23
+ yield(*input)
24
+ rescue Typingpool::Error::Argument => exception
25
+ goodbye = "Could not make sense of #{name.to_s} "
26
+ goodbye += input.map{|input| "'#{input}'" }.join(', ')
27
+ goodbye += ". #{exception.message}"
28
+ goodbye += '.' unless goodbye.match(/\.$/)
29
+ abort goodbye
30
+ end #begin
31
+ end
32
+ end #FriendlyExceptions
33
+ end #App
34
+ end #Typingpool
@@ -533,102 +533,7 @@ module Typingpool
533
533
  end #assignments.each!...
534
534
  end
535
535
  end #class << self
536
- module FriendlyExceptions
537
- #Massages terse exceptions from our model layer into a
538
- #human-friendly message suitable for an abort message from a
539
- #command-line script.
540
- # ==== Params
541
- # [name] A string used to refer to the input. For example
542
- # 'project title' or '--config argument'. Used in the
543
- # goodbye message.
544
- # [*input] One or more values. The user input that will cause
545
- # any exceptions. Used in the goodbye message.
546
- # [&block] The block to execute and monitor for
547
- # exceptions. Will be passed [*input].
548
- # ==== Errors
549
- # Will abort with a friendly message on any exception of the
550
- # type Typingpool::Error::Argument.
551
- # ==== Returns
552
- # The return value of &block.
553
- def with_friendly_exceptions(name, *input)
554
- begin
555
- yield(*input)
556
- rescue Typingpool::Error::Argument => exception
557
- goodbye = "Could not make sense of #{name.to_s} "
558
- goodbye += input.map{|input| "'#{input}'" }.join(', ')
559
- goodbye += ". #{exception.message}"
560
- goodbye += '.' unless goodbye.match(/\.$/)
561
- abort goodbye
562
- end #begin
563
- end
564
- end #FriendlyExceptions
565
- module CLI
566
- class << self
567
- include App::FriendlyExceptions
568
- #Optionally takes an ostensible path to a config file, as passed
569
- #as a command-line option. Checks to make sure the file exists;
570
- #returns nil if does not, returns a Config instance if it
571
- #does. If no path is passed, the default config file is returned
572
- #(as retrieved by Config.file with no args).
573
- def config_from_arg(arg=nil)
574
- if arg
575
- path = File.expand_path(arg)
576
- return unless File.exists?(path) && File.file?(path)
577
- Config.file(path)
578
- else
579
- Config.file
580
- end #if option
581
- end
582
-
583
- #Outputs a friendly explanation of the --help option for
584
- #appending to script usage banners.
585
- def help_arg_explanation
586
- "`#{File.basename($PROGRAM_NAME)} --help` for more information."
587
- end
588
-
589
- #Converts a user arg into a Project instance, setting up or
590
- #consulting a Config along the way.
591
- # ==== Params
592
- # [arg] A user-supplied argument specifying either an absolute
593
- # path to a Project folder (Project#local) or the
594
- # name of a project folder within
595
- # [config]#transcripts.
596
- # [config] A Config instance. If [arg] is an absolute path,
597
- # will be modified -- Config#itranscripts will be
598
- # changed to match the implied transcripts dir.
599
- # ==== Errors
600
- # Will abort with a friendly message on any errors.
601
- # ==== Returns
602
- # A Project instance.
603
- def project_from_arg_and_config(arg, config)
604
- path = if (File.exists?(arg) && File.directory?(arg))
605
- config.transcripts = File.dirname(arg)
606
- arg
607
- else
608
- abort "No 'transcripts' dir specified in your config file and '#{arg}' is not a valid path" unless config.transcripts
609
- path = File.join(config.transcripts, arg)
610
- abort "No such project '#{arg}' in dir '#{config.transcripts}'" unless File.exists? path
611
- abort "'#{arg}' is not a directory at '#{path}'" unless File.directory? path
612
- path
613
- end
614
- project = with_friendly_exceptions('project name', File.basename(path)) do
615
- Typingpool::Project.new(File.basename(path), config)
616
- end
617
- abort "Not a project directory at '#{path}'" unless project.local
618
- project
619
- end
620
-
621
- end #class << self
622
- module Formatter
623
- require 'highline/import'
624
- def cli_bold(text)
625
- HighLine.color(text, :bold)
626
- end
627
-
628
- def cli_reverse(text)
629
- HighLine.color(text, :reverse)
630
- end
631
- end #Formatter
632
- end #CLI
536
+ require 'typingpool/app/friendlyexceptions'
537
+ require 'typingpool/app/cli'
633
538
  end #App
634
539
  end #Typingpool
@@ -0,0 +1,114 @@
1
+ module Typingpool
2
+ class Config
3
+
4
+ #The root level of the config file and all full config
5
+ #objects. Kept distinct from Config because other subclasses need
6
+ #to inherit from Config, and we don't want them inheriting the
7
+ #root level fields.
8
+ class Root < Config
9
+ local_path_reader :transcripts, :cache, :templates
10
+
11
+ class SFTP < Config
12
+ never_ends_in_slash_reader :path, :url
13
+ end
14
+
15
+ class Amazon < Config
16
+ never_ends_in_slash_reader :url
17
+ end
18
+
19
+ class Assign < Config
20
+ local_path_reader :templates
21
+ time_accessor :deadline, :approval, :lifetime
22
+
23
+ define_accessor(:reward) do |value|
24
+ value.to_s.match(/(\d+(\.\d+)?)|(\d*\.\d+)/) or raise Error::Argument::Format, "Format should be N.NN"
25
+ value
26
+ end
27
+
28
+ define_reader(:confirm) do |value|
29
+ next false if value.to_s.match(/(^n)|(^0)|(^false)/i)
30
+ next true if value.to_s.match(/(^y)|(^1)|(^true)/i)
31
+ next if value.to_s.empty?
32
+ raise Error::Argument::Format, "Format should be 'yes' or 'no'"
33
+ end
34
+
35
+ def qualify
36
+ self.qualify = (@param['qualify'] || []) unless @qualify
37
+ @qualify
38
+ end
39
+
40
+ def qualify=(specs)
41
+ @qualify = specs.map{|spec| Qualification.new(spec) }
42
+ end
43
+
44
+ def add_qualification(spec)
45
+ self.qualify.push(Qualification.new(spec))
46
+ end
47
+
48
+ def keywords
49
+ @param['keywords'] ||= []
50
+ end
51
+
52
+ def keywords=(array)
53
+ @param['keywords'] = array
54
+ end
55
+
56
+ class Qualification < Config
57
+ def initialize(spec)
58
+ @raw = spec
59
+ to_arg #make sure value parses
60
+ end
61
+
62
+ def to_s
63
+ @raw
64
+ end
65
+
66
+ def to_arg
67
+ [type, opts]
68
+ end
69
+
70
+ protected
71
+
72
+ def type
73
+ type = @raw.split(/\s+/)[0]
74
+ if RTurk::Qualification::TYPES[type.to_sym]
75
+ return type.to_sym
76
+ elsif (type.match(/\d/) || type.size >= 25)
77
+ return type
78
+ else
79
+ #Seems likely to be qualification typo: Not a known
80
+ #system qualification, all letters and less than 25
81
+ #chars
82
+ raise Error::Argument, "Unknown qualification type and does not appear to be a raw qualification type ID: '#{type.to_s}'"
83
+ end
84
+ end
85
+
86
+ def opts
87
+ args = @raw.split(/\s+/)
88
+ if (args.count > 3) || (args.count < 2)
89
+ raise Error::Argument, "Unexpected number of qualification tokens: #{@raw}"
90
+ end
91
+ args.shift
92
+ comparator(args[0]) or raise Error::Argument, "Unknown comparator '#{args[0]}'"
93
+ value = 1
94
+ value = args[1] if args.count == 2
95
+ return {comparator(args[0]) => value}
96
+ end
97
+
98
+ def comparator(value)
99
+ Hash[
100
+ '>' => :gt,
101
+ '>=' => :gte,
102
+ '<' => :lt,
103
+ '<=' => :lte,
104
+ '==' => :eql,
105
+ '!=' => :not,
106
+ 'true' => :eql,
107
+ 'exists' => :exists
108
+ ][value]
109
+ end
110
+ end #Qualification
111
+ end #Assign
112
+ end #Root
113
+ end #Config
114
+ end #Typingpool
@@ -99,9 +99,9 @@ module Typingpool
99
99
  # conf.sftp.url = 'http://luvrecording.s3.amazonaws.com/'
100
100
  # puts conf.sftp.url #'http://luvrecording.s3.amazonaws.com'
101
101
  #
102
- class Config
103
-
102
+ class Config
104
103
  require 'yaml'
104
+
105
105
  @@default_file = "~/.typingpool"
106
106
 
107
107
  def initialize(params)
@@ -158,29 +158,32 @@ module Typingpool
158
158
  end
159
159
  end
160
160
 
161
- def define_reader(*syms)
161
+ def define_reader(*syms, &block)
162
+ #explicit block passing to avoid sefault in 1.9.3-p362
162
163
  syms.each do |sym|
163
164
  define_method(sym) do
164
165
  value = @param[sym.to_s]
165
- yield(value)
166
+ block.call(value)
166
167
  end
167
168
  end
168
169
  end
169
170
 
170
- def define_writer(*syms)
171
+ def define_writer(*syms, &block)
172
+ #explicit block passing to avoid sefault in 1.9.3-p362
171
173
  syms.each do |sym|
172
174
  define_method("#{sym.to_s}=".to_sym) do |value|
173
- @param[sym.to_s] = yield(value)
175
+ @param[sym.to_s] = block.call(value)
174
176
  end
175
177
  end
176
178
  end
177
179
 
178
- def define_accessor(*syms)
180
+ def define_accessor(*syms, &block)
181
+ #explicit block passing to avoid sefault in 1.9.3-p362
179
182
  define_reader(*syms) do |value|
180
- yield(value) if value
183
+ block.call(value) if value
181
184
  end
182
185
  define_writer(*syms) do |value|
183
- yield(value)
186
+ block.call(value)
184
187
  end
185
188
  end
186
189
 
@@ -230,115 +233,6 @@ module Typingpool
230
233
  match = meth.to_s.match(/([^=]+)=$/) or return
231
234
  return match[1]
232
235
  end
233
-
234
- #The root level of the config file and all full config
235
- #objects. Kept distinct from Config because other subclasses need
236
- #to inherit from Config, and we don't want them inheriting the
237
- #root level fields.
238
- class Root < Config
239
- local_path_reader :transcripts, :cache, :templates
240
-
241
- class SFTP < Config
242
- never_ends_in_slash_reader :path, :url
243
- end
244
-
245
- class Amazon < Config
246
- never_ends_in_slash_reader :url
247
- end
248
-
249
- class Assign < Config
250
- local_path_reader :templates
251
- time_accessor :deadline, :approval, :lifetime
252
-
253
- define_accessor(:reward) do |value|
254
- value.to_s.match(/(\d+(\.\d+)?)|(\d*\.\d+)/) or raise Error::Argument::Format, "Format should be N.NN"
255
- value
256
- end
257
-
258
- define_reader(:confirm) do |value|
259
- next false if value.to_s.match(/(^n)|(^0)|(^false)/i)
260
- next true if value.to_s.match(/(^y)|(^1)|(^true)/i)
261
- next if value.to_s.empty?
262
- raise Error::Argument::Format, "Format should be 'yes' or 'no'"
263
- end
264
-
265
- def qualify
266
- self.qualify = (@param['qualify'] || []) unless @qualify
267
- @qualify
268
- end
269
-
270
- def qualify=(specs)
271
- @qualify = specs.map{|spec| Qualification.new(spec) }
272
- end
273
-
274
- def add_qualification(spec)
275
- self.qualify.push(Qualification.new(spec))
276
- end
277
-
278
- def keywords
279
- @param['keywords'] ||= []
280
- end
281
-
282
- def keywords=(array)
283
- @param['keywords'] = array
284
- end
285
-
286
- class Qualification < Config
287
- def initialize(spec)
288
- @raw = spec
289
- to_arg #make sure value parses
290
- end
291
-
292
- def to_s
293
- @raw
294
- end
295
-
296
- def to_arg
297
- [type, opts]
298
- end
299
-
300
- protected
301
-
302
- def type
303
- type = @raw.split(/\s+/)[0]
304
- if RTurk::Qualification::TYPES[type.to_sym]
305
- return type.to_sym
306
- elsif (type.match(/\d/) || type.size >= 25)
307
- return type
308
- else
309
- #Seems likely to be qualification typo: Not a known
310
- #system qualification, all letters and less than 25
311
- #chars
312
- raise Error::Argument, "Unknown qualification type and does not appear to be a raw qualification type ID: '#{type.to_s}'"
313
- end
314
- end
315
-
316
- def opts
317
- args = @raw.split(/\s+/)
318
- if (args.count > 3) || (args.count < 2)
319
- raise Error::Argument, "Unexpected number of qualification tokens: #{@raw}"
320
- end
321
- args.shift
322
- comparator(args[0]) or raise Error::Argument, "Unknown comparator '#{args[0]}'"
323
- value = 1
324
- value = args[1] if args.count == 2
325
- return {comparator(args[0]) => value}
326
- end
327
-
328
- def comparator(value)
329
- Hash[
330
- '>' => :gt,
331
- '>=' => :gte,
332
- '<' => :lt,
333
- '<=' => :lte,
334
- '==' => :eql,
335
- '!=' => :not,
336
- 'true' => :eql,
337
- 'exists' => :exists
338
- ][value]
339
- end
340
- end #Qualification
341
- end #Assign
342
- end #Root
343
236
  end #Config
237
+ require 'typingpool/config/root'
344
238
  end #Typingpool
@@ -0,0 +1,84 @@
1
+ module Typingpool
2
+ class Filer
3
+
4
+ #Convenience wrapper for audio files.You can convert to mp3s,
5
+ #split into multiple files, and dynamically read the bitrate.
6
+ class Audio < Filer
7
+ require 'open3'
8
+
9
+ #Does the file have a '.mp3' extension?
10
+ def mp3?
11
+ File.extname(@path).downcase.eql?('.mp3')
12
+ end
13
+
14
+ #Convert to mp3 via ffmpeg.
15
+ # ==== Params
16
+ # [dest] Filer object corresponding to the path the mp3 version
17
+ # should end up at.
18
+ # [bitrate] If passed, bitrate should be an integer
19
+ # corresponding to kb/s. If not, we use the bitrate
20
+ # from the current file or, if that can't be read,
21
+ # default to 192kbps. Does not check if the file is
22
+ # already an mp3. Returns a new Filer::Audio
23
+ # representing the new mp3 file.
24
+ # ==== Returns
25
+ # Filer::Audio containing the new mp3.
26
+ def to_mp3(dest=self.dir.file("#{File.basename(@path, '.*') }.mp3"), bitrate=nil)
27
+ bitrate ||= self.bitrate || 192
28
+ Utility.system_quietly('ffmpeg', '-i', @path, '-acodec', 'libmp3lame', '-ab', "#{bitrate}k", '-ac', '2', dest)
29
+ File.exists?(dest) or raise Error::Shell, "Could not found output from `ffmpeg` on #{path}"
30
+ self.class.new(dest.path)
31
+ end
32
+
33
+ #Reads the bitrate of the audio file via ffmpeg. Returns an
34
+ #integer corresponding to kb/s, or nil if the bitrate could not
35
+ #be determined.
36
+ def bitrate
37
+ out, err, status = Open3.capture3('ffmpeg', '-i', @path)
38
+ bitrate = err.match(/(\d+) kb\/s/)
39
+ return bitrate ? bitrate[1].to_i : nil
40
+ end
41
+
42
+ #Splits an mp3 into smaller files.
43
+ # ==== Params
44
+ # [interval_in_min_dot_seconds] Split the file into chunks this
45
+ # large. The interval should be of the format
46
+ # minute.seconds, for example 2 minutes 15 seconds
47
+ # would be written as "2.15". For further details on
48
+ # interval format, consult the documentation for
49
+ # mp3split, a command-line unix utility.
50
+ # [basename] Name the new chunks using this base. Default is the
51
+ # basename of the original file.
52
+ # [dest] Destination directory for the new chunks as a
53
+ # Filer::Dir. Default is the same directory as the
54
+ # original file.
55
+ # ==== Returns
56
+ # Filer::Files containing the new files.
57
+ def split(interval_in_min_dot_seconds, basename=File.basename(path, '.*'), dest=dir)
58
+ #We have to cd into the wrapfile directory and do everything
59
+ #there because old/packaged versions of mp3splt were
60
+ #retarded at handling absolute directory paths
61
+ ::Dir.chdir(dir.path) do
62
+ Utility.system_quietly('mp3splt', '-t', interval_in_min_dot_seconds, '-o', "#{basename}.@m.@s", File.basename(path))
63
+ end
64
+ files = Filer::Files::Audio.new(dir.select{|file| File.basename(file.path).match(/^#{Regexp.escape(basename) }\.\d+\.\d+\.mp3$/) })
65
+ if files.to_a.empty?
66
+ raise Error::Shell, "Could not find output from `mp3splt` on #{path}"
67
+ end
68
+ if dest.path != dir.path
69
+ files.mv!(dest)
70
+ end
71
+ files.sort
72
+ end
73
+
74
+ #Extracts from the filename the offset time of the chunk
75
+ #relative to the original from which it was split. Format is
76
+ #minute.seconds. Suitable for use on files created by 'split'
77
+ #method.
78
+ def offset
79
+ match = File.basename(@path).match(/\d+\.\d\d\b/)
80
+ return match[0] if match
81
+ end
82
+ end #Audio
83
+ end #Filer
84
+ end #Typingpool
@@ -0,0 +1,57 @@
1
+ module Typingpool
2
+ class Filer
3
+
4
+ #Convenience wrapper for CSV files. Makes them Enumerable, so you
5
+ #can iterate through rows with each, map, select, etc. You can
6
+ #also modify in place with each!. See Filer base class for other
7
+ #methods.
8
+ class CSV < Filer
9
+ include Enumerable
10
+ require 'csv'
11
+
12
+ #Reads into an array of hashes, with hash keys determined by the
13
+ #first row of the CSV file. Parsing rules are the default for
14
+ #CSV.parse.
15
+ def read
16
+ raw = super or return []
17
+ rows = ::CSV.parse(raw.to_s)
18
+ headers = rows.shift or raise Error::File, "No CSV at #{@path}"
19
+ rows.map{|row| Utility.array_to_hash(row, headers) }
20
+ end
21
+
22
+ #Takes array of hashes followed by optional list of keys (by
23
+ #default keys are determined by looking at all the
24
+ #hashes). Lines are written per the defaults of
25
+ #CSV.generate_line.
26
+ def write(hashes, headers=hashes.map{|h| h.keys}.flatten.uniq)
27
+ super(
28
+ ::CSV.generate_line(headers, :encoding => @encoding) +
29
+ hashes.map do |hash|
30
+ ::CSV.generate_line(headers.map{|header| hash[header] }, :encoding => @encoding)
31
+ end.join
32
+ )
33
+ end
34
+
35
+ #Takes an array of arrays, corresponding to the rows, and a list
36
+ #of headers/keys to write at the top.
37
+ def write_arrays(arrays, headers)
38
+ write(arrays.map{|array| Utility.array_to_hash(array, headers) }, headers)
39
+ end
40
+
41
+ #Enumerate through the rows, with each row represented by a
42
+ #hash.
43
+ def each
44
+ read.each do |row|
45
+ yield row
46
+ end
47
+ end
48
+
49
+ #Same as each, but any changes to the rows will be written back
50
+ #out to the underlying CSV file.
51
+ def each!
52
+ #each_with_index doesn't return the array, so we have to use each
53
+ write(each{|hash| yield(hash) })
54
+ end
55
+ end #CSV
56
+ end #Filer
57
+ end #Typingpool