ruby-msg 1.2.17

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/lib/msg.rb ADDED
@@ -0,0 +1,505 @@
1
+ #! /usr/bin/ruby
2
+
3
+ $: << File.dirname(__FILE__)
4
+
5
+ require 'yaml'
6
+ require 'base64'
7
+
8
+ require 'support'
9
+ require 'ole/storage'
10
+ require 'msg/properties'
11
+ require 'msg/rtf'
12
+ require 'mime'
13
+
14
+ #
15
+ # = Introduction
16
+ #
17
+ # Primary class interface to the vagaries of .msg files.
18
+ #
19
+ # The core of the work is done by the <tt>Msg::Properties</tt> class.
20
+ #
21
+
22
+ class Msg
23
+ VERSION = '1.2.17'
24
+ # we look here for the yaml files in data/, and the exe files for support
25
+ # decoding at the moment.
26
+ SUPPORT_DIR = File.dirname(__FILE__) + '/..'
27
+
28
+ Log = Logger.new_with_callstack
29
+
30
+ attr_reader :root, :attachments, :recipients, :headers, :properties
31
+ attr_accessor :close_parent
32
+ alias props :properties
33
+
34
+ # Alternate constructor, to create an +Msg+ directly from +arg+ and +mode+, passed
35
+ # directly to Ole::Storage (ie either filename or seekable IO object).
36
+ def self.open arg, mode=nil
37
+ msg = Msg.new Ole::Storage.open(arg, mode).root
38
+ # we will close the ole when we are #closed
39
+ msg.close_parent = true
40
+ msg
41
+ end
42
+
43
+ # Create an Msg from +root+, an <tt>Ole::Storage::Dirent</tt> object
44
+ def initialize root
45
+ @root = root
46
+ @close_parent = false
47
+ @attachments = []
48
+ @recipients = []
49
+ @properties = Properties.load @root
50
+
51
+ # process the children which aren't properties
52
+ @properties.unused.each do |child|
53
+ if child.dir?
54
+ case child.name
55
+ # these first 2 will actually be of the form
56
+ # 1\.0_#([0-9A-Z]{8}), where $1 is the 0 based index number in hex
57
+ # should i parse that and use it as an index?
58
+ when /__attach_version1\.0_/
59
+ attach = Attachment.new(child)
60
+ @attachments << attach if attach.valid?
61
+ when /__recip_version1\.0_/
62
+ @recipients << Recipient.new(child)
63
+ when /__nameid_version1\.0/
64
+ # FIXME: ignore nameid quietly at the moment
65
+ else ignore child
66
+ end
67
+ end
68
+ end
69
+
70
+ # if these headers exist at all, they can be helpful. we may however get a
71
+ # application/ms-tnef mime root, which means there will be little other than
72
+ # headers. we may get nothing.
73
+ # and other times, when received from external, we get the full cigar, boundaries
74
+ # etc and all.
75
+ # sometimes its multipart, with no boundaries. that throws an error. so we'll be more
76
+ # forgiving here
77
+ @mime = Mime.new props.transport_message_headers.to_s, true
78
+ populate_headers
79
+ end
80
+
81
+ def close
82
+ @root.ole.close if @close_parent
83
+ end
84
+
85
+ def headers
86
+ @mime.headers
87
+ end
88
+
89
+ # copy data from msg properties storage to standard mime. headers
90
+ # i've now seen it where the existing headers had heaps on stuff, and the msg#props had
91
+ # practically nothing. think it was because it was a tnef - msg conversion done by exchange.
92
+ def populate_headers
93
+ # construct a From value
94
+ # should this kind of thing only be done when headers don't exist already? maybe not. if its
95
+ # sent, then modified and saved, the headers could be wrong?
96
+ # hmmm. i just had an example where a mail is sent, from an internal user, but it has transport
97
+ # headers, i think because one recipient was external. the only place the senders email address
98
+ # exists is in the transport headers. so its maybe not good to overwrite from.
99
+ # recipients however usually have smtp address available.
100
+ # maybe we'll do it for all addresses that are smtp? (is that equivalent to
101
+ # sender_email_address !~ /^\//
102
+ name, email = props.sender_name, props.sender_email_address
103
+ if props.sender_addrtype == 'SMTP'
104
+ headers['From'] = if name and email and name != email
105
+ [%{"#{name}" <#{email}>}]
106
+ else
107
+ [email || name]
108
+ end
109
+ elsif !headers.has_key?('From')
110
+ # some messages were never sent, so that sender stuff isn't filled out. need to find another
111
+ # way to get something
112
+ # what about marking whether we thing the email was sent or not? or draft?
113
+ # for partition into an eventual Inbox, Sent, Draft mbox set?
114
+ # i've now seen cases where this stuff is missing, but exists in transport message headers,
115
+ # so maybe i should inhibit this in that case.
116
+ if email
117
+ Log.warn "* no smtp sender email address available (only X.400). creating fake one"
118
+ # this is crap. though i've specially picked the logic so that it generates the correct
119
+ # email addresses in my case (for my organisation).
120
+ # this user stuff will give valid email i think, based on alias.
121
+ user = name ? name.sub(/(.*), (.*)/, "\\2.\\1") : email[/\w+$/].downcase
122
+ domain = (email[%r{^/O=([^/]+)}i, 1].downcase + '.com' rescue email)
123
+ headers['From'] = [name ? %{"#{name}" <#{user}@#{domain}>} : "<#{user}@#{domain}>" ]
124
+ elsif name
125
+ # we only have a name? thats screwed up.
126
+ Log.warn "* no smtp sender email address available (only name). creating fake one"
127
+ headers['From'] = [%{"#{name}"}]
128
+ else
129
+ Log.warn "* no sender email address available at all. FIXME"
130
+ end
131
+ # else we leave the transport message header version
132
+ end
133
+
134
+ # for all of this stuff, i'm assigning in utf8 strings.
135
+ # thats ok i suppose, maybe i can say its the job of the mime class to handle that.
136
+ # but a lot of the headers are overloaded in different ways. plain string, many strings
137
+ # other stuff. what happens to a person who has a " in their name etc etc. encoded words
138
+ # i suppose. but that then happens before assignment. and can't be automatically undone
139
+ # until the header is decomposed into recipients.
140
+ recips_by_type = recipients.group_by { |r| r.type }
141
+ # i want to the the types in a specific order.
142
+ [:to, :cc, :bcc].each do |type|
143
+ # don't know why i bother, but if we can, we try to sort recipients by the numerical part
144
+ # of the ole name, or just leave it if we can't
145
+ recips = recips_by_type[type]
146
+ recips = (recips.sort_by { |r| r.obj.name[/\d{8}$/].hex } rescue recips)
147
+ # are you supposed to use ; or , to separate?
148
+ # recips.empty? is strange. i wouldn't have thought it possible, but it was right?
149
+ headers[type.to_s.sub(/^(.)/) { $1.upcase }] = [recips.join('; ')] unless recips.empty?
150
+ end
151
+ headers['Subject'] = [props.subject] if props.subject
152
+
153
+ # fill in a date value. by default, we won't mess with existing value hear
154
+ if !headers.has_key?('Date')
155
+ # we want to get a received date, as i understand it.
156
+ # use this preference order, or pull the most recent?
157
+ keys = %w[message_delivery_time client_submit_time last_modification_time creation_time]
158
+ time = keys.each { |key| break time if time = props.send(key) }
159
+ time = nil unless Date === time
160
+ # can employ other methods for getting a time. heres one in a similar vein to msgconvert.pl,
161
+ # ie taking the time from an ole object
162
+ time ||= @root.ole.dirents.map(&:time).compact.sort.last
163
+
164
+ # now convert and store
165
+ # this is a little funky. not sure about time zone stuff either?
166
+ # actually seems ok. maybe its always UTC and interpreted anyway. or can be timezoneless.
167
+ # i have no timezone info anyway.
168
+ # in gmail, i see stuff like 15 Jan 2007 00:48:19 -0000, and it displays as 11:48.
169
+ # can also add .localtime here if desired. but that feels wrong.
170
+ require 'time'
171
+ headers['Date'] = [Time.iso8601(time.to_s).rfc2822] if time
172
+ end
173
+
174
+ if !headers.has_key?('Message-ID') and props.internet_message_id
175
+ headers['Message-ID'] = [props.internet_message_id]
176
+ end
177
+ if !headers.has_key?('In-Reply-To') and props.in_reply_to_id
178
+ headers['In-Reply-To'] = [props.in_reply_to_id]
179
+ end
180
+ end
181
+
182
+ def ignore obj
183
+ Log.warn "* ignoring #{obj.name} (#{obj.type.to_s})"
184
+ end
185
+
186
+ # redundant?
187
+ def type
188
+ props.message_class[/IPM\.(.*)/, 1].downcase rescue nil
189
+ end
190
+
191
+ # shortcuts to some things from the headers
192
+ %w[From To Cc Bcc Subject].each do |key|
193
+ define_method(key.downcase) { headers[key].join(' ') if headers.has_key?(key) }
194
+ end
195
+
196
+ def inspect
197
+ str = %w[from to cc bcc subject type].map do |key|
198
+ send(key) and "#{key}=#{send(key).inspect}"
199
+ end.compact.join(' ')
200
+ "#<Msg #{str}>"
201
+ end
202
+
203
+ # --------
204
+ # beginnings of conversion stuff
205
+
206
+ def convert
207
+ #
208
+ # for now, multiplex between returning a Mime object,
209
+ # a Vpim::Vcard object,
210
+ # a Vpim::Vcalendar object
211
+ #
212
+ # all of which should support a common serialization,
213
+ # to save the result to a file.
214
+ #
215
+ end
216
+
217
+ def body_to_mime
218
+ # to create the body
219
+ # should have some options about serializing rtf. and possibly options to check the rtf
220
+ # for rtf2html conversion, stripping those html tags or other similar stuff. maybe want to
221
+ # ignore it in the cases where it is generated from incoming html. but keep it if it was the
222
+ # source for html and plaintext.
223
+ if props.body_rtf or props.body_html
224
+ # should plain come first?
225
+ mime = Mime.new "Content-Type: multipart/alternative\r\n\r\n"
226
+ # its actually possible for plain body to be empty, but the others not.
227
+ # if i can get an html version, then maybe a callout to lynx can be made...
228
+ mime.parts << Mime.new("Content-Type: text/plain\r\n\r\n" + props.body) if props.body
229
+ # this may be automatically unwrapped from the rtf if the rtf includes the html
230
+ mime.parts << Mime.new("Content-Type: text/html\r\n\r\n" + props.body_html) if props.body_html
231
+ # temporarily disabled the rtf. its just showing up as an attachment anyway.
232
+ #mime.parts << Mime.new("Content-Type: text/rtf\r\n\r\n" + props.body_rtf) if props.body_rtf
233
+ # its thus currently possible to get no body at all if the only body is rtf. that is not
234
+ # really acceptable FIXME
235
+ mime
236
+ else
237
+ # check no header case. content type? etc?. not sure if my Mime class will accept
238
+ Log.debug "taking that other path"
239
+ # body can be nil, hence the to_s
240
+ Mime.new "Content-Type: text/plain\r\n\r\n" + props.body.to_s
241
+ end
242
+ end
243
+
244
+ def to_mime
245
+ # intended to be used for IPM.note, which is the email type. can use it for others if desired,
246
+ # YMMV
247
+ Log.warn "to_mime used on a #{props.message_class}" unless props.message_class == 'IPM.Note'
248
+ # we always have a body
249
+ mime = body = body_to_mime
250
+
251
+ # If we have attachments, we take the current mime root (body), and make it the first child
252
+ # of a new tree that will contain body and attachments.
253
+ unless attachments.empty?
254
+ mime = Mime.new "Content-Type: multipart/mixed\r\n\r\n"
255
+ mime.parts << body
256
+ # i don't know any better way to do this. need multipart/related for inline images
257
+ # referenced by cid: urls to work, but don't want to use it otherwise...
258
+ related = false
259
+ attachments.each do |attach|
260
+ part = attach.to_mime
261
+ related = true if part.headers.has_key?('Content-ID') or part.headers.has_key?('Content-Location')
262
+ mime.parts << part
263
+ end
264
+ mime.headers['Content-Type'] = ['multipart/related'] if related
265
+ end
266
+
267
+ # at this point, mime is either
268
+ # - a single text/plain, consisting of the body ('taking that other path' above. rare)
269
+ # - a multipart/alternative, consiting of a few bodies (plain and html body. common)
270
+ # - a multipart/mixed, consisting of 1 of the above 2 types of bodies, and attachments.
271
+ # we add this standard preamble if its multipart
272
+ # FIXME preamble.replace, and body.replace both suck.
273
+ # preamble= is doable. body= wasn't being done because body will get rewritten from parts
274
+ # if multipart, and is only there readonly. can do that, or do a reparse...
275
+ # The way i do this means that only the first preamble will say it, not preambles of nested
276
+ # multipart chunks.
277
+ mime.preamble.replace "This is a multi-part message in MIME format.\r\n" if mime.multipart?
278
+
279
+ # now that we have a root, we can mix in all our headers
280
+ headers.each do |key, vals|
281
+ # don't overwrite the content-type, encoding style stuff
282
+ next if mime.headers.has_key? key
283
+ # some new temporary hacks
284
+ next if key =~ /content-type/i and vals[0] =~ /base64/
285
+ next if mime.headers.keys.map(&:downcase).include? key.downcase
286
+ mime.headers[key] += vals
287
+ end
288
+ # just a stupid hack to make the content-type header last, when using OrderedHash
289
+ mime.headers['Content-Type'] = mime.headers.delete 'Content-Type'
290
+
291
+ mime
292
+ end
293
+
294
+ def to_vcard
295
+ require 'rubygems'
296
+ require 'vpim/vcard'
297
+ # a very incomplete mapping, but its a start...
298
+ # can't find where to set a lot of stuff, like zipcode, jobtitle etc
299
+ # FIXME all the .to_s stuff is because i was to lazy to not set if nil. and setting when nil breaks
300
+ # the Vcard#to_s later. find a neater way that scales to many properties like this.
301
+ # property map perhaps, like:
302
+ # {
303
+ # :location => 'work',
304
+ # :street => :business_address_street,
305
+ # :locality => proc { |props| [props.business_address_city, props.business_address_state].compact.join ', ' },
306
+ # ...
307
+ # and then have the vcard filled in according to this (1-way) translation map.
308
+ card = Vpim::Vcard::Maker.make2 do |m|
309
+ # these are all standard mapi properties
310
+ m.add_name do |n|
311
+ n.given = props.given_name.to_s
312
+ n.family = props.surname.to_s
313
+ n.fullname = props.subject.to_s
314
+ end
315
+
316
+ # outlook seems to eschew the mapi properties this time,
317
+ # like postal_address, street_address, home_address_city
318
+ # so we use the named properties
319
+ m.add_addr do |a|
320
+ a.location = 'work'
321
+ a.street = props.business_address_street.to_s
322
+ # i think i can just assign the array
323
+ a.locality = [props.business_address_city, props.business_address_state].compact.join ', '
324
+ a.country = props.business_address_country.to_s
325
+ a.postalcode = props.business_address_postal_code.to_s
326
+ end
327
+
328
+ # right type?
329
+ m.birthday = props.birthday if props.birthday
330
+ m.nickname = props.nickname.to_s
331
+
332
+ # photo available?
333
+ # FIXME finish, emails, telephones etc
334
+ end
335
+ end
336
+
337
+ class Attachment
338
+ attr_reader :obj, :properties
339
+ alias props :properties
340
+
341
+ def initialize obj
342
+ @obj = obj
343
+ @properties = Properties.load @obj
344
+ @embedded_ole = nil
345
+ @embedded_msg = nil
346
+
347
+ @properties.unused.each do |child|
348
+ # FIXME temporary hack. this is fairly messy stuff.
349
+ if child.dir? and child.name =~ Properties::SUBSTG_RX and
350
+ $1 == '3701' and $2.downcase == '000d'
351
+ @embedded_ole = child
352
+ class << @embedded_ole
353
+ def compobj
354
+ return nil unless compobj = self["\001CompObj"]
355
+ compobj.read[/^.{32}([^\x00]+)/m, 1]
356
+ end
357
+
358
+ def embedded_type
359
+ temp = compobj and return temp
360
+ # try to guess more
361
+ if children.select { |child| child.name =~ /__(substg|properties|recip|attach|nameid)/ }.length > 2
362
+ return 'Microsoft Office Outlook Message'
363
+ end
364
+ nil
365
+ end
366
+ end
367
+ if @embedded_ole.embedded_type == 'Microsoft Office Outlook Message'
368
+ @embedded_msg = Msg.new @embedded_ole
369
+ end
370
+ end
371
+ # FIXME warn
372
+ end
373
+ end
374
+
375
+ def valid?
376
+ # something i started to notice when handling embedded ole object attachments is
377
+ # the particularly strange case where they're are empty attachments
378
+ props.raw.keys.length > 0
379
+ end
380
+
381
+ def filename
382
+ props.attach_long_filename || props.attach_filename
383
+ end
384
+
385
+ def data
386
+ @embedded_msg || @embedded_ole || props.attach_data
387
+ end
388
+
389
+ # with new stream work, its possible to not have the whole thing in memory at one time,
390
+ # just to save an attachment
391
+ #
392
+ # a = msg.attachments.first
393
+ # a.save open(File.basename(a.filename || 'attachment'), 'wb')
394
+ def save io
395
+ raise "can only save binary data blobs, not ole dirs" if @embedded_ole
396
+ data.each_read { |chunk| io << chunk }
397
+ end
398
+
399
+ def to_mime
400
+ # TODO: smarter mime typing.
401
+ mimetype = props.attach_mime_tag || 'application/octet-stream'
402
+ mime = Mime.new "Content-Type: #{mimetype}\r\n\r\n"
403
+ mime.headers['Content-Disposition'] = [%{attachment; filename="#{filename}"}]
404
+ mime.headers['Content-Transfer-Encoding'] = ['base64']
405
+ mime.headers['Content-Location'] = [props.attach_content_location] if props.attach_content_location
406
+ mime.headers['Content-ID'] = [props.attach_content_id] if props.attach_content_id
407
+ # data.to_s for now. data was nil for some reason.
408
+ # perhaps it was a data object not correctly handled?
409
+ # hmmm, have to use read here. that assumes that the data isa stream.
410
+ # but if the attachment data is a string, then it won't work. possible?
411
+ data_str = if @embedded_msg
412
+ mime.headers['Content-Type'] = 'message/rfc822'
413
+ # lets try making it not base64 for now
414
+ mime.headers.delete 'Content-Transfer-Encoding'
415
+ # not filename. rather name, or something else right?
416
+ # maybe it should be inline?? i forget attach_method / access meaning
417
+ mime.headers['Content-Disposition'] = [%{attachment; filename="#{@embedded_msg.subject}"}]
418
+ @embedded_msg.to_mime.to_s
419
+ elsif @embedded_ole
420
+ # kind of hacky
421
+ io = StringIO.new
422
+ Ole::Storage.new io do |ole|
423
+ ole.root.type = :dir
424
+ Ole::Storage::Dirent.copy @embedded_ole, ole.root
425
+ end
426
+ io.string
427
+ else
428
+ data.read.to_s
429
+ end
430
+ mime.body.replace @embedded_msg ? data_str : Base64.encode64(data_str).gsub(/\n/, "\r\n")
431
+ mime
432
+ end
433
+
434
+ def inspect
435
+ "#<#{self.class.to_s[/\w+$/]}" +
436
+ (filename ? " filename=#{filename.inspect}" : '') +
437
+ (@embedded_ole ? " embedded_type=#{@embedded_ole.embedded_type.inspect}" : '') + ">"
438
+ end
439
+ end
440
+
441
+ #
442
+ # +Recipient+ serves as a container for the +recip+ directories in the .msg.
443
+ # It has things like office_location, business_telephone_number, but I don't
444
+ # think enough to make a vCard out of?
445
+ #
446
+ class Recipient
447
+ attr_reader :obj, :properties
448
+ alias props :properties
449
+
450
+ def initialize obj
451
+ @obj = obj
452
+ @properties = Properties.load @obj
453
+ @properties.unused.each do |child|
454
+ # FIXME warn
455
+ end
456
+ end
457
+
458
+ # some kind of best effort guess for converting to standard mime style format.
459
+ # there are some rules for encoding non 7bit stuff in mail headers. should obey
460
+ # that here, as these strings could be unicode
461
+ # email_address will be an EX:/ address (X.400?), unless external recipient. the
462
+ # other two we try first.
463
+ # consider using entry id for this too.
464
+ def name
465
+ name = props.transmittable_display_name || props.display_name
466
+ # dequote
467
+ name[/^'(.*)'/, 1] or name rescue nil
468
+ end
469
+
470
+ def email
471
+ props.smtp_address || props.org_email_addr || props.email_address
472
+ end
473
+
474
+ RECIPIENT_TYPES = { 0 => :orig, 1 => :to, 2 => :cc, 3 => :bcc }
475
+ def type
476
+ RECIPIENT_TYPES[props.recipient_type]
477
+ end
478
+
479
+ def to_s
480
+ if name = self.name and !name.empty? and email && name != email
481
+ %{"#{name}" <#{email}>}
482
+ else
483
+ email || name
484
+ end
485
+ end
486
+
487
+ def inspect
488
+ "#<#{self.class.to_s[/\w+$/]}:#{self.to_s.inspect}>"
489
+ end
490
+ end
491
+ end
492
+
493
+ if $0 == __FILE__
494
+ quiet = if ARGV[0] == '-q'
495
+ ARGV.shift
496
+ true
497
+ end
498
+ # just shut up and convert a message to eml
499
+ Msg::Log.level = Logger::WARN
500
+ Msg::Log.level = Logger::FATAL if quiet
501
+ msg = Msg.open ARGV[0]
502
+ puts msg.to_mime.to_s
503
+ msg.close
504
+ end
505
+
data/lib/ole/base.rb ADDED
@@ -0,0 +1,5 @@
1
+
2
+ module Ole # :nodoc:
3
+ Log = Logger.new_with_callstack
4
+ end
5
+
@@ -0,0 +1,181 @@
1
+ #
2
+ # = Introduction
3
+ #
4
+ # This file intends to provide file system-like api support, a la <tt>zip/zipfilesystem</tt>.
5
+ #
6
+ # Ideally, this will be the recommended interface, allowing Ole::Storage, Dir, and
7
+ # Zip::ZipFile to be used exchangablyk. It should be possible to write recursive copy using
8
+ # the plain api, such that you can copy dirs/files agnostically between any of ole docs, dirs,
9
+ # and zip files.
10
+ #
11
+ # = Usage
12
+ #
13
+ # Currently you can do something like the following:
14
+ #
15
+ # Ole::Storage.open 'test.doc' do |ole|
16
+ # ole.dir.entries '/' # => [".", "..", "\001Ole", "1Table", "\001CompObj", ...]
17
+ # ole.file.read "\001CompObj" # => "\001\000\376\377\003\n\000\000\377\377..."
18
+ # end
19
+ #
20
+ # = Notes
21
+ #
22
+ # *** This file is very incomplete
23
+ #
24
+ # i think its okay to have an api like this on top, but there are certain things that ole
25
+ # does that aren't captured.
26
+ # <tt>Ole::Storage</tt> can have multiple files with the same name, for example, or with
27
+ # / in the name, and other things that are probably invalid anyway.
28
+ # i think this should remain an addon, built on top of my core api.
29
+ # but still the ideas can be reflected in the core, ie, changing the read/write semantics.
30
+ #
31
+ # once the core changes are complete, this will be a pretty straight forward file to complete.
32
+ #
33
+
34
+ require 'ole/base'
35
+
36
+ module Ole # :nodoc:
37
+ class Storage
38
+ def file
39
+ @file ||= FileParent.new self
40
+ end
41
+
42
+ def dir
43
+ @dir ||= DirParent.new self
44
+ end
45
+
46
+ def dirent_from_path path_str
47
+ path = path_str.sub(/^\/*/, '').sub(/\/*$/, '')
48
+ dirent = @root
49
+ return dirent if path.empty?
50
+ path = path.split /\/+/
51
+ until path.empty?
52
+ raise "invalid path #{path_str.inspect}" if dirent.file?
53
+ if tmp = dirent[path.shift]
54
+ dirent = tmp
55
+ else
56
+ # allow write etc later.
57
+ raise "invalid path #{path_str.inspect}"
58
+ end
59
+ end
60
+ dirent
61
+ end
62
+
63
+ class FileParent
64
+ def initialize ole
65
+ @ole = ole
66
+ end
67
+
68
+ def open path_str, mode='r', &block
69
+ dirent = @ole.dirent_from_path path_str
70
+ # like Errno::EISDIR
71
+ raise "#{path_str.inspect} is a directory" unless dirent.file?
72
+ dirent.open(&block)
73
+ end
74
+
75
+ alias new :open
76
+
77
+ def read path
78
+ open(path) { |f| f.read }
79
+ end
80
+
81
+ # crappy copy from Dir.
82
+ def unlink path
83
+ dirent = @ole.dirent_from_path path
84
+ # EPERM
85
+ raise "operation not permitted #{path.inspect}" unless dirent.file?
86
+ # i think we should free all of our blocks. i think the best way to do that would be
87
+ # like:
88
+ # open(path) { |f| f.truncate 0 }. which should free all our blocks from the
89
+ # allocation table. then if we remove ourself from our parent, we won't be part of
90
+ # the bat at save time.
91
+ # i think if you run repack, all free blocks should get zeroed.
92
+ open(path) { |f| f.truncate 0 }
93
+ parent = @ole.dirent_from_path(('/' + path).sub(/\/[^\/]+$/, ''))
94
+ parent.children.delete dirent
95
+ 1 # hmmm. as per ::File ?
96
+ end
97
+ end
98
+
99
+ class DirParent
100
+ def initialize ole
101
+ @ole = ole
102
+ end
103
+
104
+ def open path_str
105
+ dirent = @ole.dirent_from_path path_str
106
+ # like Errno::ENOTDIR
107
+ raise "#{path_str.inspect} is not a directory" unless dirent.dir?
108
+ dir = Dir.new dirent, path_str
109
+ if block_given?
110
+ yield dir
111
+ else
112
+ dir
113
+ end
114
+ end
115
+
116
+ # certain Dir class methods proxy in this fashion:
117
+ def entries path
118
+ open(path) { |dir| dir.entries }
119
+ end
120
+
121
+ # there are some other important ones, like:
122
+ # chroot (!), mkdir, chdir, rmdir, glob etc etc. for now, i think
123
+ # mkdir, and rmdir are the main ones we'd need to support
124
+ def rmdir path
125
+ dirent = @ole.dirent_from_path path
126
+ # repeating myself
127
+ raise "#{path.inspect} is not a directory" unless dirent.dir?
128
+ # ENOTEMPTY:
129
+ raise "directory not empty #{path.inspect}" unless dirent.children.empty?
130
+ # now delete it, how to do that? the canonical representation that is
131
+ # maintained is the root tree, and the children array. we must remove it
132
+ # from the children array.
133
+ # we need the parent then. this sucks but anyway:
134
+ parent = @ole.dirent_from_path path.sub(/\/[^\/]+$/, '') || '/'
135
+ # note that the way this currently works, on save and repack time this will get
136
+ # reflected. to work properly, ie to make a difference now it would have to re-write
137
+ # the dirent. i think that Ole::Storage#close will handle that. and maybe include a
138
+ # #repack.
139
+ parent.children.delete dirent
140
+ 0 # hmmm. as per ::Dir ?
141
+ end
142
+
143
+ class Dir
144
+ include Enumerable
145
+ attr_reader :dirent, :path, :entries, :pos
146
+
147
+ def initialize dirent, path
148
+ @dirent, @path = dirent, path
149
+ @pos = 0
150
+ # FIXME: hack, and probably not really desired
151
+ @entries = %w[. ..] + @dirent.children.map(&:name)
152
+ end
153
+
154
+ def each(&block)
155
+ @entries.each(&block)
156
+ end
157
+
158
+ def close
159
+ end
160
+
161
+ def read
162
+ @entries[@pos]
163
+ ensure
164
+ @pos += 1 if @pos < @entries.length
165
+ end
166
+
167
+ def pos= pos
168
+ @pos = [[0, pos].max, @entries.length].min
169
+ end
170
+
171
+ def rewind
172
+ @pos = 0
173
+ end
174
+
175
+ alias tell :pos
176
+ alias seek :pos=
177
+ end
178
+ end
179
+ end
180
+ end
181
+