5 # A.k.a 'Best web-album Of the world, Or your money back, Humerus'.
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.
13 # Copyright (c) 2004 Guillaume Cottenceau <http://zarb.org/~gc/resource/gc_mail.png>
15 # This software may be freely redistributed under the terms of the GNU
16 # public license version 2.
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.
27 require 'booh/gtkadds'
28 require 'booh/GtkAutoTable'
32 bindtextdomain("booh")
34 require 'rexml/document'
37 require 'booh/booh-lib'
39 require 'booh/UndoHandler'
44 [ '--help', '-h', GetoptLong::NO_ARGUMENT, _("Get help message") ],
46 [ '--verbose-level', '-v', GetoptLong::REQUIRED_ARGUMENT, _("Set max verbosity level (0: errors, 1: warnings, 2: important messages, 3: other messages)") ],
49 #- default values for some globals
53 $ignore_videos = false
54 $button1_pressed_autotable = false
55 $generated_outofline = false
58 puts _("Usage: %s [OPTION]...") % File.basename($0)
60 printf " %3s, %-15s %s\n", ary[1], ary[0], ary[3]
65 parser = GetoptLong.new
66 parser.set_options(*$options.collect { |ary| ary[0..2] })
68 parser.each_option do |name, arg|
74 when '--verbose-level'
75 $verbose_level = arg.to_i
88 $config_file = File.expand_path('~/.booh-gui-rc')
89 if File.readable?($config_file)
90 $xmldoc = REXML::Document.new(File.new($config_file))
91 $xmldoc.root.elements.each { |element|
92 txt = element.get_text
94 if txt.value =~ /~~~/ || element.name == 'last-opens'
95 $config[element.name] = txt.value.split(/~~~/)
97 $config[element.name] = txt.value
99 elsif element.elements.size == 0
100 $config[element.name] = ''
102 $config[element.name] = {}
103 element.each { |chld|
105 $config[element.name][chld.name] = txt ? txt.value : nil
110 $config['video-viewer'] ||= '/usr/bin/mplayer %f'
111 $config['browser'] ||= "/usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f"
112 if !FileTest.directory?(File.expand_path('~/.booh'))
113 system("mkdir ~/.booh")
121 if !system("which convert >/dev/null 2>/dev/null")
122 show_popup($main_window, utf8(_("The program 'convert' is needed. Please install it.
123 It is generally available with the 'ImageMagick' software package.")), { :pos_centered => true })
126 if !system("which identify >/dev/null 2>/dev/null")
127 show_popup($main_window, utf8(_("The program 'identify' is needed to get images sizes and EXIF data. Please install it.
128 It is generally available with the 'ImageMagick' software package.")), { :pos_centered => true })
130 missing = %w(transcode mencoder).delete_if { |prg| system("which #{prg} >/dev/null 2>/dev/null") }
132 show_popup($main_window, utf8(_("The following program(s) are needed to handle videos: '%s'. Videos will be ignored.") % missing.join(', ')), { :pos_centered => true })
135 viewer_binary = $config['video-viewer'].split.first
136 if viewer_binary && !File.executable?(viewer_binary)
137 show_popup($main_window, utf8(_("The configured video viewer seems to be unavailable.
138 You should fix this in Edit/Preferences so that you can view videos.
140 Problem was: '%s' is not an executable file.
141 Hint: don't forget to specify the full path to the executable,
142 e.g. '/usr/bin/mplayer' is correct but 'mplayer' only is not.") % viewer_binary), { :pos_centered => true, :not_transient => true })
144 browser_binary = $config['browser'].split.first
145 if browser_binary && !File.executable?(browser_binary)
146 show_popup($main_window, utf8(_("The configured browser seems to be unavailable.
147 You should fix this in Edit/Preferences so that you can open URLs.
149 Problem was: '%s' is not an executable file.") % browser_binary), { :pos_centered => true, :not_transient => true })
154 if $config['last-opens'] && $config['last-opens'].size > 5
155 $config['last-opens'] = $config['last-opens'][-5, 5]
158 ios = File.open($config_file, "w")
159 $xmldoc = Document.new "<booh-gui-rc version='#{$VERSION}'/>"
160 $xmldoc << XMLDecl.new(XMLDecl::DEFAULT_VERSION, $CURRENT_CHARSET)
161 $config.each_pair { |key, value|
162 elem = $xmldoc.root.add_element key
164 $config[key].each_pair { |subkey, subvalue|
165 subelem = elem.add_element subkey
166 subelem.add_text subvalue.to_s
168 elsif value.is_a? Array
169 elem.add_text value.join('~~~')
174 elem.add_text value.to_s
178 $xmldoc.write(ios, 0)
181 $tempfiles.each { |f|
186 def set_mousecursor(what, *widget)
187 cursor = what.nil? ? nil : Gdk::Cursor.new(what)
188 if widget[0] && widget[0].window
189 widget[0].window.cursor = cursor
191 if $main_window && $main_window.window
192 $main_window.window.cursor = cursor
194 $current_cursor = what
196 def set_mousecursor_wait(*widget)
197 gtk_thread_protect { set_mousecursor(Gdk::Cursor::WATCH, *widget) }
198 if Thread.current == Thread.main
199 Gtk.main_iteration while Gtk.events_pending?
202 def set_mousecursor_normal(*widget)
203 gtk_thread_protect { set_mousecursor($save_cursor = nil, *widget) }
205 def push_mousecursor_wait(*widget)
206 if $current_cursor != Gdk::Cursor::WATCH
207 $save_cursor = $current_cursor
208 gtk_thread_protect { set_mousecursor_wait(*widget) }
211 def pop_mousecursor(*widget)
212 gtk_thread_protect { set_mousecursor($save_cursor || nil, *widget) }
216 source = $xmldoc.root.attributes['source']
217 dest = $xmldoc.root.attributes['destination']
218 return make_dest_filename(from_utf8($current_path.sub(/^#{Regexp.quote(source)}/, dest)))
221 def full_src_dir_to_rel(path, source)
222 return path.sub(/^#{Regexp.quote(from_utf8(source))}/, '')
225 def build_full_dest_filename(filename)
226 return current_dest_dir + '/' + make_dest_filename(from_utf8(filename))
229 def save_undo(name, closure, *params)
230 UndoHandler.save_undo(name, closure, [ *params ])
231 $undo_tb.sensitive = $undo_mb.sensitive = true
232 $redo_tb.sensitive = $redo_mb.sensitive = false
235 def view_element(filename, closures)
236 if entry2type(filename) == 'video'
237 cmd = from_utf8($config['video-viewer']).gsub('%f', "'#{from_utf8($current_path + '/' + filename)}'") + ' &'
243 w = Gtk::Window.new.set_title(filename)
245 msg 3, "filename: #{filename}"
246 dest_img = build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + "-#{$default_size['fullscreen']}.jpg"
247 #- typically this file won't exist in case of videos; try with the largest thumbnail around
248 if !File.exists?(dest_img)
249 if entry2type(filename) == 'video'
250 alternatives = Dir[build_full_dest_filename(filename).sub(/\.[^\.]+$/, '') + '-*'].sort
251 if not alternatives.empty?
252 dest_img = alternatives[-1]
255 push_mousecursor_wait
256 gen_thumbnails_element(from_utf8("#{$current_path}/#{filename}"), $xmldir, false, [ { 'filename' => dest_img, 'size' => $default_size['fullscreen'] } ])
258 if !File.exists?(dest_img)
259 msg 2, _("Could not generate fullscreen thumbnail!")
264 evt = Gtk::EventBox.new.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(Gtk::Frame.new.add(Gtk::Image.new(dest_img)).set_shadow_type(Gtk::SHADOW_ETCHED_OUT)))
265 evt.signal_connect('button-press-event') { |this, event|
266 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
267 $config['nogestures'] or $gesture_press = { :x => event.x, :y => event.y }
269 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
271 menu.append(delete_item = Gtk::ImageMenuItem.new(Gtk::Stock::DELETE))
272 delete_item.signal_connect('activate') {
274 closures[:delete].call
277 menu.popup(nil, nil, event.button, event.time)
280 evt.signal_connect('button-release-event') { |this, event|
282 if (($gesture_press[:y]-event.y)/($gesture_press[:x]-event.x)).abs > 2 && event.y-$gesture_press[:y] > 5
283 msg 3, "gesture delete: click-drag right button to the bottom"
285 closures[:delete].call
286 $statusbar.push(0, utf8(_("Mouse gesture: delete.")))
290 tooltips = Gtk::Tooltips.new
291 tooltips.set_tip(evt, File.basename(filename).gsub(/\.jpg/, ''), nil)
293 w.signal_connect('key-press-event') { |w,event|
294 if event.state & Gdk::Window::CONTROL_MASK != 0 && event.keyval == Gdk::Keyval::GDK_Delete
296 closures[:delete].call
300 bottom = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(b = Gtk::Button.new(Gtk::Stock::CLOSE))
301 b.signal_connect('clicked') { w.destroy }
304 vb.pack_start(evt, false, false)
305 vb.pack_end(bottom, false, false)
308 w.signal_connect('delete-event') { w.destroy }
309 w.window_position = Gtk::Window::POS_CENTER
313 def scroll_upper(scrolledwindow, ypos_top)
314 newval = scrolledwindow.vadjustment.value -
315 ((scrolledwindow.vadjustment.value - ypos_top - 1) / scrolledwindow.vadjustment.step_increment + 1) * scrolledwindow.vadjustment.step_increment
316 if newval < scrolledwindow.vadjustment.lower
317 newval = scrolledwindow.vadjustment.lower
319 scrolledwindow.vadjustment.value = newval
322 def scroll_lower(scrolledwindow, ypos_bottom)
323 newval = scrolledwindow.vadjustment.value +
324 ((ypos_bottom - (scrolledwindow.vadjustment.value + scrolledwindow.vadjustment.page_size) - 1) / scrolledwindow.vadjustment.step_increment + 1) * scrolledwindow.vadjustment.step_increment
325 if newval > scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
326 newval = scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
328 scrolledwindow.vadjustment.value = newval
331 def autoscroll_if_needed(scrolledwindow, image, textview)
332 #- autoscroll if cursor or image is not visible, if possible
333 if image && image.window || textview.window
334 ypos_top = (image && image.window) ? image.window.position[1] : textview.window.position[1]
335 ypos_bottom = max(textview.window.position[1] + textview.window.size[1], image && image.window ? image.window.position[1] + image.window.size[1] : -1)
336 current_miny_visible = scrolledwindow.vadjustment.value
337 current_maxy_visible = scrolledwindow.vadjustment.value + scrolledwindow.vadjustment.page_size
338 if ypos_top < current_miny_visible
339 scroll_upper(scrolledwindow, ypos_top)
340 elsif ypos_bottom > current_maxy_visible
341 scroll_lower(scrolledwindow, ypos_bottom)
346 def create_editzone(scrolledwindow, pagenum, image)
347 frame = Gtk::Frame.new
348 frame.add(textview = Gtk::TextView.new.set_wrap_mode(Gtk::TextTag::WRAP_WORD))
349 frame.set_shadow_type(Gtk::SHADOW_IN)
350 textview.signal_connect('key-press-event') { |w, event|
351 textview.set_editable(event.keyval != Gdk::Keyval::GDK_Tab)
352 if event.keyval == Gdk::Keyval::GDK_Page_Up || event.keyval == Gdk::Keyval::GDK_Page_Down
353 scrolledwindow.signal_emit('key-press-event', event)
355 if (event.keyval == Gdk::Keyval::GDK_Up || event.keyval == Gdk::Keyval::GDK_Down) &&
356 event.state & (Gdk::Window::CONTROL_MASK | Gdk::Window::SHIFT_MASK | Gdk::Window::MOD1_MASK) == 0
357 if event.keyval == Gdk::Keyval::GDK_Up
358 if scrolledwindow.vadjustment.value >= scrolledwindow.vadjustment.lower + scrolledwindow.vadjustment.step_increment
359 scrolledwindow.vadjustment.value -= scrolledwindow.vadjustment.step_increment
361 scrolledwindow.vadjustment.value = scrolledwindow.vadjustment.lower
364 if scrolledwindow.vadjustment.value <= scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.step_increment - scrolledwindow.vadjustment.page_size
365 scrolledwindow.vadjustment.value += scrolledwindow.vadjustment.step_increment
367 scrolledwindow.vadjustment.value = scrolledwindow.vadjustment.upper - scrolledwindow.vadjustment.page_size
373 textview.signal_connect('focus-in-event') { |w, event|
374 textview.buffer.select_range(textview.buffer.get_iter_at_offset(0), textview.buffer.get_iter_at_offset(-1))
378 candidate_undo_text = nil
379 textview.signal_connect('focus-in-event') { |w, event|
380 candidate_undo_text = textview.buffer.text
383 textview.signal_connect('key-release-event') { |w, event|
384 if candidate_undo_text && candidate_undo_text != textview.buffer.text
386 save_undo(_("text edit"),
388 save_text = textview.buffer.text
389 textview.buffer.text = text
391 $notebook.set_page(pagenum)
393 textview.buffer.text = save_text
395 $notebook.set_page(pagenum)
397 }, candidate_undo_text)
398 candidate_undo_text = nil
401 if event.state != 0 || ![Gdk::Keyval::GDK_Page_Up, Gdk::Keyval::GDK_Page_Down, Gdk::Keyval::GDK_Up, Gdk::Keyval::GDK_Down].include?(event.keyval)
402 autoscroll_if_needed(scrolledwindow, image, textview)
407 return [ frame, textview ]
410 def update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
412 if !$modified_pixbufs[thumbnail_img]
413 $modified_pixbufs[thumbnail_img] = { :orig => img.pixbuf }
414 elsif !$modified_pixbufs[thumbnail_img][:orig]
415 $modified_pixbufs[thumbnail_img][:orig] = img.pixbuf
418 pixbuf = $modified_pixbufs[thumbnail_img][:orig].dup
421 if $modified_pixbufs[thumbnail_img][:angle_to_orig] && $modified_pixbufs[thumbnail_img][:angle_to_orig] != 0
422 pixbuf = rotate_pixbuf(pixbuf, $modified_pixbufs[thumbnail_img][:angle_to_orig])
423 msg 3, "sizes: #{pixbuf.width} #{pixbuf.height} - desired #{desired_x}x#{desired_x}"
424 if pixbuf.height > desired_y
425 pixbuf = pixbuf.scale(pixbuf.width * (desired_y.to_f/pixbuf.height), desired_y, Gdk::Pixbuf::INTERP_BILINEAR)
426 elsif pixbuf.width < desired_x && pixbuf.height < desired_y
427 pixbuf = pixbuf.scale(desired_x, pixbuf.height * (desired_x.to_f/pixbuf.width), Gdk::Pixbuf::INTERP_BILINEAR)
432 if $modified_pixbufs[thumbnail_img][:whitebalance]
433 pixbuf.whitebalance!($modified_pixbufs[thumbnail_img][:whitebalance])
436 img.pixbuf = $modified_pixbufs[thumbnail_img][:pixbuf] = pixbuf
439 def rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
442 #- update rotate attribute
443 xmlelem.add_attribute("#{attributes_prefix}rotate", (xmlelem.attributes["#{attributes_prefix}rotate"].to_i + angle) % 360)
445 $modified_pixbufs[thumbnail_img] ||= {}
446 $modified_pixbufs[thumbnail_img][:angle_to_orig] = (($modified_pixbufs[thumbnail_img][:angle_to_orig] || 0) + angle) % 360
447 msg 3, "angle: #{angle}, angle to orig: #{$modified_pixbufs[thumbnail_img][:angle_to_orig]}"
449 update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
452 def rotate(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
455 rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
457 save_undo(angle == 90 ? _("rotate clockwise") : angle == -90 ? _("rotate counter-clockwise") : _("flip upside-down"),
459 rotate_real(angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
460 $notebook.set_page(attributes_prefix != '' ? 0 : 1)
462 rotate_real(-angle, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y)
463 $notebook.set_page(0)
464 $notebook.set_page(attributes_prefix != '' ? 0 : 1)
469 def color_swap(xmldir, attributes_prefix)
471 if xmldir.attributes["#{attributes_prefix}color-swap"]
472 xmldir.delete_attribute("#{attributes_prefix}color-swap")
474 xmldir.add_attribute("#{attributes_prefix}color-swap", '1')
478 def enhance(xmldir, attributes_prefix)
480 if xmldir.attributes["#{attributes_prefix}enhance"]
481 xmldir.delete_attribute("#{attributes_prefix}enhance")
483 xmldir.add_attribute("#{attributes_prefix}enhance", '1')
487 def change_frame_offset(xmldir, attributes_prefix, value)
489 xmldir.add_attribute("#{attributes_prefix}frame-offset", value)
492 def ask_new_frame_offset(xmldir, attributes_prefix)
494 value = xmldir.attributes["#{attributes_prefix}frame-offset"]
499 dialog = Gtk::Dialog.new(utf8(_("Change frame offset")),
501 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
502 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
503 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
507 _("Please specify the <b>frame offset</b> of the video, to take the thumbnail
508 from. There are approximately 25 frames per second in a video.
511 dialog.vbox.add(entry = Gtk::Entry.new.set_text(value))
512 entry.signal_connect('key-press-event') { |w, event|
513 if event.keyval == Gdk::Keyval::GDK_Return
514 dialog.response(Gtk::Dialog::RESPONSE_OK)
516 elsif event.keyval == Gdk::Keyval::GDK_Escape
517 dialog.response(Gtk::Dialog::RESPONSE_CANCEL)
520 false #- propagate if needed
524 dialog.window_position = Gtk::Window::POS_MOUSE
527 dialog.run { |response|
530 if response == Gtk::Dialog::RESPONSE_OK
532 msg 3, "changing frame offset to #{newval}"
533 return { :old => value, :new => newval }
540 def change_pano_amount(xmldir, attributes_prefix, value)
543 xmldir.delete_attribute("#{attributes_prefix}pano-amount")
545 xmldir.add_attribute("#{attributes_prefix}pano-amount", value)
549 def ask_new_pano_amount(xmldir, attributes_prefix)
551 value = xmldir.attributes["#{attributes_prefix}pano-amount"]
556 dialog = Gtk::Dialog.new(utf8(_("Specify panorama amount")),
558 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
559 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
560 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
564 _("Please specify the <b>panorama 'amount'</b> of the image, which indicates the width
565 of this panorama image compared to other regular images. For example, if the panorama
566 was taken out of four photos on one row, counting the necessary overlap, the width of
567 this panorama image should probably be roughly three times the width of regular images.
569 With this information, booh will be able to generate panorama thumbnails looking
573 dialog.vbox.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(Gtk::HBox.new.add(rb_no = Gtk::RadioButton.new(utf8(_("none (not a panorama image)")))).
574 add(rb_yes = Gtk::RadioButton.new(rb_no, utf8(_("amount of: ")))).
575 add(spin = Gtk::SpinButton.new(1, 8, 0.1)).
576 add(Gtk::Label.new(utf8(_("times the width of other images"))))))
577 dialog.window_position = Gtk::Window::POS_MOUSE
580 spin.value = value.to_f
587 dialog.run { |response|
591 newval = spin.value.to_f
594 if response == Gtk::Dialog::RESPONSE_OK
596 msg 3, "changing panorama amount to #{newval}"
597 return { :old => value, :new => newval }
604 def change_whitebalance(xmlelem, attributes_prefix, value)
606 xmlelem.add_attribute("#{attributes_prefix}white-balance", value)
609 def recalc_whitebalance(level, orig, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
611 #- in case the white balance was already modified in the config file and thumbnail, we cannot just revert, we need to use original file
612 if (!$modified_pixbufs[thumbnail_img] || !$modified_pixbufs[thumbnail_img][:whitebalance]) && xmlelem.attributes["#{attributes_prefix}white-balance"]
613 save_whitebalance = xmlelem.attributes["#{attributes_prefix}white-balance"]
614 xmlelem.delete_attribute("#{attributes_prefix}white-balance")
615 destfile = "#{thumbnail_img}-orig-whitebalance.jpg"
616 gen_real_thumbnail_core(attributes_prefix == '' ? 'element' : 'subdir', orig, destfile,
617 xmlelem, attributes_prefix == '' ? $default_size['thumbnails'] : $albums_thumbnail_size, infotype)
618 $modified_pixbufs[thumbnail_img] ||= {}
619 $modified_pixbufs[thumbnail_img][:orig] = pixbuf_or_nil(destfile)
620 xmlelem.add_attribute("#{attributes_prefix}white-balance", save_whitebalance)
621 $modified_pixbufs[thumbnail_img][:angle_to_orig] = 0
624 $modified_pixbufs[thumbnail_img] ||= {}
625 $modified_pixbufs[thumbnail_img][:whitebalance] = level.to_f
627 update_shown_pixbuf(thumbnail_img, img, desired_x, desired_y)
630 def ask_whitebalance(orig, thumbnail_img, img_, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
631 #- init $modified_pixbufs correctly
632 # update_shown_pixbuf(thumbnail_img, img_, desired_x, desired_y)
634 value = xmlelem ? (xmlelem.attributes["#{attributes_prefix}white-balance"] || "0") : "0"
636 dialog = Gtk::Dialog.new(utf8(_("Fix white balance")),
638 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
639 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
640 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
644 _("You can fix the <b>white balance</b> of the image, if your image is too blue
645 or too yellow because your camera didn't detect the light correctly. Drag the
646 slider below the image to the left for more blue, to the right for more yellow.
650 dialog.vbox.add(evt = Gtk::EventBox.new.add(img = Gtk::Image.new(img_.pixbuf)))
652 dialog.vbox.add(hs = Gtk::HScale.new(-200, 200, 1).set_value(value.to_i))
654 dialog.window_position = Gtk::Window::POS_MOUSE
658 timeout = Gtk.timeout_add(100) {
659 if hs.value != lastval
662 recalc_whitebalance(lastval, orig, thumbnail_img, img, xmlelem, attributes_prefix, desired_x, desired_y, infotype)
668 dialog.run { |response|
669 Gtk.timeout_remove(timeout)
670 if response == Gtk::Dialog::RESPONSE_OK
672 newval = hs.value.to_s
673 msg 3, "changing white balance to #{newval}"
675 return { :old => value, :new => newval }
678 $modified_pixbufs[thumbnail_img] ||= {}
679 $modified_pixbufs[thumbnail_img][:whitebalance] = value.to_f
680 $modified_pixbufs[thumbnail_img][:pixbuf] = img_.pixbuf
688 def gen_real_thumbnail_core(type, origfile, destfile, xmldir, size, infotype)
689 system("rm -f '#{destfile}'")
690 #- type can be 'element' or 'subdir'
692 gen_thumbnails_element(origfile, xmldir, false, [ { 'filename' => destfile, 'size' => size } ])
694 gen_thumbnails_subdir(origfile, xmldir, false, [ { 'filename' => destfile, 'size' => size } ], infotype)
698 def gen_real_thumbnail(type, origfile, destfile, xmldir, size, img, infotype)
700 push_mousecursor_wait
701 gen_real_thumbnail_core(type, origfile, destfile, xmldir, size, infotype)
704 $modified_pixbufs[destfile] = { :orig => img.pixbuf, :pixbuf => img.pixbuf, :angle_to_orig => 0 }
710 def popup_thumbnail_menu(event, optionals, fullpath, type, xmldir, attributes_prefix, possible_actions, closures)
711 distribute_multiple_call = Proc.new { |action, arg|
712 $selected_elements.each_key { |path|
713 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
715 if possible_actions[:can_multiple] && $selected_elements.length > 0
716 UndoHandler.begin_batch
717 $selected_elements.each_key { |k| $name2closures[k][action].call(arg) }
718 UndoHandler.end_batch
720 closures[action].call(arg)
722 $selected_elements = {}
725 if optionals.include?('change_image')
726 menu.append(changeimg = Gtk::ImageMenuItem.new(utf8(_("Change image"))))
727 changeimg.image = Gtk::Image.new("#{$FPATH}/images/stock-images-16.png")
728 changeimg.signal_connect('activate') { closures[:change].call }
729 menu.append(Gtk::SeparatorMenuItem.new)
731 if !possible_actions[:can_multiple] || $selected_elements.length == 0
734 menu.append(view = Gtk::ImageMenuItem.new(utf8(_("View larger"))))
735 view.image = Gtk::Image.new("#{$FPATH}/images/stock-view-16.png")
736 view.signal_connect('activate') { closures[:view].call }
738 menu.append(view = Gtk::ImageMenuItem.new(utf8(_("Play video"))))
739 view.image = Gtk::Image.new("#{$FPATH}/images/stock-video-16.png")
740 view.signal_connect('activate') { closures[:view].call }
741 menu.append(Gtk::SeparatorMenuItem.new)
744 if type == 'image' && (!possible_actions[:can_multiple] || $selected_elements.length == 0)
745 menu.append(exif = Gtk::ImageMenuItem.new(utf8(_("View EXIF data"))))
746 exif.image = Gtk::Image.new("#{$FPATH}/images/stock-list-16.png")
747 exif.signal_connect('activate') { show_popup($main_window,
748 utf8(`identify -format "%[EXIF:*]" #{fullpath}`.sub(/MakerNote.*\n/, '')),
749 { :title => utf8(_("EXIF data of %s") % File.basename(fullpath)), :nomarkup => true, :scrolled => true }) }
750 menu.append(Gtk::SeparatorMenuItem.new)
753 menu.append(r90 = Gtk::ImageMenuItem.new(utf8(_("Rotate clockwise"))))
754 r90.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-90-16.png")
755 r90.signal_connect('activate') { distribute_multiple_call.call(:rotate, 90) }
756 menu.append(r270 = Gtk::ImageMenuItem.new(utf8(_("Rotate counter-clockwise"))))
757 r270.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-270-16.png")
758 r270.signal_connect('activate') { distribute_multiple_call.call(:rotate, -90) }
759 if !possible_actions[:can_multiple] || $selected_elements.length == 0
760 menu.append(Gtk::SeparatorMenuItem.new)
761 if !possible_actions[:forbid_left]
762 menu.append(moveleft = Gtk::ImageMenuItem.new(utf8(_("Move left"))))
763 moveleft.image = Gtk::Image.new("#{$FPATH}/images/stock-move-left.png")
764 moveleft.signal_connect('activate') { closures[:move].call('left') }
765 if !possible_actions[:can_left]
766 moveleft.sensitive = false
769 if !possible_actions[:forbid_right]
770 menu.append(moveright = Gtk::ImageMenuItem.new(utf8(_("Move right"))))
771 moveright.image = Gtk::Image.new("#{$FPATH}/images/stock-move-right.png")
772 moveright.signal_connect('activate') { closures[:move].call('right') }
773 if !possible_actions[:can_right]
774 moveright.sensitive = false
777 menu.append(moveup = Gtk::ImageMenuItem.new(utf8(_("Move up"))))
778 moveup.image = Gtk::Image.new("#{$FPATH}/images/stock-move-up.png")
779 moveup.signal_connect('activate') { closures[:move].call('up') }
780 if !possible_actions[:can_up]
781 moveup.sensitive = false
783 menu.append(movedown = Gtk::ImageMenuItem.new(utf8(_("Move down"))))
784 movedown.image = Gtk::Image.new("#{$FPATH}/images/stock-move-down.png")
785 movedown.signal_connect('activate') { closures[:move].call('down') }
786 if !possible_actions[:can_down]
787 movedown.sensitive = false
791 if !possible_actions[:can_multiple] || $selected_elements.length == 0 || $selected_elements.reject { |k,v| $name2widgets[k][:type] == 'video' }.empty?
792 menu.append(Gtk::SeparatorMenuItem.new)
793 menu.append(color_swap = Gtk::ImageMenuItem.new(utf8(_("Red/blue color swap"))))
794 color_swap.image = Gtk::Image.new("#{$FPATH}/images/stock-color-triangle-16.png")
795 color_swap.signal_connect('activate') { distribute_multiple_call.call(:color_swap) }
796 menu.append(flip = Gtk::ImageMenuItem.new(utf8(_("Flip upside-down"))))
797 flip.image = Gtk::Image.new("#{$FPATH}/images/stock-rotate-180-16.png")
798 flip.signal_connect('activate') { distribute_multiple_call.call(:rotate, 180) }
799 menu.append(frame_offset = Gtk::ImageMenuItem.new(utf8(_("Specify frame offset"))))
800 frame_offset.image = Gtk::Image.new("#{$FPATH}/images/stock-video-16.png")
801 frame_offset.signal_connect('activate') {
802 if possible_actions[:can_multiple] && $selected_elements.length > 0
803 if values = ask_new_frame_offset(nil, '')
804 distribute_multiple_call.call(:frame_offset, values)
807 closures[:frame_offset].call
812 menu.append( Gtk::SeparatorMenuItem.new)
813 menu.append(whitebalance = Gtk::ImageMenuItem.new(utf8(_("Fix white-balance"))))
814 whitebalance.image = Gtk::Image.new("#{$FPATH}/images/stock-tool-color-balance-16.png")
815 whitebalance.signal_connect('activate') {
816 if possible_actions[:can_multiple] && $selected_elements.length > 0
817 if values = ask_whitebalance(nil, nil, nil, nil, '', nil, nil, '')
818 distribute_multiple_call.call(:whitebalance, values)
821 closures[:whitebalance].call
824 if !possible_actions[:can_multiple] || $selected_elements.length == 0
825 menu.append(enhance = Gtk::ImageMenuItem.new(utf8(xmldir.attributes["#{attributes_prefix}enhance"] ? _("Original contrast") :
826 _("Enhance constrast"))))
828 menu.append(enhance = Gtk::ImageMenuItem.new(utf8(_("Toggle contrast enhancement"))))
830 enhance.image = Gtk::Image.new("#{$FPATH}/images/stock-channels-16.png")
831 enhance.signal_connect('activate') { distribute_multiple_call.call(:enhance) }
832 if type == 'image' && possible_actions[:can_panorama]
833 menu.append(panorama = Gtk::ImageMenuItem.new(utf8(_("Set as panorama"))))
834 panorama.image = Gtk::Image.new("#{$FPATH}/images/stock-images-16.png")
835 panorama.signal_connect('activate') {
836 if possible_actions[:can_multiple] && $selected_elements.length > 0
837 if values = ask_new_pano_amount(nil, '')
838 distribute_multiple_call.call(:pano, values)
841 distribute_multiple_call.call(:pano)
845 if optionals.include?('delete')
846 menu.append( Gtk::SeparatorMenuItem.new)
847 menu.append(cut_item = Gtk::ImageMenuItem.new(Gtk::Stock::CUT))
848 cut_item.signal_connect('activate') { distribute_multiple_call.call(:cut) }
849 if !possible_actions[:can_multiple] || $selected_elements.length == 0
850 menu.append(paste_item = Gtk::ImageMenuItem.new(Gtk::Stock::PASTE))
851 paste_item.signal_connect('activate') { closures[:paste].call }
852 menu.append(clear_item = Gtk::ImageMenuItem.new(Gtk::Stock::CLEAR))
853 clear_item.signal_connect('activate') { $cuts = [] }
855 paste_item.sensitive = clear_item.sensitive = false
858 menu.append( Gtk::SeparatorMenuItem.new)
859 menu.append(delete_item = Gtk::ImageMenuItem.new(Gtk::Stock::DELETE))
860 delete_item.signal_connect('activate') { distribute_multiple_call.call(:delete) }
863 menu.popup(nil, nil, event.button, event.time)
866 def delete_current_subalbum
867 $xmldir.elements.each { |e|
868 if e.name == 'image' || e.name == 'video'
869 e.add_attribute('deleted', 'true')
872 #- branch if we have a non deleted subalbum
873 if $xmldir.child_byname_notattr('dir', 'deleted')
874 $xmldir.delete_attribute('thumbnails-caption')
875 $xmldir.delete_attribute('thumbnails-captionfile')
877 $xmldir.add_attribute('deleted', 'true')
879 while moveup.parent.name == 'dir'
880 moveup = moveup.parent
881 if !moveup.child_byname_notattr('dir', 'deleted') && !moveup.child_byname_notattr('image', 'deleted') && !moveup.child_byname_notattr('video', 'deleted')
882 moveup.add_attribute('deleted', 'true')
888 save_changes('forced')
889 populate_subalbums_treeview
892 def add_thumbnail(autotable, filename, type, thumbnail_img, caption)
895 frame1 = Gtk::Frame.new
896 fullpath = from_utf8("#{$current_path}/#{filename}")
898 my_gen_real_thumbnail = proc {
899 gen_real_thumbnail('element', fullpath, thumbnail_img, $xmldir, $default_size['thumbnails'], img, '')
902 #- generate the thumbnail if missing (if image was rotated but booh was not relaunched)
903 if !$modified_pixbufs[thumbnail_img] && !File.exists?(thumbnail_img)
904 frame1.add(img = Gtk::Image.new)
905 my_gen_real_thumbnail.call
907 frame1.add(img = Gtk::Image.new($modified_pixbufs[thumbnail_img] ? $modified_pixbufs[thumbnail_img][:pixbuf] : thumbnail_img))
909 evtbox = Gtk::EventBox.new.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(frame1.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)))
911 tooltips = Gtk::Tooltips.new
912 tipname = from_utf8(File.basename(filename).gsub(/\.[^\.]+$/, ''))
913 tooltips.set_tip(evtbox, utf8(type == 'video' ? (_("%s (video - %s KB)") % [tipname, commify(file_size(fullpath)/1024)]) : tipname), nil)
915 frame2, textview = create_editzone($autotable_sw, 1, img)
916 textview.buffer.text = utf8(caption)
917 textview.set_justification(Gtk::Justification::CENTER)
919 vbox = Gtk::VBox.new(false, 5)
920 vbox.pack_start(evtbox, false, false)
921 vbox.pack_start(frame2, false, false)
922 autotable.append(vbox, filename)
924 #- to be able to grab focus of textview when retrieving vbox's position from AutoTable
925 $vbox2widgets[vbox] = { :textview => textview, :image => img }
927 #- to be able to find widgets by name
928 $name2widgets[filename] = { :textview => textview, :evtbox => evtbox, :vbox => vbox, :img => img, :type => type }
930 cleanup_all_thumbnails = Proc.new {
931 #- remove out of sync images
932 dest_img_base = build_full_dest_filename(filename).sub(/\.[^\.]+$/, '')
933 for sizeobj in $images_size
934 system("rm -f #{dest_img_base}-#{sizeobj['fullscreen']}.jpg #{dest_img_base}-#{sizeobj['thumbnails']}.jpg")
939 rotate_and_cleanup = Proc.new { |angle|
940 rotate(angle, thumbnail_img, img, $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y])
941 cleanup_all_thumbnails.call
944 move = Proc.new { |direction|
945 do_method = "move_#{direction}"
946 undo_method = "move_" + case direction; when 'left'; 'right'; when 'right'; 'left'; when 'up'; 'down'; when 'down'; 'up' end
948 done = autotable.method(do_method).call(vbox)
949 textview.grab_focus #- because if moving, focus is stolen
953 save_undo(_("move %s") % direction,
955 autotable.method(undo_method).call(vbox)
956 textview.grab_focus #- because if moving, focus is stolen
957 autoscroll_if_needed($autotable_sw, img, textview)
958 $notebook.set_page(1)
960 autotable.method(do_method).call(vbox)
961 textview.grab_focus #- because if moving, focus is stolen
962 autoscroll_if_needed($autotable_sw, img, textview)
963 $notebook.set_page(1)
969 color_swap_and_cleanup = Proc.new {
970 perform_color_swap_and_cleanup = Proc.new {
971 color_swap($xmldir.elements["*[@filename='#{filename}']"], '')
972 my_gen_real_thumbnail.call
975 cleanup_all_thumbnails.call
976 perform_color_swap_and_cleanup.call
978 save_undo(_("color swap"),
980 perform_color_swap_and_cleanup.call
982 autoscroll_if_needed($autotable_sw, img, textview)
983 $notebook.set_page(1)
985 perform_color_swap_and_cleanup.call
987 autoscroll_if_needed($autotable_sw, img, textview)
988 $notebook.set_page(1)
993 change_frame_offset_and_cleanup_real = Proc.new { |values|
994 perform_change_frame_offset_and_cleanup = Proc.new { |val|
995 change_frame_offset($xmldir.elements["*[@filename='#{filename}']"], '', val)
996 my_gen_real_thumbnail.call
998 perform_change_frame_offset_and_cleanup.call(values[:new])
1000 save_undo(_("specify frame offset"),
1002 perform_change_frame_offset_and_cleanup.call(values[:old])
1004 autoscroll_if_needed($autotable_sw, img, textview)
1005 $notebook.set_page(1)
1007 perform_change_frame_offset_and_cleanup.call(values[:new])
1009 autoscroll_if_needed($autotable_sw, img, textview)
1010 $notebook.set_page(1)
1015 change_frame_offset_and_cleanup = Proc.new {
1016 if values = ask_new_frame_offset($xmldir.elements["*[@filename='#{filename}']"], '')
1017 change_frame_offset_and_cleanup_real.call(values)
1021 change_pano_amount_and_cleanup_real = Proc.new { |values|
1022 perform_change_pano_amount_and_cleanup = Proc.new { |val|
1023 change_pano_amount($xmldir.elements["*[@filename='#{filename}']"], '', val)
1025 perform_change_pano_amount_and_cleanup.call(values[:new])
1027 save_undo(_("change panorama amount"),
1029 perform_change_pano_amount_and_cleanup.call(values[:old])
1031 autoscroll_if_needed($autotable_sw, img, textview)
1032 $notebook.set_page(1)
1034 perform_change_pano_amount_and_cleanup.call(values[:new])
1036 autoscroll_if_needed($autotable_sw, img, textview)
1037 $notebook.set_page(1)
1042 change_pano_amount_and_cleanup = Proc.new {
1043 if values = ask_new_pano_amount($xmldir.elements["*[@filename='#{filename}']"], '')
1044 change_pano_amount_and_cleanup_real.call(values)
1048 whitebalance_and_cleanup_real = Proc.new { |values|
1049 perform_change_whitebalance_and_cleanup = Proc.new { |val|
1050 change_whitebalance($xmldir.elements["*[@filename='#{filename}']"], '', val)
1051 recalc_whitebalance(val, fullpath, thumbnail_img, img,
1052 $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y], '')
1053 cleanup_all_thumbnails.call
1055 perform_change_whitebalance_and_cleanup.call(values[:new])
1057 save_undo(_("fix white balance"),
1059 perform_change_whitebalance_and_cleanup.call(values[:old])
1061 autoscroll_if_needed($autotable_sw, img, textview)
1062 $notebook.set_page(1)
1064 perform_change_whitebalance_and_cleanup.call(values[:new])
1066 autoscroll_if_needed($autotable_sw, img, textview)
1067 $notebook.set_page(1)
1072 whitebalance_and_cleanup = Proc.new {
1073 if values = ask_whitebalance(fullpath, thumbnail_img, img,
1074 $xmldir.elements["*[@filename='#{filename}']"], '', $default_thumbnails[:x], $default_thumbnails[:y], '')
1075 whitebalance_and_cleanup_real.call(values)
1079 enhance_and_cleanup = Proc.new {
1080 perform_enhance_and_cleanup = Proc.new {
1081 enhance($xmldir.elements["*[@filename='#{filename}']"], '')
1082 my_gen_real_thumbnail.call
1085 cleanup_all_thumbnails.call
1086 perform_enhance_and_cleanup.call
1088 save_undo(_("enhance"),
1090 perform_enhance_and_cleanup.call
1092 autoscroll_if_needed($autotable_sw, img, textview)
1093 $notebook.set_page(1)
1095 perform_enhance_and_cleanup.call
1097 autoscroll_if_needed($autotable_sw, img, textview)
1098 $notebook.set_page(1)
1103 delete = Proc.new { |isacut|
1104 if autotable.current_order.size > 1 || show_popup($main_window, utf8(_("Do you confirm this subalbum needs to be completely removed? This operation cannot be undone.")), { :okcancel => true })
1107 perform_delete = Proc.new {
1108 after = autotable.get_next_widget(vbox)
1110 after = autotable.get_previous_widget(vbox)
1112 if $config['deleteondisk'] && !isacut
1113 msg 3, "scheduling for delete: #{fullpath}"
1114 $todelete << fullpath
1116 autotable.remove(vbox)
1118 $vbox2widgets[after][:textview].grab_focus
1119 autoscroll_if_needed($autotable_sw, $vbox2widgets[after][:image], $vbox2widgets[after][:textview])
1123 previous_pos = autotable.get_current_number(vbox)
1127 delete_current_subalbum
1129 save_undo(_("delete"),
1131 autotable.reinsert(pos, vbox, filename)
1132 $notebook.set_page(1)
1133 autotable.queue_draws << proc { textview.grab_focus; autoscroll_if_needed($autotable_sw, img, textview) }
1135 msg 3, "removing deletion schedule of: #{fullpath}"
1136 $todelete.delete(fullpath) #- unconditional because deleteondisk option could have been modified
1139 $notebook.set_page(1)
1148 $cuts << { :vbox => vbox, :filename => filename }
1149 $statusbar.push(0, utf8(_("%s elements in the clipboard.") % $cuts.size ))
1154 autotable.reinsert(autotable.get_current_number(vbox), elem[:vbox], elem[:filename])
1157 autotable.queue_draws << proc {
1158 $vbox2widgets[last[:vbox]][:textview].grab_focus
1159 autoscroll_if_needed($autotable_sw, $vbox2widgets[last[:vbox]][:image], $vbox2widgets[last[:vbox]][:textview])
1161 save_undo(_("paste"),
1163 cuts.each { |elem| autotable.remove(elem[:vbox]) }
1164 $notebook.set_page(1)
1167 autotable.reinsert(autotable.get_current_number(vbox), elem[:vbox], elem[:filename])
1169 $notebook.set_page(1)
1172 $statusbar.push(0, utf8(_("Pasted %s elements.") % $cuts.size ))
1177 $name2closures[filename] = { :rotate => rotate_and_cleanup, :enhance => enhance_and_cleanup, :delete => delete, :cut => cut,
1178 :color_swap => color_swap_and_cleanup, :frame_offset => change_frame_offset_and_cleanup_real,
1179 :whitebalance => whitebalance_and_cleanup_real, :pano => change_pano_amount_and_cleanup_real }
1181 textview.signal_connect('key-press-event') { |w, event|
1184 x, y = autotable.get_current_pos(vbox)
1185 control_pressed = event.state & Gdk::Window::CONTROL_MASK != 0
1186 shift_pressed = event.state & Gdk::Window::SHIFT_MASK != 0
1187 alt_pressed = event.state & Gdk::Window::MOD1_MASK != 0
1188 if event.keyval == Gdk::Keyval::GDK_Up && y > 0
1190 if widget_up = autotable.get_widget_at_pos(x, y - 1)
1191 $vbox2widgets[widget_up][:textview].grab_focus
1198 if event.keyval == Gdk::Keyval::GDK_Down && y < autotable.get_max_y
1200 if widget_down = autotable.get_widget_at_pos(x, y + 1)
1201 $vbox2widgets[widget_down][:textview].grab_focus
1208 if event.keyval == Gdk::Keyval::GDK_Left
1211 $vbox2widgets[autotable.get_previous_widget(vbox)][:textview].grab_focus
1218 rotate_and_cleanup.call(-90)
1221 if event.keyval == Gdk::Keyval::GDK_Right
1222 next_ = autotable.get_next_widget(vbox)
1223 if next_ && autotable.get_current_pos(next_)[0] > x
1225 $vbox2widgets[next_][:textview].grab_focus
1232 rotate_and_cleanup.call(90)
1235 if event.keyval == Gdk::Keyval::GDK_Delete && control_pressed
1238 if event.keyval == Gdk::Keyval::GDK_Return && control_pressed
1239 view_element(filename, { :delete => delete })
1242 if event.keyval == Gdk::Keyval::GDK_z && control_pressed
1245 if event.keyval == Gdk::Keyval::GDK_r && control_pressed
1249 !propagate #- propagate if needed
1252 $ignore_next_release = false
1253 evtbox.signal_connect('button-press-event') { |w, event|
1254 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
1255 if event.state & Gdk::Window::BUTTON3_MASK != 0
1256 #- gesture redo: hold right mouse button then click left mouse button
1257 $config['nogestures'] or perform_redo
1258 $ignore_next_release = true
1260 shift_or_control = event.state & Gdk::Window::SHIFT_MASK != 0 || event.state & Gdk::Window::CONTROL_MASK != 0
1262 rotate_and_cleanup.call(shift_or_control ? -90 : 90)
1264 rotate_and_cleanup.call(shift_or_control ? 90 : -90)
1265 elsif $enhance.active?
1266 enhance_and_cleanup.call
1267 elsif $delete.active?
1271 $config['nogestures'] or $gesture_press = { :filename => filename, :x => event.x, :y => event.y }
1274 $button1_pressed_autotable = true
1275 elsif event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
1276 if event.state & Gdk::Window::BUTTON1_MASK != 0
1277 #- gesture undo: hold left mouse button then click right mouse button
1278 $config['nogestures'] or perform_undo
1279 $ignore_next_release = true
1281 elsif event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
1282 view_element(filename, { :delete => delete })
1287 evtbox.signal_connect('button-release-event') { |w, event|
1288 if event.event_type == Gdk::Event::BUTTON_RELEASE && event.button == 3
1289 if !$ignore_next_release
1290 x, y = autotable.get_current_pos(vbox)
1291 next_ = autotable.get_next_widget(vbox)
1292 popup_thumbnail_menu(event, ['delete'], fullpath, type, $xmldir.elements["*[@filename='#{filename}']"], '',
1293 { :can_left => x > 0, :can_right => next_ && autotable.get_current_pos(next_)[0] > x,
1294 :can_up => y > 0, :can_down => y < autotable.get_max_y, :can_multiple => true, :can_panorama => true },
1295 { :rotate => rotate_and_cleanup, :move => move, :color_swap => color_swap_and_cleanup, :enhance => enhance_and_cleanup,
1296 :frame_offset => change_frame_offset_and_cleanup, :delete => delete, :whitebalance => whitebalance_and_cleanup,
1297 :cut => cut, :paste => paste, :view => proc { view_element(filename, { :delete => delete }) },
1298 :pano => change_pano_amount_and_cleanup })
1300 $ignore_next_release = false
1301 $gesture_press = nil
1306 #- handle reordering with drag and drop
1307 Gtk::Drag.source_set(vbox, Gdk::Window::BUTTON1_MASK, [['reorder-elements', Gtk::Drag::TARGET_SAME_APP, 1]], Gdk::DragContext::ACTION_MOVE)
1308 Gtk::Drag.dest_set(vbox, Gtk::Drag::DEST_DEFAULT_ALL, [['reorder-elements', Gtk::Drag::TARGET_SAME_APP, 1]], Gdk::DragContext::ACTION_MOVE)
1309 vbox.signal_connect('drag-data-get') { |w, ctxt, selection_data, info, time|
1310 selection_data.set(Gdk::Selection::TYPE_STRING, autotable.get_current_number(vbox).to_s)
1313 vbox.signal_connect('drag-data-received') { |w, ctxt, x, y, selection_data, info, time|
1315 #- mouse gesture first (dnd disables button-release-event)
1316 if $gesture_press && $gesture_press[:filename] == filename
1317 if (($gesture_press[:x]-x)/($gesture_press[:y]-y)).abs > 2 && ($gesture_press[:x]-x).abs > 5
1318 angle = x-$gesture_press[:x] > 0 ? 90 : -90
1319 msg 3, "gesture rotate: #{angle}: click-drag right button to the left/right"
1320 rotate_and_cleanup.call(angle)
1321 $statusbar.push(0, utf8(_("Mouse gesture: rotate.")))
1323 elsif (($gesture_press[:y]-y)/($gesture_press[:x]-x)).abs > 2 && y-$gesture_press[:y] > 5
1324 msg 3, "gesture delete: click-drag right button to the bottom"
1326 $statusbar.push(0, utf8(_("Mouse gesture: delete.")))
1331 ctxt.targets.each { |target|
1332 if target.name == 'reorder-elements'
1333 move_dnd = Proc.new { |from,to|
1336 autotable.move(from, to)
1337 save_undo(_("reorder"),
1338 Proc.new { |from, to|
1340 autotable.move(to - 1, from)
1342 autotable.move(to, from + 1)
1344 $notebook.set_page(1)
1346 autotable.move(from, to)
1347 $notebook.set_page(1)
1352 if $multiple_dnd.size == 0
1353 move_dnd.call(selection_data.data.to_i,
1354 autotable.get_current_number(vbox))
1356 UndoHandler.begin_batch
1357 $multiple_dnd.sort { |a,b| autotable.get_current_number($name2widgets[a][:vbox]) <=> autotable.get_current_number($name2widgets[b][:vbox]) }.
1359 #- need to update current position between each call
1360 move_dnd.call(autotable.get_current_number($name2widgets[path][:vbox]),
1361 autotable.get_current_number(vbox))
1363 UndoHandler.end_batch
1374 def create_auto_table
1376 $autotable = Gtk::AutoTable.new(5)
1378 $autotable_sw = Gtk::ScrolledWindow.new(nil, nil)
1379 thumbnails_vb = Gtk::VBox.new(false, 5)
1381 frame, $thumbnails_title = create_editzone($autotable_sw, 0, nil)
1382 $thumbnails_title.set_justification(Gtk::Justification::CENTER)
1383 thumbnails_vb.pack_start(Gtk::Alignment.new(0.5, 0.5, 0.5, 0).add(frame), false, false)
1384 thumbnails_vb.add($autotable)
1386 $autotable_sw.set_policy(Gtk::POLICY_NEVER, Gtk::POLICY_ALWAYS)
1387 $autotable_sw.add_with_viewport(thumbnails_vb)
1389 #- follows stuff for handling multiple elements selection
1390 press_x = nil; press_y = nil; pos_x = nil; pos_y = nil; $selected_elements = {}
1392 update_selected = Proc.new {
1393 $autotable.current_order.each { |path|
1394 w = $name2widgets[path][:evtbox].window
1395 xm = w.position[0] + w.size[0]/2
1396 ym = w.position[1] + w.size[1]/2
1397 if ym < press_y && ym > pos_y || ym < pos_y && ym > press_y
1398 if (xm < press_x && xm > pos_x || xm < pos_x && xm > press_x) && ! $selected_elements[path]
1399 $selected_elements[path] = { :pixbuf => $name2widgets[path][:img].pixbuf }
1400 $name2widgets[path][:img].pixbuf = $name2widgets[path][:img].pixbuf.saturate_and_pixelate(1, true)
1403 if $selected_elements[path] && ! $selected_elements[path][:keep]
1404 if ((xm < press_x && xm < pos_x || xm > pos_x && xm > press_x) || (ym < press_y && ym < pos_y || ym > pos_y && ym > press_y))
1405 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
1406 $selected_elements.delete(path)
1411 $autotable.signal_connect('realize') { |w,e|
1412 gc = Gdk::GC.new($autotable.window)
1413 gc.set_line_attributes(1, Gdk::GC::LINE_ON_OFF_DASH, Gdk::GC::CAP_PROJECTING, Gdk::GC::JOIN_ROUND)
1414 gc.function = Gdk::GC::INVERT
1415 #- autoscroll handling for DND and multiple selections
1416 Gtk.timeout_add(100) {
1417 w, x, y, mask = $autotable.window.pointer
1418 if mask & Gdk::Window::BUTTON1_MASK != 0
1419 if y < $autotable_sw.vadjustment.value
1421 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1423 if $button1_pressed_autotable || press_x
1424 scroll_upper($autotable_sw, y)
1427 w, pos_x, pos_y = $autotable.window.pointer
1428 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1429 update_selected.call
1432 if y > $autotable_sw.vadjustment.value + $autotable_sw.vadjustment.page_size
1434 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1436 if $button1_pressed_autotable || press_x
1437 scroll_lower($autotable_sw, y)
1440 w, pos_x, pos_y = $autotable.window.pointer
1441 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1442 update_selected.call
1450 $autotable.signal_connect('button-press-event') { |w,e|
1452 if !$button1_pressed_autotable
1455 if e.state & Gdk::Window::SHIFT_MASK == 0
1456 $selected_elements.each_key { |path| $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf] }
1457 $selected_elements = {}
1458 $statusbar.push(0, utf8(_("Nothing selected.")))
1460 $selected_elements.each_key { |path| $selected_elements[path][:keep] = true }
1462 set_mousecursor(Gdk::Cursor::TCROSS)
1466 $autotable.signal_connect('button-release-event') { |w,e|
1468 if $button1_pressed_autotable
1469 #- unselect all only now
1470 $multiple_dnd = $selected_elements.keys
1471 $selected_elements.each_key { |path| $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf] }
1472 $selected_elements = {}
1473 $button1_pressed_autotable = false
1476 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1477 if $selected_elements.length > 0
1478 $statusbar.push(0, utf8(_("%s elements selected.") % $selected_elements.length))
1481 press_x = press_y = pos_x = pos_y = nil
1482 set_mousecursor(Gdk::Cursor::LEFT_PTR)
1486 $autotable.signal_connect('motion-notify-event') { |w,e|
1489 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1493 $autotable.window.draw_lines(gc, [[press_x, press_y], [pos_x, press_y], [pos_x, pos_y], [press_x, pos_y], [press_x, press_y]])
1494 update_selected.call
1500 def create_subalbums_page
1502 subalbums_hb = Gtk::HBox.new
1503 $subalbums_vb = Gtk::VBox.new(false, 5)
1504 subalbums_hb.pack_start($subalbums_vb, false, false)
1505 $subalbums_sw = Gtk::ScrolledWindow.new(nil, nil)
1506 $subalbums_sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
1507 $subalbums_sw.add_with_viewport(subalbums_hb)
1510 def save_current_file
1514 ios = File.open($filename, "w")
1515 $xmldoc.write(ios, 0)
1520 def save_current_file_user
1521 save_tempfilename = $filename
1522 $filename = $orig_filename
1525 $generated_outofline = false
1526 $filename = save_tempfilename
1528 msg 3, "performing actual deletion of: " + $todelete.join(', ')
1529 $todelete.each { |f|
1530 system("rm -f #{f}")
1534 def mark_document_as_dirty
1535 $xmldoc.elements.each('//dir') { |elem|
1536 elem.delete_attribute('already-generated')
1540 #- ret: true => ok false => cancel
1541 def ask_save_modifications(msg1, msg2, *options)
1543 options = options.size > 0 ? options[0] : {}
1545 if options[:disallow_cancel]
1546 dialog = Gtk::Dialog.new(msg1,
1548 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1549 [options[:no] || Gtk::Stock::CLOSE, Gtk::Dialog::RESPONSE_NO],
1550 [options[:yes] || Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_YES])
1552 dialog = Gtk::Dialog.new(msg1,
1554 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
1555 [options[:cancel] || Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL],
1556 [options[:no] || Gtk::Stock::CLOSE, Gtk::Dialog::RESPONSE_NO],
1557 [options[:yes] || Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_YES])
1559 dialog.default_response = Gtk::Dialog::RESPONSE_YES
1560 dialog.vbox.add(Gtk::Label.new(msg2))
1561 dialog.window_position = Gtk::Window::POS_CENTER
1564 dialog.run { |response|
1566 if response == Gtk::Dialog::RESPONSE_YES
1567 save_current_file_user
1569 #- if we have generated an album but won't save modifications, we must remove
1570 #- already-generated markers in original file
1571 if $generated_outofline
1573 $xmldoc = REXML::Document.new File.new($orig_filename)
1574 mark_document_as_dirty
1575 ios = File.open($orig_filename, "w")
1576 $xmldoc.write(ios, 0)
1579 puts "exception: #{$!}"
1583 if response == Gtk::Dialog::RESPONSE_CANCEL
1586 $todelete = [] #- unconditionally clear the list of images/videos to delete
1592 def try_quit(*options)
1593 if ask_save_modifications(utf8(_("Save before quitting?")),
1594 utf8(_("Do you want to save your changes before quitting?")),
1600 def show_popup(parent, msg, *options)
1601 dialog = Gtk::Dialog.new
1602 if options[0] && options[0][:title]
1603 dialog.title = options[0][:title]
1605 dialog.title = utf8(_("Booh message"))
1607 lbl = Gtk::Label.new
1608 if options[0] && options[0][:nomarkup]
1613 if options[0] && options[0][:centered]
1614 lbl.set_justify(Gtk::Justification::CENTER)
1616 if options[0] && options[0][:selectable]
1617 lbl.selectable = true
1619 if options[0] && options[0][:topwidget]
1620 dialog.vbox.add(options[0][:topwidget])
1622 if options[0] && options[0][:scrolled]
1623 sw = Gtk::ScrolledWindow.new(nil, nil)
1624 sw.set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC)
1625 sw.add_with_viewport(lbl)
1627 dialog.set_default_size(400, 500)
1629 dialog.vbox.add(lbl)
1630 dialog.set_default_size(200, 120)
1632 if options[0] && options[0][:okcancel]
1633 dialog.add_button(Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL)
1635 dialog.add_button(Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK)
1637 if options[0] && options[0][:pos_centered]
1638 dialog.window_position = Gtk::Window::POS_CENTER
1640 dialog.window_position = Gtk::Window::POS_MOUSE
1643 if options[0] && options[0][:linkurl]
1644 linkbut = Gtk::Button.new('')
1645 linkbut.child.markup = "<span foreground=\"#00000000FFFF\" underline=\"single\">#{options[0][:linkurl]}</span>"
1646 linkbut.signal_connect('clicked') { open_url(options[0][:linkurl] + '/index.html' ) }
1647 linkbut.relief = Gtk::RELIEF_NONE
1648 linkbut.signal_connect('enter-notify-event') { set_mousecursor(Gdk::Cursor::HAND2, linkbut); false }
1649 linkbut.signal_connect('leave-notify-event') { set_mousecursor(nil, linkbut); false }
1650 dialog.vbox.add(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(linkbut))
1655 if !options[0] || !options[0][:not_transient]
1656 dialog.transient_for = parent
1657 dialog.run { |response|
1659 if options[0] && options[0][:okcancel]
1660 return response == Gtk::Dialog::RESPONSE_OK
1664 dialog.signal_connect('response') { dialog.destroy }
1668 def backend_wait_message(parent, msg, infopipe_path, mode)
1670 w.set_transient_for(parent)
1673 vb = Gtk::VBox.new(false, 5).set_border_width(5)
1674 vb.pack_start(Gtk::Label.new(msg), false, false)
1676 vb.pack_start(frame1 = Gtk::Frame.new(utf8(_("Thumbnails"))).add(vb1 = Gtk::VBox.new(false, 5)))
1677 vb1.pack_start(pb1_1 = Gtk::ProgressBar.new.set_text(utf8(_("Scanning images and videos..."))), false, false)
1678 if mode != 'one dir scan'
1679 vb1.pack_start(pb1_2 = Gtk::ProgressBar.new.set_text(utf8(_("not started"))), false, false)
1681 if mode == 'web-album'
1682 vb.pack_start(frame2 = Gtk::Frame.new(utf8(_("HTML pages"))).add(vb1 = Gtk::VBox.new(false, 5)))
1683 vb1.pack_start(pb2 = Gtk::ProgressBar.new.set_text(utf8(_("not started"))), false, false)
1685 vb.pack_start(Gtk::HSeparator.new, false, false)
1687 bottom = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(b = Gtk::Button.new(utf8(_("_Abort"))))
1688 b.image = Gtk::Image.new("#{$FPATH}/images/stock-close-24.png")
1689 vb.pack_end(bottom, false, false)
1691 infopipe = File.open(infopipe_path, File::RDONLY | File::NONBLOCK)
1692 refresh_thread = Thread.new {
1693 directories_counter = 0
1694 while line = infopipe.gets
1695 if line =~ /^directories: (\d+), sizes: (\d+)/
1696 directories = $1.to_f + 1
1698 elsif line =~ /^walking: (.+)\|(.+), (\d+) elements$/
1699 elements = $3.to_f + 1
1700 if mode == 'web-album'
1704 gtk_thread_protect { pb1_1.fraction = 0 }
1705 if mode != 'one dir scan'
1706 newtext = utf8(full_src_dir_to_rel($1, $2))
1707 newtext = '/' if newtext == ''
1708 gtk_thread_protect { pb1_2.text = newtext }
1709 directories_counter += 1
1710 gtk_thread_protect { pb1_2.fraction = directories_counter / directories }
1712 elsif line =~ /^processing element$/
1713 element_counter += 1
1714 gtk_thread_protect { pb1_1.fraction = element_counter / elements }
1715 elsif line =~ /^processing size$/
1716 element_counter += 1
1717 gtk_thread_protect { pb1_1.fraction = element_counter / elements }
1718 elsif line =~ /^finished processing sizes$/
1719 gtk_thread_protect { pb1_1.fraction = 1 }
1720 elsif line =~ /^creating index.html$/
1721 gtk_thread_protect { pb1_2.text = utf8(_("finished")) }
1722 gtk_thread_protect { pb1_1.fraction = pb1_2.fraction = 1 }
1723 directories_counter = 0
1724 elsif line =~ /^index.html: (.+)\|(.+)/
1725 newtext = utf8(full_src_dir_to_rel($1, $2))
1726 newtext = '/' if newtext == ''
1727 gtk_thread_protect { pb2.text = newtext }
1728 directories_counter += 1
1729 gtk_thread_protect { pb2.fraction = directories_counter / directories }
1730 elsif line =~ /^die: (.*)$/
1737 w.signal_connect('delete-event') { w.destroy }
1738 w.signal_connect('destroy') {
1739 Thread.kill(refresh_thread)
1740 gtk_thread_flush #- needed because we're about to destroy widgets in w, for which they may be some pending gtk calls
1743 system("rm -f #{infopipe_path}")
1746 w.window_position = Gtk::Window::POS_CENTER
1752 def call_backend(cmd, waitmsg, mode, params)
1753 pipe = Tempfile.new("boohpipe")
1755 system("mkfifo #{pipe.path}")
1756 cmd += " --info-pipe #{pipe.path}"
1757 button, w8 = backend_wait_message($main_window, waitmsg, pipe.path, mode)
1762 id, exitstatus = Process.waitpid2(pid)
1763 gtk_thread_protect { w8.destroy }
1765 if params[:successmsg]
1766 gtk_thread_protect { show_popup($main_window, params[:successmsg], { :linkurl => params[:successmsg_linkurl] }) }
1768 if params[:closure_after]
1769 gtk_thread_protect(¶ms[:closure_after])
1771 elsif exitstatus == 15
1772 #- say nothing, user aborted
1774 gtk_thread_protect { show_popup($main_window,
1775 utf8(_("There was something wrong, sorry:\n\n%s") % $diemsg)) }
1781 button.signal_connect('clicked') {
1782 Process.kill('SIGTERM', pid)
1786 def save_changes(*forced)
1787 if forced.empty? && (!$current_path || !$undo_tb.sensitive?)
1791 $xmldir.delete_attribute('already-generated')
1793 propagate_children = Proc.new { |xmldir|
1794 if xmldir.attributes['subdirs-caption']
1795 xmldir.delete_attribute('already-generated')
1797 xmldir.elements.each('dir') { |element|
1798 propagate_children.call(element)
1802 if $xmldir.child_byname_notattr('dir', 'deleted')
1803 new_title = $subalbums_title.buffer.text
1804 if new_title != $xmldir.attributes['subdirs-caption']
1805 parent = $xmldir.parent
1806 if parent.name == 'dir'
1807 parent.delete_attribute('already-generated')
1809 propagate_children.call($xmldir)
1811 $xmldir.add_attribute('subdirs-caption', new_title)
1812 $xmldir.elements.each('dir') { |element|
1813 if !element.attributes['deleted']
1814 path = element.attributes['path']
1815 newtext = $subalbums_edits[path][:editzone].buffer.text
1816 if element.attributes['subdirs-caption']
1817 if element.attributes['subdirs-caption'] != newtext
1818 propagate_children.call(element)
1820 element.add_attribute('subdirs-caption', newtext)
1821 element.add_attribute('subdirs-captionfile', utf8($subalbums_edits[path][:captionfile]))
1823 if element.attributes['thumbnails-caption'] != newtext
1824 element.delete_attribute('already-generated')
1826 element.add_attribute('thumbnails-caption', newtext)
1827 element.add_attribute('thumbnails-captionfile', utf8($subalbums_edits[path][:captionfile]))
1833 if $notebook.page == 0 && $xmldir.child_byname_notattr('dir', 'deleted')
1834 if $xmldir.attributes['thumbnails-caption']
1835 path = $xmldir.attributes['path']
1836 $xmldir.add_attribute('thumbnails-caption', $subalbums_edits[path][:editzone].buffer.text)
1838 elsif $xmldir.attributes['thumbnails-caption']
1839 $xmldir.add_attribute('thumbnails-caption', $thumbnails_title.buffer.text)
1842 #- remove and reinsert elements to reflect new ordering
1845 $xmldir.elements.each { |element|
1846 if element.name == 'image' || element.name == 'video'
1847 saves[element.attributes['filename']] = element.remove
1851 $autotable.current_order.each { |path|
1852 chld = $xmldir.add_element(saves[path].name, saves[path].attributes)
1853 chld.add_attribute('caption', $name2widgets[File.basename(path)][:textview].buffer.text)
1856 saves.each_key { |path|
1857 chld = $xmldir.add_element(saves[path].name, saves[path].attributes)
1858 chld.add_attribute('deleted', 'true')
1862 def remove_all_captions
1865 $autotable.current_order.each { |path|
1866 texts[File.basename(path) ] = $name2widgets[File.basename(path)][:textview].buffer.text
1867 $name2widgets[File.basename(path)][:textview].buffer.text = ''
1869 save_undo(_("remove all captions"),
1871 texts.each_key { |key|
1872 $name2widgets[key][:textview].buffer.text = texts[key]
1874 $notebook.set_page(1)
1876 texts.each_key { |key|
1877 $name2widgets[key][:textview].buffer.text = ''
1879 $notebook.set_page(1)
1885 $selected_elements.each_key { |path|
1886 $name2widgets[path][:img].pixbuf = $selected_elements[path][:pixbuf]
1892 $selected_elements = {}
1896 $undo_tb.sensitive = $undo_mb.sensitive = false
1897 $redo_tb.sensitive = $redo_mb.sensitive = false
1903 $subalbums_vb.children.each { |chld|
1904 $subalbums_vb.remove(chld)
1906 $subalbums = Gtk::Table.new(0, 0, true)
1907 current_y_sub_albums = 0
1909 $xmldir = $xmldoc.elements["//dir[@path='#{$current_path}']"]
1910 $subalbums_edits = {}
1911 subalbums_counter = 0
1912 subalbums_edits_bypos = {}
1914 add_subalbum = Proc.new { |xmldir, counter|
1915 $subalbums_edits[xmldir.attributes['path']] = { :position => counter }
1916 subalbums_edits_bypos[counter] = $subalbums_edits[xmldir.attributes['path']]
1917 if xmldir == $xmldir
1918 thumbnail_file = "#{current_dest_dir}/thumbnails-thumbnail.jpg"
1919 caption = xmldir.attributes['thumbnails-caption']
1920 captionfile, dummy = find_subalbum_caption_info(xmldir)
1921 infotype = 'thumbnails'
1923 thumbnail_file = "#{current_dest_dir}/thumbnails-#{make_dest_filename(from_utf8(File.basename(xmldir.attributes['path'])))}.jpg"
1924 captionfile, caption = find_subalbum_caption_info(xmldir)
1925 infotype = find_subalbum_info_type(xmldir)
1927 msg 3, "add subdir: #{xmldir.attributes['path']} with file: #{thumbnail_file}"
1928 hbox = Gtk::HBox.new
1929 hbox.pack_start(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + File.basename(xmldir.attributes['path']) + ':</i>')))
1931 f.set_shadow_type(Gtk::SHADOW_ETCHED_OUT)
1934 my_gen_real_thumbnail = proc {
1935 gen_real_thumbnail('subdir', captionfile, thumbnail_file, xmldir, $albums_thumbnail_size, img, infotype)
1938 if !$modified_pixbufs[thumbnail_file] && !File.exists?(thumbnail_file)
1939 f.add(img = Gtk::Image.new)
1940 my_gen_real_thumbnail.call
1942 f.add(img = Gtk::Image.new($modified_pixbufs[thumbnail_file] ? $modified_pixbufs[thumbnail_file][:pixbuf] : thumbnail_file))
1944 hbox.pack_end(Gtk::Alignment.new(0.5, 0.5, 0, 0).add(evtbox = Gtk::EventBox.new.add(f)), false, false)
1945 $subalbums.attach(hbox,
1946 0, 1, current_y_sub_albums, current_y_sub_albums + 1, Gtk::FILL, Gtk::FILL, 2, 2)
1948 frame, textview = create_editzone($subalbums_sw, 0, img)
1949 textview.buffer.text = caption
1950 $subalbums.attach(Gtk::Alignment.new(0, 0.5, 0.5, 0).add(frame),
1951 1, 2, current_y_sub_albums, current_y_sub_albums + 1, Gtk::FILL, Gtk::FILL, 2, 2)
1953 change_image = Proc.new {
1954 fc = Gtk::FileChooserDialog.new(utf8(_("Select image for caption")),
1956 Gtk::FileChooser::ACTION_OPEN,
1958 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
1959 fc.set_current_folder(from_utf8(xmldir.attributes['path']))
1960 fc.transient_for = $main_window
1961 fc.preview_widget = preview = Gtk::Alignment.new(0.5, 0.5, 0, 0).add(f = Gtk::Frame.new.set_shadow_type(Gtk::SHADOW_ETCHED_OUT))
1962 f.add(preview_img = Gtk::Image.new)
1964 fc.signal_connect('update-preview') { |w|
1966 if fc.preview_filename
1967 preview_img.pixbuf = rotate_pixbuf(Gdk::Pixbuf.new(fc.preview_filename, 240, 180), guess_rotate(fc.preview_filename))
1968 fc.preview_widget_active = true
1970 rescue Gdk::PixbufError
1971 fc.preview_widget_active = false
1974 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
1976 old_file = captionfile
1977 old_rotate = xmldir.attributes["#{infotype}-rotate"]
1978 old_color_swap = xmldir.attributes["#{infotype}-color-swap"]
1979 old_enhance = xmldir.attributes["#{infotype}-enhance"]
1980 old_frame_offset = xmldir.attributes["#{infotype}-frame-offset"]
1982 new_file = fc.filename
1983 msg 3, "new captionfile is: #{fc.filename}"
1984 perform_changefile = Proc.new {
1985 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile = new_file
1986 $modified_pixbufs.delete(thumbnail_file)
1987 xmldir.delete_attribute("#{infotype}-rotate")
1988 xmldir.delete_attribute("#{infotype}-color-swap")
1989 xmldir.delete_attribute("#{infotype}-enhance")
1990 xmldir.delete_attribute("#{infotype}-frame-offset")
1991 my_gen_real_thumbnail.call
1993 perform_changefile.call
1995 save_undo(_("change caption file for sub-album"),
1997 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile = old_file
1998 xmldir.add_attribute("#{infotype}-rotate", old_rotate)
1999 xmldir.add_attribute("#{infotype}-color-swap", old_color_swap)
2000 xmldir.add_attribute("#{infotype}-enhance", old_enhance)
2001 xmldir.add_attribute("#{infotype}-frame-offset", old_frame_offset)
2002 my_gen_real_thumbnail.call
2003 $notebook.set_page(0)
2005 perform_changefile.call
2006 $notebook.set_page(0)
2013 rotate_and_cleanup = Proc.new { |angle|
2014 rotate(angle, thumbnail_file, img, xmldir, "#{infotype}-", $default_albums_thumbnails[:x], $default_albums_thumbnails[:y])
2015 system("rm -f '#{thumbnail_file}'")
2018 move = Proc.new { |direction|
2021 save_changes('forced')
2022 if direction == 'up'
2023 oldpos = $subalbums_edits[xmldir.attributes['path']][:position]
2024 $subalbums_edits[xmldir.attributes['path']][:position] -= 1
2025 subalbums_edits_bypos[oldpos - 1][:position] += 1
2027 oldpos = $subalbums_edits[xmldir.attributes['path']][:position]
2028 $subalbums_edits[xmldir.attributes['path']][:position] += 1
2029 subalbums_edits_bypos[oldpos + 1][:position] -= 1
2033 $xmldir.elements.each('dir') { |element|
2034 if (!element.attributes['deleted'])
2035 elems << [ element.attributes['path'], element.remove ]
2038 elems.sort { |a,b| $subalbums_edits[a[0]][:position] <=> $subalbums_edits[b[0]][:position] }.
2039 each { |e| $xmldir.add_element(e[1]) }
2040 #- need to remove the already-generated tag to all subdirs because of "previous/next albums" links completely changed
2041 $xmldir.elements.each('descendant::dir') { |elem|
2042 elem.delete_attribute('already-generated')
2047 color_swap_and_cleanup = Proc.new {
2048 perform_color_swap_and_cleanup = Proc.new {
2049 color_swap(xmldir, "#{infotype}-")
2050 my_gen_real_thumbnail.call
2052 perform_color_swap_and_cleanup.call
2054 save_undo(_("color swap"),
2056 perform_color_swap_and_cleanup.call
2057 $notebook.set_page(0)
2059 perform_color_swap_and_cleanup.call
2060 $notebook.set_page(0)
2065 change_frame_offset_and_cleanup = Proc.new {
2066 if values = ask_new_frame_offset(xmldir, "#{infotype}-")
2067 perform_change_frame_offset_and_cleanup = Proc.new { |val|
2068 change_frame_offset(xmldir, "#{infotype}-", val)
2069 my_gen_real_thumbnail.call
2071 perform_change_frame_offset_and_cleanup.call(values[:new])
2073 save_undo(_("specify frame offset"),
2075 perform_change_frame_offset_and_cleanup.call(values[:old])
2076 $notebook.set_page(0)
2078 perform_change_frame_offset_and_cleanup.call(values[:new])
2079 $notebook.set_page(0)
2085 whitebalance_and_cleanup = Proc.new {
2086 if values = ask_whitebalance(captionfile, thumbnail_file, img, xmldir, "#{infotype}-",
2087 $default_albums_thumbnails[:x], $default_albums_thumbnails[:y], infotype)
2088 perform_change_whitebalance_and_cleanup = Proc.new { |val|
2089 change_whitebalance(xmldir, "#{infotype}-", val)
2090 recalc_whitebalance(val, captionfile, thumbnail_file, img, xmldir, "#{infotype}-",
2091 $default_albums_thumbnails[:x], $default_albums_thumbnails[:y], infotype)
2092 system("rm -f '#{thumbnail_file}'")
2094 perform_change_whitebalance_and_cleanup.call(values[:new])
2096 save_undo(_("fix white balance"),
2098 perform_change_whitebalance_and_cleanup.call(values[:old])
2099 $notebook.set_page(0)
2101 perform_change_whitebalance_and_cleanup.call(values[:new])
2102 $notebook.set_page(0)
2108 enhance_and_cleanup = Proc.new {
2109 perform_enhance_and_cleanup = Proc.new {
2110 enhance(xmldir, "#{infotype}-")
2111 my_gen_real_thumbnail.call
2114 perform_enhance_and_cleanup.call
2116 save_undo(_("enhance"),
2118 perform_enhance_and_cleanup.call
2119 $notebook.set_page(0)
2121 perform_enhance_and_cleanup.call
2122 $notebook.set_page(0)
2127 evtbox.signal_connect('button-press-event') { |w, event|
2128 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 1
2130 rotate_and_cleanup.call(90)
2132 rotate_and_cleanup.call(-90)
2133 elsif $enhance.active?
2134 enhance_and_cleanup.call
2137 if event.event_type == Gdk::Event::BUTTON_PRESS && event.button == 3
2138 popup_thumbnail_menu(event, ['change_image'], captionfile, entry2type(captionfile), xmldir, "#{infotype}-",
2139 { :forbid_left => true, :forbid_right => true,
2140 :can_up => counter > 1, :can_down => counter > 0 && counter < subalbums_counter },
2141 { :change => change_image, :move => move, :rotate => rotate_and_cleanup, :enhance => enhance_and_cleanup,
2142 :color_swap => color_swap_and_cleanup, :frame_offset => change_frame_offset_and_cleanup, :whitebalance => whitebalance_and_cleanup })
2144 if event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
2149 evtbox.signal_connect('button-press-event') { |w, event|
2150 $gesture_press = { :filename => thumbnail_file, :x => event.x, :y => event.y }
2154 evtbox.signal_connect('button-release-event') { |w, event|
2155 if !$r90.active? && !$r270.active? && $gesture_press && $gesture_press[:filename] == thumbnail_file
2156 msg 3, "press: #{$gesture_press[:x]} release: #{event.x}"
2157 if (($gesture_press[:x]-event.x)/($gesture_press[:y]-event.y)).abs > 2 && ($gesture_press[:x]-event.x).abs > 5
2158 angle = event.x-$gesture_press[:x] > 0 ? 90 : -90
2159 msg 3, "gesture rotate: #{angle}"
2160 rotate_and_cleanup.call(angle)
2163 $gesture_press = nil
2166 $subalbums_edits[xmldir.attributes['path']][:editzone] = textview
2167 $subalbums_edits[xmldir.attributes['path']][:captionfile] = captionfile
2168 current_y_sub_albums += 1
2171 if $xmldir.child_byname_notattr('dir', 'deleted')
2173 frame, $subalbums_title = create_editzone($subalbums_sw, 0, nil)
2174 $subalbums_title.buffer.text = $xmldir.attributes['subdirs-caption']
2175 $subalbums_title.set_justification(Gtk::Justification::CENTER)
2176 $subalbums_vb.pack_start(Gtk::Alignment.new(0.5, 0.5, 0.5, 0).add(frame), false, false)
2177 #- this album image/caption
2178 if $xmldir.attributes['thumbnails-caption']
2179 add_subalbum.call($xmldir, 0)
2182 total = { 'image' => 0, 'video' => 0, 'dir' => 0 }
2183 $xmldir.elements.each { |element|
2184 if (element.name == 'image' || element.name == 'video') && !element.attributes['deleted']
2185 #- element (image or video) of this album
2186 dest_img = build_full_dest_filename(element.attributes['filename']).sub(/\.[^\.]+$/, '') + "-#{$default_size['thumbnails']}.jpg"
2187 msg 3, "dest_img: #{dest_img}"
2188 add_thumbnail($autotable, element.attributes['filename'], element.name, dest_img, from_utf8(element.attributes['caption']))
2189 total[element.name] += 1
2191 if element.name == 'dir' && !element.attributes['deleted']
2192 #- sub-album image/caption
2193 add_subalbum.call(element, subalbums_counter += 1)
2194 total[element.name] += 1
2197 $statusbar.push(0, utf8(_("%s: %s images and %s videos, %s sub-albums") % [ File.basename(from_utf8($xmldir.attributes['path'])),
2198 total['image'], total['video'], total['dir'] ]))
2199 $subalbums_vb.add($subalbums)
2200 $subalbums_vb.show_all
2202 if !$xmldir.child_byname_notattr('image', 'deleted') && !$xmldir.child_byname_notattr('video', 'deleted')
2203 $notebook.get_tab_label($autotable_sw).sensitive = false
2204 $notebook.set_page(0)
2205 $thumbnails_title.buffer.text = ''
2207 $notebook.get_tab_label($autotable_sw).sensitive = true
2208 $thumbnails_title.buffer.text = $xmldir.attributes['thumbnails-caption']
2211 if !$xmldir.child_byname_notattr('dir', 'deleted')
2212 $notebook.get_tab_label($subalbums_sw).sensitive = false
2213 $notebook.set_page(1)
2215 $notebook.get_tab_label($subalbums_sw).sensitive = true
2219 def pixbuf_or_nil(filename)
2221 return Gdk::Pixbuf.new(filename)
2227 def theme_choose(current)
2228 dialog = Gtk::Dialog.new(utf8(_("Select your preferred theme")),
2230 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2231 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2232 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2234 model = Gtk::ListStore.new(String, Gdk::Pixbuf, Gdk::Pixbuf, Gdk::Pixbuf)
2235 treeview = Gtk::TreeView.new(model).set_rules_hint(true)
2236 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Theme name")), Gtk::CellRendererText.new, { :text => 0 }).set_alignment(0.5).set_spacing(5))
2237 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Sub-albums page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 1 }).set_alignment(0.5).set_spacing(5))
2238 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Thumbnails page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 2 }).set_alignment(0.5).set_spacing(5))
2239 treeview.append_column(Gtk::TreeViewColumn.new(utf8(_("Fullscreen page look")), Gtk::CellRendererPixbuf.new, { :pixbuf => 3 }).set_alignment(0.5).set_spacing(5))
2240 treeview.append_column(Gtk::TreeViewColumn.new('', Gtk::CellRendererText.new, {}))
2241 treeview.signal_connect('button-press-event') { |w, event|
2242 if event.event_type == Gdk::Event::BUTTON2_PRESS && event.button == 1
2243 dialog.response(Gtk::Dialog::RESPONSE_OK)
2247 dialog.vbox.add(sw = Gtk::ScrolledWindow.new(nil, nil).add(treeview).set_policy(Gtk::POLICY_AUTOMATIC, Gtk::POLICY_AUTOMATIC))
2249 `find '#{$FPATH}/themes' -mindepth 1 -maxdepth 1 -type d`.each { |dir|
2252 iter[0] = File.basename(dir)
2253 iter[1] = pixbuf_or_nil("#{dir}/metadata/screenshot-1.png")
2254 iter[2] = pixbuf_or_nil("#{dir}/metadata/screenshot-2.png")
2255 iter[3] = pixbuf_or_nil("#{dir}/metadata/screenshot-3.png")
2256 if File.basename(dir) == current
2257 treeview.selection.select_iter(iter)
2261 dialog.set_default_size(700, 400)
2262 dialog.vbox.show_all
2263 dialog.run { |response|
2264 iter = treeview.selection.selected
2266 if response == Gtk::Dialog::RESPONSE_OK && iter
2267 return model.get_value(iter, 0)
2273 def show_password_protections
2274 examine_dir_elem = Proc.new { |parent_iter, xmldir, already_protected|
2275 child_iter = $albums_iters[xmldir.attributes['path']]
2276 if xmldir.attributes['password-protect']
2277 child_iter[2] = pixbuf_or_nil("#{$FPATH}/images/galeon-secure.png")
2278 already_protected = true
2279 elsif already_protected
2280 pix = pixbuf_or_nil("#{$FPATH}/images/galeon-secure-shadow.png")
2282 pix = pix.saturate_and_pixelate(1, true)
2288 xmldir.elements.each('dir') { |elem|
2289 if !elem.attributes['deleted']
2290 examine_dir_elem.call(child_iter, elem, already_protected)
2294 examine_dir_elem.call(nil, $xmldoc.elements['//dir'], false)
2297 def populate_subalbums_treeview
2301 $subalbums_vb.children.each { |chld|
2302 $subalbums_vb.remove(chld)
2305 source = $xmldoc.root.attributes['source']
2306 msg 3, "source: #{source}"
2308 xmldir = $xmldoc.elements['//dir']
2309 if !xmldir || xmldir.attributes['path'] != source
2310 msg 1, _("Corrupted booh file...")
2314 append_dir_elem = Proc.new { |parent_iter, xmldir|
2315 child_iter = $albums_ts.append(parent_iter)
2316 child_iter[0] = File.basename(xmldir.attributes['path'])
2317 child_iter[1] = xmldir.attributes['path']
2318 $albums_iters[xmldir.attributes['path']] = child_iter
2319 msg 3, "puttin location: #{xmldir.attributes['path']}"
2320 xmldir.elements.each('dir') { |elem|
2321 if !elem.attributes['deleted']
2322 append_dir_elem.call(child_iter, elem)
2326 append_dir_elem.call(nil, xmldir)
2327 show_password_protections
2329 $albums_tv.expand_all
2330 $albums_tv.selection.select_iter($albums_ts.iter_first)
2333 def open_file(filename)
2337 $current_path = nil #- invalidate
2338 $modified_pixbufs = {}
2341 $subalbums_vb.children.each { |chld|
2342 $subalbums_vb.remove(chld)
2345 if !File.exists?(filename)
2346 return utf8(_("File not found."))
2350 $xmldoc = REXML::Document.new File.new(filename)
2355 if !$xmldoc || !$xmldoc.root || $xmldoc.root.name != 'booh'
2356 if entry2type(filename).nil?
2357 return utf8(_("Not a booh file!"))
2359 return utf8(_("Not a booh file!\n\nHint: you cannot import directly an image or video with File/Open.\nUse File/New to create a new album."))
2363 if !source = $xmldoc.root.attributes['source']
2364 return utf8(_("Corrupted booh file..."))
2367 if !dest = $xmldoc.root.attributes['destination']
2368 return utf8(_("Corrupted booh file..."))
2371 if !theme = $xmldoc.root.attributes['theme']
2372 return utf8(_("Corrupted booh file..."))
2375 if $xmldoc.root.attributes['version'] < '0.8.4'
2376 msg 2, _("File's version %s, booh version now %s, marking dirty") % [ $xmldoc.root.attributes['version'], $VERSION ]
2377 mark_document_as_dirty
2378 if $xmldoc.root.attributes['version'] < '0.8.4'
2379 msg 1, _("File's version prior to 0.8.4, migrating directories and filenames in destination directory if needed")
2380 `find '#{source}' -type d -follow`.sort.collect { |v| v.chomp }.each { |dir|
2381 old_dest_dir = make_dest_filename_old(dir.sub(/^#{Regexp.quote(source)}/, dest))
2382 new_dest_dir = make_dest_filename(dir.sub(/^#{Regexp.quote(source)}/, dest))
2383 if old_dest_dir != new_dest_dir
2384 sys("mv '#{old_dest_dir}' '#{new_dest_dir}'")
2386 if xmldir = $xmldoc.elements["//dir[@path='#{utf8(dir)}']"]
2387 xmldir.elements.each { |element|
2388 if %w(image video).include?(element.name) && !element.attributes['deleted']
2389 old_name = new_dest_dir + '/' + make_dest_filename_old(from_utf8(element.attributes['filename'])).sub(/\.[^\.]+$/, '')
2390 new_name = new_dest_dir + '/' + make_dest_filename(from_utf8(element.attributes['filename'])).sub(/\.[^\.]+$/, '')
2391 Dir[old_name + '*'].each { |file|
2392 new_file = file.sub(/^#{Regexp.quote(old_name)}/, new_name)
2393 file != new_file and sys("mv '#{file}' '#{new_file}'")
2396 if element.name == 'dir' && !element.attributes['deleted']
2397 old_name = new_dest_dir + '/thumbnails-' + make_dest_filename_old(from_utf8(File.basename(element.attributes['path']))) + '.jpg'
2398 new_name = new_dest_dir + '/thumbnails-' + make_dest_filename(from_utf8(File.basename(element.attributes['path']))) + '.jpg'
2399 old_name != new_name and sys("mv '#{old_name}' '#{new_name}'")
2403 msg 1, "could not find <dir> element by path '#{utf8(dir)}' in xml file"
2407 $xmldoc.root.add_attribute('version', $VERSION)
2410 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2411 optimizefor32 = !$xmldoc.root.attributes['optimize-for-32'].nil?
2412 nperrow = $xmldoc.root.attributes['thumbnails-per-row']
2414 $filename = filename
2415 select_theme(theme, limit_sizes, optimizefor32, nperrow)
2416 $default_size['thumbnails'] =~ /(.*)x(.*)/
2417 $default_thumbnails = { :x => $1.to_i, :y => $2.to_i }
2418 $albums_thumbnail_size =~ /(.*)x(.*)/
2419 $default_albums_thumbnails = { :x => $1.to_i, :y => $2.to_i }
2421 populate_subalbums_treeview
2423 $save.sensitive = $save_as.sensitive = $merge_current.sensitive = $merge_newsubs.sensitive = $merge.sensitive = $generate.sensitive = $view_wa.sensitive = $properties.sensitive = $remove_all_captions.sensitive = true
2427 def open_file_user(filename)
2428 result = open_file(filename)
2430 $config['last-opens'] ||= []
2431 if $config['last-opens'][-1] != utf8(filename)
2432 $config['last-opens'] << utf8(filename)
2434 $orig_filename = $filename
2435 tmp = Tempfile.new("boohtemp")
2438 ios = File.open($filename = tmp.path, File::RDWR|File::CREAT|File::EXCL)
2440 $tempfiles << $filename << "#{$filename}.backup"
2442 $orig_filename = nil
2448 if !ask_save_modifications(utf8(_("Save this album?")),
2449 utf8(_("Do you want to save the changes to this album?")),
2450 { :yes => Gtk::Stock::YES, :no => Gtk::Stock::NO })
2453 fc = Gtk::FileChooserDialog.new(utf8(_("Open file")),
2455 Gtk::FileChooser::ACTION_OPEN,
2457 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2458 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2459 fc.set_current_folder(File.expand_path("~/.booh"))
2460 fc.transient_for = $main_window
2463 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2464 push_mousecursor_wait(fc)
2465 msg = open_file_user(fc.filename)
2481 cmd = $config['browser'].gsub('%f', "'#{url}'") + ' &'
2486 def additional_booh_options
2489 options += "--mproc #{$config['mproc'].to_i} "
2491 if $config['emptycomments']
2492 options += "--empty-comments "
2498 if !ask_save_modifications(utf8(_("Save this album?")),
2499 utf8(_("Do you want to save the changes to this album?")),
2500 { :yes => Gtk::Stock::YES, :no => Gtk::Stock::NO })
2503 dialog = Gtk::Dialog.new(utf8(_("Create a new album")),
2505 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2506 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2507 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2509 frame1 = Gtk::Frame.new(utf8(_("Locations"))).add(tbl = Gtk::Table.new(0, 0, false))
2510 tbl.attach(Gtk::Label.new(utf8(_("Directory of images/videos: "))),
2511 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2512 tbl.attach(src = Gtk::Entry.new,
2513 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2514 tbl.attach(src_browse = Gtk::Button.new(utf8(_("browse..."))),
2515 2, 3, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2516 tbl.attach(Gtk::Label.new.set_markup(utf8(_("<span size='small'><i>number of images/videos down this directory:</i></span> "))),
2517 0, 1, 1, 2, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2518 tbl.attach(src_nb = Gtk::Label.new.set_markup(utf8(_("<span size='small'><i>N/A</i></span>"))),
2519 1, 2, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
2520 tbl.attach(Gtk::Label.new(utf8(_("Directory where to put the web-album: "))),
2521 0, 1, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2522 tbl.attach(dest = Gtk::Entry.new,
2523 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2524 tbl.attach(dest_browse = Gtk::Button.new(utf8(_("browse..."))),
2525 2, 3, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2526 tbl.attach(Gtk::Label.new(utf8(_("Filename to store this album's properties: "))),
2527 0, 1, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2528 tbl.attach(conf = Gtk::Entry.new.set_size_request(250, -1),
2529 1, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
2530 tbl.attach(conf_browse = Gtk::Button.new(utf8(_("browse..."))),
2531 2, 3, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2533 tooltips = Gtk::Tooltips.new
2534 frame2 = Gtk::Frame.new(utf8(_("Configuration"))).add(vb = Gtk::VBox.new)
2535 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Theme: "))), false, false, 0).
2536 pack_start(theme_button = Gtk::Button.new($config['default-theme'] || 'simple'), false, false, 0))
2537 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Sizes of images to generate: "))), false, false, 0).
2538 pack_start(sizes = Gtk::HBox.new, false, false, 0))
2539 vb.add(optimize432 = Gtk::CheckButton.new(utf8(_("Optimize for 3/2 aspect ratio"))))
2540 tooltips.set_tip(optimize432, utf8(_("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)")), nil)
2541 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Number of thumbnails per row: "))), false, false, 0).
2542 pack_start(nperrowradios = Gtk::HBox.new, false, false, 0))
2543 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("'Made with' markup on pages bottom: "))), false, false, 0).
2544 pack_start(madewithentry = Gtk::Entry.new, true, true, 0))
2545 tooltips.set_tip(madewithentry, utf8(_("Optional HTML markup to use on pages bottom for a small 'made with' label; %booh is replaced by the website of booh;\nfor example: made with <a href=%booh>booh</a>!")), nil)
2547 src_nb_calculated_for = ''
2549 process_src_nb = Proc.new {
2550 if src.text != src_nb_calculated_for
2551 src_nb_calculated_for = src.text
2553 Thread.kill(src_nb_thread)
2556 if File.directory?(from_utf8(src_nb_calculated_for)) && src_nb_calculated_for != '/'
2557 if File.readable?(from_utf8(src_nb_calculated_for))
2558 src_nb_thread = Thread.new {
2559 gtk_thread_protect { src_nb.set_markup(utf8(_("<span size='small'><i>processing...</i></span>"))) }
2560 total = { 'image' => 0, 'video' => 0, nil => 0 }
2561 `find '#{from_utf8(src_nb_calculated_for)}' -type d -follow`.each { |dir|
2562 if File.basename(dir) =~ /^\./
2566 Dir.entries(dir.chomp).each { |file|
2567 total[entry2type(file)] += 1
2569 rescue Errno::EACCES, Errno::ENOENT
2573 gtk_thread_protect { src_nb.set_markup(utf8(_("<span size='small'><i>%s images and %s videos</i></span>") % [ total['image'], total['video'] ])) }
2577 src_nb.set_markup(utf8(_("<span size='small'><i>permission denied</i></span>")))
2580 src_nb.set_markup(utf8(_("<span size='small'><i>N/A</i></span>")))
2585 timeout_src_nb = Gtk.timeout_add(100) {
2589 src_browse.signal_connect('clicked') {
2590 fc = Gtk::FileChooserDialog.new(utf8(_("Select the directory of images/videos")),
2592 Gtk::FileChooser::ACTION_SELECT_FOLDER,
2594 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2595 fc.transient_for = $main_window
2596 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2597 src.text = utf8(fc.filename)
2599 conf.text = File.expand_path("~/.booh/#{File.basename(src.text)}")
2604 dest_browse.signal_connect('clicked') {
2605 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new directory where to put the web-album")),
2607 Gtk::FileChooser::ACTION_CREATE_FOLDER,
2609 [Gtk::Stock::OPEN, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2610 fc.transient_for = $main_window
2611 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2612 dest.text = utf8(fc.filename)
2617 conf_browse.signal_connect('clicked') {
2618 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new file to store this album's properties")),
2620 Gtk::FileChooser::ACTION_SAVE,
2622 [Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2623 fc.transient_for = $main_window
2624 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2625 fc.set_current_folder(File.expand_path("~/.booh"))
2626 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2627 conf.text = utf8(fc.filename)
2634 recreate_theme_config = proc {
2635 theme_sizes.each { |e| sizes.remove(e[:widget]) }
2637 select_theme(theme_button.label, 'all', optimize432.active?, nil)
2638 $images_size.each { |s|
2639 sizes.add(cb = Gtk::CheckButton.new(sizename(s['name'])))
2643 tooltips.set_tip(cb, utf8(s['description']), nil)
2644 theme_sizes << { :widget => cb, :value => s['name'] }
2646 sizes.add(cb = Gtk::CheckButton.new(utf8(_('original'))))
2647 tooltips = Gtk::Tooltips.new
2648 tooltips.set_tip(cb, utf8(_("Include original image in web-album")), nil)
2649 theme_sizes << { :widget => cb, :value => 'original' }
2652 nperrows.each { |e| nperrowradios.remove(e[:widget]) }
2655 $allowed_N_values.each { |n|
2657 nperrowradios.add(rb = Gtk::RadioButton.new(nperrow_group, n.to_s, false))
2659 nperrowradios.add(nperrow_group = rb = Gtk::RadioButton.new(n.to_s))
2661 tooltips.set_tip(rb, utf8(_("Set the number of thumbnails per row of the 'thumbnails' pages (if chosen theme uses a row arrangement)")), nil)
2665 nperrows << { :widget => rb, :value => n }
2667 nperrowradios.show_all
2669 recreate_theme_config.call
2671 theme_button.signal_connect('clicked') {
2672 if newtheme = theme_choose(theme_button.label)
2673 theme_button.label = newtheme
2674 recreate_theme_config.call
2678 dialog.vbox.add(frame1)
2679 dialog.vbox.add(frame2)
2680 dialog.window_position = Gtk::Window::POS_MOUSE
2686 dialog.run { |response|
2687 if response == Gtk::Dialog::RESPONSE_OK
2688 srcdir = from_utf8(src.text)
2689 destdir = from_utf8(dest.text)
2690 if !File.directory?(srcdir)
2691 show_popup(dialog, utf8(_("The directory of images/videos doesn't exist. Please check your input.")))
2693 elsif conf.text == ''
2694 show_popup(dialog, utf8(_("Please specify a filename to store the album's properties.")))
2696 elsif File.directory?(from_utf8(conf.text))
2697 show_popup(dialog, utf8(_("Sorry, the filename specified to store the album's properties is an existing directory. Please choose another one.")))
2699 elsif destdir != make_dest_filename(destdir)
2700 show_popup(dialog, utf8(_("Sorry, destination directory can't contain non simple alphanumeric characters.")))
2702 elsif File.directory?(destdir) && Dir.entries(destdir).size > 2
2703 keepon = !show_popup(dialog, utf8(_("The destination directory already exists. Are you sure you want to continue?")), { :okcancel => true })
2705 elsif File.exists?(destdir) && !File.directory?(destdir)
2706 show_popup(dialog, utf8(_("There is already a file by the name of the destination directory. Please choose another one.")))
2708 elsif !theme_sizes.detect { |e| e[:value] != 'original' && e[:widget].active? }
2709 show_popup(dialog, utf8(_("You need to select at least one size (not counting original).")))
2711 system("mkdir '#{destdir}'")
2712 if !File.directory?(destdir)
2713 show_popup(dialog, utf8(_("Could not create destination directory. Permission denied?")))
2724 srcdir = from_utf8(src.text)
2725 destdir = from_utf8(dest.text)
2726 configskel = File.expand_path(from_utf8(conf.text))
2727 theme = theme_button.label
2728 sizes = theme_sizes.find_all { |e| e[:widget].active? }.collect { |e| e[:value] }.join(',')
2729 nperrow = nperrows.find { |e| e[:widget].active? }[:value]
2730 opt432 = optimize432.active?
2731 madewith = madewithentry.text
2733 Thread.kill(src_nb_thread)
2734 gtk_thread_flush #- needed because we're about to destroy widgets in dialog, for which they may be some pending gtk calls
2737 Gtk.timeout_remove(timeout_src_nb)
2740 call_backend("booh-backend --source '#{srcdir}' --destination '#{destdir}' --config-skel '#{configskel}' --for-gui " +
2741 "--verbose-level #{$verbose_level} --theme #{theme} --sizes #{sizes} --thumbnails-per-row #{nperrow} " +
2742 "#{opt432 ? '--optimize-for-32' : ''} --made-with '#{madewith}' #{additional_booh_options}",
2743 utf8(_("Please wait while scanning source directory...")),
2745 { :closure_after => proc { open_file_user(configskel) } })
2750 dialog = Gtk::Dialog.new(utf8(_("Properties of your album")),
2752 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2753 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2754 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2756 source = $xmldoc.root.attributes['source']
2757 dest = $xmldoc.root.attributes['destination']
2758 theme = $xmldoc.root.attributes['theme']
2759 opt432 = !$xmldoc.root.attributes['optimize-for-32'].nil?
2760 nperrow = $xmldoc.root.attributes['thumbnails-per-row']
2761 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2763 limit_sizes = limit_sizes.split(/,/)
2765 madewith = $xmldoc.root.attributes['made-with']
2767 tooltips = Gtk::Tooltips.new
2768 frame1 = Gtk::Frame.new(utf8(_("Locations"))).add(tbl = Gtk::Table.new(0, 0, false))
2769 tbl.attach(Gtk::Label.new(utf8(_("Directory of source images/videos: "))),
2770 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2771 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + source + '</i>')),
2772 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2773 tbl.attach(Gtk::Label.new(utf8(_("Directory where the web-album is created: "))),
2774 0, 1, 2, 3, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2775 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + dest + '</i>').set_selectable(true)),
2776 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2777 tbl.attach(Gtk::Label.new(utf8(_("Filename where this album's properties are stored: "))),
2778 0, 1, 3, 4, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
2779 tbl.attach(Gtk::Alignment.new(0, 0.5, 0, 0).add(Gtk::Label.new.set_markup('<i>' + $orig_filename + '</i>').set_selectable(true)),
2780 1, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
2782 frame2 = Gtk::Frame.new(utf8(_("Configuration"))).add(vb = Gtk::VBox.new)
2783 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Theme: "))), false, false, 0).
2784 pack_start(theme_button = Gtk::Button.new(theme), false, false, 0))
2785 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Sizes of images to generate: "))), false, false, 0).
2786 pack_start(sizes = Gtk::HBox.new, false, false, 0))
2787 vb.add(optimize432 = Gtk::CheckButton.new(utf8(_("Optimize for 3/2 aspect ratio"))).set_active(opt432))
2788 tooltips.set_tip(optimize432, utf8(_("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)")), nil)
2789 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("Number of thumbnails per row: "))), false, false, 0).
2790 pack_start(nperrowradios = Gtk::HBox.new, false, false, 0))
2791 vb.add(Gtk::HBox.new(false, 3).pack_start(Gtk::Label.new(utf8(_("'Made with' markup on pages bottom: "))), false, false, 0).
2792 pack_start(madewithentry = Gtk::Entry.new, true, true, 0))
2794 madewithentry.text = madewith
2796 tooltips.set_tip(madewithentry, utf8(_("Optional HTML markup to use on pages bottom for a small 'made with' label; %booh is replaced by the website of booh;\nfor example: made with <a href=%booh>booh</a>!")), nil)
2800 recreate_theme_config = proc {
2801 theme_sizes.each { |e| sizes.remove(e[:widget]) }
2803 select_theme(theme_button.label, 'all', optimize432.active?, nperrow)
2805 $images_size.each { |s|
2806 sizes.add(cb = Gtk::CheckButton.new(sizename(s['name'])))
2808 if limit_sizes.include?(s['name'])
2816 tooltips.set_tip(cb, utf8(s['description']), nil)
2817 theme_sizes << { :widget => cb, :value => s['name'] }
2819 sizes.add(cb = Gtk::CheckButton.new(utf8(_('original'))))
2820 tooltips = Gtk::Tooltips.new
2821 tooltips.set_tip(cb, utf8(_("Include original image in web-album")), nil)
2822 if limit_sizes && limit_sizes.include?('original')
2825 theme_sizes << { :widget => cb, :value => 'original' }
2828 nperrows.each { |e| nperrowradios.remove(e[:widget]) }
2831 $allowed_N_values.each { |n|
2833 nperrowradios.add(rb = Gtk::RadioButton.new(nperrow_group, n.to_s, false))
2835 nperrowradios.add(nperrow_group = rb = Gtk::RadioButton.new(n.to_s))
2837 tooltips.set_tip(rb, utf8(_("Set the number of thumbnails per row of the 'thumbnails' pages (if chosen theme uses a row arrangement)")), nil)
2838 nperrowradios.add(Gtk::Label.new(' '))
2839 if nperrow && n.to_s == nperrow || !nperrow && $default_N == n
2842 nperrows << { :widget => rb, :value => n.to_s }
2844 nperrowradios.show_all
2846 recreate_theme_config.call
2848 theme_button.signal_connect('clicked') {
2849 if newtheme = theme_choose(theme_button.label)
2852 theme_button.label = newtheme
2853 recreate_theme_config.call
2857 dialog.vbox.add(frame1)
2858 dialog.vbox.add(frame2)
2859 dialog.window_position = Gtk::Window::POS_MOUSE
2865 dialog.run { |response|
2866 if response == Gtk::Dialog::RESPONSE_OK
2867 if !theme_sizes.detect { |e| e[:value] != 'original' && e[:widget].active? }
2868 show_popup(dialog, utf8(_("You need to select at least one size (not counting original).")))
2877 save_theme = theme_button.label
2878 save_limit_sizes = theme_sizes.find_all { |e| e[:widget].active? }.collect { |e| e[:value] }
2879 save_opt432 = optimize432.active?
2880 save_nperrow = nperrows.find { |e| e[:widget].active? }[:value]
2881 save_madewith = madewithentry.text
2884 if ok && (save_theme != theme || save_limit_sizes != limit_sizes || save_opt432 != opt432 || save_nperrow != nperrow || save_madewith != madewith)
2885 mark_document_as_dirty
2887 call_backend("booh-backend --use-config '#{$filename}' --for-gui --verbose-level #{$verbose_level} " +
2888 "--thumbnails-per-row #{save_nperrow} --theme #{save_theme} --sizes #{save_limit_sizes.join(',')} " +
2889 "#{save_opt432 ? '--optimize-for-32' : ''} --made-with '#{save_madewith}' #{additional_booh_options}",
2890 utf8(_("Please wait while scanning source directory...")),
2892 { :closure_after => proc {
2893 open_file($filename)
2902 sel = $albums_tv.selection.selected_rows
2904 call_backend("booh-backend --merge-config-onedir '#{$filename}' --dir '#{from_utf8($current_path)}' --for-gui " +
2905 "--verbose-level #{$verbose_level} #{additional_booh_options}",
2906 utf8(_("Please wait while scanning source directory...")),
2908 { :closure_after => proc {
2909 open_file($filename)
2910 $albums_tv.selection.select_path(sel[0])
2918 sel = $albums_tv.selection.selected_rows
2920 call_backend("booh-backend --merge-config-subdirs '#{$filename}' --dir '#{from_utf8($current_path)}' --for-gui " +
2921 "--verbose-level #{$verbose_level} #{additional_booh_options}",
2922 utf8(_("Please wait while scanning source directory...")),
2924 { :closure_after => proc {
2925 open_file($filename)
2926 $albums_tv.selection.select_path(sel[0])
2934 theme = $xmldoc.root.attributes['theme']
2935 limit_sizes = $xmldoc.root.attributes['limit-sizes']
2937 limit_sizes = "--sizes #{limit_sizes}"
2939 call_backend("booh-backend --merge-config '#{$filename}' --for-gui " +
2940 "--verbose-level #{$verbose_level} --theme #{theme} #{limit_sizes} #{additional_booh_options}",
2941 utf8(_("Please wait while scanning source directory...")),
2943 { :closure_after => proc {
2944 open_file($filename)
2950 fc = Gtk::FileChooserDialog.new(utf8(_("Select a new filename to store this album's properties")),
2952 Gtk::FileChooser::ACTION_SAVE,
2954 [Gtk::Stock::SAVE, Gtk::Dialog::RESPONSE_ACCEPT], [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2955 fc.transient_for = $main_window
2956 fc.add_shortcut_folder(File.expand_path("~/.booh"))
2957 fc.set_current_folder(File.expand_path("~/.booh"))
2958 fc.filename = $orig_filename
2959 if fc.run == Gtk::Dialog::RESPONSE_ACCEPT
2960 $orig_filename = fc.filename
2961 save_current_file_user
2967 dialog = Gtk::Dialog.new(utf8(_("Edit preferences")),
2969 Gtk::Dialog::MODAL | Gtk::Dialog::DESTROY_WITH_PARENT,
2970 [Gtk::Stock::OK, Gtk::Dialog::RESPONSE_OK],
2971 [Gtk::Stock::CANCEL, Gtk::Dialog::RESPONSE_CANCEL])
2973 dialog.vbox.add(notebook = Gtk::Notebook.new)
2974 notebook.append_page(tbl = Gtk::Table.new(0, 0, false), Gtk::Label.new(utf8(_("Options"))))
2975 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Command for watching videos: ")))),
2976 0, 1, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2977 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(video_viewer_entry = Gtk::Entry.new.set_text($config['video-viewer'])),
2978 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
2979 tooltips = Gtk::Tooltips.new
2980 tooltips.set_tip(video_viewer_entry, utf8(_("Use %f to specify the filename;
2981 for example: /usr/bin/mplayer %f")), nil)
2982 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(Gtk::Label.new.set_markup(utf8(_("Browser's command: ")))),
2983 0, 1, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
2984 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(browser_entry = Gtk::Entry.new.set_text($config['browser'])),
2985 1, 2, 1, 2, Gtk::FILL, Gtk::SHRINK, 2, 2)
2986 tooltips.set_tip(browser_entry, utf8(_("Use %f to specify the filename;
2987 for example: /usr/bin/mozilla-firefox -remote 'openURL(%f,new-window)' || /usr/bin/mozilla-firefox %f")), nil)
2988 tbl.attach(Gtk::Alignment.new(1, 0.5, 0, 0).add(smp_check = Gtk::CheckButton.new(utf8(_("Use symmetric multi-processing")))),
2989 0, 1, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2990 tbl.attach(Gtk::Alignment.new(0, 0.5, 1, 0).add(smp_hbox = Gtk::HBox.new.add(smp_spin = Gtk::SpinButton.new(2, 16, 1)).add(Gtk::Label.new(utf8(_("processors")))).set_sensitive(false)),
2991 1, 2, 2, 3, Gtk::FILL, Gtk::SHRINK, 2, 2)
2992 tooltips.set_tip(smp_check, utf8(_("When activated, this option allows the thumbnails creation to run faster. However, if you don't have a multi-processor machine, this will only slow down processing!")), nil)
2993 tbl.attach(nogestures_check = Gtk::CheckButton.new(utf8(_("Disable mouse gestures"))),
2994 0, 2, 3, 4, Gtk::FILL, Gtk::SHRINK, 2, 2)
2995 tooltips.set_tip(nogestures_check, utf8(_("Mouse gestures are 'unusual' mouse movements triggering special actions, and are great for speeding up your editions. Get details on available mouse gestures from the Help menu.")), nil)
2996 tbl.attach(emptycomments_check = Gtk::CheckButton.new(utf8(_("Use empty comments for new albums"))),
2997 0, 2, 4, 5, Gtk::FILL, Gtk::SHRINK, 2, 2)
2998 tooltips.set_tip(emptycomments_check, utf8(_("Normally, filenames are used as comments for new albums. Check this if you prefer empty comments.")), nil)
2999 tbl.attach(deleteondisk_check = Gtk::CheckButton.new(utf8(_("Delete original images/videos as well"))),
3000 0, 2, 5, 6, Gtk::FILL, Gtk::SHRINK, 2, 2)
3001 tooltips.set_tip(deleteondisk_check, utf8(_("Normally, deleting an image or video in booh only removes it from the web-album. If you check this option, the original file in source directory will be removed as well. Undo is possible, since actual deletion is performed only when web-album is saved.")), nil)
3002 smp_check.signal_connect('toggled') {
3003 if smp_check.active?
3004 smp_hbox.sensitive = true
3006 smp_hbox.sensitive = false
3010 smp_check.active = true
3011 smp_spin.value = $config['mproc'].to_i
3013 nogestures_check.active = $config['nogestures']
3014 emptycomments_check.active = $config['emptycomments']
3015 deleteondisk_check.active = $config['deleteondisk']
3017 notebook.append_page(tbl = Gtk::Table.new(0, 0, false), Gtk::Label.new(utf8(_("Advanced"))))
3018 tbl.attach(Gtk::Label.new.set_markup(utf8(_("Options to pass to <i>convert</i> when\nperforming 'enhance contrast': "))),
3019 0, 1, 0, 1, Gtk::SHRINK, Gtk::SHRINK, 2, 2)
3020 tbl.attach(enhance_entry = Gtk::Entry.new.set_text($config['convert-enhance'] || $convert_enhance).set_size_request(250, -1),
3021 1, 2, 0, 1, Gtk::FILL, Gtk::SHRINK, 2, 2)
3023 dialog.vbox.show_all
3024 dialog.run { |response|
3025 if response == Gtk::Dialog::RESPONSE_OK
3026 $config['video-viewer'] = video_viewer_entry.text
3027 $config['browser'] = browser_entry.text
3028 if smp_check.active?
3029 $config['mproc'] = smp_spin.value.to_i
3031 $config.delete('mproc')
3033 $config['nogestures'] = nogestures_check.active?
3034 $config['emptycomments'] = emptycomments_check.active?
3035 $config['deleteondisk'] = deleteondisk_check.active?
3037 $config['convert-enhance'] = enhance_entry.text
3044 if $undo_tb.sensitive?
3045 $redo_tb.sensitive = $redo_mb.sensitive = true
3046 if not more_undoes = UndoHandler.undo($statusbar)
3047 $undo_tb.sensitive = $undo_mb.sensitive = false
3053 if $redo_tb.sensitive?
3054 $undo_tb.sensitive = $undo_mb.sensitive = true
3055 if not more_redoes = UndoHandler.redo($statusbar)
3056 $redo_tb.sensitive = $redo_mb.sensitive = false
3061 def show_one_click_explanation(intro)
3062 show_popup($main_window, utf8(_("<b>One-Click tools.</b>
3064 %s When such a tool is activated
3065 (<span foreground='darkblue'>Rotate clockwise</span>, <span foreground='darkblue'>Rotate counter-clockwise</span>, <span foreground='darkblue'>Enhance</span> or <span foreground='darkblue'>Delete</span>), clicking
3066 on a thumbnail will immediately apply the desired action.
3068 Click the <span foreground='darkblue'>None</span> icon when you're finished with One-Click tools.
3074 GNU GENERAL PUBLIC LICENSE
3075 Version 2, June 1991
3077 Copyright (C) 1989, 1991 Free Software Foundation, Inc.
3078 675 Mass Ave, Cambridge, MA 02139, USA
3079 Everyone is permitted to copy and distribute verbatim copies
3080 of this license document, but changing it is not allowed.
3084 The licenses for most software are designed to take away your
3085 freedom to share and change it. By contrast, the GNU General Public
3086 License is intended to guarantee your freedom to share and change free
3087 software--to make sure the software is free for all its users. This
3088 General Public License applies to most of the Free Software
3089 Foundation's software and to any other program whose authors commit to
3090 using it. (Some other Free Software Foundation software is covered by
3091 the GNU Library General Public License instead.) You can apply it to
3094 When we speak of free software, we are referring to freedom, not
3095 price. Our General Public Licenses are designed to make sure that you
3096 have the freedom to distribute copies of free software (and charge for
3097 this service if you wish), that you receive source code or can get it
3098 if you want it, that you can change the software or use pieces of it
3099 in new free programs; and that you know you can do these things.
3101 To protect your rights, we need to make restrictions that forbid
3102 anyone to deny you these rights or to ask you to surrender the rights.
3103 These restrictions translate to certain responsibilities for you if you
3104 distribute copies of the software, or if you modify it.
3106 For example, if you distribute copies of such a program, whether
3107 gratis or for a fee, you must give the recipients all the rights that
3108 you have. You must make sure that they, too, receive or can get the
3109 source code. And you must show them these terms so they know their
3112 We protect your rights with two steps: (1) copyright the software, and
3113 (2) offer you this license which gives you legal permission to copy,
3114 distribute and/or modify the software.
3116 Also, for each author's protection and ours, we want to make certain
3117 that everyone understands that there is no warranty for this free
3118 software. If the software is modified by someone else and passed on, we
3119 want its recipients to know that what they have is not the original, so
3120 that any problems introduced by others will not reflect on the original
3121 authors' reputations.
3123 Finally, any free program is threatened constantly by software
3124 patents. We wish to avoid the danger that redistributors of a free
3125 program will individually obtain patent licenses, in effect making the
3126 program proprietary. To prevent this, we have made it clear that any
3127 patent must be licensed for everyone's free use or not licensed at all.
3129 The precise terms and conditions for copying, distribution and
3130 modification follow.
3133 GNU GENERAL PUBLIC LICENSE
3134 TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
3136 0. This License applies to any program or other work which contains
3137 a notice placed by the copyright holder saying it may be distributed
3138 under the terms of this General Public License. The "Program", below,
3139 refers to any such program or work, and a "work based on the Program"
3140 means either the Program or any derivative work under copyright law:
3141 that is to say, a work containing the Program or a portion of it,
3142 either verbatim or with modifications and/or translated into another
3143 language. (Hereinafter, translation is included without limitation in
3144 the term "modification".) Each licensee is addressed as "you".
3146 Activities other than copying, distribution and modification are not
3147 covered by this License; they are outside its scope. The act of
3148 running the Program is not restricted, and the output from the Program
3149 is covered only if its contents constitute a work based on the
3150 Program (independent of having been made by running the Program).
3151 Whether that is true depends on what the Program does.
3153 1. You may copy and distribute verbatim copies of the Program's
3154 source code as you receive it, in any medium, provided that you
3155 conspicuously and appropriately publish on each copy an appropriate
3156 copyright notice and disclaimer of warranty; keep intact all the
3157 notices that refer to this License and to the absence of any warranty;
3158 and give any other recipients of the Program a copy of this License
3159 along with the Program.
3161 You may charge a fee for the physical act of transferring a copy, and
3162 you may at your option offer warranty protection in exchange for a fee.
3164 2. You may modify your copy or copies of the Program or any portion
3165 of it, thus forming a work based on the Program, and copy and
3166 distribute such modifications or work under the terms of Section 1
3167 above, provided that you also meet all of these conditions:
3169 a) You must cause the modified files to carry prominent notices
3170 stating that you changed the files and the date of any change.
3172 b) You must cause any work that you distribute or publish, that in
3173 whole or in part contains or is derived from the Program or any
3174 part thereof, to be licensed as a whole at no charge to all third
3175 parties under the terms of this License.
3177 c) If the modified program normally reads commands interactively
3178 when run, you must cause it, when started running for such
3179 interactive use in the most ordinary way, to print or display an
3180 announcement including an appropriate copyright notice and a
3181 notice that there is no warranty (or else, saying that you provide
3182 a warranty) and that users may redistribute the program under
3183 these conditions, and telling the user how to view a copy of this
3184 License. (Exception: if the Program itself is interactive but
3185 does not normally print such an announcement, your work based on
3186 the Program is not required to print an announcement.)
3189 These requirements apply to the modified work as a whole. If
3190 identifiable sections of that work are not derived from the Program,
3191 and can be reasonably considered independent and separate works in
3192 themselves, then this License, and its terms, do not apply to those
3193 sections when you distribute them as separate works. But when you
3194 distribute the same sections as part of a whole which is a work based
3195 on the Program, the distribution of the whole must be on the terms of
3196 this License, whose permissions for other licensees extend to the
3197 entire whole, and thus to each and every part regardless of who wrote it.
3199 Thus, it is not the intent of this section to claim rights or contest
3200 your rights to work written entirely by you; rather, the intent is to
3201 exercise the right to control the distribution of derivative or
3202 collective works based on the Program.
3204 In addition, mere aggregation of another work not based on the Program
3205 with the Program (or with a work based on the Program) on a volume of
3206 a storage or distribution medium does not bring the other work under
3207 the scope of this License.
3209 3. You may copy and distribute the Program (or a work based on it,
3210 under Section 2) in object code or executable form under the terms of
3211 Sections 1 and 2 above provided that you also do one of the following:
3213 a) Accompany it with the complete corresponding machine-readable
3214 source code, which must be distributed under the terms of Sections
3215 1 and 2 above on a medium customarily used for software interchange; or,
3217 b) Accompany it with a written offer, valid for at least three
3218 years, to give any third party, for a charge no more than your
3219 cost of physically performing source distribution, a complete
3220 machine-readable copy of the corresponding source code, to be
3221 distributed under the terms of Sections 1 and 2 above on a medium
3222 customarily used for software interchange; or,
3224 c) Accompany it with the information you received as to the offer
3225 to distribute corresponding source code. (This alternative is
3226 allowed only for noncommercial distribution and only if you
3227 received the program in object code or executable form with such
3228 an offer, in accord with Subsection b above.)
3230 The source code for a work means the preferred form of the work for
3231 making modifications to it. For an executable work, complete source
3232 code means all the source code for all modules it contains, plus any
3233 associated interface definition files, plus the scripts used to
3234 control compilation and installation of the executable. However, as a
3235 special exception, the source code distributed need not include
3236 anything that is normally distributed (in either source or binary
3237 form) with the major components (compiler, kernel, and so on) of the
3238 operating system on which the executable runs, unless that component
3239 itself accompanies the executable.
3241 If distribution of executable or object code is made by offering
3242 access to copy from a designated place, then offering equivalent
3243 access to copy the source code from the same place counts as
3244 distribution of the source code, even though third parties are not
3245 compelled to copy the source along with the object code.
3248 4. You may not copy, modify, sublicense, or distribute the Program
3249 except as expressly provided under this License. Any attempt
3250 otherwise to copy, modify, sublicense or distribute the Program is
3251 void, and will automatically terminate your rights under this License.
3252 However, parties who have received copies, or rights, from you under
3253 this License will not have their licenses terminated so long as such
3254 parties remain in full compliance.
3256 5. You are not required to accept this License, since you have not
3257 signed it. However, nothing else grants you permission to modify or
3258 distribute the Program or its derivative works. These actions are
3259 prohibited by law if you do not accept this License. Therefore, by
3260 modifying or distributing the Program (or any work based on the
3261 Program), you indicate your acceptance of this License to do so, and
3262 all its terms and conditions for copying, distributing or modifying
3263 the Program or works based on it.
3265 6. Each time you redistribute the Program (or any work based on the
3266 Program), the recipient automatically receives a license from the
3267 original licensor to copy, distribute or modify the Program subject to
3268 these terms and conditions. You may not impose any further
3269 restrictions on the recipients' exercise of the rights granted herein.
3270 You are not responsible for enforcing compliance by third parties to
3273 7. If, as a consequence of a court judgment or allegation of patent
3274 infringement or for any other reason (not limited to patent issues),
3275 conditions are imposed on you (whether by court order, agreement or
3276 otherwise) that contradict the conditions of this License, they do not
3277 excuse you from the conditions of this License. If you cannot
3278 distribute so as to satisfy simultaneously your obligations under this
3279 License and any other pertinent obligations, then as a consequence you
3280 may not distribute the Program at all. For example, if a patent
3281 license would not permit royalty-free redistribution of the Program by
3282 all those who receive copies directly or indirectly through you, then
3283 the only way you could satisfy both it and this License would be to
3284 refrain entirely from distribution of the Program.
3286 If any portion of this section is held invalid or unenforceable under
3287 any particular circumstance, the balance of the section is intended to
3288 apply and the section as a whole is intended to apply in other
3291 It is not the purpose of this section to induce you to infringe any
3292 patents or other property right claims or to contest validity of any
3293 such claims; this section has the sole purpose of protecting the
3294 integrity of the free software distribution system, which is
3295 implemented by public license practices. Many people have made
3296 generous contributions to the wide range of software distributed
3297 through that system in reliance on consistent application of that
3298 system; it is up to the author/donor to decide if he or she is willing
3299 to distribute software through any other system and a licensee cannot
3302 This section is intended to make thoroughly clear what is believed to
3303 be a consequence of the rest of this License.
3306 8. If the distribution and/or use of the Program is restricted in
3307 certain countries either by patents or by copyrighted interfaces, the
3308 original copyright holder who places the Program under this License
3309 may add an explicit geographical distribution limitation excluding
3310 those countries, so that distribution is permitted only in or among
3311 countries not thus excluded. In such case, this License incorporates
3312 the limitation as if written in the body of this License.
3314 9. The Free Software Foundation may publish revised and/or new versions
3315 of the General Public License from time to time. Such new versions will
3316 be similar in spirit to the present version, but may differ in detail to
3317 address new problems or concerns.
3319 Each version is given a distinguishing version number. If the Program
3320 specifies a version number of this License which applies to it and "any
3321 later version", you have the option of following the terms and conditions
3322 either of that version or of any later version published by the Free
3323 Software Foundation. If the Program does not specify a version number of
3324 this License, you may choose any version ever published by the Free Software
3327 10. If you wish to incorporate parts of the Program into other free
3328 programs whose distribution conditions are different, write to the author
3329 to ask for permission. For software which is copyrighted by the Free
3330 Software Foundation, write to the Free Software Foundation; we sometimes
3331 make exceptions for this. Our decision will be guided by the two goals
3332 of preserving the free status of all derivatives of our free software and
3333 of promoting the sharing and reuse of software generally.
3337 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
3338 FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
3339 OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
3340 PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
3341 OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
3342 MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
3343 TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
3344 PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
3345 REPAIR OR CORRECTION.
3347 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
3348 WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
3349 REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
3350 INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
3351 OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
3352 TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
3353 YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
3354 PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
3355 POSSIBILITY OF SUCH DAMAGES.
3359 def create_menu_and_toolbar
3362 mb = Gtk::MenuBar.new
3364 filemenu = Gtk::MenuItem.new(utf8(_("_File")))
3365 filesubmenu = Gtk::Menu.new
3366 filesubmenu.append(new = Gtk::ImageMenuItem.new(Gtk::Stock::NEW))
3367 filesubmenu.append(open = Gtk::ImageMenuItem.new(Gtk::Stock::OPEN))
3368 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3369 filesubmenu.append($save = Gtk::ImageMenuItem.new(Gtk::Stock::SAVE).set_sensitive(false))
3370 filesubmenu.append($save_as = Gtk::ImageMenuItem.new(Gtk::Stock::SAVE_AS).set_sensitive(false))
3371 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3372 tooltips = Gtk::Tooltips.new
3373 filesubmenu.append($merge_current = Gtk::ImageMenuItem.new(utf8(_("Merge new/removed images/videos in current subalbum"))).set_sensitive(false))
3374 $merge_current.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3375 tooltips.set_tip($merge_current, utf8(_("Take into account new/removed images/videos in currently viewed subalbum")), nil)
3376 filesubmenu.append($merge_newsubs = Gtk::ImageMenuItem.new(utf8(_("Merge new subalbums (subdirectories) in current subalbum"))).set_sensitive(false))
3377 $merge_newsubs.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3378 tooltips.set_tip($merge_newsubs, utf8(_("Take into account new subalbums in currently viewed subalbum (and only here)")), nil)
3379 filesubmenu.append($merge = Gtk::ImageMenuItem.new(utf8(_("Scan source directory to merge new subalbums and new/removed images/videos"))).set_sensitive(false))
3380 $merge.image = Gtk::Image.new("#{$FPATH}/images/stock-reset-16.png")
3381 tooltips.set_tip($merge, utf8(_("Take into account new/removed subalbums (subdirectories) and new/removed images/videos in existing subalbums (anywhere)")), nil)
3382 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3383 filesubmenu.append($generate = Gtk::ImageMenuItem.new(utf8(_("Generate web-album"))).set_sensitive(false))
3384 $generate.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
3385 tooltips.set_tip($generate, utf8(_("(Re)generate web-album from latest changes into the destination directory")), nil)
3386 filesubmenu.append($view_wa = Gtk::ImageMenuItem.new(utf8(_("View web-album with browser"))).set_sensitive(false))
3387 $view_wa.image = Gtk::Image.new("#{$FPATH}/images/stock-view-webalbum-16.png")
3388 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3389 filesubmenu.append($properties = Gtk::ImageMenuItem.new(Gtk::Stock::PROPERTIES).set_sensitive(false))
3390 tooltips.set_tip($properties, utf8(_("View and modify properties of the web-album")), nil)
3391 filesubmenu.append( Gtk::SeparatorMenuItem.new)
3392 filesubmenu.append(quit = Gtk::ImageMenuItem.new(Gtk::Stock::QUIT))
3393 filemenu.set_submenu(filesubmenu)
3396 new.signal_connect('activate') { new_album }
3397 open.signal_connect('activate') { open_file_popup }
3398 $save.signal_connect('activate') { save_current_file_user }
3399 $save_as.signal_connect('activate') { save_as_do }
3400 $merge_current.signal_connect('activate') { merge_current }
3401 $merge_newsubs.signal_connect('activate') { merge_newsubs }
3402 $merge.signal_connect('activate') { merge }
3403 $generate.signal_connect('activate') {
3405 call_backend("booh-backend --config '#{$filename}' --verbose-level #{$verbose_level} #{additional_booh_options}",
3406 utf8(_("Please wait while generating web-album...\nThis may take a while, please be patient.")),
3408 { :successmsg => utf8(_("Your web-album is now ready in directory '%s'.
3409 Click to view it in your browser:") % $xmldoc.root.attributes['destination']),
3410 :successmsg_linkurl => $xmldoc.root.attributes['destination'],
3411 :closure_after => proc {
3412 $xmldoc.elements.each('//dir') { |elem|
3413 $modified ||= elem.attributes['already-generated'].nil?
3414 elem.add_attribute('already-generated', 'true')
3416 UndoHandler.cleanup #- prevent save_changes to mark current dir as not already generated
3417 $undo_tb.sensitive = $undo_mb.sensitive = false
3418 $redo_tb.sensitive = $redo_mb.sensitive = false
3420 $generated_outofline = true
3423 $view_wa.signal_connect('activate') {
3424 indexhtml = $xmldoc.root.attributes['destination'] + '/index.html'
3425 if File.exists?(indexhtml)
3428 show_popup($main_window, utf8(_("Seems like you should generate the web-album first.")))
3431 $properties.signal_connect('activate') { properties }
3433 quit.signal_connect('activate') { try_quit }
3435 editmenu = Gtk::MenuItem.new(utf8(_("_Edit")))
3436 editsubmenu = Gtk::Menu.new
3437 editsubmenu.append($undo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::UNDO).set_sensitive(false))
3438 editsubmenu.append($redo_mb = Gtk::ImageMenuItem.new(Gtk::Stock::REDO).set_sensitive(false))
3439 editsubmenu.append( Gtk::SeparatorMenuItem.new)
3440 editsubmenu.append($remove_all_captions = Gtk::ImageMenuItem.new(utf8(_("Remove all captions in this sub-album"))).set_sensitive(false))
3441 $remove_all_captions.image = Gtk::Image.new("#{$FPATH}/images/stock-tool-eraser-16.png")
3442 tooltips.set_tip($remove_all_captions, utf8(_("Mainly useful when you don't want to type any caption, that will remove default captions made of filenames")), nil)
3443 editsubmenu.append( Gtk::SeparatorMenuItem.new)
3444 editsubmenu.append(prefs = Gtk::ImageMenuItem.new(Gtk::Stock::PREFERENCES))
3445 editmenu.set_submenu(editsubmenu)
3448 $remove_all_captions.signal_connect('activate') { remove_all_captions }
3450 prefs.signal_connect('activate') { preferences }
3452 helpmenu = Gtk::MenuItem.new(utf8(_("_Help")))
3453 helpsubmenu = Gtk::Menu.new
3454 helpsubmenu.append(one_click = Gtk::ImageMenuItem.new(utf8(_("One-click tools"))))
3455 one_click.image = Gtk::Image.new("#{$FPATH}/images/stock-tools-16.png")
3456 helpsubmenu.append(speed = Gtk::ImageMenuItem.new(utf8(_("Speedup: key shortcuts and mouse gestures"))))
3457 speed.image = Gtk::Image.new("#{$FPATH}/images/stock-info-16.png")
3458 helpsubmenu.append(tutos = Gtk::ImageMenuItem.new(utf8(_("Online tutorials (opens a web-browser)"))))
3459 tutos.image = Gtk::Image.new("#{$FPATH}/images/stock-web-16.png")
3460 helpsubmenu.append( Gtk::SeparatorMenuItem.new)
3461 helpsubmenu.append(about = Gtk::ImageMenuItem.new(Gtk::Stock::ABOUT))
3462 helpmenu.set_submenu(helpsubmenu)
3465 one_click.signal_connect('activate') {
3466 show_one_click_explanation(_("One-Click tools are available in the toolbar."))
3469 speed.signal_connect('activate') {
3470 show_popup($main_window, utf8(_("<span size='large' weight='bold'>Key shortcuts:</span>
3472 <span foreground='darkblue'>Tab</span>: go to next image caption and select text (begin typing to erase current text!)
3473 <span foreground='darkblue'>Shift-Tab</span>: go to previous image caption
3474 <span foreground='darkblue'>Control-Left/Right/Up/Down</span>: go to specified direction's image caption
3475 <span foreground='darkblue'>Control-Enter</span>: for an image, open larger view; for a video, launch player
3476 <span foreground='darkblue'>Control-Delete</span>: delete image
3477 <span foreground='darkblue'>Shift-Left/Right/Up/Down</span>: move image left/right/up/down
3478 <span foreground='darkblue'>Alt-Left/Right</span>: rotate image clockwise/counter-clockwise
3479 <span foreground='darkblue'>Control-z</span>: undo
3480 <span foreground='darkblue'>Control-r</span>: redo
3482 <span size='large' weight='bold'>Mouse gestures:</span>
3484 Mouse gestures are 'unusual' mouse movements triggering special actions, and are great
3485 for speeding up your editions. If bothered, you can disable them from Edit/Preferences.
3487 <span foreground='darkblue'>Left click, drag to the right, release</span>: rotate image clockwise
3488 <span foreground='darkblue'>Left click, drag to the left, release</span>: rotate image counter-clockwise
3489 <span foreground='darkblue'>Left click, drag to the bottom, release</span>: remove image
3490 <span foreground='darkblue'>Left click, hold left button, right click</span>: undo
3491 <span foreground='darkblue'>Right click, hold right button, left click</span>: redo
3492 ")), { :pos_centered => true, :not_transient => true })
3495 tutos.signal_connect('activate') {
3496 open_url('http://zarb.org/~gc/html/booh/tutorial.html')
3499 about.signal_connect('activate') {
3500 Gtk::AboutDialog.set_url_hook { |dialog, url| open_url(url) }
3501 Gtk::AboutDialog.show($main_window, { :name => 'booh',
3502 :version => $VERSION,
3503 :copyright => 'Copyright (c) 2005 Guillaume Cottenceau',
3504 :license => get_license,
3505 :website => 'http://zarb.org/~gc/html/booh.html',
3506 :authors => [ 'Guillaume Cottenceau' ],
3507 :artists => [ 'Ayo73' ],
3508 :comments => utf8(_("''The Web-Album of choice for discriminating Linux users''")),