hobix 0.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/bin/hobix +90 -0
  3. data/lib/hobix/api.rb +91 -0
  4. data/lib/hobix/article.rb +22 -0
  5. data/lib/hobix/base.rb +477 -0
  6. data/lib/hobix/bixwik.rb +200 -0
  7. data/lib/hobix/commandline.rb +661 -0
  8. data/lib/hobix/comments.rb +99 -0
  9. data/lib/hobix/config.rb +39 -0
  10. data/lib/hobix/datamarsh.rb +110 -0
  11. data/lib/hobix/entry.rb +83 -0
  12. data/lib/hobix/facets/comments.rb +74 -0
  13. data/lib/hobix/facets/publisher.rb +314 -0
  14. data/lib/hobix/facets/trackbacks.rb +80 -0
  15. data/lib/hobix/linklist.rb +76 -0
  16. data/lib/hobix/out/atom.rb +92 -0
  17. data/lib/hobix/out/erb.rb +64 -0
  18. data/lib/hobix/out/okaynews.rb +55 -0
  19. data/lib/hobix/out/quick.rb +312 -0
  20. data/lib/hobix/out/rdf.rb +97 -0
  21. data/lib/hobix/out/redrum.rb +26 -0
  22. data/lib/hobix/out/rss.rb +115 -0
  23. data/lib/hobix/plugin/bloglines.rb +73 -0
  24. data/lib/hobix/plugin/calendar.rb +220 -0
  25. data/lib/hobix/plugin/flickr.rb +110 -0
  26. data/lib/hobix/plugin/recent_comments.rb +82 -0
  27. data/lib/hobix/plugin/sections.rb +91 -0
  28. data/lib/hobix/plugin/tags.rb +60 -0
  29. data/lib/hobix/publish/ping.rb +53 -0
  30. data/lib/hobix/publish/replicate.rb +283 -0
  31. data/lib/hobix/publisher.rb +18 -0
  32. data/lib/hobix/search/dictionary.rb +141 -0
  33. data/lib/hobix/search/porter_stemmer.rb +203 -0
  34. data/lib/hobix/search/simple.rb +209 -0
  35. data/lib/hobix/search/vector.rb +100 -0
  36. data/lib/hobix/storage/filesys.rb +398 -0
  37. data/lib/hobix/trackbacks.rb +94 -0
  38. data/lib/hobix/util/objedit.rb +193 -0
  39. data/lib/hobix/util/patcher.rb +155 -0
  40. data/lib/hobix/webapp/cli.rb +195 -0
  41. data/lib/hobix/webapp/htmlform.rb +107 -0
  42. data/lib/hobix/webapp/message.rb +177 -0
  43. data/lib/hobix/webapp/urigen.rb +141 -0
  44. data/lib/hobix/webapp/webrick-servlet.rb +90 -0
  45. data/lib/hobix/webapp.rb +723 -0
  46. data/lib/hobix/weblog.rb +860 -0
  47. data/lib/hobix.rb +223 -0
  48. metadata +87 -0
