hobix 0.4

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.
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