add possibility to display "made with" markup at the bottom of pages
[booh] / bin / booh-backend
1 #! /usr/bin/ruby
2 #
3 #                         *  BOOH  *
4 #
5 # A.k.a `Best web-album Of the world, Or your money back, Humerus'.
6 #
7 # The acronyn sucks, however this is a tribute to Dragon Ball by
8 # Akira Toriyama, where the last enemy beaten by heroes of Dragon
9 # Ball is named "Boo". But there was already a free software project
10 # called Boo, so this one will be it "Booh". Or whatever.
11 #
12 #
13 # Copyright (c) 2005 Guillaume Cottenceau <gc3 at bluewin.ch>
14 #
15 # This software may be freely redistributed under the terms of the GNU
16 # public license version 2.
17 #
18 # You should have received a copy of the GNU General Public License
19 # along with this program; if not, write to the Free Software
20 # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
21
22 require 'getoptlong'
23 require 'gettext'
24 include GetText
25 require 'rexml/document'
26 include REXML
27
28 require 'booh/booh-lib'
29 require 'booh/html-merges'
30
31 #- bind text domain as soon as possible because some _() functions are called early to build data structures
32 bindtextdomain("booh")
33
34 #- options
35 $options = [
36     [ '--help',          '-h', GetoptLong::NO_ARGUMENT,       _("Get help message") ],
37     [ '--version',       '-V', GetoptLong::NO_ARGUMENT,       _("Print version and exit") ],
38
39     [ '--source',        '-s', GetoptLong::REQUIRED_ARGUMENT, _("Directory which contains original images/videos as files or subdirs") ],
40     [ '--destination',   '-d', GetoptLong::REQUIRED_ARGUMENT, _("Directory which will contain the web-album") ],
41 #    [ '--clean',         '-c', GetoptLong::NO_ARGUMENT,       _("Clean destination directory") ],
42
43     [ '--theme',         '-t', GetoptLong::REQUIRED_ARGUMENT, _("Select HTML theme to use") ],
44     [ '--config',        '-C', GetoptLong::REQUIRED_ARGUMENT, _("File containing config listing images and videos within directories with captions") ],
45     [ '--config-skel',   '-k', GetoptLong::REQUIRED_ARGUMENT, _("Filename where the script will output a config skeleton") ],
46     [ '--merge-config',  '-M', GetoptLong::REQUIRED_ARGUMENT, _("File containing config listing, where to merge new images/videos from --source, and change theme info") ],
47     [ '--merge-config-onedir',  '-O', GetoptLong::REQUIRED_ARGUMENT, _("File containing config listing, for merging the subdir specified with --dir") ],
48     [ '--merge-config-subdirs', '-U', GetoptLong::REQUIRED_ARGUMENT, _("File containing config listing, for merging the new subdirs down the subdir specified with --dir") ],
49     [ '--dir',           '-D', GetoptLong::REQUIRED_ARGUMENT, _("Directory for merge with --merge-config-onedir or --merge-config-subdirs") ],
50     [ '--use-config',    '-u', GetoptLong::REQUIRED_ARGUMENT, _("File containing config listing, where to change theme info") ],
51     [ '--force',         '-f', GetoptLong::NO_ARGUMENT,       _("Force generation of album even if the GUI marked some directories as already generated") ],
52
53     [ '--sizes',         '-S', GetoptLong::REQUIRED_ARGUMENT, _("Specify the list of images sizes to use instead of all specified in the theme (this is a comma-separated list)") ],
54     [ '--thumbnails-per-row', '-T', GetoptLong::REQUIRED_ARGUMENT, _("Specify the amount of thumbnails per row in the thumbnails page (if applicable in theme)") ],
55     [ '--optimize-for-32', '-o', GetoptLong::NO_ARGUMENT,       _("Resize images with optimized sizes for 3/2 aspect ratio rather than 4/3 (typical aspect ratio of pictures from non digital cameras are 3/2 when pictures from digital cameras are 4/3)") ],
56     [ '--made-with',     '-n', GetoptLong::REQUIRED_ARGUMENT, _("Specify the HTML markup to use on the bottom of pages") ],
57     [ '--empty-comments','-e', GetoptLong::NO_ARGUMENT,       _("Prefer empty comments over filename when creating new albums") ],
58
59     [ '--mproc',         '-m', GetoptLong::REQUIRED_ARGUMENT, _("Specify the number of processors for multi-processors machines") ],
60
61     [ '--for-gui',       '-g', GetoptLong::NO_ARGUMENT,       _("Do the minimum work to be able to see the album under the GUI (don't generate all thumbnails)") ],
62
63     [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
64     [ '--info-pipe',     '-i', GetoptLong::REQUIRED_ARGUMENT, _("Name a file where to write information about what's going on (used by the GUI)") ],
65 ]
66
67 #- default values for some globals 
68 $switches = []
69 $stdout.sync = true
70 $no_identify = false
71 $ignore_videos = false
72 $forgui = false
73
74 def usage
75     puts _("Usage: %s [OPTION]...") % File.basename($0)
76     $options.each { |ary|
77         printf " %3s, %-18s %s\n", ary[1], ary[0], ary[3]
78     }
79 end
80
81 def handle_options
82     parser = GetoptLong.new
83     parser.set_options(*$options.collect { |ary| ary[0..2] })
84     begin
85         parser.each_option do |name, arg|
86             case name
87             when '--help'
88                 usage
89                 exit(0)
90
91             when '--version'
92                 puts _("Booh version %s
93
94 Copyright (c) 2005 Guillaume Cottenceau.
95 This is free software; see the source for copying conditions.  There is NO
96 warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.") % $VERSION
97
98                 exit(0)
99
100             when '--source'
101                 $source = File.expand_path(arg.sub(%r|/$|, ''))
102                 if !File.directory?($source)
103                     die _("Argument to --source must be a directory")
104                 end
105             when '--destination'
106                 $dest = File.expand_path(arg.sub(%r|/$|, ''))
107                 if File.exists?($dest) && !File.directory?($dest)
108                     die _("If --destination exists, it must be a directory")
109                 end
110                 if $dest != make_dest_filename($dest)
111                     die _("Sorry, destination directory can't contain non simple alphanumeric characters.")
112                 end
113 #            when '--clean'
114 #                system("rm -rf #{$dest}")
115
116             when '--theme'
117                 $theme = arg
118             when '--config'
119                 arg = File.expand_path(arg)
120                 if File.readable?(arg)
121                     $xmldoc = REXML::Document.new File.new(arg)
122                     $mode = 'use_config'
123                 else
124                     die _('Config file does not exist or is unreadable.')
125                 end
126             when '--config-skel'
127                 arg = File.expand_path(arg)
128                 if File.exists?(arg)
129                     if File.directory?(arg)
130                         die _("Config skeleton file (%s) already exists and is a directory! Please change the filename.") % arg
131                     else
132                         msg 1, _("Config skeleton file already exists, backuping to %s.backup") % arg
133                         File.rename(arg, "#{arg}.backup")
134                     end
135                 end
136                 $config_writeto = arg
137                 $mode = 'gen_config'
138             when '--merge-config'
139                 arg = File.expand_path(arg)
140                 if File.readable?(arg)
141                     msg 2, _("Merge config notice: backuping current config file to %s.backup") % arg
142                     $xmldoc = REXML::Document.new File.new(arg)
143                     File.rename(arg, "#{arg}.backup")
144                     $config_writeto = arg
145                     $mode = 'merge_config'
146                 else
147                     die _('Config file does not exist or is unreadable.')
148                 end
149             when '--merge-config-onedir'
150                 arg = File.expand_path(arg)
151                 if File.readable?(arg)
152                     msg 2, _("Merge config notice: backuping current config file to %s.backup") % arg
153                     $xmldoc = REXML::Document.new File.new(arg)
154                     File.rename(arg, "#{arg}.backup")
155                     $config_writeto = arg
156                     $mode = 'merge_config_onedir'
157                 else
158                     die _('Config file does not exist or is unreadable.')
159                 end
160             when '--merge-config-subdirs'
161                 arg = File.expand_path(arg)
162                 if File.readable?(arg)
163                     msg 2, _("Merge config notice: backuping current config file to %s.backup") % arg
164                     $xmldoc = REXML::Document.new File.new(arg)
165                     File.rename(arg, "#{arg}.backup")
166                     $config_writeto = arg
167                     $mode = 'merge_config_subdirs'
168                 else
169                     die _('Config file does not exist or is unreadable.')
170                 end
171             when '--dir'
172                 arg = File.expand_path(arg)
173                 if !File.readable?(arg)
174                     die _('Specified directory to merge with --dir is not readable')
175                 else
176                     $onedir = arg
177                 end
178             when '--use-config'
179                 arg = File.expand_path(arg)
180                 if File.readable?(arg)
181                     msg 2, _("Use config notice: backuping current config file to %s.backup") % arg
182                     $xmldoc = REXML::Document.new File.new(arg)
183                     File.rename(arg, "#{arg}.backup")
184                     $config_writeto = arg
185                     $mode = 'use_config_changetheme'
186                 else
187                     die _('Config file does not exist or is unreadable.')
188                 end
189
190             when '--sizes'
191                 $limit_sizes = arg
192
193             when '--thumbnails-per-row'
194                 $N_per_row = arg
195
196             when '--optimize-for-32'
197                 $optimize_for_32 = true
198
199             when '--made-with'
200                 $madewith = arg
201
202             when '--empty-comments'
203                 $empty_comments = true
204
205             when '--force'
206                 $force = true
207
208             when '--mproc'
209                 $mproc = arg.to_i
210                 $pids = []
211
212             when '--for-gui'
213                 $forgui = true
214
215             when '--verbose-level'
216                 $verbose_level = arg.to_i
217
218             when '--info-pipe'
219                 $info_pipe = File.open(arg, File::WRONLY)
220                 $info_pipe.sync = true
221             end
222         end
223     rescue
224         puts $!
225         usage
226         exit(1)
227     end
228
229     if !$source && $xmldoc
230         $source = from_utf8($xmldoc.root.attributes['source'])
231         $dest = from_utf8($xmldoc.root.attributes['destination'])
232         $theme ||= $xmldoc.root.attributes['theme']
233         $limit_sizes ||= $xmldoc.root.attributes['limit-sizes']
234         if $mode == 'use_config' || $mode =~ /^merge_config/
235             $optimize_for_32 = !$xmldoc.root.attributes['optimize-for-32'].nil?
236             $N_per_row = $xmldoc.root.attributes['thumbnails-per-row']
237             $madewith = $xmldoc.root.attributes['made-with']
238         end
239     end
240
241     if $mode == 'merge_config_onedir' && !$onedir
242         die _("Missing --dir for --merge-config-onedir")
243     end
244     if $mode == 'merge_config_subdirs' && !$onedir
245         die _("Missing --dir for --merge-config-subdirs")
246     end
247
248     if !$source
249         usage
250         exit(0)
251     end
252     if !$dest
253         die _("Missing --destination parameter.")
254     end
255     if !$theme
256         $theme = 'simple'
257     end
258
259     select_theme($theme, $limit_sizes, $optimize_for_32, $N_per_row)
260
261     if !$xmldoc
262         additional_params = ''
263         if $limit_sizes
264             additional_params += "limit-sizes='#{$limit_sizes}'"
265         end
266         if $optimize_for_32
267             additional_params += " optimize-for-32='true'"
268         end
269         if $N_per_row
270             additional_params += " thumbnails-per-row='#{$N_per_row}'"
271         end
272         if $madewith
273             additional_params += " made-with='#{$madewith}'"
274         end
275         $xmldoc = Document.new "<booh version='#{$VERSION}' source='#{utf8($source)}' destination='#{utf8($dest)}' theme='#{$theme}' #{additional_params}/>"
276         $xmldoc << XMLDecl.new(XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET)
277         $mode = 'gen_config'
278     end
279
280     if $mode == 'merge_config' || $mode == 'use_config_changetheme'
281         $xmldoc.root.add_attribute('theme', $theme)
282         $xmldoc.root.add_attribute('version', $VERSION)
283         if $limit_sizes
284             $xmldoc.root.add_attribute('limit-sizes', $limit_sizes)
285         else
286             $xmldoc.root.delete_attribute('limit-sizes')
287         end
288         if $optimize_for_32
289             $xmldoc.root.add_attribute('optimize-for-32', 'true')
290         else
291             $xmldoc.root.delete_attribute('optimize-for-32')
292         end
293         if $N_per_row
294             $xmldoc.root.add_attribute('thumbnails-per-row', $N_per_row)
295         else
296             $xmldoc.root.delete_attribute('thumbnails-per-row')
297         end
298         if $madewith
299             $xmldoc.root.add_attribute('made-with', $madewith)
300         else
301             $xmldoc.root.delete_attribute('made-with')
302         end
303     end
304
305     if $madewith
306         $madewith = $madewith.gsub('%booh', 'http://zarb.org/~gc/html/booh.html')
307     end
308 end
309
310 def read_config
311     $config = {}
312 end
313
314 def write_config
315 end
316
317 def info(value)
318     if $info_pipe
319         $info_pipe.puts(value)
320     end
321 end
322
323 def check_installation
324     if !system("which convert >/dev/null 2>/dev/null")
325         die _("The program 'convert' is needed. Please install it. 
326 It is generally available with the 'ImageMagick' software package.")
327     end
328     if !system("which identify >/dev/null 2>/dev/null")
329         msg 1, _("the program 'identify' is needed to get images sizes and EXIF data. Please install it.
330 It is generally available with the 'ImageMagick' software package.")
331         $no_identify = true
332     end
333     missing = %w(transcode mencoder).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
334     if missing != []
335         msg 1, _("the following program(s) are needed to handle videos: '%s'. Videos will be ignored.") % missing.join(', ')
336         $ignore_videos = true
337     end
338 end
339
340 def replace_line(surround, keyword, line)
341     begin
342         contents = eval "$#{keyword}"
343         line.sub!(/#{surround}#{keyword}#{surround}/, contents)
344     rescue TypeError
345         die _("No '%s' found for substitution") % keyword
346     end
347 end
348
349 def build_html_skeletons
350     $html_images     = File.open("#{$FPATH}/themes/#{$theme}/skeleton_image.html").readlines
351     $html_thumbnails = File.open("#{$FPATH}/themes/#{$theme}/skeleton_thumbnails.html").readlines
352     $html_index      = File.open("#{$FPATH}/themes/#{$theme}/skeleton_index.html").readlines
353     for line in $html_images + $html_thumbnails + $html_index
354         while line =~ /~~~(\w+)~~~/
355             replace_line('~~~', $1, line)
356         end
357     end
358 end
359
360 def find_caption_value(xmldir, filename)
361     if cap = xmldir.elements["*[@filename='#{utf8(filename)}']"].attributes['caption']
362         return cap.gsub("\n", '<br/>')
363     else
364         return nil
365     end
366 end
367
368 def find_captions(xmldir, images)
369     return images.collect { |img| find_caption_value(xmldir, img) }
370 end
371
372 #- stolen from CVSspam
373 def urlencode(text)
374   text.sub(/[^a-zA-Z0-9\-,.*_\/]/) do
375     "%#{sprintf('%2X', $&[0])}"
376   end
377 end
378
379 def all_images_sizes
380     return $limit_sizes =~ /original/ ? $images_size + [ { 'name' => 'original' } ] : $images_size
381 end
382
383 def html_reload_to_thumbnails
384     html_reload_to_thumbnails = $preferred_size_reloader.clone
385     html_reload_to_thumbnails.gsub!(/~~theme~~/, $theme)
386     html_reload_to_thumbnails.gsub!(/~~default_size~~/, $default_size['name'])
387     html_reload_to_thumbnails.gsub!(/~~all_sizes~~/, all_images_sizes.collect { |s| "\"#{size2js(s['name'])}\"" }.join(', '))
388     return html_reload_to_thumbnails
389 end
390
391 def discover_iterations(iterations, line)
392     if line =~ /~~iterate(\d)_open(_max(\d+|N))?~~/
393         for iter in iterations.values
394             if iter['open']
395                 iter['open'] = false
396                 iter['close_wait'] = $1.to_i
397             end
398         end
399         max = $3 == 'N' ? ($N_per_row || $default_N) : $3
400         iterations[$1.to_i] = { 'open' => true, 'max' => max, 'opening' => '', 'closing' => '' }
401         if $1.to_i == 1
402             line.sub!(/.*/, '~~thumbnails~~')
403         else
404             line.sub!(/.*/, '')
405         end
406     elsif line =~ /~~iterate(\d)_close~~/
407         iterations[$1.to_i]['open']  = false;
408         iterations[$1.to_i]['close'] = true;
409         line.sub!(/.*/, '')
410     else
411         for iter in iterations.values
412             if iter['open']
413                 iter['opening'] += line
414                 line.sub!(/.*/, '')
415             end
416             if !iter['close'] && iter['close_wait'] && iterations[iter['close_wait']]['close']
417                 iter['closing'] += line
418                 line.sub!(/.*/, '')
419             end
420         end
421     end
422 end
423
424 def reset_iterations(iterations)
425     for iter in iterations.values
426         iter['value'] = 0
427     end
428 end
429
430 def run_iterations(iterations, amount)
431     html = ''
432     should_rerun = false
433     for level in iterations.keys.sort
434         if iterations[level]['value'] == 0
435             html += iterations[level]['opening']
436         elsif level == iterations.keys.max
437             if !iterations[level]['max'] || iterations[level]['max'] && iterations[level]['value'] + amount <= iterations[level]['max'].to_i
438                 html += iterations[level]['opening']
439             else
440                 should_rerun = true
441             end
442         end
443         iterations[level]['value'] += amount
444         if iterations[level]['max'] && iterations[level]['value'] > iterations[level]['max'].to_i
445             iterations[level]['value'] = 0
446             iterations[level-1]['value'] = 0
447             html += iterations[level-1]['closing']
448         end
449     end
450     if should_rerun
451         return html + run_iterations(iterations, amount)
452     else
453         return html
454     end
455 end
456
457 def close_iterations(iterations)
458     html = ''
459     for level in iterations.keys.sort.reverse
460         html += iterations[level]['closing']
461     end
462     return html
463 end
464
465 def img_element(fullpath)
466     if size = get_image_size(fullpath)
467         sizespec = 'width="' + size[:x].to_s + '" height="' + size[:y].to_s + '"'
468     else
469         sizespec = ''
470     end
471     return '<img src="' + File.basename(fullpath) + '" ' + sizespec + ' class="image"/>'
472 end
473
474 def size2js(name)
475     return name.gsub(/-/, '')
476 end
477
478 def substitute_html_sizes(html, sizeobj, type)
479     sizestrings = []
480     if $images_size.length > 1 || (type == 'image' && $limit_sizes =~ /original/)
481         for sizeobj2 in $images_size
482             if sizeobj != sizeobj2
483                 if type == 'thumbnails'
484                     sizestrings << '<a href="thumbnails-' + size2js(sizeobj2['name']) + '.html">' + sizename(sizeobj2['name']) + '</a>'
485                 else
486                     sizestrings << '<a id="link' + size2js(sizeobj2['name']) + '">' + sizename(sizeobj2['name']) + '</a>'
487                 end
488             else
489                 sizestrings << sizename(sizeobj2['name'])
490             end
491         end
492         if type == 'image' && $limit_sizes =~ /original/
493             sizestrings << '<a id="linkoriginal" target="newframe">' + sizename('original') + '</a>'
494         end
495     end
496     html.sub!(/~~sizes~~(.+)~~/) { sizestrings.join($1) }
497 end
498
499 def xmldir2destdir(xmldir)
500     return make_dest_filename(from_utf8(File.basename(xmldir.attributes['path'])))
501 end
502
503 def find_previous_album(xmldir)
504     relative_pos = ''
505     begin
506         #- move to previous dir element if exists
507         if prevelem = xmldir.previous_element_byname_notattr('dir', 'deleted')
508             xmldir = prevelem
509             relative_pos += '../' + xmldir2destdir(xmldir) + '/'
510             child = nil
511             #- after having moved to previous dir, we need to go down last subdir until the last one
512             while child = xmldir.elements['dir']
513                 while nextchild = child.next_element_byname_notattr('dir', 'deleted')
514                     child = nextchild
515                 end
516                 relative_pos += xmldir2destdir(child) + '/'
517                 xmldir = child
518             end
519         else
520             #- previous dir doesn't exist, move to previous dir element if exists
521             xmldir = xmldir.parent
522             if xmldir.name == 'dir' && !xmldir.attributes['deleted']
523                 relative_pos += '../'
524             else
525                 return nil
526             end
527         end
528     end while !xmldir.child_byname_notattr('image', 'deleted') && !xmldir.child_byname_notattr('video', 'deleted')
529     return File.reduce_path(relative_pos)
530 end
531
532 def find_next_album(xmldir)
533     relative_pos = ''
534     begin
535         #- first child dir element (catches when initial xmldir has both thumbnails and subdirs)
536         if firstchild = xmldir.child_byname_notattr('dir', 'deleted')
537             xmldir = firstchild
538             relative_pos += xmldir2destdir(xmldir) + '/'
539         #- next brother
540         elsif nextbro = xmldir.next_element_byname_notattr('dir', 'deleted')
541             xmldir = nextbro
542             relative_pos += '../' + xmldir2destdir(xmldir) + '/'
543         else
544             #- go up until we have a next brother or we are finished
545             begin
546                 xmldir = xmldir.parent
547                 relative_pos += '../'
548             end while xmldir && !xmldir.next_element_byname_notattr('dir', 'deleted')
549             if xmldir
550                 xmldir = xmldir.next_element_byname('dir')
551                 relative_pos += '../' + xmldir2destdir(xmldir) + '/'
552             else
553                 return nil
554             end
555         end
556     end while !xmldir.child_byname_notattr('image', 'deleted') && !xmldir.child_byname_notattr('video', 'deleted')
557     return File.reduce_path(relative_pos)
558 end
559
560 def sub_previous_next_album(previous_album, next_album, html)
561     if previous_album
562         html.gsub!(/~~previous_album~~/, '<a href="' + previous_album + 'thumbnails.html">' + utf8(_('previous album')) + '</a>')
563         html.gsub!(/~~ifprevious_album\?~~(.+?)~~fi~~/) { $1 }
564     else
565         html.gsub!(/~~previous_album~~/, '')
566         html.gsub!(/~~ifprevious_album\?~~(.+?)~~fi~~/, '')
567     end
568     if next_album
569         html.gsub!(/~~next_album~~/, '<a href="' + next_album + 'thumbnails.html">' + utf8(_('next album')) + '</a>')
570         html.gsub!(/~~ifnext_album\?~~(.+?)~~fi~~/) { $1 }
571     else
572         html.gsub!(/~~next_album~~/, '')
573         html.gsub!(/~~ifnext_album\?~~(.+?)~~fi~~/, '')
574     end
575     return html
576 end
577
578 def walk_source_dir
579
580     #- preprocess the path->dir, rexml is very slow with that; we seem to improve speed by 7%
581     optxpath = {}
582     $xmldoc.elements.each('//dir') { |elem|
583         optxpath[elem.attributes['path']] = elem
584     }
585
586     examined_dirs = nil
587     if $mode == 'merge_config_onedir'
588         examined_dirs = [ $onedir ]
589     elsif $mode == 'merge_config_subdirs'
590         examined_dirs = `find '#{$onedir}' -type d -follow`.sort.collect { |v| v.chomp }.delete_if { |v| optxpath.has_key?(utf8(v)) }
591     else
592         examined_dirs = `find '#{$source}' -type d -follow`.sort
593     end
594     info("directories: #{examined_dirs.length}, sizes: #{$images_size.length}")
595
596     examined_dirs.each { |dir|
597         dir.chomp!
598         if File.basename(dir) =~ /^\./
599             msg 1, _("Ignoring directory %s, begins with a dot (indicating a hidden directory)") % dir
600             next
601         end
602
603         if dir =~ /'/
604             die _("Source directory or sub-directories can't contain a single-quote character, sorry: %s") % dir
605         end
606
607         #- place xml document on proper node if exists, else create
608         xmldir = optxpath[utf8(dir)]
609         if $mode == 'use_config' || $mode == 'use_config_changetheme'
610             if !xmldir || (xmldir.attributes['already-generated'] && !$force) || xmldir.attributes['deleted']
611                 info("walking: #{dir}|#{$source}, 0 elements")
612                 next
613             end
614         else
615             if $mode == 'gen_config' || (($mode == 'merge_config' || $mode == 'merge_config_subdirs') && !xmldir)
616                 #- add the <dir..> element if necessary
617                 parent = File.dirname(dir)
618                 xmldir = $xmldoc.elements["//dir[@path='#{utf8(parent)}']"]
619                 if !xmldir
620                     xmldir = $xmldoc.root
621                 end
622                 #- need to remove the already-generated mark of the parent because of the sub-albums page containing now one more element
623                 xmldir.delete_attribute('already-generated')
624                 xmldir = optxpath[utf8(dir)] = xmldir.add_element('dir', { 'path' => utf8(dir) })
625             end
626         end
627         xmldir.delete_attribute('already-generated')
628
629         #- read images/videos entries from config or from directories depending on mode
630         entries = []
631         if $mode == 'use_config' || $mode == 'use_config_changetheme'
632             msg 2, _("Handling %s from config list...") % dir
633             xmldir.elements.each { |element|
634                 if %w(image video).include?(element.name) && !element.attributes['deleted']
635                     entries << from_utf8(element.attributes['filename'])
636                 end
637             }
638         else
639             msg 2, _("Examining %s...") % dir
640             entries = Dir.entries(dir).sort
641             #- populate config in case of gen_config, add new files in case of merge_config
642             for file in entries
643                 if file =~ /['"\[\]]/
644                     msg 1, _("Ignoring %s, contains one of forbidden characters: '\"[]") % "#{dir}/#{file}"
645                 else
646                     type = entry2type(file)
647                     if type && !xmldir.elements["#{type}[@filename='#{utf8(file)}']"]
648                         xmldir.add_element type, { "filename" => utf8(file), "caption" => $empty_comments ? '' : utf8(file.sub(/\.[^\.]+$/, '')[0..17]) }
649                     end
650                 end
651             end
652             if $mode != 'gen_config'
653                 #- cleanup removed files from config and reread entries from config to get proper ordering
654                 entries = []
655                 xmldir.elements.each { |element|
656                     fullpath = "#{dir}/#{from_utf8(element.attributes['filename'])}"
657                     if %w(image video).include?(element.name)
658                         if !File.readable?(fullpath)
659                             msg 1, _("Config merge: removing %s from config; use the backup file to retrieve caption info if this was a mistake") % fullpath
660                             xmldir.delete(element)
661                         elsif !element.attributes['deleted']
662                             entries << from_utf8(element.attributes['filename'])
663                         end
664                     end
665                 }
666                 #- if there is no more elements here, there is no album here anymore
667                 if !xmldir.child_byname_notattr('image', 'deleted') && !xmldir.child_byname_notattr('video', 'deleted')
668                     xmldir.delete_attribute('thumbnails-caption')
669                     xmldir.delete_attribute('thumbnails-captionfile')
670                 end
671             end
672         end
673         images = entries.find_all { |e| entry2type(e) == 'image' }
674         msg 3, _("\t%s images") % images.length
675         videos = entries.find_all { |e| entry2type(e) == 'video' }
676         msg 3, _("\t%s videos") % videos.length
677         info("walking: #{dir}|#{$source}, #{images.length + videos.length} elements")
678
679         dest_dir = make_dest_filename(dir.sub(/^#{Regexp.quote($source)}/, $dest))
680         system("mkdir -p '#{dest_dir}'")
681
682         #- pass through if there are no images and videos
683         if images.size == 0 && videos.size == 0
684             if !$forgui
685                 #- cleanup old images/videos, especially if this directory contained images/videos previously.
686                 themestuff = Dir.entries("#{$FPATH}/themes/#{$theme}").
687                                 find_all { |e| !%w(. .. skeleton_image.html skeleton_thumbnails.html skeleton_index.html metadata CVS).include?(e) }
688                 if $mode != 'gen_config'
689                     rightful_images = []
690                     if xmldir.attributes['thumbnails-caption']
691                         rightful_images << 'thumbnails-thumbnail.jpg'
692                     end
693                     xmldir.elements.each('dir') { |child|
694                         if child.attributes['deleted']
695                             next
696                         end
697                         subdir = make_dest_filename(from_utf8(File.basename(child.attributes['path'])))
698                         rightful_images << "thumbnails-#{subdir}.jpg"
699                     }
700                     to_del = Dir.entries(dest_dir).find_all { |e| !File.directory?("#{dest_dir}/#{e}") && !rightful_images.include?(e) } - themestuff
701                     if to_del.size > 0
702                         system("rm -f " + to_del.collect { |e| "#{dest_dir}/#{e}" }.join(' '))
703                     end
704                 end
705                 
706                 #- copy any resource file that goes with the theme (css, images..)
707                 themestuff.each { |entry|
708                     if !File.exists?("#{dest_dir}/#{entry}")
709                         psys("cp '#{$FPATH}/themes/#{$theme}/#{entry}' '#{dest_dir}'")
710                     end
711                 }
712             end
713             next
714         end
715
716         msg 2, _("Outputting in %s...") % dest_dir
717
718         #- populate data structure with sizes from theme
719         for sizeobj in $images_size
720             fullscreen_images ||= {}
721             fullscreen_images[sizeobj['name']] = []
722             thumbnail_images ||= {}
723             thumbnail_images[sizeobj['name']] = []
724             thumbnail_videos ||= {}
725             thumbnail_videos[sizeobj['name']] = []
726         end
727         #- a special dummy size to keep 'references' to thumbnails in case of panorama, because the GUI will use the regular thumbnails
728         thumbnail_images['dont-delete-file-for-gui'] = []
729         if $limit_sizes =~ /original/
730             fullscreen_images['original'] = []
731         end
732
733         images.size >= 1 and msg 3, _("\tcreating images thumbnails...")
734
735         #- create thumbnails for images
736         images.each { |img|
737             info("processing element")
738             base_dest_img = dest_dir + '/' + make_dest_filename(img.sub(/\.[^\.]+$/, ''))
739             if $forgui
740                 thumbnail_dest_img = base_dest_img + "-#{$default_size['thumbnails']}.jpg"
741                 gen_thumbnails_element("#{dir}/#{img}", xmldir, true, [ { 'filename' => thumbnail_dest_img, 'size' => $default_size['thumbnails'] } ])
742             else
743                 todo = []
744                 elem = xmldir.elements["image[@filename='#{utf8(img)}']"]
745                 for sizeobj in $images_size
746                     size_fullscreen = sizeobj['fullscreen']
747                     size_thumbnails = sizeobj['thumbnails']
748                     fullscreen_dest_img = base_dest_img + "-#{size_fullscreen}.jpg"
749                     fullscreen_images[sizeobj['name']] << File.basename(fullscreen_dest_img)
750                     todo << { 'filename' => fullscreen_dest_img, 'size' => size_fullscreen }
751                     if pano = pano_amount(elem)
752                         thumbnail_images['dont-delete-file-for-gui'] << File.basename(base_dest_img + "-#{size_thumbnails}.jpg")
753                         size_thumbnails = size_thumbnails.sub(/(\d+)/) { ($1.to_i * pano).to_i }
754                     end
755                     thumbnail_dest_img = base_dest_img + "-#{size_thumbnails}.jpg"
756                     thumbnail_images[sizeobj['name']] << File.basename(thumbnail_dest_img)
757                     todo << { 'filename' => thumbnail_dest_img,  'size' => size_thumbnails }
758                 end
759                 gen_thumbnails_element("#{dir}/#{img}", xmldir, true, todo)
760                 if $limit_sizes =~ /original/
761                     fullscreen_images['original'] << img
762                 end
763                 destimg = "#{dest_dir}/#{img}"
764                 if $limit_sizes =~ /original/ && !File.exists?(destimg)
765                     psys("cp '#{dir}/#{img}' '#{destimg}'")
766                 end
767             end
768         }
769
770         videos.size >= 1 and msg 3, _("\tcreating videos thumbnails...")
771
772         #- create thumbnails for videos
773         videos.each { |video|
774             info("processing element")
775             if $forgui
776                 thumbnail_dest_img = dest_dir + '/' + make_dest_filename(video.sub(/\.[^\.]+$/, '')) + "-#{$default_size['thumbnails']}.jpg"
777                 gen_thumbnails_element("#{dir}/#{video}", xmldir, true, [ { 'filename' => thumbnail_dest_img, 'size' => $default_size['thumbnails'] } ])
778             else
779                 todo = []
780                 for sizeobj in $images_size
781                     size_thumbnails = sizeobj['thumbnails']
782                     thumbnail_dest_img = dest_dir + '/' + make_dest_filename(video.sub(/\.[^\.]+$/, '')) + "-#{size_thumbnails}.jpg"
783                     thumbnail_videos[sizeobj['name']] << File.basename(thumbnail_dest_img)
784                     todo << { 'filename' => thumbnail_dest_img, 'size' => size_thumbnails }
785                 end
786                 gen_thumbnails_element("#{dir}/#{video}", xmldir, true, todo)
787             end
788             destvideo = "#{dest_dir}/#{video}"
789             if !File.exists?(destvideo)
790                 psys("cp '#{dir}/#{video}' '#{destvideo}'")
791             end
792         }
793
794         if !$forgui
795             themestuff = Dir.entries("#{$FPATH}/themes/#{$theme}").
796                              find_all { |e| !%w(. .. skeleton_image.html skeleton_thumbnails.html skeleton_index.html metadata CVS).include?(e) }
797
798             #- cleanup old images/videos (for when removing elements or sizes)
799             all_elements = fullscreen_images.collect { |e| e[1] }.flatten.
800                      concat(thumbnail_images.collect { |e| e[1] }.flatten).
801                      concat(thumbnail_videos.collect { |e| e[1] }.flatten).
802                      concat(videos)
803             to_del = Dir.entries(dest_dir).find_all { |e| !File.directory?("#{dest_dir}/#{e}") && !all_elements.include?(e) && e !~ /^thumbnails-\w+\.jpg/ } - themestuff
804             if to_del.size > 0
805                 msg 3, _("\tcleaning up: #{to_del.join(', ')}")
806                 system("rm -f " + to_del.collect { |e| "#{dest_dir}/#{e}" }.join(' '))
807             end
808
809             #- copy any resource file that goes with the theme (css, images..)
810             themestuff.each { |entry|
811                 if !File.exists?("#{dest_dir}/#{entry}")
812                     psys("cp '#{$FPATH}/themes/#{$theme}/#{entry}' '#{dest_dir}'")
813                 end
814             }
815
816             #- generate .htaccess file if needed
817             if auth_user_file = xmldir.attributes['password-protect']
818                 msg 3, _("\tgenerating password protection file #{dest_dir}/.htaccess")
819                 ios = File.open("#{dest_dir}/.htaccess", "w")
820                 ios.write("AuthType Basic\nAuthName \"protected area\"\nAuthUserFile #{auth_user_file}\nrequire valid-user\n")
821                 ios.close
822             end
823
824             msg 3, _("\tgenerating HTML pages...")
825
826             #- generate thumbnails.html (page with thumbnails)
827             for sizeobj in $images_size
828                 info("processing size")
829                 html = $html_thumbnails.collect { |l| l.clone }
830                 iterations = {}
831                 for i in html
832                     i.sub!(/~~run_slideshow~~/, images.size <= 1 ? '' : '<a href="image-' + size2js(sizeobj['name']) + '.html#run_slideshow=1">' + utf8(_('Run slideshow!'))+'</a>')
833                     i.sub!(/~~title~~/, xmldir.attributes['thumbnails-caption'] || utf8(File.basename(dir)))
834                     substitute_html_sizes(i, sizeobj, 'thumbnails')
835                     discover_iterations(iterations, i)
836                 end
837                 html_thumbnails = ''
838                 reset_iterations(iterations)
839                 for file in entries
840                     type = images.include?(file) ? 'image' : videos.include?(file) ? 'video' : nil
841                     if type
842                         if type == 'image' && elem = xmldir.elements["image[@filename='#{utf8(file)}']"]
843                             if  pano = pano_amount(elem)
844                                 html_thumbnails += run_iterations(iterations, pano)
845                                 html_thumbnails.gsub!(/~~colspan~~/) { 'colspan="' + pano.ceil.to_s + '"' }
846                             else
847                                 html_thumbnails += run_iterations(iterations, 1)
848                                 html_thumbnails.gsub!(/~~colspan~~/, '')
849                             end
850                         else 
851                             html_thumbnails += run_iterations(iterations, 1)
852                             html_thumbnails.gsub!(/~~colspan~~/, '')
853                         end
854                         if type == 'image'
855                             index = images.index(file)
856                             html_thumbnails.gsub!(/~~image_iteration~~/,
857                                                   '<a href="image-' + size2js(sizeobj['name']) + '.html#current=' + fullscreen_images[sizeobj['name']][index] +
858                                                       '" name="' + fullscreen_images[sizeobj['name']][index] + '">' +
859                                                       img_element("#{dest_dir}/#{thumbnail_images[sizeobj['name']][index]}") + '</a>')
860                             html_thumbnails.gsub!(/~~caption_iteration~~/,
861                                                   find_caption_value(xmldir, images[index]) || utf8(images[index]))
862                             html_thumbnails.gsub!(/~~ifimage\?~~(.+?)~~fi~~/) { $1 }
863                             html_thumbnails.gsub!(/~~ifvideo\?~~(.+?)~~fi~~/, '')
864                         elsif type == 'video'
865                             index = videos.index(file)
866                             if File.exists?("#{dest_dir}/#{thumbnail_videos[sizeobj['name']][index]}")
867                                 html_thumbnails.gsub!(/~~image_iteration~~/,
868                                                       '<a href="' + videos[index] + '">' + img_element("#{dest_dir}/#{thumbnail_videos[sizeobj['name']][index]}") + '</a>')
869                             else
870                                 html_thumbnails.gsub!(/~~image_iteration~~/,
871                                                       '<a href="' + videos[index] + '">' + utf8(_("(no preview)")) + '</a>')
872                             end
873                             html_thumbnails.gsub!(/~~caption_iteration~~/,
874                                                   find_caption_value(xmldir, videos[index]) || utf8(videos[index]))
875                             html_thumbnails.gsub!(/~~ifimage\?~~(.+?)~~fi~~/, '')
876                             html_thumbnails.gsub!(/~~ifvideo\?~~(.+?)~~fi~~/) { $1 }
877                         end
878                     end
879                 end
880                 html_thumbnails += close_iterations(iterations)
881                 for i in html
882                     i.sub!(/~~thumbnails~~/, html_thumbnails)
883                     i.gsub!(/~~theme~~/, $theme)
884                     i.gsub!(/~~current_size~~/, sizeobj['name'])
885                     i.gsub!(/~~current_size_js~~/, size2js(sizeobj['name']))
886                     i.gsub!(/~~madewith~~/, $madewith || '')
887                 end
888                 ios = File.open("#{dest_dir}/thumbnails-#{size2js(sizeobj['name'])}.html", "w")
889                 ios.write(html)
890                 ios.close
891             end
892
893             info("finished processing sizes")
894
895             #- generate "main" thumbnails.html page that will reload to correct size thanks to cookie
896             ios = File.open("#{dest_dir}/thumbnails.html", "w")
897             ios.write(html_reload_to_thumbnails)
898             ios.close
899
900             #- generate image.html (page with fullscreen images)
901             if images.size > 0
902                 captions4js = find_captions(xmldir, images).collect { |e| e ? '"' + e.gsub('"', '\"') + '"' : '""' }.join(', ')
903
904                 for sizeobj in $images_size
905                     html = $html_images.collect { |l| l.clone }
906                     images4js = fullscreen_images[sizeobj['name']].collect { |e| "\"#{e}\"" }.join(', ')
907                     otherimages4js = ''
908                     othersizes = []
909                     for sizeobj2 in all_images_sizes
910                         if sizeobj != sizeobj2
911                             otherimages4js += "var images_#{size2js(sizeobj2['name'])} = new Array(" + fullscreen_images[sizeobj2['name']].collect { |e| "\"#{e}\"" }.join(', ') + ")\n"
912                             othersizes << "\"#{size2js(sizeobj2['name'])}\""
913                         end
914                     end
915                     for i in html
916                         i.gsub!(/~~images~~/, images4js)
917                         i.gsub!(/~~other_images~~/, otherimages4js)
918                         i.gsub!(/~~other_sizes~~/, othersizes.join(', '))
919                         i.gsub!(/~~captions~~/, captions4js)
920                         i.gsub!(/~~title~~/, xmldir.attributes['thumbnails-caption'] || utf8(File.basename(dir)))
921                         i.gsub!(/~~thumbnails~~/, '<a href="thumbnails-' + size2js(sizeobj['name']) + '.html" id="thumbnails">' + utf8(_('return to thumbnails')) + '</a>')
922                         i.gsub!(/~~theme~~/, $theme)
923                         i.gsub!(/~~current_size~~/, size2js(sizeobj['name']))
924                         i.gsub!(/~~madewith~~/, $madewith || '')
925                         substitute_html_sizes(i, sizeobj, 'image')
926                     end
927                     ios = File.open("#{dest_dir}/image-#{size2js(sizeobj['name'])}.html", "w")
928                     ios.write(html)
929                     ios.close
930                 end
931             end
932         end
933     }
934
935     msg 3, ''
936
937     #- add attributes to <dir..> elements needing so
938     if $mode != 'use_config'
939         msg 3, _("\tfixating configuration file...")
940         $xmldoc.elements.each('//dir') { |element|
941             path = captionpath = element.attributes['path']
942             descendant_element = element.elements['descendant::image'] || element.elements['descendant::video']
943             if !descendant_element
944                 msg 3, _("\t\tremoving %s, no element in it") % path
945                 element.remove  #- means we have a directory with nothing interesting in it
946             else
947                 captionfile = "#{descendant_element.parent.attributes['path']}/#{descendant_element.attributes['filename']}"
948                 basename = File.basename(path)
949                 if element.elements['dir']
950                     if !element.attributes['subdirs-caption']
951                         element.add_attribute('subdirs-caption', basename)
952                     end
953                     if !element.attributes['subdirs-captionfile']
954                         element.add_attribute('subdirs-captionfile', captionfile)
955                     end
956                 end
957                 if element.child_byname_notattr('image', 'deleted') || element.child_byname_notattr('video', 'deleted')
958                     if !element.attributes['thumbnails-caption']
959                         element.add_attribute('thumbnails-caption', basename)
960                     end
961                     if !element.attributes['thumbnails-captionfile']
962                         element.add_attribute('thumbnails-captionfile', captionfile)
963                     end
964                 end
965             end
966         }
967     end
968
969     #- write down to disk config if necessary
970     if $config_writeto
971         ios = File.open($config_writeto, "w")
972         $xmldoc.write(ios, 0)
973         ios.close
974     end
975
976     if $forgui
977         msg 3, _(" completed necessary stuff for GUI, exiting.")
978         return
979     end
980
981     #- second pass to create index.html files and previous/next links
982     info("creating index.html")
983     msg 3, _("\trescanning directories to generate all `index.html' files...")
984
985     #- recompute the memoization because elements mights have been removed (the ones with no element in them)
986     optxpath = {}
987     $xmldoc.elements.each('//dir') { |elem|
988         optxpath[elem.attributes['path']] = elem
989     }
990
991     examined_dirs.each { |dir|
992         dir.chomp!
993         info("index.html: #{dir}|#{$source}")
994
995         xmldir = optxpath[utf8(dir)]
996         if !xmldir || (xmldir.attributes['already-generated'] && !$force) || xmldir.attributes['deleted']
997             next
998         end
999         dest_dir = make_dest_filename(dir.sub(/^#{Regexp.quote($source)}/, $dest))
1000
1001         previous_album = find_previous_album(xmldir)
1002         next_album = find_next_album(xmldir)
1003
1004         if xmldir.elements['dir']
1005             html = $html_index.collect { |l| l.clone }
1006             iterations = {}
1007             for i in html
1008                 caption = xmldir.attributes['subdirs-caption']
1009                 i.gsub!(/~~title~~/, caption)
1010                 if xmldir.parent.name == 'dir'
1011                     nav = ''
1012                     path = '..'
1013                     parent = xmldir.parent
1014                     while parent.name == 'dir'
1015                         parentcaption = parent.attributes['subdirs-caption']
1016                         nav = "<a href='#{path}/index.html'>#{parentcaption}</a> #{utf8(_(" > "))} #{nav}"
1017                         path += '/..'
1018                         parent = parent.parent
1019                     end
1020                     i.gsub!(/~~ifnavigation\?~~(.+?)~~fi~~/) { $1 }
1021                     i.gsub!(/~~navigation~~/, nav + caption)
1022                 else
1023                     i.gsub!(/~~ifnavigation\?~~(.+?)~~fi~~/, '')
1024                 end
1025                 discover_iterations(iterations, i)
1026             end
1027             
1028             html_index = ''
1029             reset_iterations(iterations)
1030             
1031             #- deal with "current" album (directs to "thumbnails" page)
1032             if xmldir.attributes['thumbnails-caption']
1033                 thumbnail = "#{dest_dir}/thumbnails-thumbnail.jpg"
1034                 gen_thumbnails_subdir(from_utf8(xmldir.attributes['thumbnails-captionfile']), xmldir, false,
1035                                       [ { 'filename' => thumbnail, 'size' => $albums_thumbnail_size } ], 'thumbnails')
1036                 html_index += run_iterations(iterations, 1)
1037                 html_index.gsub!(/~~image_iteration~~/, "<a href='thumbnails.html'>" + img_element(thumbnail) + '</a>')
1038                 html_index.gsub!(/~~caption_iteration~~/, xmldir.attributes['thumbnails-caption'])
1039             end
1040
1041             #- deal with sub-albums (direct to subdirs/index.html pages)
1042             xmldir.elements.each('dir') { |child|
1043                 if child.attributes['deleted']
1044                     next
1045                 end
1046                 subdir = make_dest_filename(from_utf8(File.basename(child.attributes['path'])))
1047                 thumbnail = "#{dest_dir}/thumbnails-#{subdir}.jpg"
1048                 html_index += run_iterations(iterations, 1)
1049                 captionfile, caption = find_subalbum_caption_info(child)
1050                 gen_thumbnails_subdir(captionfile, child, false,
1051                                       [ { 'filename' => thumbnail, 'size' => $albums_thumbnail_size } ], find_subalbum_info_type(child))
1052                 html_index.gsub!(/~~caption_iteration~~/, caption)
1053                 html_index.gsub!(/~~image_iteration~~/, "<a href='#{make_dest_filename(subdir)}/index.html'>" + img_element(thumbnail) + '</a>')
1054             }
1055
1056             html_index += close_iterations(iterations)
1057
1058             for i in html
1059                 i.gsub!(/~~thumbnails~~/, html_index)
1060                 i.gsub!(/~~madewith~~/, $madewith || '')
1061             end
1062             
1063         else
1064             html = html_reload_to_thumbnails
1065         end
1066
1067         ios = File.open("#{dest_dir}/index.html", "w")
1068         ios.write(html)
1069         ios.close
1070
1071         #- substitute "return to albums" and previous/next correctly
1072         if xmldir.child_byname_notattr('image', 'deleted') || xmldir.child_byname_notattr('video', 'deleted')
1073             for sizeobj in $images_size
1074                 substInFile("#{dest_dir}/thumbnails-#{size2js(sizeobj['name'])}.html") { |line|
1075                     sub_previous_next_album(previous_album, next_album, line)
1076                     if xmldir.elements['dir']
1077                         line.sub!(/~~return_to_albums~~/, '<a href="index.html">' + utf8(_('return to albums')) + '</a>')
1078                     else
1079                         if xmldir.parent.name == 'dir'
1080                             line.sub!(/~~return_to_albums~~/, '<a href="../index.html">' + utf8(_('return to albums')) + '</a>')
1081                         else
1082                             line.sub!(/~~return_to_albums~~/, '')
1083                         end
1084                     end
1085                     line
1086                 }
1087                 if xmldir.child_byname_notattr('image', 'deleted')
1088                     substInFile("#{dest_dir}/image-#{size2js(sizeobj['name'])}.html") { |line|
1089                         sub_previous_next_album(previous_album, next_album, line)
1090                     }
1091                 end
1092             end
1093         end
1094     }
1095
1096     msg 3, _(" all done.")
1097 end
1098
1099 handle_options
1100 read_config
1101 check_installation
1102
1103 build_html_skeletons
1104
1105 walk_source_dir