@@ -0,0 +1,209 @@
1
+ require 'hobix/search/dictionary'
2
+ require 'hobix/search/vector'
3
+
4
+ module Hobix
5
+ module Search
6
+ module Simple
7
+ class Contents < Array
8
+ def latest_mtime
9
+ latest_mtime = Time.at(0)
10
+ each do |item|
11
+ if(item.mtime > latest_mtime)
12
+ latest_mtime = item.mtime
13
+ end
14
+ end
15
+ end
16
+ end
17
+
18
+ class Content
19
+ attr_accessor :content, :identifier, :mtime, :classifications
20
+ def initialize(content, identifier, mtime, clsf)
21
+ @content = content
22
+ @identifier = identifier
23
+ @mtime = mtime
24
+ @classifications = clsf
25
+ end
26
+ end
27
+
28
+ SearchResult = Struct.new(:name, :score)
29
+
30
+ class SearchResult
31
+ # enable sort by score
32
+ def <=>(other)
33
+ self.score <=> other.score
34
+ end
35
+ end
36
+
37
+ class SearchResults
38
+ attr_reader :warnings
39
+ attr_reader :results
40
+
41
+
42
+ def initialize
43
+ @warnings = []
44
+ @results = {}
45
+ end
46
+
47
+ def add_warning(txt)
48
+ @warnings << txt
49
+ end
50
+
51
+ def add_result(name, score)
52
+ @results[name] = SearchResult.new(name, score)
53
+ end
54
+
55
+ def contains_matches
56
+ !@results.empty?
57
+ end
58
+ end
59
+
60
+
61
+ class Searcher
62
+
63
+ def initialize(dict, document_vectors, cache_file)
64
+ @dict = dict
65
+ @document_vectors = document_vectors
66
+ @cache_file = cache_file
67
+ end
68
+
69
+ # Return SearchResults based on trying to find the array of
70
+ # +words+ in our document vectors
71
+ #
72
+ # A word beginning '+' _must_ appear in the target documents
73
+ # A word beginning '-' <i>must not</i> appear
74
+ # other words are scored. The documents with the highest
75
+ # scores are returned first
76
+
77
+ def find_words(words)
78
+ search_results = SearchResults.new
79
+
80
+ general = Vector.new
81
+ must_match = Vector.new
82
+ must_not_match = Vector.new
83
+ not_found = false
84
+
85
+ extract_words_for_searcher(words.join(' ')) do |word|
86
+ case word[0]
87
+ when ?+
88
+ word = word[1,99]
89
+ vector = must_match
90
+ when ?-
91
+ word = word[1,99]
92
+ vector = must_not_match
93
+ else
94
+ vector = general
95
+ end
96
+
97
+ index = @dict.find(word.downcase)
98
+ if index
99
+ vector.add_word_index(index)
100
+ else
101
+ not_found = true
102
+ search_results.add_warning "'#{word}' does not occur in the documents"
103
+ end
104
+ end
105
+
106
+ if (general.num_bits + must_match.num_bits).zero?
107
+ search_results.add_warning "No valid search terms given"
108
+ elsif not not_found
109
+ res = []
110
+ @document_vectors.each do |entry, (dvec, mtime)|
111
+ score = dvec.score_against(must_match, must_not_match, general)
112
+ res << [ entry, score ] if score > 0
113
+ end
114
+
115
+ res.sort {|a,b| b[1] <=> a[1] }.each {|name, score|
116
+ search_results.add_result(name, score)
117
+ }
118
+
119
+ search_results.add_warning "No matches" unless search_results.contains_matches
120
+ end
121
+ search_results
122
+ end
123
+
124
+
125
+ # Serialization support. At some point we'll need to do incremental indexing.
126
+ # For now, however, the following seems to work fairly effectively
127
+ # on 1000 entry blogs, so I'll defer the change until later.
128
+ def Searcher.load(cache_file, wash=false)
129
+ dict = document_vectors = nil
130
+ modified = false
131
+ loaded = false
132
+ begin
133
+ File.open(cache_file, "r") do |f|
134
+ unless wash
135
+ dict = Marshal.load(f)
136
+ document_vectors = Marshal.load(f)
137
+ loaded = true
138
+ end
139
+ end
140
+ rescue
141
+ ;
142
+ end
143
+
144
+ unless loaded
145
+ dict = Dictionary.new
146
+ document_vectors = {}
147
+ modified = true
148
+ end
149
+
150
+ s = Searcher.new(dict, document_vectors, cache_file)
151
+ s.dump if modified
152
+ s
153
+ end
154
+
155
+ def dump
156
+ File.open(@cache_file, "w") do |fileInstance|
157
+ Marshal.dump(@dict, fileInstance)
158
+ Marshal.dump(@document_vectors, fileInstance)
159
+ end
160
+ end
161
+
162
+ def extract_words_for_searcher(text)
163
+ text.scan(/[-+]?\w[\-\w:\\]{2,}/) do |word|
164
+ yield word
165
+ end
166
+ end
167
+
168
+ def has_entry? id, mtime
169
+ dvec = @document_vectors[id]
170
+ return true if dvec and dvec.at.to_i >= mtime.to_i
171
+ end
172
+
173
+ # Create a new dictionary and document vectors from
174
+ # a blog archive
175
+
176
+ def catalog(entry)
177
+ unless has_entry? entry.identifier, entry.mtime
178
+ vector = Vector.new
179
+ vector.at = entry.mtime
180
+ extract_words_for_searcher(entry.content.downcase) do |word|
181
+ word_index = @dict.add_word(word, entry.classifications)
182
+ if word_index
183
+ vector.add_word_index(word_index)
184
+ end
185
+ end
186
+ @document_vectors[entry.identifier] = vector
187
+ end
188
+ end
189
+
190
+ def classifications(text)
191
+ score = Hash.new
192
+ @dict.clsf.each do |category, category_words|
193
+ score[category] = 0
194
+ total = category_words.values.inject(0) {|sum, element| sum+element}
195
+ extract_words_for_searcher(text) do |word|
196
+ s = category_words.has_key?(word) ? category_words[word] : 0.1
197
+ score[category] += Math.log(s/total.to_f)
198
+ end
199
+ end
200
+ score
201
+ end
202
+
203
+ def classify(text)
204
+ (classifications(text).sort_by { |a| -a[1] })[0][0]
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,100 @@
1
+ # Maintain a vector of words, where a word is represented by
2
+ # its index in our Dictionary
3
+ #
4
+ module Hobix
5
+ module Search
6
+ module Simple
7
+ class Vector
8
+
9
+ attr_accessor :at
10
+ attr_reader :num_bits, :max_bit, :bits
11
+
12
+ def initialize
13
+ # @bits = []
14
+ @bits = 0
15
+ @max_bit = -1
16
+ @num_bits = 0
17
+ end
18
+
19
+ def add_word_index(index)
20
+ if @bits[index].zero?
21
+ @bits += (1 << index)
22
+ @num_bits += 1
23
+ @max_bit = index if @max_bit < index
24
+ end
25
+ end
26
+
27
+ def dot(vector)
28
+ # We only need to calculate up to the end of the shortest vector
29
+ limit = @max_bit
30
+ # Commenting out the next line makes this vector the dominant
31
+ # one when doing the comparison
32
+ limit = vector.max_bit if limit > vector.max_bit
33
+
34
+ # because both vectors have just ones or zeros in them,
35
+ # we can pre-calculate the AnBn component
36
+ # The vector's magnitude is Sqrt(num set bits)
37
+ factor = Math.sqrt(1.0/@num_bits) * Math.sqrt(1.0/vector.num_bits)
38
+
39
+ count = 0
40
+ (limit+1).times {|i| count += 1 if @bits[i] ==1 && vector.bits[i] == 1}
41
+
42
+ factor * count
43
+ end
44
+
45
+ # We're a document's vector, and we're being matched against
46
+ # three other vectors:
47
+ # 1. A list of <i>must match</i> words
48
+ # 2. A list of <i>must not match</i> words
49
+ # 3. A list of general words. The score we return
50
+ # is the number of these that we match
51
+
52
+ def score_against(must_match, must_not_match, general)
53
+ # Eliminate if any _must_not_match_ words found
54
+ unless must_not_match.num_bits.zero?
55
+ return 0 unless (@bits & must_not_match.bits).zero?
56
+ end
57
+
58
+ # If the match was entirely negative, then we know we're passed at
59
+ # this point
60
+
61
+ if must_match.num_bits.zero? and general.num_bits.zero?
62
+ return 1
63
+ end
64
+
65
+ count = 0
66
+
67
+ # Eliminate unless all _must_match_ words found
68
+
69
+ unless must_match.num_bits.zero?
70
+ return 0 unless (@bits & must_match.bits) == must_match.bits
71
+ count = 1
72
+ end
73
+
74
+ # finally score on the rest
75
+ common = general.bits & @bits
76
+ count += count_bits(common, @max_bit+1) unless common.zero?
77
+ count
78
+ end
79
+
80
+ private
81
+
82
+ def count_bits(word, max_bit)
83
+ res = 0
84
+ ((max_bit+29)/30).times do |offset|
85
+ x = (word >> (offset*30)) & 0x3fffffff
86
+ next if x.zero?
87
+ x = x - ((x >> 1) & 0x55555555)
88
+ x = (x & 0x33333333) + ((x >> 2) & 0x33333333)
89
+ x = (x + (x >> 4)) & 0x0f0f0f0f;
90
+ x = x + (x >> 8)
91
+ x = x + (x >> 16)
92
+ res += x & 0x3f
93
+ end
94
+ res
95
+ end
96
+
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,398 @@
1
+ #
2
+ # = hobix/storage/filesys.rb
3
+ #
4
+ # Hobix command-line weblog system.
5
+ #
6
+ # Copyright (c) 2003-2004 why the lucky stiff
7
+ #
8
+ # Written & maintained by why the lucky stiff <why@ruby-lang.org>
9
+ #
10
+ # This program is free software, released under a BSD license.
11
+ # See COPYING for details.
12
+ #
13
+ #--
14
+ # $Id$
15
+ #++
16
+ require 'find'
17
+ require 'yaml'
18
+ require 'fileutils'
19
+ # require 'hobix/search/simple'
20
+
21
+ module Hobix
22
+
23
+ #
24
+ # The IndexEntry class
25
+ #
26
+ class IndexEntry < BaseContent
27
+ def initialize( entry, fields = self.class.properties.keys )
28
+ fields.each do |field|
29
+ val = if entry.respond_to? field
30
+ entry.send( field )
31
+ elsif respond_to? "make_#{field}"
32
+ send( "make_#{field}", entry )
33
+ else
34
+ :unset
35
+ end
36
+ send( "#{field}=", val )
37
+ end
38
+ yield self if block_given?
39
+ end
40
+
41
+ yaml_type "!hobix.com,2004/storage/indexEntry"
42
+ end
43
+
44
+ module Storage
45
+
46
+ #
47
+ # The FileSys class is a storage plugin, it manages the loading and dumping of
48
+ # Hobix entries and attachments. The FileSys class also keeps an index of entry
49
+ # information, to keep the system from loading unneeded entries.
50
+ class FileSys < Hobix::BaseStorage
51
+ # Start the storage plugin for the +weblog+ passed in.
52
+ def initialize( weblog )
53
+ super( weblog )
54
+ @modified = {}
55
+ @basepath = weblog.entry_path
56
+ @default_author = weblog.authors.keys.first
57
+ @weblog = weblog
58
+ end
59
+
60
+ def now; Time.at( Time.now.to_i ); end
61
+
62
+ # The default extension for entries. Defaults to: yaml.
63
+ def extension
64
+ 'yaml'
65
+ end
66
+
67
+ # Determine if +id+ is a valid entry identifier, untaint if so.
68
+ def check_id( id )
69
+ id.untaint if id.tainted? and id =~ /^[\w\/\\]+$/
70
+ end
71
+
72
+ # Build an entry's complete path based on its +id+. Optionally, extension +ext+ can
73
+ # be used to find the path of attachments.
74
+ def entry_path( id, ext = extension )
75
+ File.join( @basepath, id.split( '/' ) ) + "." + ext
76
+ end
77
+
78
+ # Brings an entry's modified time current.
79
+ def touch_entry( id )
80
+ check_id( id )
81
+ @modified[id] = Time.now
82
+ FileUtils.touch entry_path( id )
83
+ end
84
+
85
+ # Save the entry object +e+ and identify it as +id+. The +create_category+ flag
86
+ # will forcefully make the needed directories.
87
+ def save_entry( id, e, create_category=false )
88
+ load_index
89
+ check_id( id )
90
+ e.created ||= (@index.has_key?( id ) ? @index[id].created : now)
91
+ path = entry_path( id )
92
+
93
+ begin
94
+ File.open( path, 'w' ) { |f| YAML::dump( e, f ) }
95
+ rescue Errno::ENOENT
96
+ raise unless create_category and File.exists? @basepath
97
+ FileUtils.makedirs File.dirname( path )
98
+ retry
99
+ end
100
+
101
+ @entry_cache ||= {}
102
+ e.id = id
103
+ e.link = e.class.url_link e, @link, @weblog.central_ext
104
+ e.modified = now
105
+ @entry_cache[id] = e
106
+
107
+ @index[id] = @weblog.index_class.new( e ) do |i|
108
+ i.modified = e.modified
109
+ end
110
+ @modified[id] = e.modified
111
+ # catalog_search_entry( e )
112
+ sort_index( true )
113
+ e
114
+ end
115
+
116
+ # Loads the entry object identified by +id+. Entries are cached for future loading.
117
+ def load_entry( id )
118
+ return default_entry( @default_author ) if id == default_entry_id
119
+ load_index
120
+ check_id( id )
121
+ @entry_cache ||= {}
122
+ unless @entry_cache.has_key? id
123
+ entry_file = entry_path( id )
124
+ e = Hobix::Entry::load( entry_file )
125
+ e.id = id
126
+ e.link = e.class.url_link e, @link, @weblog.central_ext
127
+ e.modified = modified( id )
128
+ unless e.created
129
+ e.created = @index[id].created
130
+ File.open( entry_file, 'w' ) { |f| YAML::dump( e, f ) }
131
+ end
132
+ @entry_cache[id] = e
133
+ else
134
+ @entry_cache[id]
135
+ end
136
+ end
137
+
138
+ # Loads the search engine database. The database will be cleansed and re-scanned if +wash+ is true.
139
+ # def load_search_index( wash )
140
+ # @search_index = Hobix::Search::Simple::Searcher.load( File.join( @basepath, 'index.search' ), wash )
141
+ # end
142
+
143
+ # Catalogs an entry object +e+ in the search engine.
144
+ # def catalog_search_entry( e )
145
+ # @search_index.catalog( Hobix::Search::Simple::Content.new( e.to_search, e.id, e.modified, e.content_ratings ) )
146
+ # end
147
+
148
+ # Determines if the search engine has already scanned an entry represented by IndexEntry +ie+.
149
+ # def search_needs_update? ie
150
+ # not @search_index.has_entry? ie.id, ie.modified
151
+ # end
152
+
153
+ # Load the internal index (saved at @entry_path/index.hobix) and refresh any timestamps
154
+ # which may be stale.
155
+ def load_index
156
+ return false if @index
157
+ index_path = File.join( @basepath, 'index.hobix' )
158
+ index = if File.exists? index_path
159
+ YAML::load( File.open( index_path ) )
160
+ else
161
+ YAML::Omap::new
162
+ end
163
+ @index = YAML::Omap::new
164
+ # load_search_index( index.length == 0 )
165
+
166
+ modified = false
167
+ index_fields = @weblog.index_class.properties.keys
168
+ Find::find( @basepath ) do |path|
169
+ path.untaint
170
+ if FileTest.directory? path
171
+ Find.prune if File.basename(path)[0] == ?.
172
+ else
173
+ entry_path = path.gsub( /^#{ Regexp::quote( @basepath ) }\/?/, '' )
174
+ next if entry_path !~ /\.#{ Regexp::quote( extension ) }$/
175
+ entry_paths = File.split( $` )
176
+ entry_paths.shift if entry_paths.first == '.'
177
+ entry_id = entry_paths.join( '/' )
178
+ @modified[entry_id] = File.mtime( path )
179
+
180
+ index_entry = nil
181
+ if ( index.has_key? entry_id ) and !( index[entry_id].is_a? ::Time ) # pre-0.4 index format
182
+ index_entry = index[entry_id]
183
+ end
184
+ ## we will (re)load the entry if:
185
+ if not index_entry.respond_to?( :modified ) or # it's new
186
+ ( index_entry.modified != @modified[entry_id] ) or # it's changed
187
+ index_fields.detect { |f| index_entry.send( f ).nil? } # index fields have been added
188
+ # or search_needs_update? index_entry # entry is old or not available in search db
189
+
190
+ efile = entry_path( entry_id )
191
+ e = Hobix::Entry::load( efile )
192
+ e.id = entry_id
193
+ index_entry = @weblog.index_class.new( e, index_fields ) do |i|
194
+ i.modified = @modified[entry_id]
195
+ end
196
+ # catalog_search_entry( e )
197
+ modified = true
198
+ end
199
+ @index[index_entry.id] = index_entry
200
+ end
201
+ end
202
+ sort_index( modified )
203
+ true
204
+ end
205
+
206
+ # Sorts the internal entry index (used by load_index.)
207
+ def sort_index( modified )
208
+ return unless @index
209
+ index_path = File.join( @basepath, 'index.hobix' )
210
+ @index.sort! { |x,y| y[1].created <=> x[1].created }
211
+ if modified
212
+ File.open( index_path, 'w' ) do |f|
213
+ YAML::dump( @index, f )
214
+ end
215
+ # @search_index.dump
216
+ end
217
+ end
218
+
219
+ # Returns a Hobix::Storage::FileSys object with its scope limited
220
+ # to entries inside a certain path +p+.
221
+ def path_storage( p )
222
+ return self if ['', '.'].include? p
223
+ load_index
224
+ path_storage = self.dup
225
+ path_storage.instance_eval do
226
+ @index = @index.dup.delete_if do |id, entry|
227
+ if id.index( p ) != 0
228
+ @modified.delete( p )
229
+ true
230
+ end
231
+ end
232
+ end
233
+ path_storage
234
+ end
235
+
236
+ # Returns an Array all `sections', or directories which contain entries.
237
+ # If you have three entries: `news/article1', `about/me', and `news/misc/article2',
238
+ # then you have three sections: `news', `about', `news/misc'.
239
+ def sections( opts = nil )
240
+ load_index
241
+ hsh = {}
242
+ @index.collect { |id, e| e.section_id }.uniq.sort
243
+ end
244
+
245
+ # Find entries based on criteria from the +search+ hash.
246
+ # Possible criteria include:
247
+ #
248
+ # :after:: Select entries created after a given Time.
249
+ # :before:: Select entries created before a given Time.
250
+ # :inpath:: Select entries contained within a path.
251
+ # :match:: Select entries with an +id+ which match a Regexp.
252
+ # :search:: Fulltext search of entries for search words.
253
+ # :lastn:: Limit the search to include only a given number of entries.
254
+ #
255
+ # This method returns an Array of +IndexEntry+ objects for use in
256
+ # skel_* methods.
257
+ def find( search = {} )
258
+ load_index
259
+ _index = @index
260
+ if _index.empty?
261
+ e = default_entry( @default_author )
262
+ @modified[e.id] = e.modified
263
+ _index = {e.id => @weblog.index_class.new(e)}
264
+ end
265
+ # if search[:search]
266
+ # sr = @search_index.find_words( search[:search] )
267
+ # end
268
+ unless search[:all]
269
+ ignore_test = nil
270
+ ignored = @weblog.sections_ignored
271
+ unless ignored.empty?
272
+ ignore_test = /^(#{ ignored.collect { |i| Regexp.quote( i ) }.join( '|' ) })/
273
+ end
274
+ end
275
+ entries = _index.collect do |id, entry|
276
+ skip = false
277
+ if ignore_test and not search[:all]
278
+ skip = entry.id =~ ignore_test
279
+ end
280
+ search.each do |skey, sval|
281
+ break if skip
282
+ skip = case skey
283
+ when :after
284
+ entry.created < sval
285
+ when :before
286
+ entry.created > sval
287
+ when :inpath
288
+ entry.id.index( sval ) != 0
289
+ when :match
290
+ not entry.id.match sval
291
+ # when :search
292
+ # not sr.results[entry.id]
293
+ else
294
+ false
295
+ end
296
+ end
297
+ if skip then nil else entry end
298
+ end.compact
299
+ entries.slice!( search[:lastn]..-1 ) if search[:lastn] and entries.length > search[:lastn]
300
+ entries
301
+ end
302
+
303
+ # Returns a Time object for the latest modified time for a group of
304
+ # +entries+ (pass in an Array of IndexEntry objects).
305
+ def last_modified( entries )
306
+ entries.collect do |entry|
307
+ modified( entry.id )
308
+ end.max
309
+ end
310
+
311
+ # Returns a Time object for the latest creation time for a group of
312
+ # +entries+ (pass in an Array of IndexEntry objects).
313
+ def last_created( entries )
314
+ entries.collect do |entry|
315
+ entry.created
316
+ end.max
317
+ end
318
+
319
+ # Returns a Time object representing the +modified+ time for the
320
+ # entry identified by +entry_id+.
321
+ def modified( entry_id )
322
+ find_attached( entry_id ).inject( @modified[entry_id] ) do |max, ext|
323
+ mtime = File.mtime( entry_path( entry_id, ext ) )
324
+ mtime > max ? mtime : max
325
+ end
326
+ end
327
+
328
+ # Returns an Array of Arrays representing the months which contain
329
+ # +entries+ (pass in an Array of IndexEntry objects).
330
+ #
331
+ # See Hobix::Weblog.skel_month for an example of this method's usage.
332
+ def get_months( entries )
333
+ return [] if entries.empty?
334
+ first_time = entries.collect { |e| e.created }.min
335
+ last_time = entries.collect { |e| e.created }.max
336
+ start = Time.mktime( first_time.year, first_time.month, 1 )
337
+ stop = Time.mktime( last_time.year, last_time.month, last_time.day )
338
+ months = []
339
+ until start > stop
340
+ next_year, next_month = start.year, start.month + 1
341
+ if next_month > 12
342
+ next_year += next_month / 12
343
+ next_month %= 12
344
+ end
345
+ month_end = Time.mktime( next_year, next_month, 1 ) - 1
346
+ months << [ start, month_end, start.strftime( "/%Y/%m/" ) ]
347
+ start = month_end + 1
348
+ end
349
+ months
350
+ end
351
+
352
+ # Discovers attachments to an entry identified by +id+.
353
+ def find_attached( id )
354
+ check_id( id )
355
+ Dir[ entry_path( id, '*' ) ].collect do |att|
356
+ atp = att.match( /#{ Regexp::quote( id ) }\.(?!#{ extension }$)/ )
357
+ atp.post_match if atp
358
+ end.compact
359
+ end
360
+
361
+ # Loads an attachment to an entry identified by +id+. Entries
362
+ # can have any kind of YAML attachment, each which a specific extension.
363
+ def load_attached( id, ext )
364
+ check_id( id )
365
+ @attach_cache ||= {}
366
+ file_id = "#{ id }.#{ ext }"
367
+ unless @attach_cache.has_key? file_id
368
+ @attach_cache[id] = File.open( entry_path( id, ext ) ) do |f|
369
+ YAML::load( f )
370
+ end
371
+ else
372
+ @attach_cache[id]
373
+ end
374
+ end
375
+
376
+ # Saves an attachment to an entry identified by +id+. The attachment
377
+ # +e+ is saved with an extension +ext+.
378
+ def save_attached( id, ext, e )
379
+ check_id( id )
380
+ File.open( entry_path( id, ext ), 'w' ) do |f|
381
+ YAML::dump( e, f )
382
+ end
383
+
384
+ @attach_cache ||= {}
385
+ @attach_cache[id] = e
386
+ end
387
+
388
+ # Appends the given items to an entry attachment with the given type, and
389
+ # then saves the modified attachment. If an attachment of the given type
390
+ # does not exist, it will be created.
391
+ def append_to_attachment( entry_id, attachment_type, *items )
392
+ attachment = load_attached( entry_id, attachment_type ) rescue []
393
+ attachment += items
394
+ save_attached( entry_id, attachment_type, attachment )
395
+ end
396
+ end
397
+ end
398
+ end